From bfa3e23db3d61e3c9eacfe1deecabedc7f9d5cbc Mon Sep 17 00:00:00 2001 From: ivan-aksamentov Date: Mon, 6 Jul 2026 15:36:10 +0200 Subject: [PATCH 1/2] fix(sort): score dataset suggestion by expected single-reference minimizer hits Dataset suggestion divides matched minimizers by a per-dataset denominator. For a dataset built from several references the index stores the union of their minimizers, so a union-sized denominator grows with every reference added while a query still only matches one reference's worth, deflating the score as coverage improves. - Divide by the new optional expectedMinimizerHits, falling back to nMinimizers when it is absent so pre-existing single-reference indices score identically - Deprecate nMinimizers while keeping it required for older clients - Validate the index on load, rejecting non-positive k, cutoff, or length and a non-finite or non-positive denominator so a malformed index fails loudly instead of yielding infinite or negative scores --- Cargo.lock | 1 + Cargo.toml | 1 + .../internal-minimizer-index-json.schema.json | 10 ++ .../internal-minimizer-index-json.schema.yaml | 8 ++ packages/nextclade/Cargo.toml | 1 + .../nextclade/src/sort/minimizer_index.rs | 109 +++++++++++++++++- .../nextclade/src/sort/minimizer_search.rs | 92 ++++++++++++++- 7 files changed, 219 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2dad7585b..86db93836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,6 +2105,7 @@ dependencies = [ name = "nextclade" version = "3.21.2" dependencies = [ + "approx", "assert2", "auto_ops", "base64", diff --git a/Cargo.toml b/Cargo.toml index d73c802ee..a4daa28d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ authors = ["Nextstrain team"] [workspace.dependencies] +approx = "=0.5.1" assert2 = "=0.3.16" auto_ops = "=0.3.0" base64 = "=0.22.1" diff --git a/packages/nextclade-schemas/internal-minimizer-index-json.schema.json b/packages/nextclade-schemas/internal-minimizer-index-json.schema.json index 69761fb49..1d814c654 100644 --- a/packages/nextclade-schemas/internal-minimizer-index-json.schema.json +++ b/packages/nextclade-schemas/internal-minimizer-index-json.schema.json @@ -77,8 +77,18 @@ "type": "string" }, "nMinimizers": { + "description": "Deprecated: prefer `expected_minimizer_hits`. Retained as the integer score denominator for older clients. For indexes that provide both fields it is the rounded `expected_minimizer_hits`; older indexes instead carry the literal count of stored minimizers.", + "deprecated": true, "type": "integer", "format": "int64" + }, + "expectedMinimizerHits": { + "description": "Exact fractional expected number of minimizer hits from a single reference. Preferred as the score denominator when present; absent in older indexes, which fall back to `n_minimizers`.", + "type": [ + "number", + "null" + ], + "format": "double" } } } diff --git a/packages/nextclade-schemas/internal-minimizer-index-json.schema.yaml b/packages/nextclade-schemas/internal-minimizer-index-json.schema.yaml index 651f55058..7f3924f75 100644 --- a/packages/nextclade-schemas/internal-minimizer-index-json.schema.yaml +++ b/packages/nextclade-schemas/internal-minimizer-index-json.schema.yaml @@ -56,5 +56,13 @@ definitions: name: type: string nMinimizers: + description: 'Deprecated: prefer `expected_minimizer_hits`. Retained as the integer score denominator for older clients. For indexes that provide both fields it is the rounded `expected_minimizer_hits`; older indexes instead carry the literal count of stored minimizers.' + deprecated: true type: integer format: int64 + expectedMinimizerHits: + description: Exact fractional expected number of minimizer hits from a single reference. Preferred as the score denominator when present; absent in older indexes, which fall back to `n_minimizers`. + type: + - number + - 'null' + format: double diff --git a/packages/nextclade/Cargo.toml b/packages/nextclade/Cargo.toml index a61bc871f..1d1003c4d 100644 --- a/packages/nextclade/Cargo.toml +++ b/packages/nextclade/Cargo.toml @@ -85,6 +85,7 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] +approx = { workspace = true } assert2 = { workspace = true } criterion = { workspace = true } rstest = { workspace = true } diff --git a/packages/nextclade/src/sort/minimizer_index.rs b/packages/nextclade/src/sort/minimizer_index.rs index ade506757..8103cccff 100644 --- a/packages/nextclade/src/sort/minimizer_index.rs +++ b/packages/nextclade/src/sort/minimizer_index.rs @@ -1,6 +1,7 @@ use crate::io::fs::read_file_to_string; use crate::io::json::json_parse; use crate::io::schema_version::{SchemaVersion, SchemaVersionParams}; +use crate::make_error; use eyre::{Report, WrapErr}; use log::warn; use schemars::JsonSchema; @@ -84,8 +85,18 @@ pub struct MinimizerIndexParams { pub struct MinimizerIndexRefInfo { pub length: i64, pub name: String, + + /// Deprecated: prefer `expected_minimizer_hits`. Retained as the integer score denominator for older + /// clients. For indexes that provide both fields it is the rounded `expected_minimizer_hits`; older + /// indexes instead carry the literal count of stored minimizers. + #[deprecated(note = "use `expected_minimizer_hits`; retained for backward compatibility with older indexes")] pub n_minimizers: i64, + /// Exact fractional expected number of minimizer hits from a single reference. Preferred as the score + /// denominator when present; absent in older indexes, which fall back to `n_minimizers`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_minimizer_hits: Option, + #[serde(flatten)] pub other: serde_json::Value, } @@ -135,15 +146,111 @@ impl MinimizerIndexJson { ); } - json_parse(s).wrap_err("When parsing minimizer index") + let index: Self = json_parse(s).wrap_err("When parsing minimizer index")?; + index.validate()?; + Ok(index) + } + + /// Reject a malformed minimizer index before it reaches scoring. The index is external data (fetched + /// from a dataset server or supplied via `--input-minimizer-index-json`), so its numeric fields carry + /// no type-level guarantees: a negative `k`/`cutoff` casts to a huge unsigned value, and a + /// non-positive or non-finite denominator produces `inf`/`NaN` scores that silently win or vanish from + /// dataset selection. Fail loudly instead. + fn validate(&self) -> Result<(), Report> { + if self.params.k <= 0 { + return make_error!( + "Minimizer index parameter `k` must be positive, but found {}", + self.params.k + ); + } + if !(0 < self.params.cutoff && self.params.cutoff <= (1_i64 << 32)) { + return make_error!( + "Minimizer index parameter `cutoff` must be in (0, 2^32], but found {}", + self.params.cutoff + ); + } + for reference in &self.references { + if reference.length <= 0 { + return make_error!( + "Minimizer index reference '{}' has non-positive length {}", + reference.name, + reference.length + ); + } + #[allow(deprecated)] // reads the fallback denominator to validate it, same precedence as scoring + let denominator = reference + .expected_minimizer_hits + .unwrap_or(reference.n_minimizers as f64); + if !denominator.is_finite() || denominator <= 0.0 { + return make_error!( + "Minimizer index reference '{}' has a non-positive or non-finite score denominator ({denominator})", + reference.name + ); + } + } + Ok(()) } } #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use rstest::rstest; + // Minimal minimizer index JSON with one reference. `expected` is spliced verbatim (e.g. + // `,"expectedMinimizerHits":436.4375` or empty) so both presence and absence can be tested. + fn index_json(k: i64, cutoff: i64, length: i64, n_minimizers: i64, expected: &str) -> String { + format!( + r#"{{"schemaVersion":"3.0.0","version":"1","params":{{"k":{k},"cutoff":{cutoff}}},"references":[{{"length":{length},"name":"ds","nMinimizers":{n_minimizers}{expected}}}]}}"# + ) + } + + #[test] + fn test_minimizer_index_deserializes_expected_minimizer_hits() { + let idx = MinimizerIndexJson::from_str(index_json( + 17, + 1 << 28, + 7000, + 436, + r#","expectedMinimizerHits":436.4375"#, + )) + .unwrap(); + assert_eq!(Some(436.4375), idx.references[0].expected_minimizer_hits); + } + + #[test] + fn test_minimizer_index_absent_expected_minimizer_hits_deserializes_to_none() { + let idx = MinimizerIndexJson::from_str(index_json(17, 1 << 28, 7000, 436, "")).unwrap(); + assert_eq!(None, idx.references[0].expected_minimizer_hits); + // the camelCase key did not silently land in the flatten catch-all + assert!(idx.references[0].other.get("expectedMinimizerHits").is_none()); + } + + #[test] + fn test_minimizer_index_roundtrip_omits_none_expected_minimizer_hits() { + let idx = MinimizerIndexJson::from_str(index_json(17, 1 << 28, 7000, 436, "")).unwrap(); + let serialized = serde_json::to_string(&idx).unwrap(); + assert!(!serialized.contains("expectedMinimizerHits")); + } + + #[rustfmt::skip] + #[rstest] + #[case::valid( index_json(17, 1 << 28, 7000, 436, r#","expectedMinimizerHits":436.4375"#), true)] + #[case::valid_int_fallback( index_json(17, 1 << 28, 7000, 436, ""), true)] + #[case::negative_k( index_json(-1, 1 << 28, 7000, 436, ""), false)] + #[case::zero_k( index_json(0, 1 << 28, 7000, 436, ""), false)] + #[case::negative_cutoff( index_json(17, -1, 7000, 436, ""), false)] + #[case::zero_cutoff( index_json(17, 0, 7000, 436, ""), false)] + #[case::zero_length( index_json(17, 1 << 28, 0, 436, ""), false)] + #[case::zero_denominator( index_json(17, 1 << 28, 7000, 0, ""), false)] + #[case::negative_expected( index_json(17, 1 << 28, 7000, 436, r#","expectedMinimizerHits":-1.0"#), false)] + #[case::zero_expected( index_json(17, 1 << 28, 7000, 436, r#","expectedMinimizerHits":0.0"#), false)] + #[trace] + fn test_minimizer_index_validate(#[case] json: String, #[case] ok: bool) { + assert_eq!(ok, MinimizerIndexJson::from_str(&json).is_ok(), "for: {json}"); + } + #[rustfmt::skip] #[rstest] #[case::equal( "1", false)] diff --git a/packages/nextclade/src/sort/minimizer_search.rs b/packages/nextclade/src/sort/minimizer_search.rs index 926597d91..fff6189f2 100644 --- a/packages/nextclade/src/sort/minimizer_search.rs +++ b/packages/nextclade/src/sort/minimizer_search.rs @@ -62,7 +62,10 @@ pub fn run_minimizer_search( let reff = &index.references[i]; let qry_hits = hit_counts[i] as f64; - let ref_minimizers = reff.n_minimizers as f64; + // Prefer the exact fractional expectation when the index provides it; fall back to the rounded + // integer for older indexes that predate the `expectedMinimizerHits` field. + #[allow(deprecated)] // deprecated `n_minimizers` read intentionally as the backward-compat fallback + let ref_minimizers = reff.expected_minimizer_hits.unwrap_or(reff.n_minimizers as f64); let ref_len = reff.length as f64; let qry_len = fasta_record.seq.len() as f64; scores[i] = (qry_hits / ref_minimizers) * (ref_len / qry_len).max(1.0); @@ -259,7 +262,7 @@ fn get_hash(kmer: &[u8], params: &MinimizerIndexParams) -> u64 { return cutoff + 1; // break out of loop, return hash above cutoff } - // A=11=3, C=10=2, G=00=0, T=01=1 + // A=11=3, C=01=1, G=00=0, T=10=2 if "AC".contains(nuc) { x += 1 << j; } @@ -295,3 +298,88 @@ pub fn get_ref_search_minimizers(seq: &FastaRecord, params: &MinimizerIndexParam fn preprocess_seq(seq: impl AsRef) -> String { seq.as_ref().to_uppercase().replace('-', "") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::fasta::FastaRecord; + use crate::sort::minimizer_index::{MinimizerIndexParams, MinimizerIndexRefInfo, MinimizerMap}; + use crate::sort::params::NextcladeSeqSortParams; + use approx::assert_ulps_eq; + use rstest::rstest; + use serde_json::Value; + + // Deterministic pseudo-random ACGT sequence (LCG), avoids long inline fixtures while producing enough + // distinct k-mers to clear the min_hits threshold. + fn make_seq(n: usize) -> String { + let bases = [b'A', b'C', b'G', b'T']; + let mut x: u64 = 0x2545_F491_4F6C_DD1D; + std::iter::repeat_with(|| { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + bases[((x >> 33) & 3) as usize] as char + }) + .take(n) + .collect() + } + + // Score a single reference whose stored minimizer set matches every query minimizer, with + // ref_len == qry_len so the length factor is 1. The score reduces to qry_hits / denominator, where the + // denominator is expected_minimizer_hits when present, else n_minimizers. Denominators are expressed as + // multiples of the realized query hit count so the expected score is exact regardless of that count. + fn single_ref_score(expected_mult: Option, n_minimizers_mult: f64) -> f64 { + let params = MinimizerIndexParams { + k: 17, + cutoff: 1 << 28, + other: Value::Null, + }; + let seq = make_seq(4000); + let record = FastaRecord { + seq_name: "q".to_owned(), + seq: seq.clone(), + index: 0, + }; + + let mzs = get_ref_search_minimizers(&record, ¶ms); + let n_hits = mzs.len() as f64; + assert!(n_hits >= 10.0, "fixture must clear min_hits, got {n_hits}"); + let minimizers: MinimizerMap = mzs.into_iter().map(|m| (m, vec![0_usize])).collect(); + + #[allow(deprecated)] // constructs the deprecated `n_minimizers` to exercise the fallback path + let index = MinimizerIndexJson { + schema: String::new(), + schema_version: "3.0.0".to_owned(), + version: "1".to_owned(), + params, + minimizers, + references: vec![MinimizerIndexRefInfo { + length: seq.len() as i64, + name: "ref".to_owned(), + n_minimizers: (n_hits * n_minimizers_mult) as i64, + expected_minimizer_hits: expected_mult.map(|m| m * n_hits), + other: Value::Null, + }], + normalization: vec![], + other: Value::Null, + }; + + let result = run_minimizer_search(&record, &index, &NextcladeSeqSortParams::default()).unwrap(); + result.datasets[0].score + } + + // Denominator selection. The `prefers` case models the multi-reference regime: `n_minimizers = 4*hits` + // stands for the merged-union count and `expected = 2*hits` for the per-reference expectation; the + // score `0.5` proves the union denominator is not used (that would give `0.25`). The `falls_back` case + // exercises an older index with no `expectedMinimizerHits`. + #[rustfmt::skip] + #[rstest] + #[case::prefers_expected_hits( Some(2.0), 4.0, 0.5)] + #[case::falls_back_to_n_minimizers( None, 2.0, 0.5)] + #[trace] + fn test_minimizer_search_denominator( + #[case] expected_mult: Option, + #[case] n_minimizers_mult: f64, + #[case] expected_score: f64, + ) { + assert_ulps_eq!(single_ref_score(expected_mult, n_minimizers_mult), expected_score, max_ulps = 4); + } +} From ee5a321de58754a6e6bc6381c9d2d72a5fd3ba04 Mon Sep 17 00:00:00 2001 From: ivan-aksamentov Date: Mon, 6 Jul 2026 16:38:42 +0200 Subject: [PATCH 2/2] feat(pathogen): recognize minimizerIndex config in pathogen.json schema - Add a typed minimizerIndex field (references, cutoff exponent) to VirusProperties so the generated pathogen.json schema documents the property instead of leaving it in the untyped catch-all - Without this, dataset builds validating against the schema flag minimizerIndex as an unknown property --- .../input-pathogen-json.schema.json | 24 +++++++++++ .../input-pathogen-json.schema.yaml | 14 +++++++ .../nextclade-auspice-extensions.schema.json | 24 +++++++++++ .../nextclade-auspice-extensions.schema.yaml | 14 +++++++ .../nextclade/src/analyze/virus_properties.rs | 41 +++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/packages/nextclade-schemas/input-pathogen-json.schema.json b/packages/nextclade-schemas/input-pathogen-json.schema.json index 0d3382078..5483a618a 100644 --- a/packages/nextclade-schemas/input-pathogen-json.schema.json +++ b/packages/nextclade-schemas/input-pathogen-json.schema.json @@ -185,6 +185,17 @@ } ] }, + "minimizerIndex": { + "description": "Reference sequences used to build this dataset's entry in the shared minimizer index for dataset suggestion. Build-time metadata: read when generating the index, ignored during analysis.", + "anyOf": [ + { + "$ref": "#/definitions/MinimizerIndexConfig" + }, + { + "type": "null" + } + ] + }, "defaultCds": { "description": "CDS shown by default in the Nextclade Web sequence view (e.g. \"S\", \"HA1\").", "type": [ @@ -443,6 +454,19 @@ } } }, + "MinimizerIndexConfig": { + "description": "Configuration for building this dataset's entry in the shared minimizer index used for dataset suggestion (`nextclade sort`).", + "type": "object", + "properties": { + "references": { + "description": "FASTA files, relative to the dataset directory, whose sequences all contribute minimizers to this dataset's suggestion fingerprint. When absent, the dataset's main reference sequence is used.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "LabelledMutationsConfig": { "description": "Mapping from specific mutations to human-readable labels (e.g. clade-defining or drug-resistance mutations).", "examples": [ diff --git a/packages/nextclade-schemas/input-pathogen-json.schema.yaml b/packages/nextclade-schemas/input-pathogen-json.schema.yaml index 1c94e542a..5f04ed1fd 100644 --- a/packages/nextclade-schemas/input-pathogen-json.schema.yaml +++ b/packages/nextclade-schemas/input-pathogen-json.schema.yaml @@ -119,6 +119,11 @@ properties: description: Filenames of other dataset input files (reference FASTA, genome annotation, tree, examples). allOf: - $ref: '#/definitions/DatasetFiles' + minimizerIndex: + description: 'Reference sequences used to build this dataset''s entry in the shared minimizer index for dataset suggestion. Build-time metadata: read when generating the index, ignored during analysis.' + anyOf: + - $ref: '#/definitions/MinimizerIndexConfig' + - type: 'null' defaultCds: description: CDS shown by default in the Nextclade Web sequence view (e.g. "S", "HA1"). type: @@ -278,6 +283,15 @@ definitions: type: - string - 'null' + MinimizerIndexConfig: + description: Configuration for building this dataset's entry in the shared minimizer index used for dataset suggestion (`nextclade sort`). + type: object + properties: + references: + description: FASTA files, relative to the dataset directory, whose sequences all contribute minimizers to this dataset's suggestion fingerprint. When absent, the dataset's main reference sequence is used. + type: array + items: + type: string LabelledMutationsConfig: description: Mapping from specific mutations to human-readable labels (e.g. clade-defining or drug-resistance mutations). examples: diff --git a/packages/nextclade-schemas/nextclade-auspice-extensions.schema.json b/packages/nextclade-schemas/nextclade-auspice-extensions.schema.json index 139417c43..3f9f96cbe 100644 --- a/packages/nextclade-schemas/nextclade-auspice-extensions.schema.json +++ b/packages/nextclade-schemas/nextclade-auspice-extensions.schema.json @@ -519,6 +519,17 @@ } ] }, + "minimizerIndex": { + "description": "Reference sequences used to build this dataset's entry in the shared minimizer index for dataset suggestion. Build-time metadata: read when generating the index, ignored during analysis.", + "anyOf": [ + { + "$ref": "#/definitions/MinimizerIndexConfig" + }, + { + "type": "null" + } + ] + }, "defaultCds": { "description": "CDS shown by default in the Nextclade Web sequence view (e.g. \"S\", \"HA1\").", "type": [ @@ -777,6 +788,19 @@ } } }, + "MinimizerIndexConfig": { + "description": "Configuration for building this dataset's entry in the shared minimizer index used for dataset suggestion (`nextclade sort`).", + "type": "object", + "properties": { + "references": { + "description": "FASTA files, relative to the dataset directory, whose sequences all contribute minimizers to this dataset's suggestion fingerprint. When absent, the dataset's main reference sequence is used.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "LabelledMutationsConfig": { "description": "Mapping from specific mutations to human-readable labels (e.g. clade-defining or drug-resistance mutations).", "examples": [ diff --git a/packages/nextclade-schemas/nextclade-auspice-extensions.schema.yaml b/packages/nextclade-schemas/nextclade-auspice-extensions.schema.yaml index ffdbb66b2..fa44b4abd 100644 --- a/packages/nextclade-schemas/nextclade-auspice-extensions.schema.yaml +++ b/packages/nextclade-schemas/nextclade-auspice-extensions.schema.yaml @@ -339,6 +339,11 @@ definitions: description: Filenames of other dataset input files (reference FASTA, genome annotation, tree, examples). allOf: - $ref: '#/definitions/DatasetFiles' + minimizerIndex: + description: 'Reference sequences used to build this dataset''s entry in the shared minimizer index for dataset suggestion. Build-time metadata: read when generating the index, ignored during analysis.' + anyOf: + - $ref: '#/definitions/MinimizerIndexConfig' + - type: 'null' defaultCds: description: CDS shown by default in the Nextclade Web sequence view (e.g. "S", "HA1"). type: @@ -497,6 +502,15 @@ definitions: type: - string - 'null' + MinimizerIndexConfig: + description: Configuration for building this dataset's entry in the shared minimizer index used for dataset suggestion (`nextclade sort`). + type: object + properties: + references: + description: FASTA files, relative to the dataset directory, whose sequences all contribute minimizers to this dataset's suggestion fingerprint. When absent, the dataset's main reference sequence is used. + type: array + items: + type: string LabelledMutationsConfig: description: Mapping from specific mutations to human-readable labels (e.g. clade-defining or drug-resistance mutations). examples: diff --git a/packages/nextclade/src/analyze/virus_properties.rs b/packages/nextclade/src/analyze/virus_properties.rs index 703bfca54..a57b70235 100644 --- a/packages/nextclade/src/analyze/virus_properties.rs +++ b/packages/nextclade/src/analyze/virus_properties.rs @@ -79,6 +79,10 @@ pub struct VirusProperties { #[serde(default, skip_serializing_if = "DatasetFiles::is_default")] pub files: DatasetFiles, + /// Reference sequences used to build this dataset's entry in the shared minimizer index for dataset suggestion. Build-time metadata: read when generating the index, ignored during analysis. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimizer_index: Option, + /// CDS shown by default in the Nextclade Web sequence view (e.g. "S", "HA1"). #[serde(default, skip_serializing_if = "Option::is_none")] pub default_cds: Option, @@ -138,6 +142,18 @@ pub struct VirusProperties { pub other: serde_json::Value, } +/// Configuration for building this dataset's entry in the shared minimizer index used for dataset suggestion (`nextclade sort`). +#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerIndexConfig { + /// FASTA files, relative to the dataset directory, whose sequences all contribute minimizers to this dataset's suggestion fingerprint. When absent, the dataset's main reference sequence is used. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub references: Vec, + + #[serde(flatten)] + pub other: serde_json::Value, +} + /// Contact and documentation URLs for dataset maintenance #[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] @@ -412,6 +428,7 @@ impl VirusProperties { shortcuts: vec_of_owned!["flu_h3n2_ha_broad", "nextstrain/flu/h3n2/ha/wisconsin-67-2005"], meta: DatasetMeta::default(), files: DatasetFiles::default(), + minimizer_index: None, default_cds: Some(o!("HA1")), cds_order_preference: vec_of_owned!["HA1", "HA2"], mut_labels: LabelledMutationsConfig::example(), @@ -457,3 +474,27 @@ impl VirusProperties { .is_none_or(|compat| compat.is_cli_compatible(current_cli_version)) } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_virus_properties_parses_minimizer_index() { + let json = r#"{"schemaVersion":"3.0.0","minimizerIndex":{"references":["minimizer_refs/additional_refs.fasta"]}}"#; + let props = VirusProperties::from_str(&json).unwrap(); + let mi = props + .minimizer_index + .expect("minimizerIndex should parse into the typed field"); + assert_eq!(vec_of_owned!["minimizer_refs/additional_refs.fasta"], mi.references); + assert!(props.other.get("minimizerIndex").is_none()); + } + + #[test] + fn test_virus_properties_minimizer_index_absent_is_none() { + let json = r#"{"schemaVersion":"3.0.0"}"#; + let props = VirusProperties::from_str(&json).unwrap(); + assert_eq!(None, props.minimizer_index); + } +}