From 755acf711b1722abb582f6e7667b2e3ed221ad64 Mon Sep 17 00:00:00 2001 From: Marco Molari Date: Fri, 14 Aug 2026 18:04:26 +0200 Subject: [PATCH 1/5] test(merge): drop redundant sanity checks from the integration tests --- packages/pangraph/tests/itest_merge.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/pangraph/tests/itest_merge.rs b/packages/pangraph/tests/itest_merge.rs index 82de39a7..717a0a45 100644 --- a/packages/pangraph/tests/itest_merge.rs +++ b/packages/pangraph/tests/itest_merge.rs @@ -79,8 +79,6 @@ mod tests { merge_run(&merge_args(left.clone(), right.clone(), output.clone()))?; let merged = read_graph(&output)?; - #[cfg(debug_assertions)] - merged.sanity_check()?; // all genomes of both inputs are present, exactly once assert_eq!(merged.paths.len(), 6); @@ -131,8 +129,6 @@ mod tests { merge_run(&merge_args(left, right, output.clone()))?; let merged = read_graph(&output)?; - #[cfg(debug_assertions)] - merged.sanity_check()?; assert_eq!(merged.paths.len(), 4); Ok(()) From cb83e23b8dc2bb2e4384b650c5d5b1fcaa61e164 Mon Sep 17 00:00:00 2001 From: Marco Molari Date: Fri, 14 Aug 2026 18:04:26 +0200 Subject: [PATCH 2/5] fix(merge): vary the relabeling salt so repeated merges do not collide --- packages/pangraph/src/pangraph/pangraph.rs | 58 ++++++++++++++++++---- packages/pangraph/tests/itest_merge.rs | 33 ++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/pangraph/src/pangraph/pangraph.rs b/packages/pangraph/src/pangraph/pangraph.rs index 6336a629..c9556d1f 100644 --- a/packages/pangraph/src/pangraph/pangraph.rs +++ b/packages/pangraph/src/pangraph/pangraph.rs @@ -12,6 +12,7 @@ use crate::utils::id::id; use crate::utils::map_merge::{ConflictResolution, map_merge}; use crate::{make_internal_error, make_internal_report}; use eyre::{Report, WrapErr}; +use log::warn; use maplit::btreemap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -151,24 +152,35 @@ impl Pangraph { /// the way to the final graph. Merging therefore requires namespacing one of the two graphs /// first. pub fn make_disjoint_from(self, other: &Self) -> Result { - // Namespace under which the ids of this graph are re-derived. - const SALT: usize = 1; + const MAX_ATTEMPTS: usize = 8; // Path ids are assigned above every path id of `other`, so they cannot collide by construction. let path_id_offset = other.paths.keys().map(|pid| pid.0 + 1).max().unwrap_or(0); - let relabeled = self.relabel(SALT, path_id_offset)?; + // The salt must differ from the one used by any earlier merge whose relabeled ids survive into + // `other`, otherwise those ids get re-derived a second time and land on themselves. The path id + // offset provides that: a merger salted with `offset(P)` produces a graph with at least one more + // genome than `P`, so a later merge with that graph on the left uses a strictly larger offset. + let mut relabeled = self; + for attempt in 0..MAX_ATTEMPTS { + relabeled = relabeled.relabel(id((path_id_offset, attempt)), path_id_offset)?; - // Block and node ids are hashes, so a collision with `other` is possible in principle. At - // ~2^-64 per pair it does not happen in practice, but the check is cheap and the alternative is - // one graph silently overwriting a block of the other during the join. - if !relabeled.is_id_disjoint_from(other) { - return make_internal_error!( - "When making graphs id-disjoint: the relabeled graph still shares block or node ids with the other graph. This requires a 64-bit hash collision and should never happen." - ); + if relabeled.is_id_disjoint_from(other) { + return Ok(relabeled); + } + + // Relabeling is a composition of injective maps, so retrying on top of the previous attempt is + // safe, and path ids are assigned by rank rather than derived from the previous id, so they do + // not drift. Reaching this point needs either a genuine hash collision, or an operation that + // breaks the monotonicity of the offset: `simplify` drops paths without renumbering, so + // `build -> merge -> merge -> simplify -> merge` can bring the offset back to a value already + // used as a salt. + warn!("Identifier collision when relabeling graph ids (attempt {attempt}); retrying"); } - Ok(relabeled) + make_internal_error!( + "When making graphs id-disjoint: no collision-free relabeling of block and node ids found after {MAX_ATTEMPTS} attempts" + ) } pub fn update(&mut self, u: &GraphUpdate) { @@ -643,4 +655,28 @@ mod tests { assert_eq!(joined.blocks.len(), 4); assert_eq!(joined.nodes.len(), 4); } + + /// Appending to a graph that already absorbed a relabeled graph. With a constant salt the ids of + /// the third graph were re-derived exactly onto those the second one left behind, so the second + /// append always failed. Every graph here carries the same small ids, which is what `build` + /// assigns to blocks and nodes that never merge. + #[rstest] + fn test_make_disjoint_from_after_a_previous_merge() { + let first = colliding_graph(["a", "b"]); + let second = colliding_graph(["c", "d"]).make_disjoint_from(&first).unwrap(); + let joined = crate::pangraph::graph_merging::graph_join(&first, &second); + + let third = colliding_graph(["e", "f"]).make_disjoint_from(&joined).unwrap(); + + assert!(third.is_id_disjoint_from(&joined)); + third.sanity_check().unwrap(); + assert_eq!(third.path_ids().collect_vec(), vec![PathId(4), PathId(5)]); + + // and the three of them can be joined without conflicts + let joined = crate::pangraph::graph_merging::graph_join(&joined, &third); + joined.sanity_check().unwrap(); + assert_eq!(joined.paths.len(), 6); + assert_eq!(joined.blocks.len(), 6); + assert_eq!(joined.nodes.len(), 6); + } } diff --git a/packages/pangraph/tests/itest_merge.rs b/packages/pangraph/tests/itest_merge.rs index 717a0a45..04fbf286 100644 --- a/packages/pangraph/tests/itest_merge.rs +++ b/packages/pangraph/tests/itest_merge.rs @@ -30,6 +30,13 @@ mod tests { Ok((fastas, right)) } + /// Reads the first `n` records of a FASTA file. + fn read_records(path: &str, n: usize) -> Result, Report> { + let mut fastas = FastaReader::from_paths(&[PathBuf::from(path)])?.read_many()?; + fastas.truncate(n); + Ok(fastas) + } + /// Builds a graph out of the given records and writes it to a JSON file in `dir`. fn build_graph_file(dir: &TempDir, name: &str, fastas: Vec) -> Result { let args = PangraphBuildArgs { @@ -134,6 +141,32 @@ mod tests { Ok(()) } + /// Appending to a graph that is itself the result of an earlier merge. Identifiers relabeled by + /// the first merge survive into its output whenever a block or node finds no homologue, and a + /// constant relabeling salt then re-derived the third graph's identifiers onto exactly those + /// values, so the second append always failed. The three genomes here are mutually unrelated, so + /// nothing aligns and every identifier survives; homologous appends never hit this. + #[rstest] + fn itest_merge_appends_to_an_already_merged_graph() -> Result<(), Report> { + let dir = tempdir()?; + + let first = build_graph_file(&dir, "first.json", read_records("../../data/flu-h1.fa", 2)?)?; + let second = build_graph_file(&dir, "second.json", read_records("../../data/sc2.fa", 1)?)?; + let third = build_graph_file(&dir, "third.json", read_records("../../data/mpox.fa", 1)?)?; + + let merged_once = dir.path().join("merged-once.json"); + merge_run(&merge_args(first, second, merged_once.clone()))?; + assert_eq!(read_graph(&merged_once)?.paths.len(), 3); + + let merged_twice = dir.path().join("merged-twice.json"); + merge_run(&merge_args(merged_once, third, merged_twice.clone()))?; + + let merged = read_graph(&merged_twice)?; + assert_eq!(merged.paths.len(), 4); + + Ok(()) + } + /// Merging a graph with itself duplicates every genome name, and must be rejected. #[rstest] fn itest_merge_rejects_duplicate_genome_names() -> Result<(), Report> { From 2d77cce3fb247ae8d0a3c10074190e84b9b7f6f2 Mon Sep 17 00:00:00 2001 From: Marco Molari Date: Fri, 14 Aug 2026 18:04:33 +0200 Subject: [PATCH 3/5] fix(cli): keep non-alignment options out of the Alignment help section --- CHANGELOG.md | 1 + docs/docs/reference.md | 14 +++++++------- packages/pangraph/src/commands/build/build_args.rs | 8 +++++--- packages/pangraph/src/commands/merge/merge_args.rs | 8 +++++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14078002..f4d07e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - added `pangraph merge` command, to combine two existing pangenome graphs into a single one, see #192. - `pangraph build` now accepts a single input sequence, and rejects inputs with duplicate genome names. - `pangraph reconstruct --verify` now matches sequences by genome name rather than by position, see #193. +- in `pangraph build --help`, the `--circular`, `--verify`, `--no-progress-bar` and `--guide-tree` options are no longer listed under the "Alignment" section. ## 1.3.0 diff --git a/docs/docs/reference.md b/docs/docs/reference.md index ffa6bc09..e9572c6b 100644 --- a/docs/docs/reference.md +++ b/docs/docs/reference.md @@ -104,6 +104,12 @@ Align genomes into a multiple sequence alignment graph Use "-" to write the uncompressed data to standard output (stdout). This is the default, if the argument is not provided. Default value: `-` +* `-c`, `--circular` — Toggle if input genomes are circular +* `-f`, `--verify` — Sanity check: after construction verifies that the original sequences can be reconstructed exactly from the resulting pangraph. Raises an error otherwise +* `--no-progress-bar` — Toggle to disable progress bar. Notice that the progress bar is only displayed if the output is specified via the `-o` argument +* `--guide-tree ` — Path to a Newick-format guide tree to use instead of the default neighbor-joining tree. + + When provided, the tree's topology drives the bottom-up graph-merging order. Each input FASTA sequence must appear exactly once as a leaf (matched by sequence name), and every internal node must be strictly bifurcating. Branch lengths and internal labels, if present, are ignored. Accepts plain or compressed files (gz, bz2, xz, zst). * `-l`, `--len ` — Minimum block size for alignment graph (in nucleotides) Default value: `100` @@ -134,12 +140,6 @@ Align genomes into a multiple sequence alignment graph * `--max-alignment-attempts ` — For within-block alignment: number of times Nextclade will retry alignment with more relaxed results if alignment band boundaries are hit Default value: `4` -* `-c`, `--circular` — Toggle if input genomes are circular -* `-f`, `--verify` — Sanity check: after construction verifies that the original sequences can be reconstructed exactly from the resulting pangraph. Raises an error otherwise -* `--no-progress-bar` — Toggle to disable progress bar. Notice that the progress bar is only displayed if the output is specified via the `-o` argument -* `--guide-tree ` — Path to a Newick-format guide tree to use instead of the default neighbor-joining tree. - - When provided, the tree's topology drives the bottom-up graph-merging order. Each input FASTA sequence must appear exactly once as a leaf (matched by sequence name), and every internal node must be strictly bifurcating. Branch lengths and internal labels, if present, are ignored. Accepts plain or compressed files (gz, bz2, xz, zst). @@ -169,6 +169,7 @@ Merge two pangenome graphs into a single one Use "-" to write the uncompressed data to standard output (stdout). This is the default, if the argument is not provided. Default value: `-` +* `-f`, `--verify` — Sanity check: after merging verifies that every genome of the two input graphs can still be reconstructed exactly from the merged graph. Raises an error otherwise * `-l`, `--len ` — Minimum block size for alignment graph (in nucleotides) Default value: `100` @@ -199,7 +200,6 @@ Merge two pangenome graphs into a single one * `--max-alignment-attempts ` — For within-block alignment: number of times Nextclade will retry alignment with more relaxed results if alignment band boundaries are hit Default value: `4` -* `-f`, `--verify` — Sanity check: after merging verifies that every genome of the two input graphs can still be reconstructed exactly from the merged graph. Raises an error otherwise diff --git a/packages/pangraph/src/commands/build/build_args.rs b/packages/pangraph/src/commands/build/build_args.rs index aa490a51..850fb5ce 100644 --- a/packages/pangraph/src/commands/build/build_args.rs +++ b/packages/pangraph/src/commands/build/build_args.rs @@ -29,9 +29,6 @@ pub struct PangraphBuildArgs { #[clap(value_hint = ValueHint::AnyPath)] pub output_json: PathBuf, - #[clap(flatten)] - pub merge_params: GraphMergeParams, - /// Toggle if input genomes are circular #[clap(long, short = 'c')] pub circular: bool, @@ -52,4 +49,9 @@ pub struct PangraphBuildArgs { /// are ignored. Accepts plain or compressed files (gz, bz2, xz, zst). #[clap(long, value_hint = ValueHint::FilePath)] pub guide_tree: Option, + + // Declared last: it opens the "Alignment" help section, and `next_help_heading` applies to every + // argument declared after it. + #[clap(flatten)] + pub merge_params: GraphMergeParams, } diff --git a/packages/pangraph/src/commands/merge/merge_args.rs b/packages/pangraph/src/commands/merge/merge_args.rs index 5eefe25f..6ed6e35c 100644 --- a/packages/pangraph/src/commands/merge/merge_args.rs +++ b/packages/pangraph/src/commands/merge/merge_args.rs @@ -36,11 +36,13 @@ pub struct PangraphMergeArgs { #[clap(value_hint = ValueHint::AnyPath)] pub output_json: PathBuf, - #[clap(flatten)] - pub merge_params: GraphMergeParams, - /// Sanity check: after merging verifies that every genome of the two input graphs can still be /// reconstructed exactly from the merged graph. Raises an error otherwise. #[clap(long, short = 'f')] pub verify: bool, + + // Declared last: it opens the "Alignment" help section, and `next_help_heading` applies to every + // argument declared after it. + #[clap(flatten)] + pub merge_params: GraphMergeParams, } From e6bb9e9b0a50d31cb7e129298b5cc43b4302186c Mon Sep 17 00:00:00 2001 From: Marco Molari Date: Fri, 14 Aug 2026 18:04:33 +0200 Subject: [PATCH 4/5] refactor(merge): verify merged genomes one at a time --- .../pangraph/src/commands/merge/merge_run.rs | 60 +++---- packages/pangraph/src/pangraph/reconstruct.rs | 146 +++++++++++++++--- 2 files changed, 149 insertions(+), 57 deletions(-) diff --git a/packages/pangraph/src/commands/merge/merge_run.rs b/packages/pangraph/src/commands/merge/merge_run.rs index 1e4bd04e..22343ec9 100644 --- a/packages/pangraph/src/commands/merge/merge_run.rs +++ b/packages/pangraph/src/commands/merge/merge_run.rs @@ -5,14 +5,13 @@ use crate::make_error; use crate::pangraph::graph_merging::merge_graphs; use crate::pangraph::pangraph::Pangraph; use crate::pangraph::pangraph_path::PangraphPath; -use crate::pangraph::reconstruct::{GenomeCoverage, reconstruct_by_name, verify_graph_sequences}; -use crate::representation::seq::Seq; +use crate::pangraph::reconstruct::{path_ids_by_name, verify_graph_against_graphs}; use crate::utils::collections::find_duplicates; use color_eyre::owo_colors::{AnsiColors, OwoColorize}; use color_eyre::{Help, SectionExt}; use eyre::{Report, WrapErr}; use log::{info, warn}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::path::Path; pub fn merge_run(args: &PangraphMergeArgs) -> Result<(), Report> { @@ -27,13 +26,6 @@ pub fn merge_run(args: &PangraphMergeArgs) -> Result<(), Report> { .make_disjoint_from(&left) .wrap_err("When making the identifiers of the two input graphs disjoint")?; - // Reconstruct the expected genomes from the (relabeled) inputs, before they are consumed by the - // merger. Keyed by path name: neither path ids nor record order survive a merge. - let expected = args - .verify - .then(|| expected_sequences(args, &left, &right)) - .transpose()?; - info!( "=== Graph merging start: graph sizes {} + {}", left.paths.len(), @@ -50,13 +42,18 @@ pub fn merge_run(args: &PangraphMergeArgs) -> Result<(), Report> { merged.blocks.len() ); - if let Some(expected) = expected { + if args.verify { #[cfg(debug_assertions)] merged.sanity_check().wrap_err("When checking the merged graph")?; - verify_graph_sequences(&merged, &expected, GenomeCoverage::Complete) + // Compared against the inputs one genome at a time: reconstructing both graphs up front would + // hold their entire sequence content in memory for the duration of the check. + verify_graph_against_graphs(&merged, &[&left, &right]) .wrap_err("When verifying the sequences of the merged graph")?; - info!("Merged graph reconstructs all {} input genomes exactly", expected.len()); + info!( + "Merged graph reconstructs all {} input genomes exactly", + merged.paths.len() + ); } json_write_file(&args.output_json, &merged, JsonPretty(true))?; @@ -95,6 +92,20 @@ fn merge_cmd_preliminary_checks(args: &PangraphMergeArgs, left: &Pangraph, right ); } + // Verification matches genomes by name, so it needs every path of both inputs to carry one. + // Checked here rather than at verification time so that it fails before the expensive merge. + if args.verify { + for (graph, filepath) in [(left, &args.left_graph), (right, &args.right_graph)] { + path_ids_by_name(graph) + .wrap_err_with(|| format!("When resolving the genome names of graph '{}'", filepath.display())) + .with_section(|| { + "Verification matches genomes by name. Re-run without `--verify` to skip it." + .color(AnsiColors::Cyan) + .header("Suggestion:") + })?; + } + } + // Circularity is a per-path property, so mixing is structurally fine. It is however most often a // mistake, since `build --circular` applies to all genomes of a graph at once. if circularity(left) != circularity(right) { @@ -110,26 +121,3 @@ fn merge_cmd_preliminary_checks(args: &PangraphMergeArgs, left: &Pangraph, right fn circularity(graph: &Pangraph) -> BTreeSet { graph.paths().map(PangraphPath::circular).collect() } - -/// Reconstructs the genomes of both input graphs, keyed by genome name. -/// -/// Cross-graph name collisions are already rejected by `merge_cmd_preliminary_checks`, so the two -/// sets cannot overwrite each other here. -fn expected_sequences( - args: &PangraphMergeArgs, - left: &Pangraph, - right: &Pangraph, -) -> Result, Report> { - let mut expected = BTreeMap::new(); - for (graph, filepath) in [(left, &args.left_graph), (right, &args.right_graph)] { - let genomes = reconstruct_by_name(graph) - .wrap_err_with(|| format!("When reconstructing the genomes of graph '{}'", filepath.display())) - .with_section(|| { - "Verification matches genomes by name. Re-run without `--verify` to skip it." - .color(AnsiColors::Cyan) - .header("Suggestion:") - })?; - expected.extend(genomes); - } - Ok(expected) -} diff --git a/packages/pangraph/src/pangraph/reconstruct.rs b/packages/pangraph/src/pangraph/reconstruct.rs index 02bd8059..b475cecb 100644 --- a/packages/pangraph/src/pangraph/reconstruct.rs +++ b/packages/pangraph/src/pangraph/reconstruct.rs @@ -9,7 +9,7 @@ use crate::utils::string::str_slice_safe; use crate::{make_error, make_internal_report}; use eyre::{Report, WrapErr}; use itertools::Itertools; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// Number of genome names listed in full in an error message before the rest are elided. const MAX_NAMES_IN_ERROR: usize = 10; @@ -105,20 +105,6 @@ pub fn reconstruct_genome(graph: &Pangraph, path_id: PathId) -> Result Result, Report> { - path_ids_by_name(graph)? - .into_iter() - .map(|(name, path_id)| { - let seq = - reconstruct_genome(graph, path_id).wrap_err_with(|| format!("When reconstructing the genome of '{name}'"))?; - Ok((name.to_owned(), seq)) - }) - .collect() -} - /// Collects FASTA records into genome sequences keyed by name. /// /// Errors on duplicate names: the name is the key that verification matches on. @@ -212,6 +198,63 @@ pub fn verify_graph_sequences( Ok(()) } +/// Checks that `graph` reconstructs exactly the genomes of `sources`, matched by name. +/// +/// Used to verify a merged graph against the graphs it was built from. Both sides of every +/// comparison are reconstructed on demand and dropped again, so this holds two genomes at a time +/// instead of the whole sequence content of `sources`. The saving is proportional to the total +/// genome length and modest in practice — peak usage during a merge is dominated by the graphs +/// themselves — but it keeps verification consistent with `reconstruct --verify`, which streams for +/// the same reason. +/// +/// Every genome of `sources` must appear in `graph`, and `graph` must contain nothing else. The +/// `sources` are expected to have disjoint genome names; a name appearing in two of them is +/// verified twice rather than reported, since the callers reject that case up front. +pub fn verify_graph_against_graphs(graph: &Pangraph, sources: &[&Pangraph]) -> Result<(), Report> { + let path_ids = path_ids_by_name(graph)?; + let mut n_expected = 0; + + for source in sources { + for (name, source_path_id) in path_ids_by_name(source)? { + let Some(path_id) = path_ids.get(name) else { + return make_error!("Graph is missing genome '{name}', which is present in the input graphs"); + }; + + let expected = reconstruct_genome(source, source_path_id) + .wrap_err_with(|| format!("When reconstructing genome '{name}' from the input graphs"))?; + let actual = + reconstruct_genome(graph, *path_id).wrap_err_with(|| format!("When reconstructing genome '{name}'"))?; + + verify_genome(name, &expected, &actual)?; + n_expected += 1; + } + } + + if path_ids.len() != n_expected { + let expected_names: BTreeSet<&str> = sources + .iter() + .map(|source| path_ids_by_name(source)) + .collect::, Report>>()? + .into_iter() + .flat_map(|ids| ids.into_keys()) + .collect(); + + let extra = path_ids + .keys() + .filter(|name| !expected_names.contains(*name)) + .copied() + .collect_vec(); + + return make_error!( + "Graph contains {} genome(s) that are not present in the input graphs: {}", + extra.len(), + format_names(&extra) + ); + } + + Ok(()) +} + /// Formats a list of genome names for an error message, eliding all but the first few. fn format_names>(names: &[S]) -> String { let shown = names.iter().take(MAX_NAMES_IN_ERROR).map(AsRef::as_ref).join(", "); @@ -280,6 +323,7 @@ mod tests { use crate::pangraph::edits::Edit; use crate::pangraph::pangraph_block::{BlockId, PangraphBlock}; use crate::pangraph::pangraph_node::PangraphNode; + use crate::pangraph::strand::Strand; use crate::pangraph::strand::Strand::{Forward, Reverse}; use crate::utils::error::report_to_string; use maplit::btreemap; @@ -304,6 +348,31 @@ mod tests { Pangraph { paths, blocks, nodes } } + /// A one-genome graph, so that a pair of them stands in for the two inputs of a merge. Every id + /// is `0`, exactly as `Pangraph::singleton` assigns them, which is also what makes two of these + /// indistinguishable by id. + fn one_genome_graph(name: &str, consensus: &str, strand: Strand) -> Pangraph { + let len = consensus.len(); + let blocks = btreemap! { + BlockId(0) => PangraphBlock::new(BlockId(0), consensus, btreemap!{ NodeId(0) => Edit::empty() }), + }; + let nodes = btreemap! { + NodeId(0) => PangraphNode::new(Some(NodeId(0)), BlockId(0), PathId(0), strand, (0, len)), + }; + let paths = btreemap! { + PathId(0) => PangraphPath::new(Some(PathId(0)), [NodeId(0)], len, false, Some(name.to_owned()), None), + }; + Pangraph { paths, blocks, nodes } + } + + /// The two single-genome graphs whose merger `graph()` stands for. + fn sources() -> (Pangraph, Pangraph) { + ( + one_genome_graph("a", "ACGTACGT", Forward), + one_genome_graph("b", "TTTTGGGG", Reverse), + ) + } + fn graph() -> Pangraph { two_genome_graph([Some("a"), Some("b")]) } @@ -312,16 +381,10 @@ mod tests { btreemap! { o!("a") => Seq::from_str("ACGTACGT"), o!("b") => Seq::from_str("CCCCAAAA") } } - #[rstest] - fn test_reconstruct_by_name() { - assert_eq!(reconstruct_by_name(&graph()).unwrap(), expected_genomes()); - } - #[rstest] fn test_path_ids_by_name_rejects_unnamed_path() { let graph = two_genome_graph([Some("a"), None]); assert!(report_to_string(&path_ids_by_name(&graph).unwrap_err()).contains("without a name")); - assert!(report_to_string(&reconstruct_by_name(&graph).unwrap_err()).contains("without a name")); } #[rstest] @@ -415,6 +478,47 @@ mod tests { ); } + #[rstest] + fn test_verify_graph_against_graphs_accepts_exact_match() { + let (left, right) = sources(); + verify_graph_against_graphs(&graph(), &[&left, &right]).unwrap(); + } + + #[rstest] + fn test_verify_graph_against_graphs_detects_missing_genome() { + let (left, right) = sources(); + let extra = one_genome_graph("c", "GGGGCCCC", Forward); + + let err = report_to_string(&verify_graph_against_graphs(&graph(), &[&left, &right, &extra]).unwrap_err()); + assert!(err.contains("missing genome 'c'"), "unexpected error: {err}"); + } + + #[rstest] + fn test_verify_graph_against_graphs_detects_extra_genome() { + let (left, _) = sources(); + + let err = report_to_string(&verify_graph_against_graphs(&graph(), &[&left]).unwrap_err()); + assert!( + err.contains("not present in the input graphs"), + "unexpected error: {err}" + ); + assert!(err.contains('b'), "unexpected error: {err}"); + } + + /// The name matches but the sequence does not: the mismatch is reported against the source graph + /// the genome came from, with the position of the first difference. + #[rstest] + fn test_verify_graph_against_graphs_detects_mutated_genome() { + let (_, right) = sources(); + let mutated = one_genome_graph("a", "ACGTTCGT", Forward); + + let err = report_to_string(&verify_graph_against_graphs(&graph(), &[&mutated, &right]).unwrap_err()); + assert!( + err.contains("Sequence mismatch for genome 'a'"), + "unexpected error: {err}" + ); + } + #[rstest] fn test_verify_genome_reports_first_difference() { let expected = Seq::from_str("ACGTACGT"); From bad3c2b8a7be7dab7dbb0963475f4c982e8eeb8e Mon Sep 17 00:00:00 2001 From: Marco Molari Date: Fri, 14 Aug 2026 18:19:03 +0200 Subject: [PATCH 5/5] docs: simplify changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d07e56..14078002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ - added `pangraph merge` command, to combine two existing pangenome graphs into a single one, see #192. - `pangraph build` now accepts a single input sequence, and rejects inputs with duplicate genome names. - `pangraph reconstruct --verify` now matches sequences by genome name rather than by position, see #193. -- in `pangraph build --help`, the `--circular`, `--verify`, `--no-progress-bar` and `--guide-tree` options are no longer listed under the "Alignment" section. ## 1.3.0