From dc36b3ab761bcb85ba905c07d152d8164de18633 Mon Sep 17 00:00:00 2001 From: Paula Ruiz-Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:43:03 +0200 Subject: [PATCH 1/2] fix: carry gaps that were never searched into the evolutionary audit and run comparison Gain and loss calls in evolutionary_events.tsv were built on absences whether or not Recore had searched them, so a gain on a cluster below --min-prevalence read as an audited result. New columns count those absences and say why, a call none of whose absences were searched drops one support level, and every affected call is tagged and explained. compare_runs.py also counted NOT_SEARCHED_* cells as calls, listing every unsearched cell as a new correction against older runs; they are absences now, and a change in search coverage has its own class. --- CHANGELOG.md | 10 ++ benchmarks/README.md | 5 +- benchmarks/compare_runs.py | 22 +++- docs/guide/outputs.md | 3 +- docs/how-it-works/evolutionary-events.md | 32 +++++- src/pipeline.rs | 1 + src/pipeline/evolution.rs | 134 ++++++++++++++++++++++- src/pipeline/output.rs | 9 ++ src/pipeline/tests.rs | 77 +++++++++++++ tests/integration.rs | 38 +++++++ 10 files changed, 322 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8e5e1..e6bdfda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and Recore uses semantic versioning. ### Fixed +- `evolutionary_events.tsv` no longer presents a gain or loss built on gaps + Recore never searched as an audited call. New columns `absent_not_searched` + and `not_searched_reason` count those absences and say why; a call none of + whose absences were searched drops one support level (and so in score and + priority), and every affected call is tagged in `mechanism_detail` and + explained in `recommended_wording`. +- `benchmarks/compare_runs.py` counted `NOT_SEARCHED_*` cells as calls, so a + comparison against a run from before those states listed every unsearched + cell as a new correction. They are absences now, and a cell searched in one + run and not the other is `search_coverage_changed`. - A gap Recore never searched no longer reads like one it searched and found empty. The states file writes `NOT_SEARCHED_LOW_PREVALENCE`, `NOT_SEARCHED_NO_CLUSTER_SEQUENCE` or `NOT_SEARCHED_NO_FASTA` where it wrote diff --git a/benchmarks/README.md b/benchmarks/README.md index d177e23..b79e17f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -112,7 +112,10 @@ python3 benchmarks/compare_runs.py --old old_recore_output --new new_recore_outp The comparison uses the union of matrix cells, so clusters or genomes present in only one run are reported as `old_only_cell` / `new_only_cell` instead of -being silently ignored. +being silently ignored. A cell absent in both runs but searched in only one of +them (an `ABSENT` against a `NOT_SEARCHED_*`, as older runs wrote `ABSENT` for +both) is `search_coverage_changed`, not a correction, and is counted apart from +`changed_cells`. Generate a compact Markdown/HTML report: diff --git a/benchmarks/compare_runs.py b/benchmarks/compare_runs.py index 372c426..c6b53d3 100644 --- a/benchmarks/compare_runs.py +++ b/benchmarks/compare_runs.py @@ -72,19 +72,32 @@ def read_evolution(run_dir: Path) -> dict[str, dict[str, str]]: return {row["gene_cluster"]: row for row in reader} +def is_absent(state: str) -> bool: + """No presence and no evidence: a gap searched and found empty, or one that + was never searched. A NOT_SEARCHED_* state is an absence, and counting it as + a call made every unsearched cell a "new correction" against older runs, + which wrote ABSENT for both.""" + return state in {"", "ABSENT"} or state.startswith("NOT_SEARCHED") + + def classify_change(old_state: str, new_state: str) -> str: if old_state == "MISSING_CELL": return "new_only_cell" if new_state == "MISSING_CELL": return "old_only_cell" - old_present = old_state not in {"", "ABSENT"} - new_present = new_state not in {"", "ABSENT"} if old_state == new_state: return "same" + old_present = not is_absent(old_state) + new_present = not is_absent(new_state) if not old_present and new_present: return "new_correction" if old_present and not new_present: return "lost_correction" + if not old_present and not new_present: + # Absent in both runs, but searched in one and not the other, or left + # out of the search for a different reason: the search changed, the + # matrix did not. + return "search_coverage_changed" return "state_changed" @@ -128,6 +141,7 @@ def main() -> int: "old_only_cell", "new_only_cell", "changed_cells", + "search_coverage_changed", ]) for cluster in sorted(cluster_counts): counts = cluster_counts[cluster] @@ -138,7 +152,7 @@ def main() -> int: + counts["old_only_cell"] + counts["new_only_cell"] ) - if changed: + if changed or counts["search_coverage_changed"]: writer.writerow([ cluster, counts["same"], @@ -148,6 +162,7 @@ def main() -> int: counts["old_only_cell"], counts["new_only_cell"], changed, + counts["search_coverage_changed"], ]) old_evo = read_evolution(args.old) @@ -199,6 +214,7 @@ def main() -> int: "state_changed", "old_only_cell", "new_only_cell", + "search_coverage_changed", ]: writer.writerow([key, cell_counts[key]]) diff --git a/docs/guide/outputs.md b/docs/guide/outputs.md index 99e9347..1f24ef8 100644 --- a/docs/guide/outputs.md +++ b/docs/guide/outputs.md @@ -213,6 +213,7 @@ corrected binary state for each cluster. |--------|-------------| | `gene_cluster` | Panaroo cluster name | | `n_present_corrected`, `prevalence_corrected` | Presence after accepted matrix corrections and NSC presence updates | +| `absent_not_searched`, `not_searched_reason` | How many of the cluster's absences in the corrected matrix were never searched, and why: `low_prevalence` (the cluster is below `--min-prevalence`), `no_cluster_sequence`, or `no_fasta` (some genomes have no FASTA); `0` and blank when every absence was searched | | `tree_matched_tips` | Number of tree tips matched to matrix genomes; blank when no tree is provided | | `reference_state` | `present`, `absent`, or `not_assessed` for the requested `--reference-genome` | | `phylogenetic_pattern` | `ancestral_retained`, `ancestral_loss`, `lineage_specific_gain`, `clade_associated_presence`, `clade_associated_presence_with_losses`, `recurrent_gain`, `recurrent_loss`, `homoplastic_gain_loss`, `ambiguous_gain_loss`, `singleton_or_noise`, `dispersed_gain_loss`, or `not_assessed_no_tree` | @@ -224,7 +225,7 @@ corrected binary state for each cluster. | `carrier_monophyly`, `max_carrier_clade_fraction` | Whether all carrier tips form one pure carrier-only clade, and the largest carrier-only clade fraction | | `carrier_mrca_tips`, `carrier_mrca_purity` | Size and carrier purity of the smallest tree clade containing all carrier tips; low purity means a single-gain story would require many internal losses | | `molecular_mechanism` | Best conservative mechanism call: reference-derived allele, IS interruption, pseudogenization, fragmentation, duplication/paralog expansion, context shift, acquisition/insertion candidate, deletion/loss candidate, homoplastic/unstable signal, ancestral retention, or unresolved | -| `mechanism_detail`, `support_level`, `evolutionary_signal_score`, `highlight_priority`, `recommended_wording` | Evidence summary, integrated prioritization, and publication-facing interpretation. With `--nscluster`, `mechanism_detail` also carries `nsc_representative` on the cluster that receives the merged support and `nsc_member_merged_into_representative` on each redundant member | +| `mechanism_detail`, `support_level`, `evolutionary_signal_score`, `highlight_priority`, `recommended_wording` | Evidence summary, integrated prioritization, and publication-facing interpretation. With `--nscluster`, `mechanism_detail` also carries `nsc_representative` on the cluster that receives the merged support and `nsc_member_merged_into_representative` on each redundant member. A gain or loss call that rests on absences never searched carries `absences_not_searched` (none searched, and one support level lower) or `absences_not_searched=N/M` (some), and its wording says so; see [Evolutionary events](../how-it-works/evolutionary-events.md#calls-that-rest-on-gaps-recore-never-searched) | | `cluster_component_id`, `cluster_component_size`, `cluster_component_role`, `cluster_component_relations`, `cluster_component_recommendation` | Connected-component context for split, annotation-matched, NSC, and paralog-linked clusters | | `is_interruption_supported`, `mobile_element_*` | Nearby mobile element signal. `mobile_element_source=sequence` comes from `--mobile-element-reference` alignments and is name-agnostic; `mobile_element_source=annotation` is the fallback from GFF labels/products/notes. IS calls are conservative and require sequence support or an insertion-sequence-like label within 250 bp of a fragmented/disrupted rescue | | `reference_event_class`, `target_reference_cluster`, `target_reference_gene` | Reference-derived event evidence when available | diff --git a/docs/how-it-works/evolutionary-events.md b/docs/how-it-works/evolutionary-events.md index 72c7748..97e9990 100644 --- a/docs/how-it-works/evolutionary-events.md +++ b/docs/how-it-works/evolutionary-events.md @@ -119,7 +119,9 @@ For manuscript highlighting, the safest candidates are usually clusters with: - coherent `phylogenetic_pattern` with low/moderate blink flag; - high `carrier_mrca_purity` or `dollo_origin_purity`; - no paralog/component warning; -- compatible synteny and clear ORF integrity. +- compatible synteny and clear ORF integrity; +- `absent_not_searched` of 0, so every absence the call rests on was searched + (see below). Clusters with `duplication_or_paralog_expansion`, `annotation_or_clustering_artifact`, @@ -131,6 +133,34 @@ the locus is inspected manually. Clusters with `reference_gene_*` or reference-absent genes, even if the original pangenome matrix marked them as absent from the reference. +## Calls that rest on gaps Recore never searched + +Gains and losses are made of absences: a gain needs the gene missing in the +rest of the tree, a loss needs it missing where it was lost. A gap outside the +search (a cluster below `--min-prevalence`, a cluster with no sequence to search +with, or a genome with no FASTA) keeps the input matrix's call, so a call built +on such absences is only as good as that call. Under the default prevalence +floor that is every gain on a cluster in fewer than one genome in ten. + +`absent_not_searched` counts those absences and `not_searched_reason` says why +(`low_prevalence`, `no_cluster_sequence` or `no_fasta`). For the calls that rest +on absences (`clean_insertion_or_acquisition_candidate`, +`reference_or_lineage_loss_candidate`, `deletion_loss_candidate`, +`unstable_or_homoplastic_accessory_signal` and +`unresolved_accessory_variation`): + +- when none of the absences behind the call were searched (for a reference + loss, when the reference genome's was not), `support_level` drops one level, + `mechanism_detail` gains `absences_not_searched`, and `recommended_wording` + says so. The lower support lowers `evolutionary_signal_score`, and with it + `highlight_priority`; +- when only some were not searched, `mechanism_detail` gains + `absences_not_searched=N/M` and the wording says how many, but the searched + absences still carry the call. + +Calls made from rescue evidence (pseudogenes, fragments, paralogs, reference +events) do not rest on absences and are left as they are. + ## Where the fields live Every field named on this page is a column of `evolutionary_events.tsv`; its diff --git a/src/pipeline.rs b/src/pipeline.rs index 5999906..212dfc7 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1058,6 +1058,7 @@ pub fn run(args: &Args) -> Result<()> { reference_genome: args.reference_genome.as_deref(), nsc_result: &nsc_result, component_info: &component_info, + coverage: &coverage, output_dir: &args.output, })?; if args.write_corrected_gff { diff --git a/src/pipeline/evolution.rs b/src/pipeline/evolution.rs index 6187985..10d613c 100644 --- a/src/pipeline/evolution.rs +++ b/src/pipeline/evolution.rs @@ -695,6 +695,7 @@ pub(super) struct EvolutionaryEventInputs<'a> { pub(super) reference_genome: Option<&'a str>, pub(super) nsc_result: &'a Option, pub(super) component_info: &'a [Option], + pub(super) coverage: &'a SearchCoverage, pub(super) output_dir: &'a Path, } @@ -1343,6 +1344,100 @@ pub(super) fn classify_mechanism(input: MechanismInput<'_>) -> MechanismCall { } } +/// How much of a cluster's absence the search actually tested. +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct AbsenceCoverage { + /// Genomes where the cluster is absent from the corrected matrix. + pub(super) absent: usize, + /// Of those, the ones whose gap was never searched. + pub(super) not_searched: usize, + /// Why, when any were not searched: the cluster's own reason when it was + /// left out of the search, else the genomes without a FASTA. + pub(super) reason: Option, + /// The reference genome lacks the cluster and its gap was never searched. + pub(super) reference_untested: bool, +} + +/// Mechanisms whose call rests on the cluster being absent somewhere: a gain +/// needs the gene missing elsewhere, a loss needs it missing where it was lost, +/// and a homoplastic or unresolved pattern is made of absences too. +const ABSENCE_BASED_MECHANISMS: [&str; 5] = [ + "clean_insertion_or_acquisition_candidate", + "reference_or_lineage_loss_candidate", + "deletion_loss_candidate", + "unstable_or_homoplastic_accessory_signal", + "unresolved_accessory_variation", +]; + +fn downgraded_support(support: &'static str) -> &'static str { + match support { + "high" => "moderate", + "moderate" => "low", + other => other, + } +} + +/// Qualify a mechanism call that rests on absences Recore never searched. +/// +/// Gaps outside the search keep the input matrix's call, so a gain inferred +/// from a cluster missing everywhere else, or a loss inferred where it is +/// missing, is only as good as that call when the absences behind it were not +/// tested. Every such call is tagged in `mechanism_detail` and explained in the +/// returned sentence for `recommended_wording`; when none of its absences were +/// searched (for a reference loss, when the reference genome's was not), its +/// support also drops one level, which lowers its score and priority with it. +/// Under the default `--min-prevalence` this is every gain call on a cluster in +/// fewer than one genome in ten, and it used to read as an audited result. +pub(super) fn qualify_unsearched_absences( + mechanism: &mut MechanismCall, + coverage: &AbsenceCoverage, +) -> Option { + if !ABSENCE_BASED_MECHANISMS.contains(&mechanism.mechanism) { + return None; + } + let reason = coverage.reason.filter(|_| coverage.not_searched > 0)?; + let one = coverage.not_searched == 1; + let why = match reason { + NotSearched::LowPrevalence => "the cluster is below --min-prevalence", + NotSearched::NoClusterSequence => "the cluster has no sequence to search with", + NotSearched::NoFasta if one => "that genome has no FASTA", + NotSearched::NoFasta => "those genomes have no FASTA", + }; + let reference_loss = mechanism.mechanism == "reference_or_lineage_loss_candidate"; + let none_searched = coverage.not_searched == coverage.absent; + if none_searched || (reference_loss && coverage.reference_untested) { + mechanism.support = downgraded_support(mechanism.support); + mechanism.detail.push_str(";absences_not_searched"); + return Some(if !none_searched { + // Only reachable without a cluster-level reason: the reference + // genome is one of the genomes without a FASTA. + "The reference genome has no FASTA, so its absence was never searched and is the input matrix's call, not Recore's.".to_string() + } else if coverage.absent == 1 { + format!("The only absence behind this call was never searched ({why}); it is the input matrix's call, not Recore's.") + } else { + format!( + "None of the {} absences behind this call were searched ({why}); they are the input matrix's calls, not Recore's.", + coverage.absent + ) + }); + } + mechanism.detail.push_str(&format!( + ";absences_not_searched={}/{}", + coverage.not_searched, coverage.absent + )); + Some(if one { + format!( + "1 of the {} absences was never searched ({why}); that one is the input matrix's call.", + coverage.absent + ) + } else { + format!( + "{} of the {} absences were never searched ({why}); those are the input matrix's calls.", + coverage.not_searched, coverage.absent + ) + }) +} + fn phylogenetic_blink_flag(phylo: &PhyloPatternSummary) -> &'static str { if !phylo.assessed || phylo.pattern == "absent_from_tree" { "not_assessed" @@ -1536,7 +1631,7 @@ pub(super) fn write_evolutionary_events(inputs: EvolutionaryEventInputs<'_>) -> let mut w = BufWriter::new(file); writeln!( w, - "gene_cluster\tn_present_corrected\tprevalence_corrected\ttree_matched_tips\treference_state\tphylogenetic_pattern\tphylogenetic_reliability\tphylogenetic_blink_flag\tpenalized_phylo_model\tpenalized_phylo_score\tdollo_loss_count\tdollo_violation_count\tdollo_origin_tips\tdollo_origin_purity\tancestral_state\troot_state_confidence\tphylogenetic_changes\tphylogenetic_gains\tphylogenetic_losses\tcarrier_monophyly\tmax_carrier_clade_fraction\tcarrier_mrca_tips\tcarrier_mrca_purity\tmolecular_mechanism\tmechanism_detail\tsupport_level\tevolutionary_signal_score\thighlight_priority\tcluster_component_id\tcluster_component_size\tcluster_component_role\tcluster_component_relations\tcluster_component_recommendation\tis_interruption_supported\tmobile_element_source\tmobile_element_kind\tmobile_element_label\tmobile_element_genome\tmobile_element_distance_bp\tmobile_element_identity\tmobile_element_coverage\treference_event_class\ttarget_reference_cluster\ttarget_reference_gene\tcontext_interpretation\tparalog_locus_risk\tsynteny_mean_score\tsynteny_pass_fraction\torf_integrity_summary\trescue_evidence_counts\trecommended_wording" + "gene_cluster\tn_present_corrected\tprevalence_corrected\tabsent_not_searched\tnot_searched_reason\ttree_matched_tips\treference_state\tphylogenetic_pattern\tphylogenetic_reliability\tphylogenetic_blink_flag\tpenalized_phylo_model\tpenalized_phylo_score\tdollo_loss_count\tdollo_violation_count\tdollo_origin_tips\tdollo_origin_purity\tancestral_state\troot_state_confidence\tphylogenetic_changes\tphylogenetic_gains\tphylogenetic_losses\tcarrier_monophyly\tmax_carrier_clade_fraction\tcarrier_mrca_tips\tcarrier_mrca_purity\tmolecular_mechanism\tmechanism_detail\tsupport_level\tevolutionary_signal_score\thighlight_priority\tcluster_component_id\tcluster_component_size\tcluster_component_role\tcluster_component_relations\tcluster_component_recommendation\tis_interruption_supported\tmobile_element_source\tmobile_element_kind\tmobile_element_label\tmobile_element_genome\tmobile_element_distance_bp\tmobile_element_identity\tmobile_element_coverage\treference_event_class\ttarget_reference_cluster\ttarget_reference_gene\tcontext_interpretation\tparalog_locus_risk\tsynteny_mean_score\tsynteny_pass_fraction\torf_integrity_summary\trescue_evidence_counts\trecommended_wording" )?; for ci_val in 0..inputs.pa.n_clusters() { @@ -1592,7 +1687,28 @@ pub(super) fn write_evolutionary_events(inputs: EvolutionaryEventInputs<'_>) -> inputs.mobile_element_window, ); let nsc_role = nsc_role_for_cluster(ci, inputs.nsc_result); - let mechanism = classify_mechanism(MechanismInput { + // Which of the cluster's absences the search tested; the cluster's own + // reason wins, as it does in the states file. + let mut absence = AbsenceCoverage { + reason: inputs.coverage.cluster_reason(ci), + ..AbsenceCoverage::default() + }; + for (gi_val, &present) in present_by_genome.iter().enumerate() { + if present { + continue; + } + absence.absent += 1; + let gi = GenomeIdx(gi_val); + if let Some(reason) = inputs.coverage.reason(ci, gi) { + absence.not_searched += 1; + absence.reason.get_or_insert(reason); + absence.reference_untested |= Some(gi) == reference_gi; + } + } + if absence.not_searched == 0 { + absence.reason = None; + } + let mut mechanism = classify_mechanism(MechanismInput { pa: inputs.pa, phylo: &phylo, reference_state, @@ -1604,6 +1720,9 @@ pub(super) fn write_evolutionary_events(inputs: EvolutionaryEventInputs<'_>) -> mobile, nsc_role: &nsc_role, }); + // Before the score, so a call that rests on untested absences is scored + // and prioritised as the weaker call it is. + let not_searched_note = qualify_unsearched_absences(&mut mechanism, &absence); let component = inputs .component_info .get(ci_val) @@ -1669,6 +1788,12 @@ pub(super) fn write_evolutionary_events(inputs: EvolutionaryEventInputs<'_>) -> inputs.pa.cluster_name(ci).to_string(), n_present.to_string(), format!("{:.4}", prevalence), + absence.not_searched.to_string(), + absence + .reason + .map(NotSearched::tag) + .unwrap_or("") + .to_string(), if phylo.assessed { phylo.matched_tips.to_string() } else { @@ -1790,7 +1915,10 @@ pub(super) fn write_evolutionary_events(inputs: EvolutionaryEventInputs<'_>) -> synteny_pass_fraction(&all_rescue_evidence), orf_signal_counts(&all_rescue_evidence), rescue_type_counts(accepted), - mechanism.wording.to_string(), + match not_searched_note { + Some(note) => format!("{} {}", mechanism.wording, note), + None => mechanism.wording.to_string(), + }, ], )?; } diff --git a/src/pipeline/output.rs b/src/pipeline/output.rs index ee8d7e1..2d56686 100644 --- a/src/pipeline/output.rs +++ b/src/pipeline/output.rs @@ -197,6 +197,15 @@ impl NotSearched { NotSearched::NoFasta => "NOT_SEARCHED_NO_FASTA", } } + + /// The reason alone, as `evolutionary_events.tsv` writes it. + pub(super) fn tag(self) -> &'static str { + match self { + NotSearched::LowPrevalence => "low_prevalence", + NotSearched::NoClusterSequence => "no_cluster_sequence", + NotSearched::NoFasta => "no_fasta", + } + } } /// Which gaps the search covered. The default, with empty vectors, is a run diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index a50a212..34e6b89 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -2847,3 +2847,80 @@ fn clusters_absent_from_the_tree_are_not_assessed() { }; assert_eq!(evolution::phylogenetic_reliability(&phylo), "not_assessed"); } + +/// A mechanism call that rests on absences Recore never searched is the input +/// matrix's call: it is tagged and explained, and one support level weaker +/// when none of its absences were searched. +#[test] +fn calls_resting_on_unsearched_absences_are_qualified() { + let call = |mechanism: &'static str| evolution::MechanismCall { + mechanism, + detail: "base".to_string(), + support: "moderate", + wording: "w", + mobile: None, + }; + let coverage = |absent, not_searched, reason, reference_untested| evolution::AbsenceCoverage { + absent, + not_searched, + reason, + reference_untested, + }; + + // A gain on a cluster below the prevalence floor: no absence was searched. + let mut gain = call("clean_insertion_or_acquisition_candidate"); + let note = evolution::qualify_unsearched_absences( + &mut gain, + &coverage(9, 9, Some(NotSearched::LowPrevalence), false), + ) + .unwrap(); + assert_eq!(gain.support, "low"); + assert_eq!(gain.detail, "base;absences_not_searched"); + assert!(note.starts_with("None of the 9 absences behind this call were searched")); + assert!(note.contains("--min-prevalence")); + + // A loss with one absence of four in a genome without a FASTA: tagged and + // explained, but the three searched absences still carry it. + let mut loss = call("deletion_loss_candidate"); + let note = evolution::qualify_unsearched_absences( + &mut loss, + &coverage(4, 1, Some(NotSearched::NoFasta), false), + ) + .unwrap(); + assert_eq!(loss.support, "moderate"); + assert_eq!(loss.detail, "base;absences_not_searched=1/4"); + assert!(note.starts_with("1 of the 4 absences was never searched (that genome has no FASTA)")); + + // A reference loss rests on the reference genome's absence above all. + let mut reference = call("reference_or_lineage_loss_candidate"); + let note = evolution::qualify_unsearched_absences( + &mut reference, + &coverage(3, 1, Some(NotSearched::NoFasta), true), + ) + .unwrap(); + assert_eq!(reference.support, "low"); + assert!( + note.starts_with("The reference genome has no FASTA, so its absence was never searched") + ); + + // Calls made from rescue evidence, and fully searched clusters, are left alone. + let mut pseudogene = call("pseudogenization"); + assert!(evolution::qualify_unsearched_absences( + &mut pseudogene, + &coverage(5, 5, Some(NotSearched::LowPrevalence), false), + ) + .is_none()); + assert_eq!( + (pseudogene.support, pseudogene.detail.as_str()), + ("moderate", "base") + ); + let mut searched = call("deletion_loss_candidate"); + assert!( + evolution::qualify_unsearched_absences(&mut searched, &coverage(4, 0, None, false)) + .is_none() + ); + assert_eq!( + (searched.support, searched.detail.as_str()), + ("moderate", "base") + ); +} diff --git a/tests/integration.rs b/tests/integration.rs index 6fd784c..7357f6c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -634,4 +634,42 @@ fn test_unsearched_gaps_say_why_instead_of_absent() { "genome totals must count not-searched cells apart from absent ones" ); assert!(html.contains("6 gaps were never searched (3 in clusters below the 50% prevalence floor, 2 in clusters with no sequence in the pan-genome reference, 1 in genomes with no FASTA file)")); + + // The evolutionary audit counts the absences it cannot vouch for, says why, + // and does not present a call made of them as an audited one. + let events = fs::read_to_string(output_dir.join("evolutionary_events.tsv")).unwrap(); + let mut rows = events.lines(); + let header: Vec<&str> = rows.next().unwrap().split('\t').collect(); + let column = |name: &str| header.iter().position(|h| *h == name).unwrap(); + let event = |cluster: &str| -> Vec { + events + .lines() + .find(|l| l.split('\t').next() == Some(cluster)) + .unwrap() + .split('\t') + .map(str::to_string) + .collect() + }; + let (count, reason) = (column("absent_not_searched"), column("not_searched_reason")); + for (cluster, n, why) in [ + ("gA", "0", ""), + ("gB", "3", "low_prevalence"), + ("gC", "0", ""), + ("gD", "2", "no_cluster_sequence"), + ("gE", "1", "no_fasta"), + ] { + let row = event(cluster); + assert_eq!( + (row[count].as_str(), row[reason].as_str()), + (n, why), + "{cluster}" + ); + } + let gb = event("gB"); + assert!(gb[column("mechanism_detail")].ends_with(";absences_not_searched")); + assert!(gb[column("recommended_wording")] + .contains("None of the 3 absences behind this call were searched")); + let gc = event("gC"); + assert!(!gc[column("mechanism_detail")].contains("absences_not_searched")); + assert!(!gc[column("recommended_wording")].contains("never searched")); } From c392930e55ff69ff661c5f374134209f282aea6b Mon Sep 17 00:00:00 2001 From: Paula Ruiz-Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:43:18 +0200 Subject: [PATCH 2/2] docs: regenerate the example report from the tutorial run Rv3810, below the prevalence floor, now carries the not-searched tag and wording in its mechanism card. --- docs/assets/example-report.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/example-report.html b/docs/assets/example-report.html index 917620c..6600d2d 100644 --- a/docs/assets/example-report.html +++ b/docs/assets/example-report.html @@ -4203,7 +4203,7 @@ fill="#0265ba" d="m 734.53229,859.93067 c 6.32782,-1.50009 12.68772,2.36408 14.27189,8.67088 1.58527,6.30791 -2.19262,12.71869 -8.47839,14.38804 -4.14185,1.10073 -8.55473,-0.11394 -11.55159,-3.17829 -2.99576,-3.06324 -4.11197,-7.50267 -2.91942,-11.61907 1.19255,-4.1164 4.50691,-7.27256 8.67751,-8.26156 z" style="stroke-width:1.10626" /> -Corrected pangenome matrix
24clusters
12genomes
8corrected
20.5%of gaps
3kept out
0rejected
+3core
Balancedpolicy
v0.2.0
Phylogeny
Corrected matrixinputcorrectedabsent
Unique syntenies
⌘K find? shortcuts