From 2b67b975121aee50c9dac30556bc9ebb3ada311e Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 24 Jun 2026 10:54:03 -0700 Subject: [PATCH 1/5] fix: address Phase-6 dual-review findings Codec/downsample-class validation, bgzf buffer-pool cap, UMI one-unique fast path preserving invalid->None, aligner reaping, extract InvalidData, tag lookup; plus regression-test strengthening and doc corrections surfaced by review. --- crates/fgumi-bgzf/src/writer.rs | 93 ++++++++++++++-- crates/fgumi-pipeline-core/src/builder.rs | 76 ++++++++----- .../fgumi-pipeline-core/src/runtime/driver.rs | 69 ++++++++++++ crates/fgumi-raw-bam/src/tags.rs | 46 ++++---- crates/fgumi-umi/src/assigner.rs | 29 ++--- src/lib/aligner.rs | 32 +++--- src/lib/commands/codec.rs | 26 ++--- src/lib/commands/filter.rs | 68 +++++++++++- src/lib/commands/runall.rs | 10 +- src/lib/pipeline/steps/extract.rs | 31 ++++-- src/lib/pipeline/steps/source/pair_fastq.rs | 23 ++-- tests/integration/test_runall_parity.rs | 101 +++++++++++------- tests/integration/test_streaming_input.rs | 35 ++++-- 13 files changed, 470 insertions(+), 169 deletions(-) diff --git a/crates/fgumi-bgzf/src/writer.rs b/crates/fgumi-bgzf/src/writer.rs index b680892ad..f984fc0b4 100644 --- a/crates/fgumi-bgzf/src/writer.rs +++ b/crates/fgumi-bgzf/src/writer.rs @@ -216,12 +216,22 @@ impl InlineBgzfCompressor { /// /// Returns an error if writing to the output fails. pub fn write_blocks_to(&mut self, output: &mut W) -> io::Result<()> { - for block in self.completed_blocks.drain(..) { - output.write_all(&block.data)?; - // Recycle the buffer for reuse - let mut buf = block.data; - buf.clear(); - self.buffer_pool.push(buf); + // Drain into a temporary so we can call `recycle_buffer` (which borrows + // `self` mutably) for each block without holding a borrow on + // `self.completed_blocks`. On a write error, restore the unwritten block + // and the remaining tail to `completed_blocks` so the queue is never + // silently emptied — a caller can retry or surface the partial state. + let mut remaining = std::mem::take(&mut self.completed_blocks).into_iter(); + while let Some(block) = remaining.next() { + if let Err(e) = output.write_all(&block.data) { + self.completed_blocks.push(block); + self.completed_blocks.extend(remaining); + return Err(e); + } + // Route the drained buffer through the capped recycle path so the + // pool stays bounded by MAX_POOLED_BUFFERS, just like the + // steady-state recycle path. + self.recycle_buffer(block.data); } Ok(()) } @@ -360,6 +370,77 @@ mod tests { assert_eq!(block_count, 2); } + #[test] + fn test_write_blocks_to_respects_pool_cap() { + // Draining many blocks through write_blocks_to must not grow buffer_pool + // beyond the cap enforced by recycle_buffer (MAX_POOLED_BUFFERS). + let mut compressor = InlineBgzfCompressor::new(6); + + // Produce many full blocks so there are far more drained buffers than + // the pool cap. Each full block's worth of data yields one block. + let data = vec![b'Z'; BGZF_MAX_BLOCK_SIZE * 20]; + compressor.write_all(&data).expect("writing data should succeed"); + compressor.flush().expect("flushing compressor should succeed"); + + let mut output = Vec::new(); + compressor.write_blocks_to(&mut output).expect("writing blocks to output should succeed"); + + // With 20 drained blocks against a cap of 4, the pool must end *exactly* + // at the cap. A `<= 4` check alone would also pass if `write_blocks_to` + // stopped recycling and just dropped the drained buffers (the pool would + // stay at 0), so the equality assertion pins both postconditions at once: + // bounded growth (not > 4) AND actual reuse (not < 4 / lost recycling). + assert_eq!( + compressor.buffer_pool.len(), + 4, + "buffer_pool should be filled to the cap by recycling, got {}", + compressor.buffer_pool.len() + ); + } + + #[test] + fn test_write_blocks_to_preserves_unwritten_blocks_on_error() { + // A writer that accepts the first `write` and then fails. Each block is + // emitted with a single `write_all` (the writer returns the full length + // each time), so the failure lands partway through the block queue. + struct FailAfterFirst { + writes: usize, + } + impl io::Write for FailAfterFirst { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.writes += 1; + if self.writes > 1 { + return Err(io::Error::other("simulated write failure")); + } + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let mut compressor = InlineBgzfCompressor::new(6); + // Produce several full blocks so the failure has unwritten blocks to drop. + let data = vec![b'Q'; BGZF_MAX_BLOCK_SIZE * 3]; + compressor.write_all(&data).expect("writing data should succeed"); + compressor.flush().expect("flushing compressor should succeed"); + let serials: Vec = + compressor.completed_blocks.iter().map(|block| block.serial).collect(); + assert!(serials.len() >= 3, "expected multiple blocks, got {}", serials.len()); + + let mut output = FailAfterFirst { writes: 0 }; + let err = + compressor.write_blocks_to(&mut output).expect_err("write must surface the error"); + assert_eq!(err.kind(), io::ErrorKind::Other); + + // Only the first block (successfully written) was consumed; the block + // that failed plus every block after it must survive in the queue, in + // their original order, so a retry/recovery path can re-emit them. + let remaining: Vec = + compressor.completed_blocks.iter().map(|block| block.serial).collect(); + assert_eq!(remaining, serials[1..].to_vec()); + } + #[test] fn test_write_blocks_to_equivalence() { // Test that write_blocks_to produces same output as take_blocks diff --git a/crates/fgumi-pipeline-core/src/builder.rs b/crates/fgumi-pipeline-core/src/builder.rs index 3907d075d..f61580cee 100644 --- a/crates/fgumi-pipeline-core/src/builder.rs +++ b/crates/fgumi-pipeline-core/src/builder.rs @@ -1924,19 +1924,24 @@ mod tests { } } - /// Byte-bounded twin of [`SharedCountingSource`] for the monitor-armed - /// fail-fast test. The deadlock monitor's `in_flight_bytes` probe only sees - /// `ByteBounded` transports, and `assert_monitor_visible_transports` (a - /// debug-build invariant) rejects an `Unbounded`/`CountBounded` source on a - /// monitor-armed run. Using this source lets - /// `pipeline_run_reraises_worker_panic_with_monitor_enabled` actually reach - /// the monitor startup/shutdown path instead of tripping that assertion - /// first (and passing for the wrong reason). + /// Byte-bounded variant of [`SharedCountingSource`]. Identical claim/push + /// logic but declares a `ByteBounded` output transport so it is + /// monitor-visible — required by any test that arms the deadlock monitor + /// (`deadlock_timeout_secs > 0` + stats), which asserts every output edge is + /// `ByteBounded` (see `assert_monitor_visible_transports`) before workers + /// spawn. Using the `Unbounded` source there would trip that debug-assert + /// before the worker-panic path is ever reached. #[derive(Clone)] - struct SharedCountingByteBoundedSource { + struct SharedCountingSourceByteBounded { remaining: Arc, - } - impl Step for SharedCountingByteBoundedSource { + /// A value claimed from `remaining` but not yet accepted by the output + /// (the byte-bounded push hit backpressure). Held per-worker and retried + /// on a later tick so the exact ordinal survives — rolling `remaining` + /// back instead would let a peer re-claim the count and drop/duplicate an + /// ordinal under contention. + pending: Option, + } + impl Step for SharedCountingSourceByteBounded { type Input = (); type Outputs = Single; fn profile(&self) -> StepProfile { @@ -1944,14 +1949,23 @@ mod tests { name: "SharedSourceByteBounded", kind: StepKind::Parallel, sticky: false, - // ByteBounded so the monitor probe can see this edge; sized large - // enough that the handful of items pushed before the sink panics - // never hit backpressure. output_queues: vec![QueueSpec::ByteBounded { limit_bytes: 1 << 20 }], branch_ordering: vec![BranchOrdering::None], } } fn try_run(&mut self, ctx: &mut StepCtx<'_, Self>) -> std::io::Result { + // First flush any value claimed on a prior tick whose push was + // rejected by backpressure. Retrying the exact held ordinal (rather + // than rolling `remaining` back) keeps the emitted set a clean + // permutation of `1..=N` even under contention. + if let Some(n) = self.pending { + return if ctx.outputs.push(n).is_ok() { + self.pending = None; + Ok(StepOutcome::Progress) + } else { + Ok(StepOutcome::NoProgress) + }; + } let n = self.remaining.load(AtomicOrd::Acquire); if n == 0 { return Ok(StepOutcome::Finished); @@ -1961,13 +1975,19 @@ mod tests { .compare_exchange(n, n - 1, AtomicOrd::AcqRel, AtomicOrd::Acquire) .is_ok() { - // ByteBounded can reject under backpressure: hand the claimed - // item's budget back and retry next dispatch rather than erroring. - if ctx.outputs.push(n).is_err() { - self.remaining.fetch_add(1, AtomicOrd::AcqRel); - return Ok(StepOutcome::NoProgress); + // Byte-bounded push can hit backpressure; hold the claimed + // ordinal and retry it on a later tick. Holding a claimed item + // counts as progress per the `StepOutcome` contract ("pushed or + // held an item" — see `step.rs`) and matches the production + // held-slot sources (`sort::merge` / `sort::and_spill`), so the + // scheduler's deadlock accounting sees the claim as forward + // motion rather than a stall. + if ctx.outputs.push(n).is_ok() { + Ok(StepOutcome::Progress) + } else { + self.pending = Some(n); + Ok(StepOutcome::Progress) } - Ok(StepOutcome::Progress) } else { Ok(StepOutcome::NoProgress) } @@ -2110,14 +2130,20 @@ mod tests { // and joined — rather than deadlocking the join loop or leaking the // helper thread. The panicking worker signals cancellation so any wedged // peer observes `is_done()` and exits, letting every join complete. + // + // The source MUST be byte-bounded: arming the monitor (stats + + // non-zero timeout) runs `assert_monitor_visible_transports`, which + // debug-asserts every output edge is `ByteBounded`. An `Unbounded` + // source would trip that assert before any worker is spawned, so the + // test would "pass" on the wrong panic and never exercise the + // worker-panic deferral path it is meant to cover. let remaining = Arc::new(AtomicU32::new(1_000)); let builder = PipelineBuilder::new(); - // ByteBounded source so the monitor-visible-transports invariant holds and - // the test reaches the real monitor startup/shutdown path (a plain - // `SharedCountingSource` declares an `Unbounded` edge, which the debug - // `assert_monitor_visible_transports` would reject before the monitor runs). builder - .chain(SharedCountingByteBoundedSource { remaining: Arc::clone(&remaining) }) + .chain(SharedCountingSourceByteBounded { + remaining: Arc::clone(&remaining), + pending: None, + }) .chain(PanickingSink) .into_sink_marker(); let pipeline = builder.build().unwrap(); diff --git a/crates/fgumi-pipeline-core/src/runtime/driver.rs b/crates/fgumi-pipeline-core/src/runtime/driver.rs index 0e7f84d4e..2f59230e4 100644 --- a/crates/fgumi-pipeline-core/src/runtime/driver.rs +++ b/crates/fgumi-pipeline-core/src/runtime/driver.rs @@ -427,6 +427,32 @@ mod tests { } } + /// `() → u32` source that returns `NoProgress` on its first `try_run` (so + /// the sticky fast-path yields back to round-robin without removing it) and + /// `Finished` on every later call (so it is removed during the round-robin + /// pass, exercising `RoundRobinOutcome::removed_sticky_owner`). + #[derive(Clone)] + struct SrcIdleThenFinish { + calls: Arc, + } + impl Step for SrcIdleThenFinish { + type Input = (); + type Outputs = Single; + fn profile(&self) -> StepProfile { + StepProfile { + name: "SrcIdleThenFinish", + kind: StepKind::Exclusive, + sticky: true, + output_queues: vec![QueueSpec::CountBounded { capacity: 4 }], + branch_ordering: vec![BranchOrdering::None], + } + } + fn try_run(&mut self, _ctx: &mut StepCtx<'_, Self>) -> io::Result { + let n = self.calls.fetch_add(1, Ordering::Relaxed); + if n == 0 { Ok(StepOutcome::NoProgress) } else { Ok(StepOutcome::Finished) } + } + } + /// `u32 → u32` step that always returns `Finished`. Used both as a /// `Parallel` body (counter-gated output close) and a `Serial` body /// (`DrainGate` short-circuit). The `runs` counter records every `try_run` @@ -609,4 +635,47 @@ mod tests { // and finishes too. `run_worker_loop` must return (no hang). run_worker_loop(&mut worker, &mut entries, &contexts, &drain_counters, &signal, None); } + + /// A sticky owner that returns `NoProgress` on its first call (yielding out + /// of the sticky fast-path back to round-robin) and `Finished` later must be + /// removed via the round-robin path (`RoundRobinOutcome::removed_sticky_owner`), + /// after which the next outer iteration skips the sticky re-entry. This pins + /// the round-robin removal branch (lines around `outcome.removed_sticky_owner`), + /// not just the sticky fast-path removal exercised by + /// `sticky_owner_completes_and_loop_exits`. + #[test] + fn sticky_owner_removed_via_round_robin_and_loop_exits() { + let mut graph = ChainGraph::new(); + let src = graph.register_step("SrcIdleThenFinish", 1); + let sink = graph.register_step("Sink", 0); + graph.wire(src, BranchIdx(0), sink); + + let calls = Arc::new(AtomicUsize::new(0)); + let steps: Vec> = vec![ + Box::new(TypedStep::new(SrcIdleThenFinish { calls: Arc::clone(&calls) })), + Box::new(TypedStep::new(SinkStep)), + ]; + let contexts = Arc::new(build_chain_contexts(&steps, &graph)); + + let mut entries: Vec = vec![ + WorkerStepEntry::Exclusive { step: steps.into_iter().next().unwrap() }, + WorkerStepEntry::Exclusive { step: Box::new(TypedStep::new(SinkStep)) }, + ]; + let drain_counters = vec![StepDrainCounter::new(1), StepDrainCounter::new(1)]; + let signal = PipelineSignal::new(); + let mut worker = WorkerCore::new(0, Some(src), Some(src)); + + // First sticky call → NoProgress (yield to round-robin); the source then + // returns Finished during a round-robin pass, which must remove it and + // disable the sticky fast-path so the loop terminates rather than hangs. + run_worker_loop(&mut worker, &mut entries, &contexts, &drain_counters, &signal, None); + + // The source must have been called at least twice: once idle (sticky), + // then again to Finished (round-robin). + assert!( + calls.load(Ordering::Relaxed) >= 2, + "source should idle once then finish via round-robin, got {} call(s)", + calls.load(Ordering::Relaxed) + ); + } } diff --git a/crates/fgumi-raw-bam/src/tags.rs b/crates/fgumi-raw-bam/src/tags.rs index 7febf3ba5..d37a6dce9 100644 --- a/crates/fgumi-raw-bam/src/tags.rs +++ b/crates/fgumi-raw-bam/src/tags.rs @@ -2203,33 +2203,39 @@ mod tests { } #[test] - fn test_find_two_string_tags_missing_second_returns_none() { - let aux = b"MIZ7\x00"; - let rec = make_bam_bytes(0, 0, 0, b"rea", &[], 0, -1, -1, aux); - let (mi, cb) = find_two_string_tags_in_record(&rec, SamTag::MI, SamTag::CB); - assert_eq!(mi, Some(b"7".as_ref())); - assert_eq!(cb, None); - } - - #[test] - fn test_find_two_string_tags_identical_tags_fills_both() { - // When both arguments name the same tag, the helper must stay equivalent - // to calling `find_string_tag_in_record` twice — i.e. both slots filled, - // not `(Some, None)`. + fn test_find_two_string_tags_same_tag_fills_both() { + // When the same tag is requested for both first and second, a single + // matching aux entry must populate both outputs (not just one). let aux = b"MIZ7\x00CBZACGT\x00"; let rec = make_bam_bytes(0, 0, 0, b"rea", &[], 0, -1, -1, aux); let (a, b) = find_two_string_tags_in_record(&rec, SamTag::MI, SamTag::MI); assert_eq!(a, Some(b"7".as_ref())); assert_eq!(b, Some(b"7".as_ref())); - assert_eq!(a, find_string_tag_in_record(&rec, SamTag::MI)); - assert_eq!(b, find_string_tag_in_record(&rec, SamTag::MI)); + // Both outputs must agree with an independent single-tag lookup, proving + // the same-tag case behaves like two separate `find_string_tag_in_record` + // calls (the contract the `mi_group.rs` production path relies on). + let mi = find_string_tag_in_record(&rec, SamTag::MI); + assert_eq!(a, mi, "first output must match an independent MI lookup"); + assert_eq!(b, mi, "second output must match an independent MI lookup"); - // Identical tags, both absent → both None. - let aux = b"CBZACGT\x00"; + // Same-tag MISS path: when the (identical) requested tag is absent, both + // outputs must be None — the zero-match branch of the same-tag case, + // matching two independent single-tag lookups that each miss. + let rec_without_mi = make_bam_bytes(0, 0, 0, b"rea", &[], 0, -1, -1, b"CBZACGT\x00"); + let (a_missing, b_missing) = + find_two_string_tags_in_record(&rec_without_mi, SamTag::MI, SamTag::MI); + assert_eq!(a_missing, None); + assert_eq!(b_missing, None); + assert_eq!(a_missing, find_string_tag_in_record(&rec_without_mi, SamTag::MI)); + } + + #[test] + fn test_find_two_string_tags_missing_second_returns_none() { + let aux = b"MIZ7\x00"; let rec = make_bam_bytes(0, 0, 0, b"rea", &[], 0, -1, -1, aux); - let (a, b) = find_two_string_tags_in_record(&rec, SamTag::MI, SamTag::MI); - assert_eq!(a, None); - assert_eq!(b, None); + let (mi, cb) = find_two_string_tags_in_record(&rec, SamTag::MI, SamTag::CB); + assert_eq!(mi, Some(b"7".as_ref())); + assert_eq!(cb, None); } #[test] diff --git a/crates/fgumi-umi/src/assigner.rs b/crates/fgumi-umi/src/assigner.rs index a61150c3a..1dc54ed55 100644 --- a/crates/fgumi-umi/src/assigner.rs +++ b/crates/fgumi-umi/src/assigner.rs @@ -1537,15 +1537,17 @@ impl UmiAssigner for AdjacencyUmiAssigner { let t_after_sort = std::time::Instant::now(); if umi_counts.len() == 1 { - // Exactly one *encodable* UMI, but the batch may still contain reads - // with invalid (non-encodable) UMIs. Those must stay `MoleculeId::None` - // — exactly as the general path below maps them — rather than being - // folded into the single molecule. A `vec![id; raw_umis.len()]` here - // would mis-assign e.g. the middle read of `["AAAAAA","XXXXXX","AAAAAA"]`. + // Exactly one valid unique UMI. Every *valid* read maps to the same + // single molecule ID, but reads whose UMI was invalid (recorded as + // `None` in `presort_idx_per_read`) must keep their own + // `MoleculeId::None` rather than inheriting the shared ID. let id = MoleculeId::Single(self.next_id()); return presort_idx_per_read .iter() - .map(|presort_idx| if presort_idx.is_some() { id } else { MoleculeId::None }) + .map(|presort_idx| match presort_idx { + Some(_) => id, + None => MoleculeId::None, + }) .collect(); } @@ -2287,18 +2289,19 @@ mod tests { assert_ne!(assignments[0], assignments[3], "distinct UMIs → distinct ids"); } - /// Regression: with exactly ONE unique encodable UMI the assigner takes the - /// single-unique-UMI fast path. An invalid UMI in that same batch must still - /// map to `MoleculeId::None`, not inherit the lone molecule's id. + /// Regression: when there is exactly one valid unique UMI (so the + /// `umi_counts.len() == 1` fast path is taken), reads with invalid UMIs must + /// still map to `MoleculeId::None` instead of inheriting the single shared + /// molecule id. #[test] - fn test_adjacency_invalid_umi_maps_to_none_single_unique_fast_path() { + fn test_adjacency_single_unique_umi_with_invalid_maps_invalid_to_none() { let assigner = AdjacencyUmiAssigner::new(0, 1, DEFAULT_INDEX_THRESHOLD); let umis: Vec = ["AAAAAA", "XXXXXX", "AAAAAA"].into_iter().map(str::to_string).collect(); let assignments = assigner.assign(&umis); - assert_eq!(assignments[1], MoleculeId::None, "invalid UMI → None even on the fast path"); - assert_ne!(assignments[0], MoleculeId::None, "valid AAAAAA read keeps a molecule id"); - assert_eq!(assignments[0], assignments[2], "both valid AAAAAA reads share the same id"); + assert_ne!(assignments[0], MoleculeId::None, "valid UMI → assigned id"); + assert_eq!(assignments[1], MoleculeId::None, "invalid UMI → None (fast path)"); + assert_eq!(assignments[0], assignments[2], "both valid reads share the single id"); } // ======================================================================== diff --git a/src/lib/aligner.rs b/src/lib/aligner.rs index 17b481109..cea5bdf6a 100644 --- a/src/lib/aligner.rs +++ b/src/lib/aligner.rs @@ -210,26 +210,22 @@ impl AlignerProcess { // Wait for the process to actually exit so its resources are cleaned up. let deadline = std::time::Instant::now() + Duration::from_secs(1); - let mut reaped = false; - loop { - match self.child.try_wait() { - // Child exited (we reaped it), or `try_wait` errored (e.g. ECHILD - // because the child was already reaped elsewhere). Either way the - // child is gone, not leaked — mark reaped so the warning below - // doesn't fire spuriously, then stop polling. - Ok(Some(_)) | Err(_) => { - reaped = true; - break; - } - Ok(None) => { - if std::time::Instant::now() >= deadline { - break; - } - thread::sleep(Duration::from_millis(50)); - } + // Tracks whether the loop gave up because the 1s deadline elapsed while + // the process was still alive. Only that case warrants a warning; a + // successful reap or a non-leak `try_wait` error (e.g. ECHILD) does not. + let mut deadline_exceeded = false; + // `Ok(None)` means still alive — keep polling. Any other result + // (`Ok(Some)` = reaped, or `Err` such as ECHILD = already reaped) is a + // non-leak: exit without warning. Only hitting the deadline while still + // alive sets `deadline_exceeded`. + while let Ok(None) = self.child.try_wait() { + if std::time::Instant::now() >= deadline { + deadline_exceeded = true; + break; } + thread::sleep(Duration::from_millis(50)); } - if !reaped { + if deadline_exceeded { log::warn!( "aligner process (pid {pid}) did not exit within 1s of SIGKILL; it may be \ stuck (e.g. uninterruptible I/O) and left unreaped" diff --git a/src/lib/commands/codec.rs b/src/lib/commands/codec.rs index f72a4cbef..462359384 100644 --- a/src/lib/commands/codec.rs +++ b/src/lib/commands/codec.rs @@ -199,15 +199,14 @@ impl CodecOptions { bail!("min-duplex-length must be >= 1"); } - // Validate disagreement rate. Use a range `contains` check rather than - // two `<`/`>` comparisons so `NaN` is rejected too: `NaN < 0.0` and - // `NaN > 1.0` are both false (a comparison pair would let `NaN` - // through), but `NaN` is in no range so `!contains` rejects it. - if !(0.0..=1.0).contains(&self.max_duplex_disagreement_rate) { - bail!( - "max-duplex-disagreement-rate must be between 0.0 and 1.0 (got {})", - self.max_duplex_disagreement_rate - ); + // Validate disagreement rate. Reject non-finite values (NaN/inf) first, + // since NaN comparisons are always false and would otherwise slip past + // the `0.0..=1.0` range test below. + if !self.max_duplex_disagreement_rate.is_finite() + || self.max_duplex_disagreement_rate < 0.0 + || self.max_duplex_disagreement_rate > 1.0 + { + bail!("max-duplex-disagreement-rate must be between 0.0 and 1.0"); } Ok(()) @@ -1110,13 +1109,14 @@ mod tests { #[case::disagreement_rate_high( CodecOptions { max_duplex_disagreement_rate: 1.5, ..Default::default() } )] + #[case::disagreement_rate_nan( + CodecOptions { max_duplex_disagreement_rate: f64::NAN, ..Default::default() } + )] #[case::disagreement_rate_negative( CodecOptions { max_duplex_disagreement_rate: -0.1, ..Default::default() } )] - // Regression: `NaN < 0.0` and `NaN > 1.0` are both false, so a comparison - // pair would let `NaN` through; the range `contains` check rejects it. - #[case::disagreement_rate_nan( - CodecOptions { max_duplex_disagreement_rate: f64::NAN, ..Default::default() } + #[case::disagreement_rate_inf( + CodecOptions { max_duplex_disagreement_rate: f64::INFINITY, ..Default::default() } )] fn codec_options_validate_rejects_degenerate(#[case] options: CodecOptions) { assert!( diff --git a/src/lib/commands/filter.rs b/src/lib/commands/filter.rs index c9c30d98c..e4dae006e 100644 --- a/src/lib/commands/filter.rs +++ b/src/lib/commands/filter.rs @@ -623,10 +623,10 @@ impl FilterOptions { /// Validates that parameter vectors have 1-3 values and are in valid ranges /// /// Also validates the duplex stringency-ordering invariant: when a vector - /// supplies separate AB/BA/CC values, the more-stringent value must come - /// first. + /// supplies separate CC/AB/BA values (indexed as `[0]` = CC/duplex, + /// `[1]` = AB, `[2]` = BA), the more-stringent value must come first. /// - For min-reads: ba <= ab <= cc (more reads required = more stringent) - /// - For error rates: ab <= ba (lower error allowed = more stringent) + /// - For error rates: cc <= ab <= ba (lower error allowed = more stringent) pub(crate) fn validate_parameters(&self) -> Result<()> { // Validate min-reads if self.min_reads.is_empty() || self.min_reads.len() > 3 { @@ -696,7 +696,19 @@ impl FilterOptions { } } - // Validate error rate ordering (AB must be more stringent or equal to BA) + // Validate error rate ordering: CC (duplex) <= AB <= BA, where the lower + // allowed error is the more-stringent floor and must come first. The + // two-value case ([0]=CC, [1]=AB) is checked here; the three-value case + // adds the AB <= BA edge below. + if self.max_read_error_rate.len() >= 2 { + let cc_error = self.max_read_error_rate[0]; + let ab_error = self.max_read_error_rate[1]; + if cc_error > ab_error { + bail!( + "max-read-error-rate for duplex (CC) must be <= AB (more stringent), got CC={cc_error} > AB={ab_error}" + ); + } + } if self.max_read_error_rate.len() >= 3 { let ab_error = self.max_read_error_rate[1]; let ba_error = self.max_read_error_rate[2]; @@ -707,6 +719,15 @@ impl FilterOptions { } } + if self.max_base_error_rate.len() >= 2 { + let cc_error = self.max_base_error_rate[0]; + let ab_error = self.max_base_error_rate[1]; + if cc_error > ab_error { + bail!( + "max-base-error-rate for duplex (CC) must be <= AB (more stringent), got CC={cc_error} > AB={ab_error}" + ); + } + } if self.max_base_error_rate.len() >= 3 { let ab_error = self.max_base_error_rate[1]; let ba_error = self.max_base_error_rate[2]; @@ -1043,6 +1064,45 @@ mod tests { assert!(cmd.options.validate_parameters().is_err()); } + #[test] + fn test_validate_read_error_rate_cc_gt_ab_rejected() { + // [0]=CC, [1]=AB: CC must be the more-stringent (lower) floor, so + // CC > AB violates the documented duplex-to-AB ordering even with only + // two values supplied (the prior code only checked the 3-value AB/BA edge). + let mut cmd = create_filter_with_paths( + PathBuf::from("input.bam"), + PathBuf::from("output.bam"), + PathBuf::from("ref.fa"), + ); + cmd.options.max_read_error_rate = vec![0.20, 0.10]; // CC=0.20 > AB=0.10 + assert!(cmd.options.validate_parameters().is_err()); + } + + #[test] + fn test_validate_base_error_rate_cc_gt_ab_rejected() { + let mut cmd = create_filter_with_paths( + PathBuf::from("input.bam"), + PathBuf::from("output.bam"), + PathBuf::from("ref.fa"), + ); + cmd.options.max_base_error_rate = vec![0.20, 0.10]; // CC=0.20 > AB=0.10 + assert!(cmd.options.validate_parameters().is_err()); + } + + #[test] + fn test_validate_error_rate_cc_le_ab_le_ba_accepted() { + // A correctly ordered CC <= AB <= BA triple (and its 2-value prefix) + // must pass for both read- and base-error-rate vectors. + let mut cmd = create_filter_with_paths( + PathBuf::from("input.bam"), + PathBuf::from("output.bam"), + PathBuf::from("ref.fa"), + ); + cmd.options.max_read_error_rate = vec![0.05, 0.10, 0.20]; + cmd.options.max_base_error_rate = vec![0.05, 0.10]; + assert!(cmd.options.validate_parameters().is_ok()); + } + #[test] fn test_validate_strand_agreement_requires_ref() { let mut cmd = create_filter_with_paths( diff --git a/src/lib/commands/runall.rs b/src/lib/commands/runall.rs index c818ba4e3..09413783d 100644 --- a/src/lib/commands/runall.rs +++ b/src/lib/commands/runall.rs @@ -459,12 +459,12 @@ pub struct RunAll { #[command(flatten)] pub correct_opts: crate::commands::correct::MultiCorrectOptions, - // ───────── aligner-side options (used only when --start-from=align-and-merge) ───────── + // ───────── aligner-side options (used only when --start-from=align) ───────── /// Per-stage aligner tuning, exposed as `--aligner::preset`, /// `--aligner::command`, `--aligner::threads`, `--aligner::chunk-size` /// via the `MultiAlignerOptions` companion struct (generated by /// `#[multi_options]` on `AlignerOptions` in `crate::aligner`). - /// Ignored when `--start-from` is not `align-and-merge`. + /// Ignored when `--start-from` is not `align`. #[command(flatten)] pub aligner_opts: crate::aligner::MultiAlignerOptions, @@ -472,7 +472,7 @@ pub struct RunAll { /// unset, the preset's binary (`bwa-mem3` / `bwa`) is found via /// `which::which()` on `PATH`. Rejected with a clear error if /// `--aligner::command` is used (command mode owns its own - /// binary). Ignored when `--start-from` is not `align-and-merge`. + /// binary). Ignored when `--start-from` is not `align`. /// /// Note: this flag is at the runall top level (`--aligner-bin`), /// NOT inside the `--aligner::*` family. The `--aligner::*` flags @@ -1193,7 +1193,7 @@ impl RunAll { // follow-up PR per the design doc. if self.methylation_mode.is_some() { bail!( - "--methylation-mode is not yet supported with --start-from align-and-merge. \ + "--methylation-mode is not yet supported with --start-from align. \ For EM-seq today, use `--aligner::command \"bwameth.py ...\"` (or \ `bwa-mem3 --methylation-mode em-seq ...`) in command mode and apply \ methylation downstream as a separate step." @@ -1208,7 +1208,7 @@ impl RunAll { "no sequence-dictionary file found next to --ref {} \ (expected `.dict` or `.dict`). \ Generate one with `samtools dict {} -o .dict` before running \ - `--start-from align-and-merge`.", + `--start-from align`.", reference.display(), reference.display(), ); diff --git a/src/lib/pipeline/steps/extract.rs b/src/lib/pipeline/steps/extract.rs index ac95a97c5..7b8534f23 100644 --- a/src/lib/pipeline/steps/extract.rs +++ b/src/lib/pipeline/steps/extract.rs @@ -42,8 +42,8 @@ use crate::template::Template; /// * `read_structures` — one per FASTQ input file, shared across workers. /// * `extract_opts` — tag-output and name-annotation options, shared across /// workers. -/// * `records_emitted` — running counter incremented with the number of -/// *templates* (not records) produced per batch. +/// * `records_emitted` — running counter incremented with the number of BAM +/// records (reads) emitted per batch. /// * `output_byte_limit` — byte-bounded queue limit for the output branch. pub fn build_extract_step( read_structures: Arc>, @@ -69,7 +69,8 @@ pub fn build_extract_step( /// the [`build_extract_step`] closure, factored out so it can be unit-tested /// directly (the closure inside `ProcessOrdered` is otherwise unreachable). /// -/// `records_emitted` is incremented by the number of templates produced. +/// `records_emitted` is incremented by the number of BAM records (reads) +/// emitted — summed across templates, not the template count. /// /// # Errors /// @@ -128,21 +129,24 @@ pub(crate) fn extract_batch( rs, &[], // No skip reasons ) - .map_err(io::Error::other)?; + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; fastq_sets.push(fastq_set); } let combined = FastqSet::combine_readsets(fastq_sets); let raw_records = make_raw_records_from_fastq_set(&combined, extract_opts) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let template = Template::from_records(raw_records) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; templates.push(template); } - let count = templates.len() as u64; + // Count emitted BAM records, not templates: each template carries one + // record per read (R1, R2, …), and the finalize hook reports "records + // emitted". Summing `templates.len()` would undercount on paired-end input. + let count: u64 = templates.iter().map(|t| t.read_count() as u64).sum(); records_emitted.fetch_add(count, Ordering::Relaxed); Ok(BamTemplateBatch::new(serial, templates)) } @@ -308,9 +312,10 @@ mod tests { } /// `extract_batch` propagates the input `batch_serial` to the output and - /// bumps `records_emitted` by the number of templates converted. + /// bumps `records_emitted` by the number of BAM records (reads) emitted, + /// not the number of templates: 3 paired templates × 2 reads each = 6. #[test] - fn extract_batch_propagates_serial_and_counts_templates() { + fn extract_batch_propagates_serial_and_counts_records() { let read_structures = vec!["5T".parse::().unwrap(); 2]; let opts = default_extract_opts(); let emitted = AtomicU64::new(0); @@ -320,7 +325,13 @@ mod tests { assert_eq!(out.ordinal(), 7, "output batch_serial must equal the input batch_serial"); assert_eq!(out.templates.len(), 3, "all templates converted"); - assert_eq!(emitted.load(Ordering::Relaxed), 3, "records_emitted bumped by template count"); + let total_records: usize = out.templates.iter().map(Template::read_count).sum(); + assert_eq!(total_records, 6, "3 paired templates yield 6 records"); + assert_eq!( + emitted.load(Ordering::Relaxed), + 6, + "records_emitted bumped by emitted record count, not template count" + ); } /// A template whose record count differs from `read_structures.len()` is a diff --git a/src/lib/pipeline/steps/source/pair_fastq.rs b/src/lib/pipeline/steps/source/pair_fastq.rs index ed2ab27f3..10356c382 100644 --- a/src/lib/pipeline/steps/source/pair_fastq.rs +++ b/src/lib/pipeline/steps/source/pair_fastq.rs @@ -648,11 +648,11 @@ mod tests { } } - /// Serial (single-consumer) sink that records each paired batch's - /// `chunk_serial` in the exact order it is emitted. A `Serial` sink — not - /// `Parallel` — is what lets the test assert true emission order: a single - /// worker drains the queue in FIFO order, so the recorded sequence is the - /// real downstream order rather than a multi-consumer interleaving. + /// Serial sink that records each paired batch's `chunk_serial` in the order + /// it is popped. Declared `Serial` (not `Parallel`) so the recorded order is + /// exactly the emission order of the upstream `Serial` `PairRawFastq` step — + /// a `Parallel` sink would let multiple workers race on `pop()` and reorder + /// the observations, masking any ordering regression in the pairing step. #[derive(Clone)] struct PairSink { seen_serials: Arc>>, @@ -757,10 +757,10 @@ mod tests { } worker.join().expect("pipeline thread panicked").expect("pipeline run returned Err"); - // Full output: one pair per serial, every serial present AND in order. - // The single-consumer `PairSink` records the true emission order, so we - // assert the sequence directly (no sort) to prove `PairRawFastq` emits - // pairs in contiguous `chunk_serial` order, not merely as a set. + // Full output: one pair per serial, every serial present AND observed in + // ascending `chunk_serial` order. The Serial `PairSink` records in pop + // order, so we assert the observed vector directly (no sorting) — an + // out-of-order emission from `PairRawFastq` would now fail the test. let serials = seen.lock().unwrap().clone(); assert_eq!( serials.len(), @@ -769,6 +769,9 @@ mod tests { serials.len() ); let expected: Vec = (0..X3_N_SERIALS).collect(); - assert_eq!(serials, expected, "every chunk_serial must be paired exactly once, in order"); + assert_eq!( + serials, expected, + "every chunk_serial must be paired exactly once and emitted in order" + ); } } diff --git a/tests/integration/test_runall_parity.rs b/tests/integration/test_runall_parity.rs index 68093547b..dc24c0fff 100644 --- a/tests/integration/test_runall_parity.rs +++ b/tests/integration/test_runall_parity.rs @@ -52,7 +52,7 @@ use crate::helpers::cli_runner::{ ParityArgs, Stage, fgumi, fgumi_binary, run_runall, run_runall_consensus_to_filter, run_staged_chain, run_standalone, run_standalone_filter, }; -use crate::helpers::parity::assert_bams_record_equivalent; +use crate::helpers::parity::{assert_bams_record_equivalent, read_bam_records}; // ────────────────────────── Fixtures ────────────────────────── // @@ -628,6 +628,27 @@ fn new_002_group_to_duplex_allow_unmapped_parity() { let r = fgumi(&group_args); assert!(r.status.success(), "staged group: {}", String::from_utf8_lossy(&r.stderr)); + // Independent intermediate check: the group stage with --allow-unmapped must + // retain the both-unmapped template (MI-tagged) BEFORE duplex runs. This + // pins the bridge/filter contract directly — without it, a regression that + // drops the unmapped template at the group stage could still pass the final + // fused-vs-staged comparison if both paths dropped it identically. + { + use noodles::sam::alignment::record::data::field::Tag; + let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); + let group_records = read_bam_records(&group_bam); + let unmapped_mi_count = group_records + .iter() + .filter(|rec| rec.flags().is_unmapped() && rec.data().get(&mi_tag).is_some()) + .count(); + assert!( + unmapped_mi_count > 0, + "group --allow-unmapped output must retain the MI-tagged unmapped template; \ + found {unmapped_mi_count} such records in {}", + group_bam.display() + ); + } + let duplex_args: Vec = vec![ "duplex".into(), "--input".into(), @@ -939,12 +960,48 @@ fn rejects_backwards_group_to_sort() { } /// S5c2-003: runall `--group::*` flag combos that standalone `fgumi group` -/// rejects must also be rejected on the fused path. Runs `runall -/// --start-from group --stop-after group` with the bad combo and asserts the -/// same error message standalone produces. +/// rejects must also be rejected on the fused path. This uses standalone +/// `fgumi group` (with the same flags, `--group::` prefix stripped) as the +/// independent ORACLE: it asserts standalone rejects the combo with +/// `expected_fragment`, then asserts runall rejects it the same way. Comparing +/// against the live standalone failure (not just a hard-coded substring) keeps +/// the two code paths aligned if the standalone error text ever changes. fn assert_runall_group_combo_rejects(extra_group_flags: &[&str], expected_fragment: &str) { let tmp = TempDir::new().unwrap(); let fixture = sorted_duplex_fixture(tmp.path()); + + // ── Oracle: standalone `fgumi group` with the un-prefixed flags. ── + let standalone_out = tmp.path().join("standalone_out.bam"); + let mut standalone_args: Vec = vec![ + "group".into(), + "--input".into(), + fixture.as_os_str().to_owned(), + "--output".into(), + standalone_out.as_os_str().to_owned(), + "--threads".into(), + "1".into(), + ]; + for f in extra_group_flags { + // Translate the runall-prefixed `--group::` into the standalone + // `--`; non-prefixed tokens (values) pass through unchanged. + let translated = f + .strip_prefix("--group::") + .map_or_else(|| (*f).to_string(), |bare| format!("--{bare}")); + standalone_args.push(translated.into()); + } + let standalone = fgumi(&standalone_args); + let standalone_stderr = String::from_utf8_lossy(&standalone.stderr); + assert!( + !standalone.status.success(), + "oracle: expected standalone group combo {extra_group_flags:?} to FAIL but it \ + succeeded; stderr={standalone_stderr}" + ); + assert!( + standalone_stderr.contains(expected_fragment), + "oracle: standalone stderr did not contain {expected_fragment:?}; got: {standalone_stderr}" + ); + + // ── Runall must reject the same combo with the same error fragment. ── let out = tmp.path().join("out.bam"); let mut args: Vec = vec![ "runall".into(), @@ -971,40 +1028,8 @@ fn assert_runall_group_combo_rejects(extra_group_flags: &[&str], expected_fragme let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains(expected_fragment), - "runall stderr did not contain {expected_fragment:?}; got: {stderr}" - ); - - // Independent oracle: run standalone `fgumi group` with the same flags - // (de-prefixed from `--group::*`). Standalone must reject the combo with the - // same message, so this pins runall↔standalone validation parity rather than - // trusting a hard-coded fragment that would silently drift if standalone's - // wording or behavior changed. - let standalone_out = tmp.path().join("standalone.bam"); - let mut group_args: Vec = vec![ - "group".into(), - "--input".into(), - fixture.as_os_str().to_owned(), - "--output".into(), - standalone_out.as_os_str().to_owned(), - "--threads".into(), - "1".into(), - ]; - for f in extra_group_flags { - group_args.push( - f.strip_prefix("--group::") - .map_or_else(|| (*f).into(), |bare| format!("--{bare}").into()), - ); - } - let standalone = fgumi(&group_args); - assert!( - !standalone.status.success(), - "expected standalone group {extra_group_flags:?} to FAIL but it succeeded; stderr={}", - String::from_utf8_lossy(&standalone.stderr) - ); - let standalone_stderr = String::from_utf8_lossy(&standalone.stderr); - assert!( - standalone_stderr.contains(expected_fragment), - "standalone group stderr did not contain {expected_fragment:?}; got: {standalone_stderr}" + "runall stderr did not contain {expected_fragment:?} (the standalone oracle rejected \ + with it); got: {stderr}" ); } diff --git a/tests/integration/test_streaming_input.rs b/tests/integration/test_streaming_input.rs index 609cafc4c..557648984 100644 --- a/tests/integration/test_streaming_input.rs +++ b/tests/integration/test_streaming_input.rs @@ -15,7 +15,6 @@ use std::process::{Command, Stdio}; use tempfile::TempDir; use crate::helpers::bam_generator::{create_minimal_header, create_umi_family, to_record_buf}; -use crate::helpers::parity::assert_bams_record_equivalent; /// Test that the group command works correctly with piped input. #[test] @@ -188,12 +187,34 @@ fn test_downsample_command_with_piped_input() { assert!(output.status.success(), "downsample with piped input failed; stderr: {stderr}"); assert!(output_bam.exists(), "Output BAM from pipe not created"); - // `--fraction 1.0` is an identity transform, so the piped output must hold - // the same records, in the same order, as the input sorted BAM. A header- - // only or truncated BAM (which the success + existence checks above would - // still accept) fails here — this validates stdin *correctness*, not just - // that the upfront existence check was skipped for `-`. - assert_bams_record_equivalent(&sorted_bam, &output_bam); + // Oracle: run the SAME downsample directly against the sorted file (no + // stdin). With `--fraction 1.0` and a fixed seed, the stdin path must + // produce a byte-equivalent record stream to the direct-file path — + // `output_bam.exists()` alone would pass even if the stdin path silently + // dropped or reordered records. + let direct_out = temp_dir.path().join("direct_out.bam"); + let direct = Command::new(env!("CARGO_BIN_EXE_fgumi")) + .args([ + "downsample", + "--input", + sorted_bam.to_str().unwrap(), + "--output", + direct_out.to_str().unwrap(), + "--fraction", + "1.0", + "--seed", + "42", + "--compression-level", + "1", + ]) + .output() + .expect("Failed to run downsample with direct file input"); + assert!( + direct.status.success(), + "downsample with direct input failed; stderr: {}", + String::from_utf8_lossy(&direct.stderr) + ); + crate::helpers::parity::assert_bams_record_equivalent(&output_bam, &direct_out); } /// Test simplex command with piped input. From 6ae3053a61aeeb3aa8aedfed7cf1d1b142a2ee9e Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 24 Jun 2026 10:54:29 -0700 Subject: [PATCH 2/5] docs: campaign-wide doc, CHANGELOG, and comment sweep Correct stale module/doc comments across pipeline-core, sort, chains, and tests; reconcile config/memory wording; update CHANGELOG (incl. the feature-collapse entry) and the user guide; drop stale pipeline literals. --- .github/workflows/check.yml | 9 ++- .gitignore | 3 + CHANGELOG.md | 7 +- CLAUDE.md | 2 +- benches/core_functions.rs | 38 +++++++-- crates/fgumi-consensus/src/duplex_caller.rs | 3 +- crates/fgumi-pipeline-core/src/builder.rs | 79 +++++++++++-------- crates/fgumi-pipeline-core/src/handles.rs | 16 ++-- crates/fgumi-pipeline-core/src/held.rs | 5 ++ crates/fgumi-pipeline-core/src/queues.rs | 13 +-- crates/fgumi-pipeline-core/src/reorder.rs | 7 +- .../fgumi-pipeline-core/src/runtime/fused.rs | 9 ++- .../fgumi-pipeline-core/src/runtime/pool.rs | 12 +-- .../fgumi-pipeline-core/src/runtime/stats.rs | 6 +- .../src/runtime/worker_core.rs | 3 +- crates/fgumi-pipeline-core/src/signal.rs | 28 +++++++ crates/fgumi-pipeline-core/src/step.rs | 5 +- .../fgumi-pipeline-io/src/sink/write_bgzf.rs | 2 +- crates/fgumi-pipeline-io/src/sort/merge.rs | 3 +- crates/fgumi-pipeline-io/src/sort/protocol.rs | 12 ++- .../fgumi-pipeline-io/src/source/read_bam.rs | 8 ++ crates/fgumi-sort-cli/Cargo.toml | 1 - crates/fgumi-sort-cli/src/sort.rs | 12 +-- crates/fgumi-sort/src/worker_pool.rs | 16 ++-- crates/fgumi-umi/src/assigner.rs | 3 +- docs/design/deterministic-mi-numbering.md | 3 +- docs/simulate-cli.md | 13 ++- docs/src/guide/best-practices.md | 25 +++++- docs/src/guide/performance-tuning.md | 21 +++-- src/lib/aligner.rs | 18 ++--- src/lib/commands/common.rs | 4 +- src/lib/commands/correct.rs | 2 +- src/lib/commands/filter.rs | 2 +- src/lib/commands/runall.rs | 3 - src/lib/commands/simulate/mod.rs | 2 +- src/lib/commands/zipper.rs | 6 +- src/lib/pipeline/chains/build.rs | 3 +- src/lib/pipeline/chains/builder.rs | 67 ++++++++-------- src/lib/pipeline/chains/validate.rs | 7 +- src/lib/pipeline/steps/coalesce.rs | 5 +- src/lib/pipeline/steps/correct/mod.rs | 10 +-- src/lib/pipeline/steps/process.rs | 14 +++- src/lib/pipeline/steps/roundtrip.rs | 4 + src/lib/pipeline/steps/serialize_processed.rs | 7 ++ src/lib/pipeline/steps/source/read_fastq.rs | 3 + src/lib/validation.rs | 8 +- tests/integration/helpers/assertions.rs | 1 - tests/integration/helpers/bam_generator.rs | 46 ++++++++++- tests/integration/helpers/cli_runner.rs | 9 +-- tests/integration/test_codec_command.rs | 4 +- tests/integration/test_runall_parity.rs | 68 +++++++--------- tests/integration/test_streaming_input.rs | 18 ++--- 52 files changed, 438 insertions(+), 237 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a9de13ea4..b0f587fe1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -33,10 +33,11 @@ jobs: with: tool: nextest # Install bwa + bwa-mem3 from bioconda for the AAM - # (--start-from align-and-merge) integration tests. Local dev - # without these binaries gets gracefully-skipped tests via - # `which::which` checks in the test code; CI installs them so - # the real-aligner parity tests actually exercise the chain. + # (--start-from align-and-merge) integration tests. The real-aligner + # parity tests are `#[ignore]`'d (they need bwa / bwa-mem3 on PATH), so + # local dev without these binaries simply does not run them; CI installs + # them here and runs the ignored set explicitly below so the chain is + # actually exercised. - name: Install aligners (bwa-mem3, bwa) from bioconda uses: mamba-org/setup-micromamba@06375d89d211a1232ef63355742e9e2e564bc7f7 # v2.0.7 with: diff --git a/.gitignore b/.gitignore index 94b51d32f..65dbfcda6 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ docs/design/* # Tracked: cited from read_ahead.rs (S3-015 deferral); must be committed so the # code's PERF NOTE citation does not dangle. !docs/design/sort-queryname-arena-deferral.md +# Tracked: the simulate-aligner replay design doc (committed alongside the +# `simulate aligner` subcommand). +!docs/design/2026-06-18-simulate-aligner.md scripts/bench-*.sh scripts/parity-*.sh /TRACKER.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c30fcb855..f385c7658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - [**breaking**] `fgumi runall` no longer accepts the `--raw-tag`/`-t` and `--assign-tag`/`-T` flags. They were validated but never propagated into any stage — group reads the UMI from `RX` and emits `MI`, and the consensus callers read `MI`, all via shared constants — so the flags were silent no-ops that misleadingly implied configurability. Removing them frees the `-t`/`-T` short flags. There is no replacement: the UMI/assign tags are fixed (`RX`/`MI`) across the fused chain. - [**breaking**] `fgumi zipper`'s `--skip-pa-tags` flag (and `runall`'s `--zipper::skip-pa-tags`) is renamed to `--skip-tc-tags`. The flag never controlled a `pa` tag — it gates the `TC` (template-coordinate) tag added to secondary/supplementary reads — so the old name and help were misleading. Behavior is unchanged; update any scripts passing `--skip-pa-tags`. +- [**breaking**] The separate `simplex`, `duplex`, and `codec` Cargo features on the root `fgumi` crate are collapsed into a single default-on `consensus` umbrella feature, which gates all consensus calling (the `simplex`, `duplex`, `codec`, `runall`, `simplex-metrics`, and `duplex-metrics` subcommands). Default builds are unaffected. Embedders that built with a subset of the old features should use `--features consensus` (or `--no-default-features` to drop all consensus code); the granular `simplex`/`duplex`/`codec` features still exist one level down in the `fgumi-consensus` crate for reduced library builds. - `fgumi sort --write-index --order coordinate` BAM output bytes now match the corresponding `--write-index`-off run for the same input — both paths now share the same writer backend. Pre-`#330` Phase 4 the `--write-index` path used a *different* writer backend (the in-sort indexer wrote through noodles' `MultithreadedWriter` so it could track per-record virtual offsets during write) than the off path (`PooledBamWriter`); the two backends emit different BGZF block boundaries, so the BAMs diverged byte-for-byte even though both were multi-threaded. The indexer now runs as a post-write pass via `IndexBamFinalizeHook`, so the `--write-index`-on and -off runs go through the identical `PooledBamWriter` path. BAI content (record-set returned by samtools region queries) is unchanged; only the BAM's BGZF block layout differs from prior releases. - `fgumi extract` output BAM block layout may differ from prior releases. When `--threads` is unset, the typed-step framework's BGZF compression schedule produces different block boundaries than the legacy `process_singlethreaded` path. Record content (queryname + sequence + quality + tags) is unchanged. @@ -21,7 +22,9 @@ All notable changes to this project will be documented in this file. ### Features -- `fgumi runall` now supports `--start-from extract` so users can run FASTQ-to-consensus in a single fused invocation without intermediate BAM files. +- New `fgumi runall` command: runs the full FASTQ/BAM-to-consensus pipeline as a single fused invocation, streaming the stages together (no intermediate stage BAMs on disk — the in-pipeline sort may still spill temporary chunks under memory pressure). Use `--start-from`/`--stop-after` to run a sub-range of stages; per-stage tuning is exposed as prefixed flags (`--sort::max-memory`, `--group::strategy`, …). With `--start-from extract` it runs FASTQ-to-consensus end to end without intermediate stage BAM files. +- New `fgumi simulate aligner` subcommand (gated behind the `simulate` feature): a fake streaming replay aligner that replays a pre-recorded BAM, for benchmarking the `runall` align-and-merge chain without a real aligner. +- The intermediate `fgumi sort` stage now runs as an in-pipeline streaming sort within `runall` (streaming, not file-to-file), and the standalone `fgumi sort` command is re-homed into the new `fgumi-sort-cli` crate (`fgumi sort`'s CLI surface is unchanged). ### Bug Fixes @@ -30,7 +33,7 @@ All notable changes to this project will be documented in this file. ### Refactor -- Extracted the sort engine into the new `fgumi-sort` crate and the BAM-pipeline I/O layer into the new `fgumi-bam-io` crate. The main `fgumi` binary now consumes both as workspace dependencies; behavior is unchanged. +- Extracted the typed-step pipeline engine into the new `fgumi-pipeline-core` crate, the BAM-pipeline I/O layer into `fgumi-pipeline-io`, shared CLI plumbing into `fgumi-cli-common`, the multi-options proc-macro into `fgumi-cli-macros`, and the standalone sort CLI into `fgumi-sort-cli`. The main `fgumi` binary consumes these as workspace dependencies; behavior is unchanged. - Unified chain-builder refactor (`#330`): the typed-step pipeline framework is the single execution path for all multi-stage commands. `fgumi runall`'s 15 fused dispatchers collapse into a declarative `ChainSpec` consumed by `chains::build_for`; `fgumi sort --write-index` lifts to a post-pipeline `IndexBamFinalizeHook` keyed off `SinkSpec::BamWithIndex` (see Behavioural changes above for the user-visible BAM-layout consequence). Internal-only otherwise. - `fgumi extract` migrated onto the typed-step `chains::build_for` framework. The 5462-LOC custom FASTQ-pipeline framework (`unified_pipeline/fastq.rs`) is deleted; `Extract::execute` collapses to a ~25-line `ChainSpec` construction. Net: −3879 LOC. diff --git a/CLAUDE.md b/CLAUDE.md index 2aab618a2..c345e9347 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Commands implement the `Command` trait dispatched via enum: - **Thread Pooling:** Work-stealing with per-command thread optimization - **2-bit Encoding:** DNA bases packed efficiently for fast operations - **Typed-Step Pipeline Framework (`fgumi_pipeline_core`, re-exported as `pipeline::core`):** All multi-threaded commands run through the typed-step framework (`Pipeline::builder().chain(step1).chain(step2).…build().run(...)`). The execution engine lives in the `fgumi-pipeline-core` crate (`crates/fgumi-pipeline-core/`) and is re-exported as `crate::pipeline::core`; the `chains` and `steps` layers remain in the `fgumi` crate. Steps declare `StepKind` (Serial/Parallel/Exclusive), input/output handle types, and an `on_input_drained` callback for end-of-stream cleanup. The `--use-new-pipeline` flag and the legacy `run_bam_pipeline_from_reader{,_with_mi_assign}` drivers were removed in Phase 1 of issue #330; the typed-step framework is the execution mechanism for sort, group, simplex, duplex, codec, correct, zipper, clip, filter, and dedup. New commands should follow the typed-step pattern. -- **Chain-builder façade (`unified_pipeline::chains`):** Phase 2 introduced a single declarative chain-construction entry point — `chains::build_for(spec) -> Result`. Phase 3 introduced the stage-by-stage `ChainBuilder`: `build_for` validates the spec, then constructs a `ChainBuilder`, calls `add_source()`, walks `spec.stages` calling `chain.add_stage(stage, position)` for each, and finishes with `add_sink()` + `build()`. Each `add_` method (one per `Stage` variant — `add_dedup`, `add_filter`, `add_clip`, `add_sort`, `add_group`, `add_simplex`, `add_duplex`, `add_codec`, `add_correct`, `add_zipper`, `add_align`) reads `spec.stage_opts.` (already validated present), pushes the canonical step sequence via factories in `chains::commands::::build_*_step`, and registers a `FinalizeHook`. `StagePosition::{Terminal, Intermediate}` gates the serialize step so intermediate stages leave their typed output for the next `add_`. Shared config wiring (threads, deadlock_timeout, queue_memory, pipeline stats) lives in `chains::build_helpers::build_pipeline_config_for_chain`. The chain-level `StageTimingFinalizeHook` is registered by `ChainBuilder::build()`; `PipelineStatsFinalizeHook` (gated on `--pipeline-stats`) is the next hook in order. `BuiltPipeline::run()` executes the pipeline then drains hooks in registration order. **New commands MUST add: one `Stage` variant, one bag slot, one validator entry, one `add_` method, and one factory per per-stage step. Do NOT construct chains inline in command `execute` methods.** `runall::execute` routes every chain shape — including chains with an intermediate Sort — through `build_for`; `ChainBuilder::add_sort` performs the intermediate sort in-pipeline (streaming, not file-to-file), so there is no file-to-file fallback dispatcher in `commands::runall`. +- **Chain-builder façade (`pipeline::chains`):** Phase 2 introduced a single declarative chain-construction entry point — `chains::build_for(spec) -> Result`. Phase 3 introduced the stage-by-stage `ChainBuilder`: `build_for` validates the spec, then constructs a `ChainBuilder`, calls `add_source()`, walks `spec.stages` calling `chain.add_stage(stage, position)` for each, and finishes with `add_sink()` + `build()`. Each `add_` method (one per `Stage` variant — `add_dedup`, `add_filter`, `add_clip`, `add_sort`, `add_group`, `add_simplex`, `add_duplex`, `add_codec`, `add_correct`, `add_zipper`, `add_align`, `add_extract`) reads `spec.stage_opts.` (already validated present), pushes the canonical step sequence via factories in `chains::commands::::build_*_step`, and registers a `FinalizeHook`. `StagePosition::{Terminal, Intermediate}` gates the serialize step so intermediate stages leave their typed output for the next `add_`. Shared config wiring (threads, deadlock_timeout, queue_memory, pipeline stats) lives in `chains::build_helpers::build_pipeline_config_for_chain`. The chain-level `StageTimingFinalizeHook` is registered by `ChainBuilder::build()`; `PipelineStatsFinalizeHook` (gated on `--pipeline-stats`) is the next hook in order. `BuiltPipeline::run()` executes the pipeline then drains hooks in registration order. **New commands MUST add: one `Stage` variant, one bag slot, one validator entry, one `add_` method, and one factory per per-stage step. Do NOT construct chains inline in command `execute` methods.** `runall::execute` routes every chain shape — including chains with an intermediate Sort — through `build_for`; `ChainBuilder::add_sort` performs the intermediate sort in-pipeline (streaming, not file-to-file), so there is no file-to-file fallback dispatcher in `commands::runall`. - **Multi-Options Macro (`crates/fgumi-cli-macros`):** Per-stage tuning options used by both a standalone command (`fgumi sort`, `fgumi group`, …) and the fused `fgumi runall` command live in a single `Options` struct annotated with `#[multi_options("stage", "Help Heading")]`. The standalone command flattens `Options` directly so its CLI surface is unchanged (`--max-memory`, `--strategy`, …). The proc-macro generates a sibling `MultiOptions` struct that runall flattens, exposing the same fields as prefixed `--::` flags (`--sort::max-memory`, `--group::strategy`, …) grouped under a `--help` heading. The Multi struct carries a `validate(self) -> Result<Options>` method that runall calls before executing a stage; required-without-default fields (e.g. `--group::strategy`) become `Option` on the Multi side and the validator surfaces a clear "required when `` is selected" error if missing. The convention is established in `commands::{sort,group,duplex,codec}` and is the way to expose new per-stage options on runall going forward. ## Development Practices diff --git a/benches/core_functions.rs b/benches/core_functions.rs index e08413c27..5b0132148 100644 --- a/benches/core_functions.rs +++ b/benches/core_functions.rs @@ -338,7 +338,11 @@ fn bench_vanilla_consensus_caller(c: &mut Criterion) { let reads: Vec = (0..num_reads) .map(|i| { let mut read_seq = seq.clone(); - // Introduce small errors (~1% error rate) + // Introduce a single-base error on every 10th read. NOTE: this + // only fires for `i` a positive multiple of 10, so the smaller + // molecule sizes here (num_reads in {2, 3, 5}) get NO injected + // error and exercise the unanimous fast path; only num_reads + // 10 and 20 actually introduce a mismatch. if i > 0 && i % 10 == 0 { read_seq[i % read_len] = b"TGCA"[(read_seq[i % read_len] as usize) % 4]; } @@ -403,6 +407,15 @@ fn bench_vanilla_consensus_caller(c: &mut Criterion) { // ============================================================================ /// Compare using only the first 8 bytes as a u64 (O(1), wrong results, upper-bound test). +/// +/// DEGENERATE for shared-prefix name styles: names whose first 8 bytes are +/// identical (e.g. a common `read_`/`SRR…` prefix) all hash to the same `u64` +/// and compare `Equal`, so this becomes a no-op (always-`Equal`) on such inputs +/// rather than a representative comparator. It is only a meaningful O(1) +/// lower-bound for name styles that diverge within their first 8 bytes. The +/// `from_le_bytes` packing also makes the `u64` ordering byte-reversed relative +/// to lexicographic order, so it does not model real name ordering even when it +/// does discriminate. #[inline] fn compare_u64_hash(a: &[u8], b: &[u8]) -> Ordering { let a_val = if a.len() >= 8 { @@ -713,7 +726,12 @@ fn bench_queryname_comparators(c: &mut Criterion) { }, ); - // Benchmark normalize_natural_key encoding throughput + // Benchmark normalize_natural_key encoding throughput. + // + // NOTE: this measures the buffer-REUSE path — `buf` is allocated once + // and cleared per call, so the per-call allocation cost is excluded. + // Production call sites that allocate a fresh buffer per call will be + // slower than this number; treat it as a lower bound on encode cost. group.bench_with_input( BenchmarkId::new("normalize_key_encode", style), &pairs, @@ -1225,7 +1243,16 @@ fn bench_queryname_sort_strategies(c: &mut Criterion) { ); }); - // (h) Natural sort with allocation-free inline prefix + // (h) Natural sort with allocation-free inline prefix. + // + // NOTE: prefixes are pre-computed *outside* the timed loop (above), and + // the timed body is `sort_natural_with_norm_prefix` — identical to (f). + // So (h) and (f) measure the *same sort* and report the same timing by + // construction; the inline-prefix benefit is in prefix COMPUTATION + // (`normalize_prefix_inline` vs `precompute_norm_prefixes_for_sort`), + // which this bench does not time. Treat (h) as a sanity check that the + // inline-computed prefixes sort identically, not as a measurement of the + // inline-prefix speedup. group.bench_with_input( BenchmarkId::new("natural_inline_prefix", count), &(&names, &inline_prefixes), @@ -1309,8 +1336,9 @@ fn bench_queryname_sort_strategies(c: &mut Criterion) { /// `bytes_needed` widens to the full 8 bytes whenever an unmapped read is /// present, since the unmapped sentinel is `u64::MAX`) against the /// mapped-max path (`bytes_needed` sized from the largest *mapped* key, ~4–5 -/// bytes), plus a `sort_unstable_by_key` baseline. The input mixes ~24 tids -/// with a 5% unmapped tail to exercise the sentinel handling. +/// bytes), plus a `sort_unstable_by_key` baseline. The input mixes 25 tids +/// (`nref` only bounds the tid range `r % nref`; it does not affect key +/// byte-packing) with a 5% unmapped tail to exercise the sentinel handling. fn bench_coordinate_radix_sort(c: &mut Criterion) { use fgumi_sort::{ PackedCoordinateKey, RecordRef, radix_sort_record_refs, radix_sort_record_refs_with_max, diff --git a/crates/fgumi-consensus/src/duplex_caller.rs b/crates/fgumi-consensus/src/duplex_caller.rs index 85c44e86e..8772664b8 100644 --- a/crates/fgumi-consensus/src/duplex_caller.rs +++ b/crates/fgumi-consensus/src/duplex_caller.rs @@ -74,7 +74,8 @@ //! //! **Two values** `[D, M]`: //! - Single-strand consensus requires M reads per strand (both AB and BA) -//! - Final duplex must have D reads supporting it (typically D ≤ M since we need both strands) +//! - Final duplex must have D reads supporting it (D ≥ M: the total threshold +//! must be at least the per-strand threshold, as validated by the constructor) //! //! **Three values** `[D, M_AB, M_BA]`: //! - AB single-strand requires `M_AB` reads diff --git a/crates/fgumi-pipeline-core/src/builder.rs b/crates/fgumi-pipeline-core/src/builder.rs index f61580cee..c04aec7cb 100644 --- a/crates/fgumi-pipeline-core/src/builder.rs +++ b/crates/fgumi-pipeline-core/src/builder.rs @@ -155,7 +155,8 @@ impl PipelineBuilder { /// /// This deliberately bypasses the typed `Chain<'_, S::Outputs>` API. /// Type correctness is NOT validated at [`Self::build`] time — `build()` - /// only checks that every output branch is wired. A mis-typed step + /// only checks that the chain is non-empty (rejecting a zero-step chain with + /// `BuildError::Empty`) and that every output branch is wired. A mis-typed step /// sequence (e.g. a step whose `Input=A` consumes an output of type `B`) /// builds successfully and panics at the first dispatch in /// `TypedStep::resolve_input` with "input handle downcast failed — @@ -462,8 +463,8 @@ impl<'b, A: Send + HeapSize + 'static, B: Send + HeapSize + 'static> MultiChain2 /// Join two parallel sub-chains into a single [`Step2`] consumer. /// /// Wires `b0` into the consumer's input slot 0 - /// ([`StepCtx2::a`]) and `b1` into input slot 1 - /// ([`StepCtx2::b`]), registering the consumer with input arity + /// ([`StepCtx2`]'s `a`) and `b1` into input slot 1 + /// ([`StepCtx2`]'s `b`), registering the consumer with input arity /// 2 in the chain graph. Each branch's typed producer-side /// [`OutputQueueSet`] is later (at chain-run time) drained into /// the consumer's @@ -555,8 +556,8 @@ where /// Join two parallel ordered/byte-bounded sub-chains into a single /// [`Step2`] consumer. Ordered counterpart of /// [`MultiChain2::join`]: wires `b0` into the consumer's input - /// slot 0 ([`StepCtx2::a`]) and `b1` into input slot 1 - /// ([`StepCtx2::b`]), registering the consumer with input arity 2. + /// slot 0 ([`StepCtx2`]'s `a`) and `b1` into input slot 1 + /// ([`StepCtx2`]'s `b`), registering the consumer with input arity 2. /// /// Returns a single-branch downstream [`Chain`] typed by the /// joined step's `S::Outputs`. @@ -825,12 +826,13 @@ impl Pipeline { // 2-pre-monitor invariant: if the deadlock monitor will be armed, every // output transport must be ByteBounded so `in_flight_bytes` can see a // wedge on it. A CountBounded/Unbounded edge is invisible to the probe - // and would silently disable fail-fast on that edge. Checked here (debug - // builds only) while `steps` is still alive — it is consumed by + // and would silently disable fail-fast on that edge. Checked here (in + // every build, release included — the blind spot only matters in a real + // fail-fast run) while `steps` is still alive — it is consumed by // `build_worker_storage` below. Test chains use CountBounded/Unbounded // but do not arm the monitor, so this only fires for a fail-fast run. if deadlock_timeout_secs > 0 && stats_arc.is_some() { - assert_monitor_visible_transports(&steps, &graph); + ensure_monitor_visible_transports(&steps, &graph)?; } // 2a. If a total queue-memory budget was supplied, evenly @@ -1157,25 +1159,30 @@ fn first_monitor_blind_transport( None } -/// Debug-build invariant check (run only when the deadlock monitor is armed): -/// every production output transport must be `ByteBounded` so the -/// [`in_flight_bytes`] probe can see a wedge on it. A `CountBounded`/`Unbounded` -/// edge would be invisible to the monitor, silently disabling fail-fast on that -/// edge — exactly the blind spot this guard exists to catch. The framework still -/// permits `CountBounded`/`Unbounded` for `#[cfg(test)]` chains (which do not arm -/// the monitor), so this only fires for a real fail-fast pipeline. -fn assert_monitor_visible_transports( +/// Invariant check (run only when the deadlock monitor is armed): every +/// production output transport must be `ByteBounded` so the [`in_flight_bytes`] +/// probe can see a wedge on it. A `CountBounded`/`Unbounded` edge would be +/// invisible to the monitor, silently disabling fail-fast on that edge — exactly +/// the blind spot this guard exists to catch. The framework still permits +/// `CountBounded`/`Unbounded` for `#[cfg(test)]` chains (which do not arm the +/// monitor), so this only fires for a real fail-fast pipeline. +/// +/// Returns a [`PipelineError::MonitorBlindTransport`] (rather than panicking or +/// being a debug-only check) so the guard runs in release builds — where the +/// blind spot actually matters — yet a misconfigured chain fails gracefully at +/// startup, consistent with the other build/run-time validations (e.g. +/// [`PipelineError::NotEnoughThreads`]) rather than crashing the process. +fn ensure_monitor_visible_transports( steps: &[Box], graph: &super::topology::ChainGraph, -) { - debug_assert!( - first_monitor_blind_transport(steps, graph).is_none(), - "deadlock monitor is armed but step '{}' declares a {:?} output transport, \ - which is invisible to the in_flight_bytes probe — a wedge on that edge would \ - silently disable fail-fast. Production transports must be QueueSpec::ByteBounded.", - first_monitor_blind_transport(steps, graph).map_or("?", |(name, _)| name), - first_monitor_blind_transport(steps, graph).map(|(_, spec)| spec), - ); +) -> Result<(), super::signal::PipelineError> { + if let Some((name, spec)) = first_monitor_blind_transport(steps, graph) { + return Err(super::signal::PipelineError::MonitorBlindTransport { + step: name, + spec: format!("{spec:?}"), + }); + } + Ok(()) } /// Background deadlock monitor body. Polls `stats` every `poll_interval`, @@ -2525,24 +2532,28 @@ mod tests { } #[test] - fn assert_monitor_visible_transports_passes_for_all_byte_bounded() { - // The armed-monitor invariant holds (does not panic) when every output + fn ensure_monitor_visible_transports_ok_for_all_byte_bounded() { + // The armed-monitor invariant holds (returns Ok) when every output // transport is ByteBounded. let steps = vec![ step_with_output_spec("A", QueueSpec::ByteBounded { limit_bytes: 1 << 20 }), step_with_output_spec("B", QueueSpec::ByteBounded { limit_bytes: 1 << 20 }), ]; - assert_monitor_visible_transports(&steps, &graph_matching_specs(&steps)); + assert!(ensure_monitor_visible_transports(&steps, &graph_matching_specs(&steps)).is_ok()); } #[test] - #[cfg(debug_assertions)] - #[should_panic(expected = "invisible to the in_flight_bytes probe")] - fn assert_monitor_visible_transports_panics_on_count_bounded_in_debug() { - // In debug builds, a monitor-blind transport on an armed pipeline trips - // the invariant instead of silently losing the wedge verdict. + fn ensure_monitor_visible_transports_errs_on_count_bounded() { + // A monitor-blind transport on an armed pipeline is rejected with a + // graceful error (in every build, release included) instead of silently + // losing the wedge verdict or crashing the process. let steps = vec![step_with_output_spec("Blind", QueueSpec::CountBounded { capacity: 8 })]; - assert_monitor_visible_transports(&steps, &graph_matching_specs(&steps)); + let err = ensure_monitor_visible_transports(&steps, &graph_matching_specs(&steps)) + .expect_err("a CountBounded transport on an armed pipeline must be rejected"); + assert!( + matches!(err, PipelineError::MonitorBlindTransport { step: "Blind", .. }), + "expected MonitorBlindTransport for the blind step, got {err:?}" + ); } #[test] diff --git a/crates/fgumi-pipeline-core/src/handles.rs b/crates/fgumi-pipeline-core/src/handles.rs index 1b0dec3a5..f898245ed 100644 --- a/crates/fgumi-pipeline-core/src/handles.rs +++ b/crates/fgumi-pipeline-core/src/handles.rs @@ -29,13 +29,14 @@ //! those are framework-managed. Step authors see `outputs.push(item)` and //! `input.pop() -> Option`. //! -//! PR 1 caveat: `QueueSpec::ByteBounded` requires `T: HeapSize`. The -//! `build_*_queues` entry points used by `outputs.rs` don't have a -//! `HeapSize` bound on `T`, so they panic with a clear message if a caller -//! declares `ByteBounded`. Steps that need byte-bounded outputs use the -//! `build_*_queues_byte_bounded` overloads (lands alongside the first such -//! step in PR 2). The trait machinery is fully in place; only the entry -//! points are deferred. +//! `QueueSpec::ByteBounded` requires `T: HeapSize`. `Single` (and the +//! ordered-bytes paths) support `ByteBounded` directly: `build_single_queues` +//! bounds `T: HeapSize` and routes `ByteBounded` through +//! `build_branch_byte_aware`. The only remaining panics are for tuple branches +//! built via `build_branch` — which has the `HeapSize` bound but is the +//! non-byte-aware path, so it never constructs a byte-bounded queue and panics +//! on `ByteBounded` (the byte-aware `build_branch_byte_aware` must be used +//! instead) — and for `ByItemOrdinal` declared without `Ordered`. use std::any::Any; use std::marker::PhantomData; @@ -1542,7 +1543,6 @@ mod handle_tests { &[BranchOrdering::None], ); let _input: BranchInputHandle = set.take_typed_input::(0); - // Subsequent take_typed_input on the same branch panics (slot is empty). } } diff --git a/crates/fgumi-pipeline-core/src/held.rs b/crates/fgumi-pipeline-core/src/held.rs index a36069f19..e227b43d1 100644 --- a/crates/fgumi-pipeline-core/src/held.rs +++ b/crates/fgumi-pipeline-core/src/held.rs @@ -3,6 +3,11 @@ //! When a step can't push to a downstream queue (it's full), it stashes the //! item in a `HeldSlot` and returns `StepOutcome::Progress`. The next //! `try_run` call drains the held item before doing new work. +//! +//! Draining the held item before new work is a *step-author convention*, not +//! something `HeldSlot` enforces: the type only provides single-slot put/take +//! (with a double-put panic). The step is responsible for checking and draining +//! the slot first on each `try_run`. pub struct HeldSlot { inner: Option, diff --git a/crates/fgumi-pipeline-core/src/queues.rs b/crates/fgumi-pipeline-core/src/queues.rs index 9e6f5b83d..fde86a787 100644 --- a/crates/fgumi-pipeline-core/src/queues.rs +++ b/crates/fgumi-pipeline-core/src/queues.rs @@ -135,9 +135,9 @@ impl ItemQueue for CountBoundedQueue { /// items the byte cap (default 4 MiB) imposes a tighter bound. /// /// Sized in pages of `crossbeam_queue::ArrayQueue` storage (one -/// pre-allocated slot array, no per-push allocation). Mirrors legacy -/// `ArrayQueue::new(queue_capacity)` (`base.rs:1638`+) — the legacy -/// pipeline uses a fixed-capacity `ArrayQueue` everywhere for the same +/// pre-allocated slot array, no per-push allocation). Mirrors the +/// `ArrayQueue::new(queue_capacity)` strategy the legacy pipeline used — it +/// also used a fixed-capacity `ArrayQueue` everywhere for the same /// reason: `SegQueue` allocates segments on demand under load, and /// the resulting allocator churn shows up as `mi_*` overhead in /// profiles (≈260 samples vs legacy on CODEC 8M). @@ -171,8 +171,9 @@ const BYTE_BOUNDED_QUEUE_SLOT_CAPACITY: usize = 1024; /// `T::heap_size()` for the budget update. For types whose /// `heap_size()` is O(items inside) (e.g. `BatchedRawPositionGroups`, /// `OrderedRawPositionGroup`) this avoids recomputing a O(group) -/// walk on every pop. Mirrors legacy `ReorderBuffer`'s -/// `(T, usize)` storage (`fgumi-bam-io/src/reorder.rs:50`). +/// walk on every pop. Mirrors the legacy `ReorderBuffer`'s +/// cached-size storage strategy (`(T, usize)` there; `(T, u64)` here, +/// matching `inner`'s `ArrayQueue<(T, u64)>` above). pub struct ByteBoundedQueue { inner: ArrayQueue<(T, u64)>, current_bytes: AtomicU64, @@ -272,7 +273,7 @@ impl ItemQueue for ByteBoundedQueue { !self.drained.load(Ordering::Acquire), "try_push after mark_drained — producer contract violation" ); - // Legacy `ReorderBufferState::can_proceed` (`base.rs:766-781`) + // Like the legacy `ReorderBufferState::can_proceed`, this // gates on `heap_bytes < limit` — accept if currently *under* // budget, regardless of incoming item size. Per-item-larger-than // -limit is a real case (busy-locus position-group batches can diff --git a/crates/fgumi-pipeline-core/src/reorder.rs b/crates/fgumi-pipeline-core/src/reorder.rs index bdbe8b259..da9fae375 100644 --- a/crates/fgumi-pipeline-core/src/reorder.rs +++ b/crates/fgumi-pipeline-core/src/reorder.rs @@ -97,9 +97,14 @@ pub enum BranchOrdering { } /// Framework-internal wrapper carrying a producer-assigned ordinal. -/// Step authors never see this type. +/// +/// Step authors never see this type; it is `pub` only because it appears in the +/// public `ItemQueue>` trait bounds that ordered queues (and their +/// tests) instantiate, so callers may construct it directly when wiring queues. pub struct Sequenced { + /// Producer-assigned monotonic ordinal used to restore emission order. pub ordinal: u64, + /// The wrapped payload carried alongside its `ordinal`. pub item: T, } diff --git a/crates/fgumi-pipeline-core/src/runtime/fused.rs b/crates/fgumi-pipeline-core/src/runtime/fused.rs index 5288e9119..7d841c5de 100644 --- a/crates/fgumi-pipeline-core/src/runtime/fused.rs +++ b/crates/fgumi-pipeline-core/src/runtime/fused.rs @@ -1,13 +1,14 @@ //! Single-thread *fused* execution mode (issue #330). //! -//! At `--threads 1` a linear `source → … → sink` chain gains nothing from the -//! scheduled worker pool: there is one worker, so the inter-step bounded +//! At `--threads 1` a forward-wired `source → … → sink` chain gains nothing from +//! the scheduled worker pool: there is one worker, so the inter-step bounded //! queues, round-robin polling, held-slot retries, and reorder bookkeeping are //! pure overhead (profiling showed ~2/3 of `try_run` calls do no useful work). //! //! This module is the structural fix. Fusion is **not** a per-command rewrite -//! — it is an execution mode of the existing pipeline. [`is_linear_chain`] -//! detects a linear chain; [`run_fused_single_thread`] then drives the chain's +//! — it is an execution mode of the existing pipeline. [`is_fusible_chain`] +//! detects a fusible chain (forward-wired, fan-out allowed); +//! [`run_fused_single_thread`] then drives the chain's //! own type-erased steps inline, in topological order, over **direct, //! unbounded** buffers (built by [`build_chain_contexts_fused`]). FIFO push //! order is already the correct order at one worker, so the reorder stage is diff --git a/crates/fgumi-pipeline-core/src/runtime/pool.rs b/crates/fgumi-pipeline-core/src/runtime/pool.rs index 5a3cd877c..4e495c059 100644 --- a/crates/fgumi-pipeline-core/src/runtime/pool.rs +++ b/crates/fgumi-pipeline-core/src/runtime/pool.rs @@ -50,11 +50,13 @@ pub fn assign_exclusive_owners( /// - `Parallel` steps are never sticky-driven (each worker has its own /// clone — sticky drive on one would not gate others). /// -/// If two steps' sticky-ownership rules collide on the same worker (e.g., -/// the worker is both an Exclusive-sticky owner AND a Serial-sticky-Affinity -/// target), the exclusive ownership wins — the framework only models one -/// sticky-owned step per worker today. Returns `None` for workers without -/// any sticky-owned step. +/// If two steps' sticky-ownership rules collide on the same worker, the first +/// writer to that worker's slot wins — the framework only models one +/// sticky-owned step per worker today. The Exclusive pass runs before the Serial +/// pass and each pass only fills empty slots, so an Exclusive owner beats a +/// later Serial-sticky-Affinity target on the same worker; the same first-wins +/// rule resolves Exclusive-vs-Exclusive and Serial-vs-Serial collisions too. +/// Returns `None` for workers without any sticky-owned step. #[must_use] pub fn assign_sticky_owners( steps: &[Box], diff --git a/crates/fgumi-pipeline-core/src/runtime/stats.rs b/crates/fgumi-pipeline-core/src/runtime/stats.rs index a52ba41fa..c2275e560 100644 --- a/crates/fgumi-pipeline-core/src/runtime/stats.rs +++ b/crates/fgumi-pipeline-core/src/runtime/stats.rs @@ -14,8 +14,10 @@ //! - `progress_count` — `StepOutcome::Progress`. //! - `no_progress_count` — `StepOutcome::NoProgress`. //! - `contention_count` — `StepOutcome::Contention` (Serial step mutex -//! held by another worker; or skipped via `try_lock`). -//! - `finished_count` — `StepOutcome::Finished` (sources only). +//! held by another worker; or skipped via `try_lock`). Always 0 under the +//! fused single-thread driver, which holds no mutex and never contends. +//! - `finished_count` — `StepOutcome::Finished` (any step on end-of-stream: +//! source, mid, or sink all record `Finished` once their inputs drain). //! - `error_count` — `try_run_erased` returned `Err`. //! - `total_run_ns` — cumulative wall time across all dispatches. //! diff --git a/crates/fgumi-pipeline-core/src/runtime/worker_core.rs b/crates/fgumi-pipeline-core/src/runtime/worker_core.rs index f63487e2d..452f74a70 100644 --- a/crates/fgumi-pipeline-core/src/runtime/worker_core.rs +++ b/crates/fgumi-pipeline-core/src/runtime/worker_core.rs @@ -19,8 +19,7 @@ pub struct WorkerCore { /// step whose `Affinity` targets this worker), the step's index. /// The driver drives this step in a tight inner loop until it returns /// `NoProgress` / `Contention` / `Finished`, then yields to round- - /// robin. Mirrors legacy `pipeline/base.rs:4360-4392` sticky - /// read. + /// robin. Mirrors the legacy pipeline's sticky read. pub sticky_owner: Option, /// Backoff duration in microseconds. Doubled on no-progress; reset on progress. backoff_us: u64, diff --git a/crates/fgumi-pipeline-core/src/signal.rs b/crates/fgumi-pipeline-core/src/signal.rs index b73ba2623..8fca6f9b7 100644 --- a/crates/fgumi-pipeline-core/src/signal.rs +++ b/crates/fgumi-pipeline-core/src/signal.rs @@ -24,6 +24,14 @@ pub enum PipelineError { /// while work was still stuck in queues/reorder buffers — a wedge. The /// pipeline is failed fast rather than left to hang forever. TimedOut { stalled_secs: u64 }, + /// The pipeline was built with the deadlock monitor armed + /// (`deadlock_timeout_secs > 0`), but `step` declares a non-`ByteBounded` + /// output transport (`spec`, e.g. `CountBounded`/`Unbounded`). The + /// `in_flight_bytes` probe cannot see a wedge on such an edge, so fail-fast + /// would be silently disabled there. The pipeline is rejected at startup + /// rather than allowed to hang. This is a chain-construction error, not a + /// runtime condition: production chains wire only `ByteBounded` transports. + MonitorBlindTransport { step: &'static str, spec: String }, } impl PipelineError { @@ -45,6 +53,9 @@ impl PipelineError { Self::NotEnoughThreads { required: *required, available: *available } } Self::TimedOut { stalled_secs } => Self::TimedOut { stalled_secs: *stalled_secs }, + Self::MonitorBlindTransport { step, spec } => { + Self::MonitorBlindTransport { step, spec: spec.clone() } + } } } } @@ -62,6 +73,12 @@ impl std::fmt::Display for PipelineError { f, "pipeline deadlock detected: no progress for {stalled_secs}s with work still in flight" ), + Self::MonitorBlindTransport { step, spec } => write!( + f, + "deadlock monitor is armed but step {step:?} declares a {spec} output transport, \ + which is invisible to the in_flight_bytes probe — a wedge on that edge would \ + silently disable fail-fast. Production transports must be QueueSpec::ByteBounded." + ), } } } @@ -147,6 +164,8 @@ impl PipelineSignal { // `Cancelled` — an inconsistent state/payload pair. Guarding on the CAS // (as `record_error` does) keeps the two consistent: whoever wins the // state transition is the one that sets the payload. + // (See the loom NOTE at the end of the tests module for why this + // race-guard is not exercised by a unit test.) if self .state .compare_exchange( @@ -306,6 +325,15 @@ mod tests { let to = PipelineError::TimedOut { stalled_secs: 60 }.reconstruct(); assert!(matches!(to, PipelineError::TimedOut { stalled_secs: 60 })); + + let blind = PipelineError::MonitorBlindTransport { + step: "s", + spec: "QueueSpec::Unbounded".to_string(), + } + .reconstruct(); + assert!( + matches!(blind, PipelineError::MonitorBlindTransport { step: "s", spec } if spec == "QueueSpec::Unbounded") + ); } #[test] diff --git a/crates/fgumi-pipeline-core/src/step.rs b/crates/fgumi-pipeline-core/src/step.rs index 38fe54c80..e7f3f0b6b 100644 --- a/crates/fgumi-pipeline-core/src/step.rs +++ b/crates/fgumi-pipeline-core/src/step.rs @@ -45,9 +45,8 @@ pub enum StepKind { /// affinity only the hinted worker(s) ever acquire it. /// /// Mirrors the legacy framework's per-thread `exclusive_step_owned` -/// mapping (`pipeline/scheduler/mod.rs:263-308`) where T0 is the -/// reader, T(N-1) is the writer, and interior exclusive steps fan out -/// from both ends. +/// mapping, where T0 is the reader, T(N-1) is the writer, and interior +/// exclusive steps fan out from both ends. /// /// Default `None` keeps the existing pure-mutex-shared `Serial` behavior. /// Ignored for `Parallel` and `Exclusive` kinds. diff --git a/crates/fgumi-pipeline-io/src/sink/write_bgzf.rs b/crates/fgumi-pipeline-io/src/sink/write_bgzf.rs index 0a65b8ecd..a8f106b56 100644 --- a/crates/fgumi-pipeline-io/src/sink/write_bgzf.rs +++ b/crates/fgumi-pipeline-io/src/sink/write_bgzf.rs @@ -16,7 +16,7 @@ use fgumi_pipeline_core::{ step::{Affinity, Step, StepCtx, StepKind, StepOutcome, StepProfile}, }; -/// `Exclusive + sticky` BAM sink that consumes pre-compressed `BgzfBlock`s. +/// `Serial + sticky` BAM sink that consumes pre-compressed `BgzfBlock`s. pub struct WriteBgzfFile { state: Mutex>, name: &'static str, diff --git a/crates/fgumi-pipeline-io/src/sort/merge.rs b/crates/fgumi-pipeline-io/src/sort/merge.rs index 160d08737..e23f09fa7 100644 --- a/crates/fgumi-pipeline-io/src/sort/merge.rs +++ b/crates/fgumi-pipeline-io/src/sort/merge.rs @@ -189,7 +189,8 @@ fn slot_set_complete( ) } -/// `Serial + ByItemOrdinal` middle-of-chain merge. +/// `Serial + ByItemOrdinal` terminal merge: the final of the three sort steps, +/// producing the sorted `RecordBatch` stream consumed by the sink. pub struct SortMerge { state: SortMergeState, held: HeldSlot>, diff --git a/crates/fgumi-pipeline-io/src/sort/protocol.rs b/crates/fgumi-pipeline-io/src/sort/protocol.rs index 7c65c507a..114277342 100644 --- a/crates/fgumi-pipeline-io/src/sort/protocol.rs +++ b/crates/fgumi-pipeline-io/src/sort/protocol.rs @@ -11,7 +11,17 @@ use fgumi_sort::{ use fgumi_pipeline_core::item::HeapSize; -/// Approximate fixed overhead of a `Vec<(K, RawRecord)>` entry. +/// Approximate fixed per-entry overhead of a `Vec<(K, RawRecord)>`, in bytes, +/// added once per record in [`MemoryChunkErased::approx_heap_bytes`] on top of +/// the variable record payload (`RawRecord::as_ref().len()`). +/// +/// This is the size of one `(K, RawRecord)` tuple slot — the sort key `K` +/// (largest variant: `TemplateKey`) inline plus the `RawRecord` handle — together +/// with the per-`RawRecord` heap-allocation and allocator-bucket slack that the +/// payload byte count does not capture. It is an intentionally conservative +/// constant, not a `size_of` expression, so the queue accounting over-counts +/// rather than under-counts memory. If the key or record types grow materially, +/// re-derive it from `size_of::<(TemplateKey, RawRecord)>()` plus allocator slack. const PER_MEMORY_RECORD_OVERHEAD: usize = 354; /// In-memory sorted residual chunk produced by `SortAndSpill`, type-erased diff --git a/crates/fgumi-pipeline-io/src/source/read_bam.rs b/crates/fgumi-pipeline-io/src/source/read_bam.rs index 03eefbc14..b09ef94cc 100644 --- a/crates/fgumi-pipeline-io/src/source/read_bam.rs +++ b/crates/fgumi-pipeline-io/src/source/read_bam.rs @@ -164,6 +164,14 @@ pub fn read_bam_from_reader( /// Convenience helper: open a BAM file, parse its header, return the /// `(step, header)` pair. /// +/// The file is deliberately opened twice: first via +/// `create_raw_bam_reader_with_opts` to parse and return the `Header`, then +/// re-opened with `File::open` and a fresh `BufReader` at offset 0 so the raw +/// BGZF stream — including the header blocks — is emitted in full. Those header +/// blocks are stripped downstream by `FindBamBoundaries`. Do not "optimize" this +/// by reusing the first reader's position: skipping the header blocks corrupts +/// the raw-block stream. +/// /// # Errors /// /// Returns I/O errors from file open or BAM-header parse. diff --git a/crates/fgumi-sort-cli/Cargo.toml b/crates/fgumi-sort-cli/Cargo.toml index 0c129affe..a032a8e0f 100644 --- a/crates/fgumi-sort-cli/Cargo.toml +++ b/crates/fgumi-sort-cli/Cargo.toml @@ -23,7 +23,6 @@ noodles = { version = "0.111.0", features = ["bam", "sam"] } parking_lot = "0.12" [dev-dependencies] -noodles = { version = "0.111.0", features = ["bam", "sam"] } rstest = "0" tempfile = "3.4" diff --git a/crates/fgumi-sort-cli/src/sort.rs b/crates/fgumi-sort-cli/src/sort.rs index 0fb9a0289..0ed2221ea 100644 --- a/crates/fgumi-sort-cli/src/sort.rs +++ b/crates/fgumi-sort-cli/src/sort.rs @@ -140,8 +140,8 @@ PERFORMANCE: - Handles BAM files larger than available RAM via spill-to-disk - Uses parallel sorting (--threads) for in-memory chunks - Configurable temp file compression (--temp-compression) - - Default 768M per-thread memory limit (samtools-compatible); pass - `--max-memory auto` to detect system memory (opt-in) + - Default 768MiB per-thread memory limit (samtools-compatible); pass + `--max-memory auto` to detect host memory (opt-in) EXAMPLES: @@ -154,13 +154,13 @@ EXAMPLES: # Sort by queryname for zipper fgumi sort -i input.bam -o sorted.bam --order queryname - # Multi-threaded sort (default 768M per thread) + # Multi-threaded sort (default 768MiB per thread) fgumi sort -i input.bam -o sorted.bam --order template-coordinate --threads 8 # Override the per-thread memory limit fgumi sort -i input.bam -o sorted.bam -m 2GiB --threads 8 - # Opt in to auto-detected system memory (subtracts --memory-reserve) + # Opt in to auto-detected host memory (subtracts --memory-reserve) fgumi sort -i input.bam -o sorted.bam -m auto --threads 8 # Reserve extra memory for bwa mem running in a pipeline @@ -255,7 +255,7 @@ pub struct SortOptions { /// Maximum memory for in-memory sorting. /// /// Default is "768MiB" per thread (matching samtools' 768 MiB). Pass "auto" - /// to detect system memory and subtract --memory-reserve, leaving room + /// to detect host memory and subtract --memory-reserve, leaving room /// for the OS and co-running processes (e.g. an aligner). Explicit values /// like "512MiB", "1GiB", "4GiB" are per-thread when --memory-per-thread is /// enabled (default). Note bare "M"/"G" are decimal (1000ⁿ); "MiB"/"GiB" are @@ -267,7 +267,7 @@ pub struct SortOptions { /// Memory to reserve for other processes when --max-memory=auto. /// - /// "auto" (default) reserves min(10 GiB, 50% of system memory). Explicit + /// "auto" (default) reserves min(10 GiB, 50% of host memory). Explicit /// values like "10G", "8GiB" set a fixed reservation. Set higher when /// running alongside a memory-intensive aligner (e.g. `bwa mem` with a /// human genome index uses ~8 GiB). diff --git a/crates/fgumi-sort/src/worker_pool.rs b/crates/fgumi-sort/src/worker_pool.rs index 98e5ee50f..84d66c5ab 100644 --- a/crates/fgumi-sort/src/worker_pool.rs +++ b/crates/fgumi-sort/src/worker_pool.rs @@ -958,13 +958,15 @@ struct SortBackpressureState { /// Backpressure-driven priority selection — the sort pipeline's equivalent /// of `BalancedChaseDrain.build_priorities()`. /// -/// Returns a static slice of steps ordered by priority. The scheduler naturally -/// adapts to all 7 sub-phases without explicit phase tracking because: -/// - During 1A (reading): compress queue empty, decompressed low → read/decompress -/// - During 1C (sort): decompressed full → skip decompress, do compress if available -/// - During 1D (spill): compress queue fills → prioritize compress -/// - During 1E (overlap): both compress and decompress needed → split by queue depths -/// - During Phase 2: both compress (output) and decompress (chunks) → split +/// Returns a static slice of steps ordered by priority. The scheduler adapts to +/// the workload purely from the current backpressure state (no explicit phase +/// tracking) via two decision points: +/// - Phase 1 (read/decompress/sort/spill): when spill compression is the +/// bottleneck (compress queue has items and decompressed blocks are not low), +/// prioritize draining compression; otherwise feed the main thread first +/// (decompress, read) and compress only when work is available. +/// - Phase 2 (merge): when output compression has items (the writer-side +/// bottleneck), drain it before file work; otherwise do file work first. fn get_sort_priorities(bp: &SortBackpressureState) -> &'static [SortStep] { match bp.phase { phase::PHASE1 => { diff --git a/crates/fgumi-umi/src/assigner.rs b/crates/fgumi-umi/src/assigner.rs index 1dc54ed55..57f1bf189 100644 --- a/crates/fgumi-umi/src/assigner.rs +++ b/crates/fgumi-umi/src/assigner.rs @@ -1093,7 +1093,8 @@ impl AdjacencyUmiAssigner { /// /// * `max_mismatches` - Maximum number of mismatches allowed for UMIs to be adjacent /// * `threads` - Number of threads to use for matching (default: 1) - /// * `index_threshold` - Minimum UMIs per position to use N-gram/BK-tree index (default: 1000) + /// * `index_threshold` - Minimum UMIs per position to use N-gram/BK-tree index + /// (default: 100, see [`DEFAULT_INDEX_THRESHOLD`]) /// /// # Returns /// diff --git a/docs/design/deterministic-mi-numbering.md b/docs/design/deterministic-mi-numbering.md index 6fe5201f3..87e918b41 100644 --- a/docs/design/deterministic-mi-numbering.md +++ b/docs/design/deterministic-mi-numbering.md @@ -159,7 +159,8 @@ its own intermediate `ArrayQueue`. This keeps the change isolated to `try_step_serialize`'s input path and avoids touching the scheduler / deadlock detector with a new `PipelineStep` variant. -In `src/lib/unified_pipeline/bam.rs`: +In the BAM pipeline (the logic since moved into the typed-step `MiAssign` step +under `src/lib/pipeline/steps/`): ```rust enum MiAssignPopOutcome

{ diff --git a/docs/simulate-cli.md b/docs/simulate-cli.md index e0257326d..e41cedb7e 100644 --- a/docs/simulate-cli.md +++ b/docs/simulate-cli.md @@ -300,7 +300,7 @@ fgumi simulate grouped-reads \ | Tag | Type | Description | |-----|------|-------------| | `RX` | String | Raw UMI sequence | -| `MI` | String | Molecule ID (integer for simplex, "N/A" or "N/B" for duplex) | +| `MI` | String | Molecule ID (integer for simplex, `/A` or `/B` for duplex) | | `RG` | String | Read group (default: "A") | ### Truth File Format @@ -595,9 +595,14 @@ R2 reads have an additional offset (`r2-quality-offset`, typically -2) applied. ### Template-Coordinate Sorting -For `mapped-reads` and `grouped-reads`, output is sorted by template coordinate: -- Primary sort: 5' position of the leftmost read in the pair -- Secondary sort: Read name (for determinism) +For `mapped-reads` and `grouped-reads`, output is sorted by template coordinate, +matching `samtools sort --template-coordinate` (the order `fgumi group` expects): +- Position/strand: ref IDs and unclipped 5' positions of both reads, then strand + (reverse before forward), for both ends of the pair +- MI tag: molecular identifier (suffix-stripped, length-then-lexicographic) +- Read name, then upper/lower-of-pair: final tie-breaks for determinism + +(Library is omitted because simulated data is single-library.) BAM header includes: `SO:unsorted`, `GO:query`, `SS:template-coordinate` diff --git a/docs/src/guide/best-practices.md b/docs/src/guide/best-practices.md index c06f483de..af6fc6d65 100644 --- a/docs/src/guide/best-practices.md +++ b/docs/src/guide/best-practices.md @@ -70,6 +70,22 @@ The diagram shows the workflow from FASTQ files to filtered consensus reads: - **Green**: CODEC consensus - **Orange**: Optional UMI correction for fixed UMI sets +> **Tip — fused `runall`.** The per-step commands below give you full control and +> are easiest to reason about, but you can also fuse the stages into a single +> invocation with `fgumi runall` — covering +> `extract`/`correct`/`align`/`zipper`/`sort`/`group`/`consensus`/`filter`. It +> supports multiple entry points (selected with `--start-from`), each with its own +> input contract: start at `extract` (`--start-from extract`) to feed raw FASTQ +> straight through, resume from an already-extracted unmapped UMI BAM with +> `--start-from correct`, or resume a later stage from its own stage-appropriate +> BAM (`--start-from sort` from an unsorted BAM, `--start-from group` from a +> template-coordinate–sorted BAM, `--start-from consensus` from a grouped MI-tagged +> BAM). It +> streams the stages together (no intermediate BAMs on disk) and +> exposes per-stage tuning as prefixed flags (`--sort::max-memory`, +> `--group::strategy`, …). Use `--start-from`/`--stop-after` to run a sub-range of +> stages. See `fgumi runall --help` for the full surface. + ### Phase 1: FASTQ → Grouped BAM ```mermaid @@ -426,8 +442,15 @@ For production use where filtering parameters are established, combine steps for ### Step 2b.1: Group and Call Consensus (manual pipe) +`fgumi group` requires template-coordinate-sorted input, so sort once up front (the +`runall --start-from sort` example above folds this same `sort` step into the fused chain). +`fgumi sort` writes a file, so it is a separate command rather than a pipe stage; the +group → consensus fusion then runs as a single pipe with no intermediate consensus BAM: + ```bash -fgumi group --input aligned.bam --strategy adjacency --threads 4 --compression-level 1 \ +fgumi sort --input aligned.bam --output sorted.bam --order template-coordinate --threads 4 + +fgumi group --input sorted.bam --strategy adjacency --threads 4 --compression-level 1 \ | fgumi simplex --input /dev/stdin --min-reads 1 --output-per-base-tags true \ --output consensus.bam --threads 4 --compression-level 1 ``` diff --git a/docs/src/guide/performance-tuning.md b/docs/src/guide/performance-tuning.md index 3ad90ba3a..8e6789826 100644 --- a/docs/src/guide/performance-tuning.md +++ b/docs/src/guide/performance-tuning.md @@ -25,18 +25,21 @@ For memory-constrained environments, pass `--max-memory auto` to detect (cgroup- ## Threading Options ### No-flag Fast Path (default) + - **Usage**: Omit `--threads` entirely - **Behavior**: Uses optimized single-threaded fast path with minimal overhead - **Best for**: Small files, memory-constrained systems, debugging ### Explicit Single-threaded Mode + - **Usage**: `--threads 1` -- **Behavior**: Uses the unified pipeline with a single worker thread — same pipeline as `--threads N` but with N=1; does **not** use the no-flag fast path +- **Behavior**: Uses the typed-step work-stealing pipeline with a single worker thread — same pipeline as `--threads N` but with N=1; does **not** use the no-flag fast path - **Best for**: Isolating pipeline behavior in a single-threaded context ### Multi-threaded Mode + - **Usage**: `--threads N` where N > 1 -- **Behavior**: Uses unified 7-step pipeline with work-stealing scheduler +- **Behavior**: Uses the typed-step work-stealing pipeline - **Best for**: Large files, high-performance systems, production workloads > **`fgumi runall`:** all fused stages share a single `--threads` thread pool, so set `--threads` @@ -65,10 +68,16 @@ fgumi filter --max-memory auto fgumi filter --max-memory auto --memory-reserve 12GiB ``` -This is the same `--max-memory` surface as `fgumi sort`. The default is opt-in: -the budget stays 768 MiB/thread unless you pass `auto`, so on a fixed-RAM host -(e.g. a 30 GiB container at `--threads 16`) prefer `--max-memory auto` or a fixed -total budget to avoid OOM. +This is the same `--max-memory` surface as `fgumi sort`. Out of the box the +budget is **768 MiB per thread with per-thread scaling ON**, so the total grows +with `--threads` (4 threads → 3 GiB, 8 → 6 GiB, …). This per-thread default is +deliberately conservative; opt into host-aware sizing with `--max-memory auto` +(detects RAM and divides across threads) or cap the total with +`--memory-per-thread false`. On a fixed-RAM host (e.g. a 30 GiB container at +`--threads 16`, where the per-thread default would want a 12 GiB *queue* budget — +and total RSS, which also includes UMI structures, decompressors/compressors, and +thread stacks, is often 2–3× that) prefer `--max-memory auto` or a fixed total +budget to avoid OOM. ### Memory Scaling Behavior diff --git a/src/lib/aligner.rs b/src/lib/aligner.rs index cea5bdf6a..41c3c1064 100644 --- a/src/lib/aligner.rs +++ b/src/lib/aligner.rs @@ -16,11 +16,10 @@ //! [`substitute_template`] helper fills the `{ref}` / `{threads}` //! placeholders. //! -//! Used by Step 2 of the `AlignAndMerge` chain in -//! `src/lib/commands/runall/align_and_merge.rs` (lands in a follow-up -//! commit). This module is the framework-agnostic subprocess primitive; -//! the typed `Step` impl that owns the I/O threads lives in the runall -//! module. +//! Used by the `AlignAndMergeStep` in +//! `src/lib/pipeline/steps/align_and_merge.rs`. This module is the +//! framework-agnostic subprocess primitive; the typed `Step` impl that owns the +//! I/O threads lives in that step module. use std::collections::VecDeque; use std::io::{BufRead, BufReader}; @@ -578,9 +577,10 @@ impl Default for AlignerOptions { /// `pub(crate)` because only the runall AAM dispatch consumes it; /// promote to `pub` if a cross-crate caller materializes. /// -/// Fields are flagged `#[allow(dead_code)]` because C3 only -/// validates them (via `_resolved` in `validate_align_and_merge`); -/// C4 will consume them when wiring the 3-step chain. +/// Fields are flagged `#[allow(dead_code)]` because the AAM validation path +/// currently only validates them (via `_resolved` in +/// `validate_align_and_merge`); the wired chain sources its parameters +/// independently. #[allow(dead_code)] #[derive(Debug, Clone)] pub(crate) struct ResolvedAligner { @@ -599,7 +599,7 @@ pub(crate) struct ResolvedAligner { } /// How a [`ResolvedAligner`] was produced. Same dead-code rationale -/// as [`ResolvedAligner`] — C4 wiring consumes this. +/// as [`ResolvedAligner`]. #[allow(dead_code)] #[derive(Debug, Clone, Copy)] pub(crate) enum ResolvedAlignerMode { diff --git a/src/lib/commands/common.rs b/src/lib/commands/common.rs index dee18060f..dc09db97a 100644 --- a/src/lib/commands/common.rs +++ b/src/lib/commands/common.rs @@ -399,7 +399,7 @@ impl ThreadingMode { /// /// ```bash /// fgumi group --threads 8 ... -/// # Uses up to 8 threads with work-stealing scheduler +/// # Uses up to 8 threads with the round-robin pipeline scheduler /// ``` #[derive(Debug, Clone, Args)] pub struct ThreadingOptions { @@ -407,7 +407,7 @@ pub struct ThreadingOptions { /// /// If not specified, uses a single-threaded fast path optimized for /// simple streaming. When specified (even with --threads 1), uses the - /// 7-step parallel pipeline with work-stealing scheduler. + /// typed-step round-robin pipeline. #[arg(long = "threads")] pub threads: Option, } diff --git a/src/lib/commands/correct.rs b/src/lib/commands/correct.rs index e6511a48e..c6d74dbd8 100644 --- a/src/lib/commands/correct.rs +++ b/src/lib/commands/correct.rs @@ -393,7 +393,7 @@ pub(crate) struct TemplateCorrection { } // ============================================================================ -// 7-Step Pipeline Types +// Typed-Step Pipeline Types // ============================================================================ /// Metrics collected from UMI correction processing, aggregated post-pipeline. diff --git a/src/lib/commands/filter.rs b/src/lib/commands/filter.rs index e4dae006e..26a3b6a5f 100644 --- a/src/lib/commands/filter.rs +++ b/src/lib/commands/filter.rs @@ -248,7 +248,7 @@ impl Default for FilterOptions { } // ============================================================================ -// 7-Step Pipeline Types +// Typed-Step Pipeline Types // ============================================================================ /// Per-thread accumulator merged into final counts after pipeline completion. diff --git a/src/lib/commands/runall.rs b/src/lib/commands/runall.rs index 09413783d..c77c44587 100644 --- a/src/lib/commands/runall.rs +++ b/src/lib/commands/runall.rs @@ -929,9 +929,6 @@ fn validate_stages_for(start_from: RunAllStage, stop_after: RunAllStage) -> Resu /// reject (should not be reached if `validate_stages` was called first), or /// if the Stage derivation reaches a branch that is a programming error (e.g. /// a non-consensus `stop` that makes it through all prior early-returns). -// T3b.3 will call this from `RunAll::execute`; until then it is -// only called from `RunAll::derive_stages` and from the unit tests. -#[allow(dead_code)] fn derive_stages_for( start_from: RunAllStage, stop_after: RunAllStage, diff --git a/src/lib/commands/simulate/mod.rs b/src/lib/commands/simulate/mod.rs index dfb4e1414..71fb31582 100644 --- a/src/lib/commands/simulate/mod.rs +++ b/src/lib/commands/simulate/mod.rs @@ -77,7 +77,7 @@ impl SimulateCommand { #[allow(clippy::eq_op, clippy::cast_possible_truncation)] #[must_use] pub fn region_to_bin(start_1based: Option, end_1based: Option) -> u16 { - /// SAM spec §4.2.1: `reg2bin(-1, 0)` = 4680. + /// SAM spec §5.3 (indexing): `reg2bin(-1, 0)` = 4680. const UNMAPPED_BIN: u16 = 4680; let (Some(start_1), Some(end_1)) = (start_1based, end_1based) else { diff --git a/src/lib/commands/zipper.rs b/src/lib/commands/zipper.rs index d9386bdfe..11807dff6 100644 --- a/src/lib/commands/zipper.rs +++ b/src/lib/commands/zipper.rs @@ -898,9 +898,9 @@ impl Command for Zipper { validate_file_exists(&self.reference, "Reference FASTA (--ref)")?; if crate::reference::find_dict_path(&self.reference).is_none() { bail!( - "Reference FASTA {:?} has no sequence dictionary (`.dict`); \ + "Reference FASTA {} has no sequence dictionary (`.dict`); \ zipper needs it to build the output BAM header", - self.reference.display().to_string() + self.reference.display() ); } @@ -927,7 +927,7 @@ impl Command for Zipper { } } -/// Typed-step pipeline merger for the zipper merge stage. +/// Typed-step pipeline merger for the zipper merge path. /// /// Implements [`Step2`] over two `BamTemplateBatch` streams (unmapped /// `InputA` and mapped `InputB`) and emits merged diff --git a/src/lib/pipeline/chains/build.rs b/src/lib/pipeline/chains/build.rs index 52d19343a..dede21a28 100644 --- a/src/lib/pipeline/chains/build.rs +++ b/src/lib/pipeline/chains/build.rs @@ -1,7 +1,6 @@ //! [`build_for`] — the single chain-construction entry point. //! -//! T3b.3 converts this from a match-based dispatch (10 single-stage arms + -//! catch-all bail) to a stage-by-stage loop. `build_for` now: +//! `build_for` constructs the chain as a stage-by-stage loop: //! //! 1. Validates the spec (progression, options presence, cross-stage constraints). //! 2. Constructs a [`ChainBuilder`] from the spec. diff --git a/src/lib/pipeline/chains/builder.rs b/src/lib/pipeline/chains/builder.rs index 62024cff9..0176714aa 100644 --- a/src/lib/pipeline/chains/builder.rs +++ b/src/lib/pipeline/chains/builder.rs @@ -194,24 +194,8 @@ pub(crate) enum ChainTailKind { // ChainBuilder // ───────────────────────────────────────────────────────────────────────────── -/// In-progress chain builder. Constructed by -/// [`crate::pipeline::chains::build_for`] (or by a per-command -/// builder during Phase 3a). -/// -/// Owns the resolved output header, an accumulating `PipelineBuilder`, and a -/// growing `Vec>` populated by `add_` -/// methods. Per-stage methods are private — callers drive the chain -/// via the public `add_source` / `add_stage(Stage, StagePosition)` / -/// `add_sink` / `build()` flow. -/// -/// ## Type erasure -/// -/// The framework's `Chain<'b, O>` provides compile-time step-compatibility -/// enforcement but cannot span method boundaries on `&mut self`. Instead, -/// `ChainBuilder` tracks the chain tail as `(StepIdx, BranchIdx)` and uses -/// `PipelineBuilder::append_source` / `append_step`, which bypass the -/// typed-chain API. See the module-level type-erasure note for the runtime -/// behaviour when a type mismatch is introduced. +/// Per-worker scratch state for the `templates_to_mi_step` bridge: a reusable +/// byte buffer and an MI-key string buffer, reset per template. #[cfg(feature = "consensus")] pub(crate) struct FuseState { scratch: Vec, @@ -245,8 +229,8 @@ fn duplex_record_filter(raw: &[u8]) -> bool { /// `add_group` (which emits `BatchedProcessedPositionGroups`) into a consensus /// stage (which consumes `BatchedMiGroups`). For each template it splices the /// assigned molecular identifier into the `MI` tag and runs the records into -/// per-MI groups in a single pass, replicating `runall.rs`'s -/// `templates_to_mi_step`. +/// per-MI groups in a single pass. This is the in-builder successor of the +/// former `runall.rs` `templates_to_mi_step` (folded into the chain builder). /// /// Grouping on the MI alone (without a cell-barcode partition) is exactly /// equivalent to the non-fused `GroupByMi::with_cell_tag(Some(CB))` path @@ -371,6 +355,24 @@ where ) } +/// In-progress chain builder. Constructed by +/// [`crate::pipeline::chains::build_for`] (or by a per-command +/// builder during Phase 3a). +/// +/// Owns the resolved output header, an accumulating `PipelineBuilder`, and a +/// growing `Vec>` populated by `add_` +/// methods. Per-stage methods are private — callers drive the chain +/// via the public `add_source` / `add_stage(Stage, StagePosition)` / +/// `add_sink` / `build()` flow. +/// +/// ## Type erasure +/// +/// The framework's `Chain<'b, O>` provides compile-time step-compatibility +/// enforcement but cannot span method boundaries on `&mut self`. Instead, +/// `ChainBuilder` tracks the chain tail as `(StepIdx, BranchIdx)` and uses +/// `PipelineBuilder::append_source` / `append_step`, which bypass the +/// typed-chain API. See the module-level type-erasure note for the runtime +/// behaviour when a type mismatch is introduced. pub struct ChainBuilder<'a> { spec: &'a ChainSpec, tuning: BamPipelineTuning, @@ -1428,8 +1430,11 @@ impl<'a> ChainBuilder<'a> { /// For [`StagePosition::Terminal`], `SerializeBamRecords` is appended and /// the chain tail is [`DecompressedBlock`] (bytes ready for `BgzfCompress`). /// - /// For [`StagePosition::Intermediate`], correct returns `Err` as a guard — - /// no Phase 3 runall combination requires intermediate correct. + /// For [`StagePosition::Intermediate`], `SerializeBamRecords` is **not** + /// appended: the chain tail is left as `BamTemplateBatch` so the next stage + /// (`add_align` → `GroupByQueryname → AlignAndMergeStep`) can consume the + /// correct step's kept output (branch 0) directly. This is the correct→align + /// fused path. /// /// When `--rejects` is set, branch 1 of the correct step carries pre-framed /// `DecompressedBlock` bytes and is wired here directly to its own @@ -1456,9 +1461,8 @@ impl<'a> ChainBuilder<'a> { /// /// # Errors /// - /// Returns errors if correct options are missing from the spec bag, if UMI - /// sequence loading fails, or if `position` is `Intermediate` (not yet - /// implemented). + /// Returns errors if correct options are missing from the spec bag or if UMI + /// sequence loading fails. /// /// [`EncodedUmiSet`]: crate::commands::correct::EncodedUmiSet /// [`DecompressedBlock`]: crate::pipeline::steps::types::DecompressedBlock @@ -2360,11 +2364,10 @@ impl<'a> ChainBuilder<'a> { /// chain tail is [`DecompressedBlock`] (bytes ready for `BgzfCompress`). /// /// For [`StagePosition::Intermediate`], only the first three steps are - /// appended; the chain tail stays as [`BatchedProcessedPositionGroups`] for - /// the next stage to consume. Intermediate group is not yet needed by any - /// Phase 3a runall combination; calling `add_group` with `Intermediate` - /// returns `Err` as a guard until Phase 3b's fused group→consensus chains - /// require it. + /// appended; the chain tail stays as [`BatchedProcessedPositionGroups`] so + /// the consensus stages (`add_simplex`/`add_duplex`/`add_codec`) can prepend + /// `templates_to_mi_step` and consume it. This is the fused group→consensus + /// path. /// /// Accepts both template-coordinate-sorted and (with `--allow-unmapped`) /// queryname-sorted inputs, matching `GroupReadsByUmi::execute`'s validation. @@ -2376,8 +2379,8 @@ impl<'a> ChainBuilder<'a> { /// /// # Errors /// - /// Returns errors if the sort order is wrong, if the group options are missing - /// from the spec bag, or if `position` is `Intermediate` (not yet implemented). + /// Returns errors if the sort order is wrong or if the group options are + /// missing from the spec bag. /// /// [`BatchedProcessedPositionGroups`]: crate::pipeline::steps::group::position::BatchedProcessedPositionGroups #[allow(clippy::too_many_lines)] diff --git a/src/lib/pipeline/chains/validate.rs b/src/lib/pipeline/chains/validate.rs index 3bcd68861..4b075152d 100644 --- a/src/lib/pipeline/chains/validate.rs +++ b/src/lib/pipeline/chains/validate.rs @@ -2,7 +2,8 @@ //! //! 1. [`validate_stage_progression`] — ordering rules and mutual exclusions. //! 2. [`validate_stage_opts_present`] — each stage has its options in the bag. -//! 3. [`validate_cross_stage_constraints`] — placeholder for Phase 3 cross-stage rules. +//! 3. [`validate_cross_stage_constraints`] — Zipper source, Duplex/Group +//! strategy, `BamWithIndex` terminal-sort, and Extract source rules. use anyhow::{Result, bail}; @@ -138,9 +139,9 @@ pub fn validate_stage_progression(spec: &ChainSpec) -> Result<()> { /// Reject specs where a referenced stage has no options in the bag. /// -/// As of Phase 5 T5.3, eleven stages have their options in the bag: +/// As of Phase 5 T5.3, twelve stages have their options in the bag: /// Correct, Sort, Group, Zipper, Duplex, Codec, Dedup, Filter, Clip, Simplex, -/// and Extract. The remaining stage (Downsample) adds its slot incrementally +/// Align, and Extract. The remaining stage (Downsample) adds its slot incrementally /// during its migration task (T2.19–T2.22). For now that stage skips the /// options-presence check — once its slot lands in the bag, add the check here. /// diff --git a/src/lib/pipeline/steps/coalesce.rs b/src/lib/pipeline/steps/coalesce.rs index 90af196ee..2bbfaf2b7 100644 --- a/src/lib/pipeline/steps/coalesce.rs +++ b/src/lib/pipeline/steps/coalesce.rs @@ -188,8 +188,9 @@ impl Step for CoalesceBytes { } // 4. No input this call. If upstream is drained, flush the final - // partial block (held is empty here — step 1 returned `Contention` - // otherwise) and report `Finished` once nothing remains. A bounced + // partial block (held is empty here because step 1 early-returns + // `Contention` on a failed retry, so control only reaches here with + // `held` empty) and report `Finished` once nothing remains. A bounced // final push is parked in `held` and retried by step 1 next pass. if ctx.input.is_drained() { if !self.pending.is_empty() { diff --git a/src/lib/pipeline/steps/correct/mod.rs b/src/lib/pipeline/steps/correct/mod.rs index 7bb0b2924..7fe000059 100644 --- a/src/lib/pipeline/steps/correct/mod.rs +++ b/src/lib/pipeline/steps/correct/mod.rs @@ -51,7 +51,7 @@ mod tests; /// can't impl `HeapSize` (orphan rule). Reports `heap_size = 0` because /// the cache size is bounded by the user-set `cache_size` option, not /// dynamically grown — same convention as `ConsensusState` / -/// `CodecState` / `DuplexState` at `commands/runall.rs:3517,3665,3772`. +/// `CodecState` / `DuplexState` in `commands::runall`. pub(crate) struct CorrectWorkerState { cache: Option, UmiMatch>>, } @@ -327,14 +327,12 @@ fn run_batch_kept_only( /// Append one raw BAM record to `dst` using the standard BAM framing: /// 4-byte LE `block_size` followed by the record body. Matches the -/// legacy rejects writer's framing at `correct.rs:~1184-1186` (the -/// `serialize_fn` body that frames kept records) and what -/// `BgzfCompress` expects in a `DecompressedBlock`. +/// legacy rejects writer's framing (the `serialize_fn` body that frames +/// kept records) and what `BgzfCompress` expects in a `DecompressedBlock`. fn append_framed_raw_record(dst: &mut Vec, rec: &RawRecord) { // BAM record body size is u32-bounded per the spec; the underlying // RawRecord buffer was sized to fit a single record, so the cast - // cannot truncate in practice. Matches the legacy serialize_fn cast - // at `correct.rs:~1201`. + // cannot truncate in practice. Matches the legacy serialize_fn cast. #[allow(clippy::cast_possible_truncation)] let block_size = rec.len() as u32; dst.extend_from_slice(&block_size.to_le_bytes()); diff --git a/src/lib/pipeline/steps/process.rs b/src/lib/pipeline/steps/process.rs index a527aab8c..0abbf4e0f 100644 --- a/src/lib/pipeline/steps/process.rs +++ b/src/lib/pipeline/steps/process.rs @@ -4,8 +4,10 @@ //! The four are colocated per Phase 0 design line 371: //! `process.rs # process(fn), process_with_worker_state(init, fn), mi_assign(fn)`. //! -//! Phase 3 ships single-output Process only. Multi-output (e.g., -//! `correct`'s `CorrectOutputs`) is Phase 4 work. +//! In addition to the single-output `Process*` family, this module also +//! provides the 2-output fan-out variants `Process2`, `Process2Ordered`, and +//! `Process2WithWorkerState` for the kept/rejects shape (e.g. `correct`'s +//! `CorrectOutputs`). use std::io; use std::marker::PhantomData; @@ -709,8 +711,12 @@ where // fail, the rejected item is parked in `held_a` and the next `try_run` // drains it before popping new input. Branch B is independent. This means // a stuck consumer on one side won't drop items — but it also means a -// permanently slow consumer will eventually block the step (held slot full, -// dispatch returns `Contention`). +// permanently slow consumer will eventually block the step. The dispatch that +// first parks an item still returns `Progress` (with the produced item(s) +// buffered — up to one per branch, since a single dispatch can fill both +// `held_a` and `held_b`); `Contention` is reported only on a *subsequent* +// dispatch, by the held-drain preamble, when a still-held item cannot be +// pushed. Each branch holds at most one item (one input is popped per call). // ───────────────────────────────────────────────────────────────────────────── /// Output of a [`Process2`] closure: one optional value per output branch. diff --git a/src/lib/pipeline/steps/roundtrip.rs b/src/lib/pipeline/steps/roundtrip.rs index 350a91e50..d74f878a3 100644 --- a/src/lib/pipeline/steps/roundtrip.rs +++ b/src/lib/pipeline/steps/roundtrip.rs @@ -77,6 +77,10 @@ pub fn run_bam_roundtrip(input: &Path, output: &Path, cfg: RoundtripConfig) -> i // (CIGAR walk + tag scan + library/cell-barcode hashing) in one // parallel pass. Matches the legacy pipeline's combined Decode // step; drops the intermediate Parse → Decode queue. + // `GroupKeyConfig::default()` (empty library index, no cell tag) is used + // deliberately: this round-trip validates byte-equivalence only and does + // not exercise UMI/library/cell grouping (`GroupBam` batches by qname + // regardless), so the computed group key does not affect output bytes. .chain(DecodeRecords::new(GroupKeyConfig::default(), t.per_step_byte_limit)) .chain(GroupBam::new(t.template_batch_size, t.per_step_byte_limit)) .chain(SerializeBamRecords::new(t.per_step_byte_limit)) diff --git a/src/lib/pipeline/steps/serialize_processed.rs b/src/lib/pipeline/steps/serialize_processed.rs index e2c20325a..aff242def 100644 --- a/src/lib/pipeline/steps/serialize_processed.rs +++ b/src/lib/pipeline/steps/serialize_processed.rs @@ -106,6 +106,13 @@ pub fn build_serialize_processed_groups_step( // progress counter below. let total_input_records: u64 = groups.iter().fold(0u64, |acc, g| acc.saturating_add(g.input_record_count)); + // The `saturating_mul`/`unwrap_or(usize::MAX)` is purely overflow- + // defensive and unreachable for realistic record counts (a count + // approaching `usize::MAX / 800` is impossible for any BAM). It is a + // theoretical guard only: if it ever did saturate to `usize::MAX`, + // the `with_capacity(usize::MAX)` would itself abort — so the guard + // protects against the multiply wrapping, not against a real + // allocation of that size. let mut output = Vec::with_capacity( usize::try_from(total_input_records.saturating_mul(800)).unwrap_or(usize::MAX), ); diff --git a/src/lib/pipeline/steps/source/read_fastq.rs b/src/lib/pipeline/steps/source/read_fastq.rs index 5fa5aec5a..bc558129e 100644 --- a/src/lib/pipeline/steps/source/read_fastq.rs +++ b/src/lib/pipeline/steps/source/read_fastq.rs @@ -61,6 +61,9 @@ pub struct FastqRawChunk { /// Per-stream round-robin cycle serial, for the `ZipFastqRecords` join. pub chunk_serial: u64, /// Decompressed raw FASTQ bytes, aligned to whole records (4 lines each). + /// + /// Rebuild this chunk rather than mutating it in place: an in-place edit + /// that breaks whole-record (4-line) alignment desyncs downstream parsing. pub data: Vec, } diff --git a/src/lib/validation.rs b/src/lib/validation.rs index 2be3efd93..4d28170cc 100644 --- a/src/lib/validation.rs +++ b/src/lib/validation.rs @@ -88,7 +88,9 @@ pub fn validate_min_max( /// /// # Arguments /// * `rate` - Error rate to validate -/// * `name` - Name of the parameter for error messages +/// * `_name` - Parameter name, accepted for call-site symmetry with +/// `validate_min_max`/`validate_positive` but currently unused: the returned +/// `FgumiError::InvalidFrequency` does not interpolate it. /// /// # Errors /// Returns an error if the rate is not in [0.0, 1.0] @@ -114,7 +116,9 @@ pub fn validate_error_rate(rate: f64, _name: &str) -> Result<()> { /// /// # Arguments /// * `quality` - Quality score to validate -/// * `name` - Name of the parameter for error messages +/// * `_name` - Parameter name, accepted for call-site symmetry with +/// `validate_min_max`/`validate_positive` but currently unused: the returned +/// `FgumiError::InvalidQuality` does not interpolate it. /// /// # Errors /// Returns an error if the quality is not in [0, 93] diff --git a/tests/integration/helpers/assertions.rs b/tests/integration/helpers/assertions.rs index 21a508869..58cc80bf6 100644 --- a/tests/integration/helpers/assertions.rs +++ b/tests/integration/helpers/assertions.rs @@ -220,7 +220,6 @@ pub fn assert_rx_tag(record: &RecordBuf, expected: &str) { /// Asserts that consensus quality improved compared to input reads. /// /// Checks that the consensus read has: -/// - Higher minimum quality score than any input read /// - Mean quality >= mean of input reads /// /// # Panics diff --git a/tests/integration/helpers/bam_generator.rs b/tests/integration/helpers/bam_generator.rs index 0876e750c..884d62643 100644 --- a/tests/integration/helpers/bam_generator.rs +++ b/tests/integration/helpers/bam_generator.rs @@ -37,16 +37,31 @@ pub fn create_umi_family( base_name: &str, sequence: &str, quality: u8, +) -> Vec { + create_umi_family_at(99, umi, depth, base_name, sequence, quality) +} + +/// Like [`create_umi_family`], but maps every read in the family to the explicit +/// reference position `pos` instead of the fixed default. Lets callers spread +/// families across distinct template-coordinate positions so the sort/group +/// path is exercised across multiple positions (and parallel workers). +pub fn create_umi_family_at( + pos: i32, + umi: &str, + depth: usize, + base_name: &str, + sequence: &str, + quality: u8, ) -> Vec { let seq = sequence.as_bytes(); - let cigar_op = u32::try_from(seq.len()).expect("seq.len() fits u32") << 4; // NM + let cigar_op = u32::try_from(seq.len()).expect("seq.len() fits u32") << 4; // M (op 0) (0..depth) .map(|i| { let name = format!("{base_name}_{i}"); let mut b = SamBuilder::new(); b.read_name(name.as_bytes()) .ref_id(0) - .pos(99) + .pos(pos) .mapq(60) .flags(0) .cigar_ops(&[cigar_op]) @@ -474,6 +489,33 @@ mod tests { } } + #[test] + fn create_umi_family_at_round_trips_pos() { + // `create_umi_family_at` is what spreads the determinism fixtures across + // multiple coordinates, but nothing else in this file asserts that the + // passed `pos` actually survives into the generated records — so a + // regression in `.pos(pos)` would silently collapse those fixtures to a + // single coordinate and make the multi-worker sort/group oracles + // vacuous. Pin the round-trip directly here. A non-default 0-based POS + // (distinct from `create_umi_family`'s 99) is checked both on the raw + // record (BAM stores POS 0-based) and via the noodles `RecordBuf` + // (1-based `Position`). + const POS: i32 = 4242; + let family = create_umi_family_at(POS, "ACGTACGT", 3, "atpos", "AAAA", 30); + assert_eq!(family.len(), 3); + for record in &family { + assert_eq!(record.pos(), POS, "raw 0-based POS must round-trip through the builder"); + let buf = to_record_buf(record); + let start = + usize::from(buf.alignment_start().expect("mapped record has an alignment start")); + assert_eq!( + start, + usize::try_from(POS).expect("POS fits usize") + 1, + "1-based alignment_start must be the 0-based POS + 1", + ); + } + } + #[test] fn test_create_paired_umi_family() { let family = create_paired_umi_family("ACGT-TGCA", 3, "pair", "AAAA", "TTTT", 30); diff --git a/tests/integration/helpers/cli_runner.rs b/tests/integration/helpers/cli_runner.rs index a70d36538..beedc4534 100644 --- a/tests/integration/helpers/cli_runner.rs +++ b/tests/integration/helpers/cli_runner.rs @@ -12,12 +12,9 @@ //! intermediate BAMs (`fgumi runall --start-from S --stop-after T` //! vs `fgumi S → tmp.bam → ... → fgumi T`). //! -//! Today's runall only accepts `{Sort, Group} → {Simplex, Duplex, Codec}` -//! (6 combos). The remaining 6 combos (5 Class A diagonal + Sort→Group) -//! are gated behind #33 tasks 5-9 and the corresponding tests are -//! `#[ignore = "TDD: blocked on task N of #33"]` — they compile, build -//! the right CLI invocation via this module, and turn green -//! automatically when the gate is lifted. +//! The parity matrix covers the valid `(--start-from, --stop-after)` pairs; +//! consult `RunAllStage`/the validator (`RunAllStage::validate_with`) for the +//! authoritative supported set, since the surface grows as new stages are wired. #![allow(dead_code)] diff --git a/tests/integration/test_codec_command.rs b/tests/integration/test_codec_command.rs index 56289f1d7..e8209b588 100644 --- a/tests/integration/test_codec_command.rs +++ b/tests/integration/test_codec_command.rs @@ -38,8 +38,8 @@ pub(crate) fn create_codec_read_pair( ) -> (RawRecord, RawRecord) { let r1_len = r1_seq.len(); let r2_len = r2_seq.len(); - let r1_cigar_op = u32::try_from(r1_len).expect("r1_len fits u32") << 4; // nM - let r2_cigar_op = u32::try_from(r2_len).expect("r2_len fits u32") << 4; // nM + let r1_cigar_op = u32::try_from(r1_len).expect("r1_len fits u32") << 4; // M (op 0) + let r2_cigar_op = u32::try_from(r2_len).expect("r2_len fits u32") << 4; // M (op 0) // SamBuilder pos is 0-based; ref_start is 1-based let pos = i32::try_from(ref_start).expect("ref_start fits i32") - 1; // MC is the mate's CIGAR, so b1 carries R2's tag and vice versa. diff --git a/tests/integration/test_runall_parity.rs b/tests/integration/test_runall_parity.rs index dc24c0fff..af216a523 100644 --- a/tests/integration/test_runall_parity.rs +++ b/tests/integration/test_runall_parity.rs @@ -8,17 +8,16 @@ //! `fgumi runall --start-from X --stop-after X` and `fgumi X` on //! the same input, asserts the output BAMs contain the same //! record stream. Pins that runall's per-stage execution path -//! matches the standalone command's execution path. Activated by -//! #33 tasks 5-9 as each diagonal combo is wired through the -//! validator. +//! matches the standalone command's execution path. All five run +//! by default. //! //! * **Class B — composition / multi-stage parity (7 tests).** Runs //! `fgumi runall --start-from S --stop-after T` and the equivalent //! staged chain (`fgumi S → tmp.bam → fgumi ... → fgumi T`), //! asserts the output BAMs contain the same record stream. Pins //! that runall's FUSED chain matches the standalone STAGED chain. -//! Six of these (`{Sort,Group}→{Simplex,Duplex,Codec}`) are -//! unblocked today; one (`Sort→Group`) waits on task 9. +//! All seven (`{Sort,Group}→{Simplex,Duplex,Codec}` plus +//! `Sort→Group`) run by default. //! //! Total: 12 parity tests covering every valid `(start, stop)` pair //! the validator accepts (5 diagonal + 7 off-diagonal = 12; the @@ -292,12 +291,12 @@ fn grouped_codec_fixture(dir: &Path) -> PathBuf { // ────────────────────────── Class A — single-stage parity ────────────────────────── // // Each Class A test runs `fgumi runall --start-from X --stop-after X` -// against `fgumi X` on the same input. Activated as #33 tasks 5-9 lift -// the corresponding `--start-from`/`--stop-after` gate. +// against `fgumi X` on the same input. -/// `Sort → Sort` parity vs standalone `fgumi sort`. Unblocked by -/// task 8 of #33 — runall delegates to `Sort::execute` for -/// byte-identical output by construction. +/// `Sort → Sort` parity vs standalone `fgumi sort`. Runall delegates to +/// `Sort::execute`, so the two are record- and header-equivalent — identical +/// records and `@HD`/`@SQ`/`@RG`, ignoring only the `@PG` command-line +/// provenance (which legitimately differs between runall and standalone). #[test] fn parity_a_sort_to_sort() { let tmp = TempDir::new().unwrap(); @@ -315,9 +314,10 @@ fn parity_a_sort_to_sort() { assert_bams_record_equivalent(&runall_out, &standalone_out); } -/// `Group → Group` parity vs standalone `fgumi group`. Unblocked by -/// task 9 of #33 — runall delegates to `GroupReadsByUmi::execute` -/// for byte-identical output by construction. +/// `Group → Group` parity vs standalone `fgumi group`. Runall delegates to +/// `GroupReadsByUmi::execute`, so the two are record- and header-equivalent — +/// identical records and `@HD`/`@SQ`/`@RG`, ignoring only the `@PG` +/// command-line provenance (which legitimately differs). #[test] fn parity_a_group_to_group() { let tmp = TempDir::new().unwrap(); @@ -336,8 +336,8 @@ fn parity_a_group_to_group() { } /// `Simplex → Simplex` parity vs standalone `fgumi simplex`. -/// Unblocked by task 5 of #33; uses the consensus-only delegation -/// fast path (`execute_consensus_only`) added in a later commit — +/// Uses the consensus-only delegation fast path +/// (`execute_consensus_only`) — /// runall constructs a `Simplex` struct from its own flags and calls /// `Simplex::execute` directly, skipping the group step entirely. /// Parity therefore holds by construction (same standalone command, @@ -361,10 +361,9 @@ fn parity_a_simplex_to_simplex() { assert_bams_record_equivalent(&runall_out, &standalone_out); } -/// `Duplex → Duplex` parity vs standalone `fgumi duplex`. Unblocked -/// by task 6 of #33; runs through the consensus-only delegation fast -/// path. See `parity_a_simplex_to_simplex` for the delegation -/// contract this pins. +/// `Duplex → Duplex` parity vs standalone `fgumi duplex`. Runs through +/// the consensus-only delegation fast path. See +/// `parity_a_simplex_to_simplex` for the delegation contract this pins. #[cfg(feature = "consensus")] #[test] fn parity_a_duplex_to_duplex() { @@ -383,8 +382,8 @@ fn parity_a_duplex_to_duplex() { assert_bams_record_equivalent(&runall_out, &standalone_out); } -/// `Codec → Codec` parity vs standalone `fgumi codec`. Unblocked by -/// task 7 of #33; runs through the consensus-only delegation fast +/// `Codec → Codec` parity vs standalone `fgumi codec`. Runs through +/// the consensus-only delegation fast /// path. Uses `grouped_codec_fixture` (paired-end, single-strand /// UMI, grouped with `--strategy adjacency`) — the documented CODEC /// input — so this actually exercises the duplex consensus logic @@ -410,20 +409,13 @@ fn parity_a_codec_to_codec() { // ────────────────────────── Class B — multi-stage parity ────────────────────────── // // Each Class B test runs `fgumi runall --start-from S --stop-after T` -// against the equivalent staged chain. Today (post-task-4 of #33), 6 -// of the 7 Class B combos are accepted by the validator; only -// `Sort → Group` waits on task 9. -// -// All six already-unblocked tests are also `#[ignore]`'d in this -// scaffold commit — they will be activated one-by-one as each test -// is verified end-to-end (the helper modules and fixtures still need -// to be exercised together at least once; see the activation TODO -// note in each). +// against the equivalent staged chain. All seven Class B combos are +// accepted by the validator and run by default. /// `Sort → Group` parity vs staged `fgumi sort | fgumi group`. -/// Unblocked by task 9 of #33 — runall delegates to `Sort::execute` -/// then `GroupReadsByUmi::execute` (via a tempfile) for -/// byte-identical output by construction. +/// Runall delegates to `Sort::execute` then `GroupReadsByUmi::execute` +/// (via a tempfile), so the output is record- and header-equivalent +/// (ignoring `@PG` command-line provenance) by construction. #[test] fn parity_b_sort_to_group() { let tmp = TempDir::new().unwrap(); @@ -443,8 +435,6 @@ fn parity_b_sort_to_group() { } /// `Sort → Simplex` parity vs staged `fgumi sort | fgumi group | fgumi simplex`. -/// Unblocked by validator (today); test activation pending end-to-end -/// verification of the parity scaffold. #[cfg(feature = "consensus")] #[test] fn parity_b_sort_to_simplex() { @@ -2554,7 +2544,8 @@ fn correct_parity_fixture(dir: &Path) -> PathBuf { // (umi, family_name, sequence) — same length as ACGTACGT (8) for // all variants. ACGTACGT exact match; ACGTACGC = 1 mismatch; - // ACGTACCC = 2 mismatches; ACGTCCCC = 4 mismatches (rejected). + // ACGTACCC = 2 mismatches; ACGTCCCC = 3 mismatches (exceeds + // --max-mismatches 2, rejected). let families = [ ("ACGTACGT", "fam_exact", "ACGTACGTACGTACGT"), ("ACGTACGC", "fam_1mm", "TTTTAAAACCCCGGGG"), @@ -2578,8 +2569,9 @@ fn correct_parity_fixture(dir: &Path) -> PathBuf { /// Class A — `correct → correct` parity vs standalone `fgumi correct`. /// Pins the runall delegation: runall constructs a `CorrectUmis` -/// struct from its own flags and calls `CorrectUmis::execute`, so -/// the output is byte-identical by construction. The fixture +/// struct from its own flags and calls `CorrectUmis::execute`, so the +/// output is record- and header-equivalent (ignoring `@PG` command-line +/// provenance) by construction. The fixture /// (`correct_parity_fixture`) is built specifically to exercise /// every branch of the correction code path — no-op, single- /// mismatch rewrite (RX overwritten + OX stashed), and rejection diff --git a/tests/integration/test_streaming_input.rs b/tests/integration/test_streaming_input.rs index 557648984..0c5f4ba1c 100644 --- a/tests/integration/test_streaming_input.rs +++ b/tests/integration/test_streaming_input.rs @@ -473,9 +473,9 @@ fn convert_bam_to_sam(bam_path: &PathBuf, sam_path: &PathBuf) { } } -/// SAM-input parity for the new typed-step pipeline. Generates a SAM file +/// SAM-input parity for the typed-step pipeline. Generates a SAM file /// from the same record set as the BAM baseline, runs `fgumi group -/// --use-new-pipeline --input foo.sam`, and compares the output BAMs. +/// --input foo.sam`, and compares the output BAMs. /// Exercises the `ReadSamChunks` + `ParseSamChunk` parallel-parse path. #[test] fn test_group_command_with_sam_input_new_pipeline_matches_bam_baseline() { @@ -505,7 +505,7 @@ fn test_group_command_with_sam_input_new_pipeline_matches_bam_baseline() { "2", ]) .status() - .expect("Failed to run group --use-new-pipeline with BAM input"); + .expect("Failed to run group with BAM input"); assert!(status.success(), "BAM-input baseline failed"); let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) @@ -525,7 +525,7 @@ fn test_group_command_with_sam_input_new_pipeline_matches_bam_baseline() { "2", ]) .status() - .expect("Failed to run group --use-new-pipeline with SAM input"); + .expect("Failed to run group with SAM input"); assert!(status.success(), "SAM-input run failed"); compare_bam_records(&out_bam_baseline, &out_sam); @@ -786,7 +786,7 @@ fn test_group_command_with_sam_stdin_new_pipeline_matches_bam_baseline() { } /// Same shape as `test_group_command_with_piped_input` but routed through -/// the typed-step pipeline (`--use-new-pipeline`). Exercises the +/// the typed-step pipeline. Exercises the /// `read_bam_auto` dispatcher introduced for issue #330 Phase 1 T1.3.4: /// when the input path is `-`, the source step is `read_bam_stdin` /// (which buffers the BAM header via `TeeReader` and replays it ahead @@ -817,8 +817,8 @@ fn test_group_command_with_piped_input_new_pipeline() { "2", ]) .status() - .expect("Failed to run group --use-new-pipeline with file input"); - assert!(status.success(), "group --use-new-pipeline (file) failed"); + .expect("Failed to run group with file input"); + assert!(status.success(), "group (file) failed"); assert!(output_from_file.exists(), "file-input output not created"); let cat_child = Command::new("cat") @@ -845,8 +845,8 @@ fn test_group_command_with_piped_input_new_pipeline() { ]) .stdin(cat_child.stdout.unwrap()) .status() - .expect("Failed to run group --use-new-pipeline with piped input"); - assert!(status.success(), "group --use-new-pipeline (stdin) failed"); + .expect("Failed to run group with piped input"); + assert!(status.success(), "group (stdin) failed"); assert!(output_from_pipe.exists(), "stdin output not created"); compare_bam_records(&output_from_file, &output_from_pipe); From d32f1dab620f0fcd4b90f4267a8a3453d8435847 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 24 Jun 2026 10:54:55 -0700 Subject: [PATCH 3/5] test(sort): spill-matrix coverage with an independent order oracle Un-ignore the order x spill sort-correctness matrix on in-process SamBuilder fixtures; add an independent order oracle (not just --verify) with a samtools cross-check installed in CI; cover the sort-engine internals (spill counts, zstd Phase 2, success wiring, comparator equivalence). --- .github/workflows/check.yml | 26 +- .../src/sort/and_spill/tests.rs | 137 +- crates/fgumi-raw-bam/src/sort.rs | 240 ++++ crates/fgumi-sort-cli/src/sort.rs | 195 +++ crates/fgumi-sort/src/worker_pool.rs | 135 ++ tests/integration/test_dedup_command.rs | 8 +- tests/integration/test_simulate_aligner.rs | 19 +- tests/integration/test_sort_correctness.rs | 1124 ++++++++++++----- tests/integration/test_sort_write_index.rs | 144 ++- tests/integration/test_streaming_input.rs | 38 +- 10 files changed, 1654 insertions(+), 412 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b0f587fe1..634c78cd4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,13 +32,20 @@ jobs: uses: taiki-e/install-action@0631aa6515c7d545823c67cfae7ef4fc7f490154 # v2.81.8 with: tool: nextest - # Install bwa + bwa-mem3 from bioconda for the AAM - # (--start-from align-and-merge) integration tests. The real-aligner - # parity tests are `#[ignore]`'d (they need bwa / bwa-mem3 on PATH), so - # local dev without these binaries simply does not run them; CI installs - # them here and runs the ignored set explicitly below so the chain is - # actually exercised. - - name: Install aligners (bwa-mem3, bwa) from bioconda + # Install bwa + bwa-mem3 + samtools from bioconda. bwa / bwa-mem3 back the + # AAM (--start-from align-and-merge) parity tests (still `#[ignore]`'d, run + # explicitly below). samtools backs the sort-correctness oracle: the + # queryname-natural / template-coordinate order checks cross-reference + # `samtools sort -n` / `samtools sort --template-coordinate`, and the + # sort --write-index region-query tests. Those are NOT `#[ignore]`'d — they + # run in the default `cargo ci-test` step below and are gated only by a + # runtime `which samtools` check, so installing samtools here makes them + # actually exercise the independent order oracle in CI (X3-005). + # + # samtools is pinned because it is the oracle: an unpinned bioconda solve + # could silently change sort-order semantics with no repo change. Bump the + # pin deliberately (and re-validate the parity expectations) when updating. + - name: Install bioconda tools (bwa-mem3, bwa, samtools) uses: mamba-org/setup-micromamba@06375d89d211a1232ef63355742e9e2e564bc7f7 # v2.0.7 with: micromamba-version: '2.0.5-0' @@ -47,16 +54,19 @@ jobs: -c bioconda -c conda-forge bwa bwa-mem3 + samtools=1.23.1 init-shell: bash cache-environment: true cache-downloads: true - - name: Verify aligner binaries on PATH + - name: Verify bioconda binaries on PATH shell: bash -el {0} run: | which bwa which bwa-mem3 + which samtools bwa 2>&1 | head -3 || true bwa-mem3 2>&1 | head -3 || true + samtools --version 2>&1 | head -2 || true - name: Unit tests shell: bash -el {0} run: cargo ci-test diff --git a/crates/fgumi-pipeline-io/src/sort/and_spill/tests.rs b/crates/fgumi-pipeline-io/src/sort/and_spill/tests.rs index 1094133c5..356d03b8d 100644 --- a/crates/fgumi-pipeline-io/src/sort/and_spill/tests.rs +++ b/crates/fgumi-pipeline-io/src/sort/and_spill/tests.rs @@ -1 +1,136 @@ -// No unit tests for and_spill directly; integration tests live in sort/tests.rs. +//! Unit tests for `SortAndSpill::finalize_into_pending` (S2-003). +//! +//! The end-to-end three-step chain tests live in `sort/tests.rs`; those exercise +//! the happy path (in-memory / multi-spill matches the legacy sorter) but never +//! assert the *values* inside the terminal `AllAnnounced` sentinel — i.e. the +//! `slot_count` / `memory_chunk_count` derivation and the empty-chunk-skip logic +//! the #449 overflow guard protects. This module covers `finalize_into_pending` +//! directly: +//! +//! - the announced `slot_count` equals the number of emitted `SpillReady` events; +//! - the announced `memory_chunk_count` equals the number of emitted +//! (non-empty) `MemoryChunk` events; +//! - an all-in-memory stream announces zero slots; +//! - a stream with nothing buffered finalizes to `None` (no events at all). + +use super::*; + +use fgumi_raw_bam::RawRecord; +use fgumi_raw_bam::testutil::make_bam_bytes; +use fgumi_sort::{RawExternalSorter, SortOrder}; +use noodles::sam::Header; + +/// Build `n` coordinate-sortable records, each carrying a `payload_len`-base +/// (and `payload_len`-quality) body so the in-memory footprint per record is +/// large enough that a small `memory_limit` forces spills. +fn sized_records(n: usize, payload_len: usize) -> Vec { + (0..n) + .map(|i| { + let name = format!("rec_{i:06}"); + // tid 0, ascending positions; coordinate sort handles these directly. + let pos = i32::try_from(i).expect("pos fits i32"); + let bytes = make_bam_bytes(0, pos, 0, name.as_bytes(), &[], payload_len, -1, -1, &[]); + RawRecord::from(bytes) + }) + .collect() +} + +/// Drive a coordinate `SortStream` to completion via `finalize_into_pending` and +/// return `(spill_ready_events, non_empty_memory_chunk_events, announced)` where +/// `announced` is the trailing `AllAnnounced { slot_count, memory_chunk_count }`, +/// or `None` if `finalize_into_pending` produced no events. +fn finalize_counts(memory_limit: usize, records: &[RawRecord]) -> Option<(usize, usize, u32, u32)> { + let header = Header::default(); + let sorter = RawExternalSorter::new(SortOrder::Coordinate) + .memory_limit(memory_limit) + .threads(2) + .output_compression(1) + .temp_compression(1); + + let mut stream = build_stream(sorter, &header).expect("build coordinate stream"); + stream.push_records(records.iter().map(RawRecord::as_ref)).expect("push records into stream"); + + let pending = SortAndSpill::finalize_into_pending(stream, "test") + .expect("finalize_into_pending succeeds")?; + let (events, _temp_dirs) = pending; + + let mut spill_ready = 0usize; + let mut memory_chunks = 0usize; + let mut announced: Option<(u32, u32)> = None; + for event in &events { + match event { + SortPhase1Event::SpillReady { .. } => spill_ready += 1, + SortPhase1Event::MemoryChunk { chunk, .. } => { + // Empty chunks must never be emitted (they are skipped during + // finalize); assert that invariant directly. + assert!(!chunk.is_empty(), "an empty MemoryChunk leaked into the event stream"); + memory_chunks += 1; + } + SortPhase1Event::AllAnnounced { slot_count, memory_chunk_count, .. } => { + assert!(announced.is_none(), "more than one AllAnnounced emitted"); + announced = Some((*slot_count, *memory_chunk_count)); + } + } + } + + // AllAnnounced must be the LAST event of the stream. + assert!( + matches!(events.back(), Some(SortPhase1Event::AllAnnounced { .. })), + "AllAnnounced must be the final event" + ); + + let (slot_count, memory_chunk_count) = announced.expect("AllAnnounced present"); + Some((spill_ready, memory_chunks, slot_count, memory_chunk_count)) +} + +#[test] +fn announced_counts_match_emitted_events_with_spills() { + // A small memory limit against many sizeable records forces at least one + // spill AND leaves an in-memory residual chunk, so both announced counts are + // exercised against actual emitted events. + let records = sized_records(4_000, 200); + let (spill_ready, memory_chunks, slot_count, memory_chunk_count) = + finalize_counts(64 * 1024, &records).expect("non-empty finalize"); + + assert!(slot_count >= 1, "small memory limit should have forced at least one spill slot"); + assert_eq!( + usize::try_from(slot_count).unwrap(), + spill_ready, + "announced slot_count must equal the number of SpillReady events" + ); + assert_eq!( + usize::try_from(memory_chunk_count).unwrap(), + memory_chunks, + "announced memory_chunk_count must equal the number of (non-empty) MemoryChunk events" + ); +} + +#[test] +fn all_in_memory_announces_zero_slots() { + // A generous memory limit keeps everything in memory: zero spill slots, + // and the residual is announced purely as memory chunks. + let records = sized_records(500, 50); + let (spill_ready, memory_chunks, slot_count, memory_chunk_count) = + finalize_counts(256 * 1024 * 1024, &records).expect("non-empty finalize"); + + assert_eq!(slot_count, 0, "no spills expected under a large memory limit"); + assert_eq!(spill_ready, 0, "no SpillReady events expected"); + assert!(memory_chunk_count >= 1, "the in-memory residual must be announced as a chunk"); + assert_eq!(usize::try_from(memory_chunk_count).unwrap(), memory_chunks); +} + +#[test] +fn empty_stream_finalizes_to_none() { + // No records pushed -> nothing to announce -> finalize_into_pending returns + // None (no events, not an AllAnnounced with zeroed counts). + let header = Header::default(); + let sorter = RawExternalSorter::new(SortOrder::Coordinate) + .memory_limit(256 * 1024 * 1024) + .threads(2) + .output_compression(1) + .temp_compression(1); + let stream = build_stream(sorter, &header).expect("build coordinate stream"); + let pending = + SortAndSpill::finalize_into_pending(stream, "test").expect("finalize_into_pending"); + assert!(pending.is_none(), "an empty stream must finalize to None (no events emitted)"); +} diff --git a/crates/fgumi-raw-bam/src/sort.rs b/crates/fgumi-raw-bam/src/sort.rs index cee3dd5e3..059380b9b 100644 --- a/crates/fgumi-raw-bam/src/sort.rs +++ b/crates/fgumi-raw-bam/src/sort.rs @@ -423,4 +423,244 @@ mod tests { b[8] = 0; assert_eq!(compare_names_raw(&a, &b), Ordering::Equal); } + + // ======================================================================== + // Candidate-comparator equivalence with the production oracle (S3-024) + // ======================================================================== + // + // `benches/core_functions.rs` holds candidate queryname comparators + // (`compare_illumina_structured`, the samtools `strnum_cmp` port, and the + // `natural_compare_nul` wrapper) used to benchmark sort strategies. A + // "fast" candidate must not be promoted on speed alone without agreeing + // with the production ordering, so this module ports the candidate + // comparators as test-only helpers and asserts every pairwise comparison + // over `generate_name_pairs`-style data matches `natural_compare`. (The + // bench is `harness = false`, so a `#[cfg(test)]` module *inside* the bench + // never runs — the equivalence oracle belongs in this crate's test module.) + // + // `compare_u64_hash` is intentionally excluded: it is documented as a + // degenerate O(1) upper bound that byte-reverses ordering and collapses + // shared-prefix names to `Equal`, so it is deliberately NOT equivalent. + + /// Faithful port of samtools' `strnum_cmp` over NUL-terminated byte slices, + /// mirroring the bench candidate. Used here only as an equivalence reference + /// for `natural_compare`. + /// + /// This is a *safe*, index-based port (no raw-pointer walk): the equivalence + /// oracle runs only in tests, which have no hot-path perf budget, so there is + /// no reason to introduce `unsafe` here. The caller NUL-terminates both + /// slices, so the `!= 0` guards stop the walk at the terminator and every + /// index stays in bounds. + fn strnum_cmp_samtools(a: &[u8], b: &[u8]) -> i32 { + let (mut pa, mut pb) = (0usize, 0usize); + while a[pa] != 0 && b[pb] != 0 { + if !a[pa].is_ascii_digit() || !b[pb].is_ascii_digit() { + if a[pa] != b[pb] { + return i32::from(a[pa]) - i32::from(b[pb]); + } + pa += 1; + pb += 1; + } else { + while a[pa] == b'0' { + pa += 1; + } + while b[pb] == b'0' { + pb += 1; + } + while a[pa].is_ascii_digit() && a[pa] == b[pb] { + pa += 1; + pb += 1; + } + let diff = i32::from(a[pa]) - i32::from(b[pb]); + while a[pa].is_ascii_digit() && b[pb].is_ascii_digit() { + pa += 1; + pb += 1; + } + if a[pa].is_ascii_digit() { + return 1; + } else if b[pb].is_ascii_digit() { + return -1; + } else if diff != 0 { + return diff; + } + } + } + if a[pa] != 0 { + 1 + } else if b[pb] != 0 { + -1 + } else { + 0 + } + } + + fn compare_strnum_samtools(a: &[u8], b: &[u8]) -> Ordering { + let mut a_nul = a.to_vec(); + a_nul.push(0); + let mut b_nul = b.to_vec(); + b_nul.push(0); + strnum_cmp_samtools(&a_nul, &b_nul).cmp(&0) + } + + fn parse_int_fast(bytes: &[u8]) -> u64 { + let mut val: u64 = 0; + for &b in bytes { + if b.is_ascii_digit() { + val = val * 10 + u64::from(b - b'0'); + } else { + break; + } + } + val + } + + /// Port of the bench's Illumina-structured comparator: compare the first + /// four colon-delimited fields as bytes, then tile/x/y as integers. + fn compare_illumina_structured(a: &[u8], b: &[u8]) -> Ordering { + let mut a_fields = a.splitn(7, |&c| c == b':'); + let mut b_fields = b.splitn(7, |&c| c == b':'); + for _ in 0..4 { + let af = a_fields.next().unwrap_or(b""); + let bf = b_fields.next().unwrap_or(b""); + match af.cmp(bf) { + Ordering::Equal => {} + ord => return ord, + } + } + for _ in 0..3 { + let af = a_fields.next().unwrap_or(b""); + let bf = b_fields.next().unwrap_or(b""); + match parse_int_fast(af).cmp(&parse_int_fast(bf)) { + Ordering::Equal => {} + ord => return ord, + } + } + Ordering::Equal + } + + /// Generate realistic sorted name pairs, mirroring the bench's + /// `generate_name_pairs`. Numbers are non-zero-padded so the samtools-strnum + /// and natural comparators agree (no leading-zero ambiguity). + fn generate_name_pairs(style: &str, count: usize) -> Vec<(Vec, Vec)> { + let mut pairs = Vec::with_capacity(count); + match style { + "illumina" => { + let prefixes = + ["A00132:53:HFHJKDSXX", "A00132:54:HFH2JDSXX", "A00132:55:HFHKKDSXX"]; + for i in 0..count { + let p1 = prefixes[i % 3]; + let p2 = prefixes[(i + 1) % 3]; + let a = format!( + "{p1}:{}:{}:{}:{}", + (i % 4) + 1, + 1100 + (i % 500), + 1000 + (i * 17 % 30000), + 1000 + (i * 31 % 50000) + ); + let b = format!( + "{p2}:{}:{}:{}:{}", + ((i + 1) % 4) + 1, + 1100 + ((i + 7) % 500), + 1000 + ((i + 3) * 17 % 30000), + 1000 + ((i + 5) * 31 % 50000) + ); + pairs.push((a.into_bytes(), b.into_bytes())); + } + } + "illumina_same_prefix" => { + for i in 0..count { + let a = format!( + "A00132:53:HFHJKDSXX:{}:{}:{}:{}", + (i % 4) + 1, + 1100 + (i % 500), + 1000 + (i * 17 % 30000), + 1000 + (i * 31 % 50000) + ); + let b = format!( + "A00132:53:HFHJKDSXX:{}:{}:{}:{}", + ((i + 1) % 4) + 1, + 1100 + ((i + 7) % 500), + 1000 + ((i + 3) * 17 % 30000), + 1000 + ((i + 5) * 31 % 50000) + ); + pairs.push((a.into_bytes(), b.into_bytes())); + } + } + "srr" => { + for i in 0..count { + let a = format!("SRR6109273.{}", 100_000 + i * 7); + let b = format!("SRR6109273.{}", 100_000 + (i + 1) * 7); + pairs.push((a.into_bytes(), b.into_bytes())); + } + } + other => panic!("unknown name style: {other}"), + } + pairs + } + + fn assert_candidate_matches_oracle(style: &str, candidate: impl Fn(&[u8], &[u8]) -> Ordering) { + for (a, b) in generate_name_pairs(style, 5_000) { + // Forward agreement with the oracle. + assert_eq!( + candidate(&a, &b), + natural_compare(&a, &b), + "candidate disagrees with natural_compare (style={style}): {:?} vs {:?}", + String::from_utf8_lossy(&a), + String::from_utf8_lossy(&b), + ); + // Reverse agreement — pins antisymmetry, so a candidate cannot match + // the oracle on `(a, b)` while disagreeing on `(b, a)`. + assert_eq!( + candidate(&b, &a), + natural_compare(&b, &a), + "candidate disagrees with natural_compare on reversed pair (style={style}): \ + {:?} vs {:?}", + String::from_utf8_lossy(&b), + String::from_utf8_lossy(&a), + ); + // Reflexivity — every name must compare equal to itself. + assert_eq!( + candidate(&a, &a), + Ordering::Equal, + "candidate is not reflexive (style={style}): {:?}", + String::from_utf8_lossy(&a), + ); + assert_eq!( + candidate(&b, &b), + Ordering::Equal, + "candidate is not reflexive (style={style}): {:?}", + String::from_utf8_lossy(&b), + ); + } + } + + #[test] + fn strnum_samtools_candidate_matches_natural_compare() { + // samtools strnum_cmp agrees with natural_compare on non-zero-padded + // numeric runs, which every generated style produces. + for style in ["illumina", "illumina_same_prefix", "srr"] { + assert_candidate_matches_oracle(style, compare_strnum_samtools); + } + } + + #[test] + #[allow(unsafe_code)] + fn natural_nul_candidate_matches_natural_compare() { + // The production `natural_compare_nul` (via the `compare_nul` wrapper) + // agrees with the slice-based `natural_compare` when names carry no + // leading-zero ambiguity (none of the styles do). + for style in ["illumina", "illumina_same_prefix", "srr"] { + assert_candidate_matches_oracle(style, compare_nul); + } + } + + #[test] + fn illumina_structured_candidate_matches_natural_compare() { + // The field-parsing Illumina comparator is only a faithful model for + // Illumina-format names, so it is checked against the oracle on the + // Illumina styles only. + for style in ["illumina", "illumina_same_prefix"] { + assert_candidate_matches_oracle(style, compare_illumina_structured); + } + } } diff --git a/crates/fgumi-sort-cli/src/sort.rs b/crates/fgumi-sort-cli/src/sort.rs index 0ed2221ea..38531e302 100644 --- a/crates/fgumi-sort-cli/src/sort.rs +++ b/crates/fgumi-sort-cli/src/sort.rs @@ -969,6 +969,201 @@ mod tests { writer.finish().expect("finish"); } + /// Compute the spec-valid BAM bin for a 0-based half-open alignment range + /// `[beg, end)` using the SAM spec §5.3 `reg2bin` reference algorithm. The + /// fixtures here emit 10M alignments, so on-wire records must carry a real + /// bin rather than `0` (which is only valid for the largest-window layer). + #[allow(clippy::eq_op, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn reg2bin(beg: i32, end: i32) -> u16 { + let beg = beg as usize; + let end = (end - 1) as usize; // spec works on the inclusive last base + let bin = if beg >> 14 == end >> 14 { + ((1 << 15) - 1) / 7 + (beg >> 14) + } else if beg >> 17 == end >> 17 { + ((1 << 12) - 1) / 7 + (beg >> 17) + } else if beg >> 20 == end >> 20 { + ((1 << 9) - 1) / 7 + (beg >> 20) + } else if beg >> 23 == end >> 23 { + ((1 << 6) - 1) / 7 + (beg >> 23) + } else if beg >> 26 == end >> 26 { + ((1 << 3) - 1) / 7 + (beg >> 26) + } else { + 0 + }; + bin as u16 + } + + /// Build a single 10-base mapped BAM record body at `(ref_id, pos)` with the + /// given read `name`. Mirrors the layout in `write_coordinate_bam`. + #[allow(clippy::cast_possible_truncation)] + fn mapped_record_body(name: &[u8], ref_id: i32, pos: i32) -> Vec { + let name_with_null = name.len() + 1; + let padding = (4 - (name_with_null % 4)) % 4; + // 10M alignment spanning [pos, pos + 10); emit the spec-valid bin so the + // success-path fixture mirrors the production on-wire BAM encoding. + let bin = reg2bin(pos, pos + 10); + let mut record = Vec::with_capacity(64); + record.extend_from_slice(&ref_id.to_le_bytes()); + record.extend_from_slice(&pos.to_le_bytes()); + record.push((name_with_null + padding) as u8); // l_read_name + record.push(60_u8); // mapq + record.extend_from_slice(&bin.to_le_bytes()); // bin (SAM spec §5.3 reg2bin) + record.extend_from_slice(&1_u16.to_le_bytes()); // n_cigar_op + record.extend_from_slice(&0_u16.to_le_bytes()); // flag + record.extend_from_slice(&10_u32.to_le_bytes()); // l_seq + record.extend_from_slice(&(-1_i32).to_le_bytes()); // next_ref_id + record.extend_from_slice(&(-1_i32).to_le_bytes()); // next_pos + record.extend_from_slice(&0_i32.to_le_bytes()); // tlen + record.extend_from_slice(name); + record.push(0); + record.extend(std::iter::repeat_n(0_u8, padding)); + record.extend_from_slice(&(10_u32 << 4).to_le_bytes()); // 10M cigar + record.extend_from_slice(&[0x11_u8; 5]); // packed seq + record.extend_from_slice(&[30_u8; 10]); // qualities + record + } + + /// Write a small UNSORTED two-reference coordinate BAM to `path`. Returns the + /// number of records written. The records are emitted out of coordinate + /// order so a real sort has work to do. + fn write_unsorted_coordinate_bam(path: &std::path::Path) -> usize { + use noodles::sam::Header; + use noodles::sam::header::record::value::{Map, map::ReferenceSequence}; + use std::num::NonZeroUsize; + + let mut builder = Header::builder(); + for chrom in [b"chr1".as_slice(), b"chr2".as_slice()] { + builder = builder.add_reference_sequence( + chrom, + Map::::new(NonZeroUsize::new(10_000).expect("non-zero")), + ); + } + // Header SO is `unsorted` for input; the writer is told nothing about order. + let header = builder.build(); + + // (ref_id, pos) emitted deliberately out of coordinate order. + let layout: &[(&[u8], i32, i32)] = &[ + (b"r_chr2_300", 1, 300), + (b"r_chr1_500", 0, 500), + (b"r_chr1_100", 0, 100), + (b"r_chr2_50", 1, 50), + (b"r_chr1_900", 0, 900), + (b"r_chr2_700", 1, 700), + (b"r_chr1_300", 0, 300), + ]; + + let mut writer = fgumi_bam_io::create_raw_bam_writer(path, &header, 1, 1) + .expect("create_raw_bam_writer"); + for &(name, ref_id, pos) in layout { + writer.write_raw_record(&mapped_record_body(name, ref_id, pos)).expect("write record"); + } + writer.finish().expect("finish"); + layout.len() + } + + /// Canonical per-record identity: the full logical record. + /// + /// We key on the `Debug` rendering of the entire [`noodles` `RecordBuf`], which + /// covers the complete alignment payload — read name, flags, reference id / + /// position, mapping quality, CIGAR, mate fields, template length, SEQ, QUAL, + /// and AUX tags. A weaker `(name, tid, pos, flags)` projection would still + /// pass if a sort mutated CIGAR, mate fields, SEQ/QUAL, or a tag. `RecordBuf` + /// is the parsed *logical* record, so this is robust to encoding-only + /// differences (e.g. the recomputed `bin`) that a raw-byte compare would + /// spuriously flag. `RecordBuf` is not `Ord`, so we sort the string forms. + type RecordIdentity = String; + + /// Read a canonical per-record identity for every record of a BAM, sorted + /// into a multiset. Comparing the input and output multisets proves the sort + /// preserved every record exactly — not just its coordinate. + fn read_record_identities(path: &std::path::Path) -> Vec { + let mut reader = + noodles::bam::io::Reader::new(std::fs::File::open(path).expect("open bam")); + let header = reader.read_header().expect("read header"); + let mut ids: Vec = + reader.record_bufs(&header).map(|r| format!("{:?}", r.expect("read record"))).collect(); + ids.sort(); + ids + } + + /// Read back `(reference_sequence_id, alignment_start)` for every record of a + /// BAM, in file order, via noodles. + fn read_coordinate_keys(path: &std::path::Path) -> Vec<(Option, Option)> { + let mut reader = + noodles::bam::io::Reader::new(std::fs::File::open(path).expect("open sorted output")); + let header = reader.read_header().expect("read header"); + reader + .record_bufs(&header) + .map(|r| { + let rec = r.expect("read record"); + (rec.reference_sequence_id(), rec.alignment_start().map(usize::from)) + }) + .collect() + } + + /// Success-path coverage for the streaming sort engine wiring + /// (`execute_sort` / `build_sort_step` / write-index) — S3-010. A small + /// unsorted coordinate BAM is sorted end-to-end with `--write-index`; the + /// output must be coordinate-ordered, preserve every record, and produce a + /// readable `.bam.bai` sidecar. + #[test] + fn execute_sort_success_path_sorts_and_writes_index() { + let dir = tempfile::tempdir().expect("tempdir"); + let input = dir.path().join("in.bam"); + let output = dir.path().join("out.bam"); + let expected_count = write_unsorted_coordinate_bam(&input); + + let sort = Sort::try_parse_from([ + "sort", + "-i", + input.to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "--order", + "coordinate", + "--write-index", + "true", + // Bound the per-thread reservation well below the 768 MiB default so + // nextest's concurrent execution stays light. + "--max-memory", + "16MiB", + ]) + .expect("parse Sort"); + + sort.execute("fgumi sort").expect("end-to-end coordinate sort must succeed"); + + // Output BAM and a readable BAI sidecar must both exist. + assert!(output.exists(), "sorted output BAM not written"); + let bai = fgumi_bam_io::bai_sidecar_path(&output); + assert!(bai.exists(), "BAI sidecar not written on the success path"); + noodles::bam::bai::fs::read(&bai).expect("BAI sidecar must parse"); + + // Exact record count preserved (no loss / duplication). + let keys = read_coordinate_keys(&output); + assert_eq!(keys.len(), expected_count, "record count changed across sort"); + + // Record identity preserved: the input and output multisets must match + // exactly, so a sort cannot silently mutate, drop, or duplicate a record + // while still satisfying the count and coordinate-order checks below. + assert_eq!( + read_record_identities(&input), + read_record_identities(&output), + "record identity changed across sort (mutation / loss / duplication)" + ); + + // Independent coordinate-order check: (tid, pos) non-decreasing, with + // no-reference records (None) sorting last. + let rank = |k: &(Option, Option)| (k.0.is_none(), k.0, k.1); + for pair in keys.windows(2) { + assert!( + rank(&pair[0]) <= rank(&pair[1]), + "output is not coordinate-ordered: {:?} then {:?}", + pair[0], + pair[1] + ); + } + } + /// A failed sort must not write (or overwrite) the `.bam.bai`: the index /// finalize hook only runs once the pipeline run succeeds. #[test] diff --git a/crates/fgumi-sort/src/worker_pool.rs b/crates/fgumi-sort/src/worker_pool.rs index 84d66c5ab..5f105a2bd 100644 --- a/crates/fgumi-sort/src/worker_pool.rs +++ b/crates/fgumi-sort/src/worker_pool.rs @@ -3033,4 +3033,139 @@ mod tests { assert_eq!(phase2_file_position(&files[0]), 0); pool.shutdown(); } + + // ======================================================================== + // End-to-end Phase 2 over REAL zstd spill files (S3-009) + // + // The codec-detection tests above stop at `set_phase2_files`; the + // `read_raw_zstd_frames` tests feed opaque non-zstd bytes. Neither drives + // the real `decompress_to_buffer` arm. This test writes genuine zstd spill + // chunks (`ZSPILL_MAGIC` + `[u32 LE frame-len][zstd frame]` records), + // including a frame whose DECOMPRESSED size approaches `ZSTD_FRAME_DECOMP_CAP`, + // drives the pool through Phase 2, drains each file's reorder buffer in + // order, and asserts the reconstructed bytes are byte-identical to the + // original uncompressed payload — exercising the 256 KiB scratch-buffer + // boundary and the real zstd decode path the integration tests cover only + // indirectly. + // ======================================================================== + + /// Write a zstd spill file: `ZSPILL_MAGIC` followed by one + /// `[u32 LE compressed-len][zstd frame]` record per element of `frames`. + /// Returns the byte-concatenation of the original (uncompressed) frames, + /// which is what draining Phase 2 must reconstruct. + fn write_zstd_spill_file(path: &std::path::Path, frames: &[Vec]) -> Vec { + use std::io::Write; + + let mut file = std::fs::File::create(path).expect("create zstd spill file"); + file.write_all(&ZSPILL_MAGIC).expect("write magic"); + + let mut compressor = ZstdCompressor::new(3).expect("zstd compressor"); + let mut expected = Vec::new(); + for frame in frames { + let compressed = compressor.compress(frame).expect("zstd compress frame"); + let len = u32::try_from(compressed.len()).expect("frame fits u32"); + file.write_all(&len.to_le_bytes()).expect("write frame length prefix"); + file.write_all(&compressed).expect("write compressed frame"); + expected.extend_from_slice(frame); + } + file.flush().expect("flush spill file"); + expected + } + + /// Drain every Phase 2 file's reorder buffer in serial order, concatenating + /// the decompressed blocks. Mirrors `external.rs::advance_to_next_block`'s + /// pop / error-check / drained / park protocol. + fn drain_phase2_to_bytes(pool: &SortWorkerPool) -> Vec { + let files = pool.phase2_files(); + let decompress_error = pool.decompress_error_flag(); + let chunk_read_error = pool.chunk_read_error_flag(); + let worker_panicked = pool.worker_panicked_flag(); + + let mut out = Vec::new(); + for file in files.iter() { + // Hard deadline so a Phase-2 liveness regression that stalls without + // setting an error flag fails fast instead of parking the suite forever. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let popped = { + let mut guard = file.decompressed.lock().expect("decompressed mutex"); + guard.try_pop_next() + }; + if let Some(data) = popped { + out.extend_from_slice(&data); + if matches!(file.codec, SpillCodec::Zstd) { + file.buffer_pool.checkin(data); + } + continue; + } + assert!(!decompress_error.load(Ordering::Acquire), "decompression error flagged"); + assert!(!chunk_read_error.load(Ordering::Acquire), "chunk read error flagged"); + assert!(!worker_panicked.load(Ordering::Acquire), "worker panicked"); + if file.is_drained() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for Phase 2 drain" + ); + std::thread::park_timeout(std::time::Duration::from_millis(50)); + } + } + out + } + + #[test] + fn phase2_end_to_end_decodes_real_zstd_frames_including_near_cap_frame() { + let dir = tempfile::tempdir().expect("tempdir"); + let spill_a = dir.path().join("chunk_a.zsp"); + let spill_b = dir.path().join("chunk_b.zsp"); + + // Build frames with structured (compressible) content so decompression + // is a real transform, not a memcpy. One frame is sized to approach + // ZSTD_FRAME_DECOMP_CAP (256 KiB) so the worker's scratch buffer is used + // at its capacity boundary; the buffer must hold the full decompressed + // frame, so stay just under the cap. + let near_cap_len = ZSTD_FRAME_DECOMP_CAP - 4096; + let frames_a: Vec> = vec![ + (0..1024u32).flat_map(u32::to_le_bytes).collect(), + (0..near_cap_len).map(|i| u8::try_from(i % 251).expect("< 256")).collect(), + b"a short trailing frame".to_vec(), + ]; + let frames_b: Vec> = vec![ + (0..8192u32).map(|i| u8::try_from(i.wrapping_mul(7) % 256).expect("< 256")).collect(), + vec![0xCDu8; 4096], + ]; + + let expected_a = write_zstd_spill_file(&spill_a, &frames_a); + let expected_b = write_zstd_spill_file(&spill_b, &frames_b); + + // Single worker keeps the drained-detection deterministic for the test. + let pool = SortWorkerPool::new(1, 1, 6, SpillCodec::Zstd); + pool.set_main_thread(std::thread::current()); + pool.set_phase2_files(&[spill_a.clone(), spill_b.clone()]).expect("set_phase2_files"); + + // Both files must have been routed through the zstd decoder. + let files = pool.phase2_files(); + assert!( + files.iter().all(|f| matches!(f.codec, SpillCodec::Zstd)), + "both spill files must be detected as zstd" + ); + + pool.set_phase(phase::PHASE2); + let got = drain_phase2_to_bytes(&pool); + + let mut expected = expected_a; + expected.extend_from_slice(&expected_b); + assert_eq!( + got.len(), + expected.len(), + "reconstructed byte length differs after Phase 2 zstd decode" + ); + assert_eq!( + got, expected, + "Phase 2 zstd decode did not reconstruct the original payload byte-for-byte" + ); + + pool.shutdown(); + } } diff --git a/tests/integration/test_dedup_command.rs b/tests/integration/test_dedup_command.rs index b5c8c7a19..9f065d341 100644 --- a/tests/integration/test_dedup_command.rs +++ b/tests/integration/test_dedup_command.rs @@ -175,12 +175,14 @@ fn test_dedup_command_remove_duplicates() { cmd.execute("fgumi dedup").expect("Dedup command with --remove-duplicates failed"); assert!(output_bam.exists(), "Output BAM not created"); - // With remove-duplicates, only the best pair should remain + // With --remove-duplicates, exactly one pair (2 records) survives: the 3 + // duplicate pairs (6 records) collapse to the single best representative + // pair, so the other 2 pairs (4 records) are dropped. Assert the exact + // expected count rather than loose bounds (S9b-008). let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap()); let _header = reader.read_header().unwrap(); let count = reader.records().count(); - assert!(count < 6, "Remove-duplicates should produce fewer reads than input"); - assert!(count >= 2, "Should keep at least one pair"); + assert_eq!(count, 2, "remove-duplicates over 3 duplicate pairs must keep exactly one pair"); } /// SAM-input parity: dedup's typed-step path accepts both BAM and SAM via diff --git a/tests/integration/test_simulate_aligner.rs b/tests/integration/test_simulate_aligner.rs index d2a045fc7..14aed9ac8 100644 --- a/tests/integration/test_simulate_aligner.rs +++ b/tests/integration/test_simulate_aligner.rs @@ -99,15 +99,26 @@ fn errors_when_replay_bam_is_missing() { let tmp = TempDir::new().unwrap(); let missing = tmp.path().join("does-not-exist.bam"); - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) + let output = Command::new(env!("CARGO_BIN_EXE_fgumi")) .args(["simulate", "aligner", "--replay-bam"]) .arg(&missing) .arg("/fake/reference.fa") .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() + .output() .expect("spawn fgumi simulate aligner"); - assert!(!status.success(), "missing replay BAM must be a non-zero exit"); + assert!(!output.status.success(), "missing replay BAM must be a non-zero exit"); + + // The error must name the failing operation AND the offending path so the + // user can act on it — not just exit non-zero (S9b-005). + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("opening replay BAM"), + "stderr must explain the failing operation; got: {stderr}" + ); + assert!( + stderr.contains(&*missing.to_string_lossy()), + "stderr must name the missing replay BAM path; got: {stderr}" + ); } diff --git a/tests/integration/test_sort_correctness.rs b/tests/integration/test_sort_correctness.rs index 38e04e948..7817b0645 100644 --- a/tests/integration/test_sort_correctness.rs +++ b/tests/integration/test_sort_correctness.rs @@ -1,171 +1,260 @@ //! Sort correctness tests for all sort orders and spill configurations. //! -//! Validates that `fgumi sort` produces correctly sorted output across: -//! - All 4 sort orders (coordinate, queryname-lex, queryname-natural, template-coordinate) -//! - Multiple thread counts (1, 2, 4) -//! - Various memory limits forcing 0, 1, and multiple spills -//! - Record content is preserved (byte-identical records) +//! Validates that `fgumi sort` produces correctly ordered output across the +//! full order × spill matrix: +//! +//! - **Orders**: coordinate, queryname-lexicographic, queryname-natural, +//! template-coordinate. +//! - **Spill regimes**: in-memory (large `-m`), single-spill, many-spill (small +//! `-m`, 4 threads), and the `--temp-compression 0` uncompressed-bgzf spill +//! path. +//! +//! ## Independent order oracle (S9b-002) +//! +//! Sort order is NOT proven solely by `fgumi sort --verify`: that path re-derives +//! the key with the SAME extractor functions and comparison operators the sort +//! path uses, so a wrong-but-consistent comparator would sort records into a +//! bogus order that its own verify then blesses. `--verify` is kept here only as +//! a write-corruption / post-write guard (truncation, reordering by the writer, +//! dropped records), demoted from the sole order proof. +//! +//! The actual order assertion is INDEPENDENT of the sort crate: +//! - coordinate and queryname-lexicographic orders are checked against an +//! in-test expected order computed directly from the fixture records (the test +//! constructs the records, so the correct `(tid, pos)` / lexicographic name +//! order is computable without any sort-crate code); +//! - queryname-natural and template-coordinate orders — whose exact rules +//! (`strnum_cmp` semantics; the packed template cell key) are error-prone to +//! restate — are cross-checked against `samtools sort -n` / +//! `samtools sort --template-coordinate`. Those samtools tests RUN in CI +//! (samtools is installed in the workflow) and are gated only by a runtime +//! `samtools_available()` check so local dev without samtools skips them +//! gracefully with a message. +//! +//! ## Fixtures (S9b-001) +//! +//! Input BAMs are built in-process with `SamBuilder` + the noodles BAM writer +//! (no samtools, no committed data files), so the whole matrix runs by default +//! in CI — the spill/streaming-sort path is the branch's headline behaviour and +//! must execute on every PR. use std::ffi::OsString; -use std::fmt::Write as _; use std::path::Path; use std::process::Command; -use tempfile::TempDir; use clap::Parser; use fgumi_lib::commands::command::Command as FgumiCommand; use fgumi_lib::commands::sort::Sort; +use fgumi_lib::sam::SamTag; +use fgumi_raw_bam::{RawRecord, SamBuilder, flags}; +use noodles::bam; +use noodles::sam::Header; +use noodles::sam::alignment::io::Write as AlignmentWrite; +use noodles::sam::alignment::record_buf::RecordBuf; +use rstest::rstest; +use tempfile::TempDir; + +use crate::helpers::bam_generator::to_record_buf; // --------------------------------------------------------------------------- -// Helpers +// Fixture construction (in-process, no samtools) // --------------------------------------------------------------------------- -fn fgumi_sort_in_process(args: &[OsString]) -> anyhow::Result<()> { - // `clap::Parser::try_parse_from` accepts any iterator of `Into` - // values, so feeding `OsString` through avoids a UTF-8 round-trip that - // would panic on a non-UTF-8 temp-dir path. - let cmd = Sort::try_parse_from(args.iter().cloned()).expect("failed to parse sort args"); - cmd.execute("fgumi sort") -} - -fn samtools_available() -> bool { - Command::new("samtools").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) +/// Build a multi-chromosome unsorted SAM header with one read group. +fn unsorted_header() -> Header { + use bstr::BString; + use noodles::sam::header::record::value::map::Header as HeaderRecord; + use noodles::sam::header::record::value::map::header::tag::Tag as HeaderTag; + use noodles::sam::header::record::value::map::{Map, ReferenceSequence}; + use std::num::NonZeroUsize; + + let HeaderTag::Other(so_tag) = HeaderTag::from(*b"SO") else { unreachable!() }; + let header_map = Map::::builder() + .insert(so_tag, "unsorted") + .build() + .expect("valid header map"); + + let mut builder = Header::builder().set_header(header_map); + for chrom in ["chr1", "chr2", "chr3"] { + builder = builder.add_reference_sequence( + BString::from(chrom), + Map::::new(NonZeroUsize::new(100_000).expect("non-zero ref len")), + ); + } + builder.build() } -/// Create a test BAM with many records at various positions using samtools. -/// Returns the path to the unsorted BAM. -fn create_test_bam(dir: &Path, num_reads: usize) -> std::path::PathBuf { - let bam_path = dir.join("unsorted.bam"); - - // Build SAM content with reads spread across multiple chromosomes - let mut sam = String::new(); - sam.push_str("@HD\tVN:1.6\tSO:unsorted\n"); - sam.push_str("@SQ\tSN:chr1\tLN:100000\n"); - sam.push_str("@SQ\tSN:chr2\tLN:100000\n"); - sam.push_str("@SQ\tSN:chr3\tLN:100000\n"); - sam.push_str("@RG\tID:rg1\tSM:sample1\tLB:lib1\n"); - - let seq = "ACGTACGTAC"; - let qual = "IIIIIIIIII"; - - // Generate reads in a deliberately unsorted order - for i in 0..num_reads { - let chrom = match i % 3 { - 0 => "chr2", - 1 => "chr1", - _ => "chr3", +/// Generate a deliberately unsorted set of paired-end records spread across +/// three chromosomes with non-zero-padded read names. +/// +/// The records are crafted so the four sort orders are all genuinely distinct: +/// - names are `read_0`, `read_1`, ... `read_N` (no zero padding) so natural and +/// lexicographic orders diverge (`read_2` < `read_10` naturally, but +/// `read_10` < `read_2` lexically); +/// - reads round-robin across chr2/chr1/chr3 and positions count *backwards* +/// within each chromosome, so input order matches no sort order; +/// - each template is assigned to one of four cells (`CB`) so the +/// template-coordinate cell-aware key path is exercised. +/// +/// Returns the raw records in (unsorted) emission order. +fn unsorted_records(num_templates: usize) -> Vec { + let seq = b"ACGTACGTAC"; + let qual = vec![40u8; seq.len()]; + let cigar = u32::try_from(seq.len()).expect("seq len fits u32") << 4; // 10M (op 0) + // Mate-cigar (MC) text — every read's mate is also a 10M alignment. + // `samtools sort --template-coordinate` requires MC on paired-end inputs. + let mc = format!("{}M", seq.len()); + + let mut records = Vec::with_capacity(num_templates * 2 + 20); + + for i in 0..num_templates { + // ref_id chosen so input order is NOT coordinate order. + let ref_id: i32 = match i % 3 { + 0 => 1, // chr2 + 1 => 0, // chr1 + _ => 2, // chr3 }; - // Positions go backwards within each chromosome to ensure unsorted input - let pos = 50000 - (i % 1000) * 50 + 1; - // Use non-zero-padded names so natural and lexicographic sort produce different - // orderings (e.g. read_2 < read_10 under natural but read_10 < read_2 under lex). - let name = format!("read_{i}"); - - // Paired-end reads + // Positions decrease as i grows within each residue class -> unsorted. + let pos: i32 = 50_000 - i32::try_from((i % 1000) * 50).expect("pos offset fits i32"); let mate_pos = pos + 200; - // Assign each template to one of 4 cells so template-coordinate tests - // exercise the cell-aware sort key path, not just the fallback. + let name = format!("read_{i}"); let cell = format!("cell{}", i % 4); - // R1 - writeln!( - sam, - "{name}\t99\t{chrom}\t{pos}\t60\t10M\t=\t{mate_pos}\t210\t{seq}\t{qual}\tRG:Z:rg1\tMI:Z:umi_{i}\tCB:Z:{cell}" - ) - .expect("write SAM record"); - // R2 - writeln!( - sam, - "{name}\t147\t{chrom}\t{mate_pos}\t60\t10M\t=\t{pos}\t-210\t{seq}\t{qual}\tRG:Z:rg1\tMI:Z:umi_{i}\tCB:Z:{cell}" - ) - .expect("write SAM record"); + let umi = format!("umi_{i}"); + let template_len = 210_i32; + + // R1 (0x63 = PAIRED | PROPER_PAIR | MATE_REVERSE | FIRST_SEGMENT). + let mut r1 = SamBuilder::new(); + r1.read_name(name.as_bytes()) + .ref_id(ref_id) + .pos(pos) + .mapq(60) + .flags(flags::PAIRED | flags::PROPER_PAIR | flags::MATE_REVERSE | flags::FIRST_SEGMENT) + .mate_ref_id(ref_id) + .mate_pos(mate_pos) + .template_length(template_len) + .cigar_ops(&[cigar]) + .sequence(seq) + .qualities(&qual) + .add_string_tag(SamTag::RG, b"rg1") + .add_string_tag(SamTag::MI, umi.as_bytes()) + .add_string_tag(SamTag::CB, cell.as_bytes()) + .add_string_tag(SamTag::MC, mc.as_bytes()); + records.push(r1.build()); + + // R2 (PAIRED | PROPER_PAIR | REVERSE | LAST_SEGMENT). + let mut r2 = SamBuilder::new(); + r2.read_name(name.as_bytes()) + .ref_id(ref_id) + .pos(mate_pos) + .mapq(60) + .flags(flags::PAIRED | flags::PROPER_PAIR | flags::REVERSE | flags::LAST_SEGMENT) + .mate_ref_id(ref_id) + .mate_pos(pos) + .template_length(-template_len) + .cigar_ops(&[cigar]) + .sequence(seq) + .qualities(&qual) + .add_string_tag(SamTag::RG, b"rg1") + .add_string_tag(SamTag::MI, umi.as_bytes()) + .add_string_tag(SamTag::CB, cell.as_bytes()) + .add_string_tag(SamTag::MC, mc.as_bytes()); + records.push(r2.build()); } - // Add some unmapped reads + // A handful of fully-unmapped pairs (tid = -1) to exercise the + // "no reference sorts last" branch of the coordinate / template key. for i in 0..10 { - let name = format!("unmapped_{i:04}"); - writeln!(sam, "{name}\t77\t*\t0\t0\t*\t*\t0\t0\t{seq}\t{qual}\tRG:Z:rg1") - .expect("write SAM record"); - writeln!(sam, "{name}\t141\t*\t0\t0\t*\t*\t0\t0\t{seq}\t{qual}\tRG:Z:rg1") - .expect("write SAM record"); + let name = format!("unmapped_{i}"); + for &seg in &[flags::FIRST_SEGMENT, flags::LAST_SEGMENT] { + let mut b = SamBuilder::new(); + b.read_name(name.as_bytes()) + .flags(flags::PAIRED | flags::UNMAPPED | flags::MATE_UNMAPPED | seg) + .sequence(seq) + .qualities(&qual) + .add_string_tag(SamTag::RG, b"rg1"); + records.push(b.build()); + } } - let sam_path = dir.join("unsorted.sam"); - std::fs::write(&sam_path, sam).expect("write SAM"); - - // Convert to BAM - let status = Command::new("samtools") - .args(["view", "-b", "-o", bam_path.to_str().unwrap(), sam_path.to_str().unwrap()]) - .status() - .expect("samtools view"); - assert!(status.success(), "samtools view failed"); + records +} - bam_path +/// Write `records` under `header` to a BAM at `path` using the noodles writer. +fn write_bam(path: &Path, header: &Header, records: &[RawRecord]) { + let mut writer = + bam::io::Writer::new(std::fs::File::create(path).expect("create input BAM file")); + writer.write_header(header).expect("write header"); + for record in records { + writer.write_alignment_record(header, &to_record_buf(record)).expect("write record"); + } + writer.try_finish().expect("finish BAM"); } -/// Run `fgumi sort --verify` and return whether the file is correctly sorted. -fn verify_sorted(bam_path: &Path, order: &str) -> bool { - let cmd_args: Vec = vec![ - "sort".into(), - "--verify".into(), - "-i".into(), - bam_path.as_os_str().to_owned(), - "--order".into(), - order.into(), - ]; - fgumi_sort_in_process(&cmd_args).is_ok() +/// Build an unsorted input BAM with `num_templates` paired templates and return +/// the temp dir (kept alive) plus the input path. +fn build_unsorted_fixture(num_templates: usize) -> (TempDir, std::path::PathBuf) { + let dir = TempDir::new().expect("tempdir"); + let input = dir.path().join("unsorted.bam"); + let header = unsorted_header(); + let records = unsorted_records(num_templates); + write_bam(&input, &header, &records); + (dir, input) } -/// Count records in a BAM using samtools. -fn count_records(bam_path: &Path) -> u64 { - let output = Command::new("samtools") - .args(["view", "-c", bam_path.to_str().unwrap()]) - .output() - .expect("samtools view -c"); - assert!(output.status.success()); - String::from_utf8_lossy(&output.stdout) - .trim() - .parse() - .expect("samtools view -c output should be a valid integer") +// --------------------------------------------------------------------------- +// fgumi sort invocation +// --------------------------------------------------------------------------- + +fn fgumi_sort_in_process(args: &[OsString]) -> anyhow::Result<()> { + let cmd = Sort::try_parse_from(args.iter().cloned()).expect("failed to parse sort args"); + cmd.execute("fgumi sort") } -/// Decode a BAM's records to SAM text (via `samtools view`) and return the -/// record lines in a canonical, order-independent order (sorted lexically). -/// -/// Sorting reorders records but must not alter their content, so two BAMs that -/// hold the same records — regardless of sort order or output compression -/// codec — produce identical results here. Comparing the input against a sorted -/// output therefore asserts byte-identical record preservation through the -/// write path, which a record-count check alone cannot (a truncated or mangled -/// record with the right count would slip past a count assertion). -fn record_lines_canonical(bam_path: &Path) -> Vec { - let output = Command::new("samtools") - .args(["view", bam_path.to_str().unwrap()]) - .output() - .expect("samtools view"); - assert!(output.status.success(), "samtools view failed for {}", bam_path.display()); - let mut lines: Vec = String::from_utf8(output.stdout) - .expect("samtools view output is valid UTF-8") - .lines() - .map(str::to_owned) - .collect(); - lines.sort(); - lines +/// Spill regime applied to a sort run. Each variant maps to a concrete memory +/// limit + thread count + temp-compression configuration that forces the +/// targeted number of spills. +#[derive(Clone, Copy, Debug)] +enum Spill { + /// Large memory budget — everything sorts in memory, no spill files. + InMemory, + /// Small memory budget — forces a small number of spill files. + Single, + /// Very small memory budget + 4 threads — forces many spill files. + Many, + /// Small memory budget with `--temp-compression 0 --temp-codec bgzf` — + /// exercises the uncompressed-bgzf spill path under spill pressure. + UncompressedBgzf, } -/// Sort a BAM file with fgumi and return the output path. -fn sort_bam(input: &Path, output: &Path, order: &str, threads: usize, max_memory: &str) { - sort_bam_with_args(input, output, order, threads, max_memory, &[]); +impl Spill { + fn max_memory(self) -> &'static str { + match self { + Spill::InMemory => "256M", + Spill::Single | Spill::UncompressedBgzf => "200K", + Spill::Many => "64K", + } + } + + fn threads(self) -> usize { + match self { + Spill::Many => 4, + _ => 2, + } + } + + fn extra_args(self) -> Vec<&'static str> { + match self { + Spill::UncompressedBgzf => { + vec!["--temp-compression", "0", "--temp-codec", "bgzf"] + } + _ => vec!["--temp-compression", "1"], + } + } } -fn sort_bam_with_args( - input: &Path, - output: &Path, - order: &str, - threads: usize, - max_memory: &str, - extra_args: &[&str], -) { - let mut cmd_args: Vec = vec![ +/// Sort `input` into `output` with `order` under the given spill regime. +fn run_sort(input: &Path, output: &Path, order: &str, spill: Spill) { + let mut args: Vec = vec![ "sort".into(), "-i".into(), input.as_os_str().to_owned(), @@ -174,277 +263,654 @@ fn sort_bam_with_args( "--order".into(), order.into(), "--threads".into(), - threads.to_string().into(), + spill.threads().to_string().into(), "-m".into(), - max_memory.into(), - "--temp-compression".into(), - "1".into(), + spill.max_memory().into(), ]; - for arg in extra_args { - cmd_args.push((*arg).into()); + for a in spill.extra_args() { + args.push(a.into()); } - fgumi_sort_in_process(&cmd_args).unwrap_or_else(|e| { - panic!("fgumi sort failed for order={order} threads={threads} memory={max_memory}: {e:#}") - }); + fgumi_sort_in_process(&args) + .unwrap_or_else(|e| panic!("fgumi sort failed (order={order}, spill={spill:?}): {e:#}")); +} + +/// Run `fgumi sort --verify` as a write-corruption guard (NOT the order oracle). +fn verify_sorted(bam_path: &Path, order: &str) -> bool { + let args: Vec = vec![ + "sort".into(), + "--verify".into(), + "-i".into(), + bam_path.as_os_str().to_owned(), + "--order".into(), + order.into(), + ]; + fgumi_sort_in_process(&args).is_ok() } // --------------------------------------------------------------------------- -// Tests: Coordinate Sort +// Reading output back (noodles, no samtools) // --------------------------------------------------------------------------- -#[test] -#[ignore = "requires samtools"] -fn test_sort_coordinate_in_memory() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 500); - let output = dir.path().join("sorted.bam"); +/// Read every record of `path` as a `RecordBuf`, in file (sorted) order. +fn read_records(path: &Path) -> Vec { + let mut reader = + bam::io::Reader::new(std::fs::File::open(path).expect("open output BAM for reading")); + let header = reader.read_header().expect("read header"); + reader + .record_bufs(&header) + .collect::>>() + .expect("read records from output BAM") +} - // Large memory = no spills (in-memory sort) - sort_bam(&input, &output, "coordinate", 2, "100M"); - assert!(verify_sorted(&output, "coordinate"), "coordinate sort verification failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); +/// A small, order-relevant projection of a record: `(tid, pos, name, flags)`. +/// `tid == -1` for records with no reference (BAM convention: sort last). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +struct RecordKey { + tid: i32, + pos: i32, + name: Vec, + flags: u16, } -/// `--compression-level 0` must produce a valid, readable, correctly-sorted BAM -/// that preserves every input record byte-for-byte. -/// Level 0 routes through the `BgzfCompress`/`WriteBgzfFile` path with -/// `InlineBgzfCompressor::new(0)`, which emits uncompressed (stored) BGZF blocks -/// (#361). Guards that the #330 pipeline honours level 0 rather than rejecting or -/// silently remapping it. Run with spills so the level-0 setting flows through to -/// the final BAM write; the spill path keeps the helper's default -/// `--temp-compression 1` (a separate knob from `--compression-level`). -#[test] -#[ignore = "requires samtools"] -fn test_sort_compression_level_zero() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 2000); - let output = dir.path().join("sorted_l0.bam"); - - // Small memory forces spills; -c 0 is the uncompressed output level. - sort_bam_with_args(&input, &output, "coordinate", 2, "50K", &["--compression-level", "0"]); - assert!(verify_sorted(&output, "coordinate"), "level-0 coordinate sort verification failed"); - - // Content preservation: the level-0 (uncompressed-BGZF) write path must round-trip - // every record intact. Compare the input against the sorted output order-independently - // — sorting reorders records but must not alter them — so a dropped, truncated, or - // mangled record fails here even when the count happens to match. +fn record_key(r: &RecordBuf) -> RecordKey { + let tid = r.reference_sequence_id().map_or(-1, |id| i32::try_from(id).expect("tid fits i32")); + let pos = r + .alignment_start() + .map_or(-1, |p| i32::try_from(usize::from(p)).expect("pos fits i32") - 1); + let name: Vec = r.name().map(|n| AsRef::<[u8]>::as_ref(n).to_vec()).unwrap_or_default(); + let flags = u16::from(r.flags()); + RecordKey { tid, pos, name, flags } +} + +/// Assert exact record-count preservation and no content loss: the multiset of +/// records (by `RecordBuf` equality) must be identical between input and output. +/// Sorting reorders records but must not add, drop, or mutate any. +fn assert_records_preserved(input: &Path, output: &Path) { + let mut in_recs = read_records(input); + let mut out_recs = read_records(output); assert_eq!( - record_lines_canonical(&input), - record_lines_canonical(&output), - "level-0 output records differ from the input (uncompressed-BGZF write corrupted content?)" + in_recs.len(), + out_recs.len(), + "record count changed: input {} != output {}", + in_recs.len(), + out_recs.len() ); + // Sort both by a stable canonical key so the comparison is order-independent; + // record content must round-trip the sort + (possibly uncompressed-bgzf) + // write path byte-for-byte. + let canon = |r: &RecordBuf| record_key(r); + in_recs.sort_by_key(&canon); + out_recs.sort_by_key(&canon); + assert_eq!(in_recs, out_recs, "record content changed across sort (loss/dup/mutation)"); } -#[test] -#[ignore = "requires samtools"] -fn test_sort_coordinate_with_spills() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 2000); - let output = dir.path().join("sorted.bam"); +// --------------------------------------------------------------------------- +// Independent in-test order oracles (coordinate, queryname-lex) +// --------------------------------------------------------------------------- - // Small memory = forces spills to disk - sort_bam(&input, &output, "coordinate", 2, "50K"); - assert!(verify_sorted(&output, "coordinate"), "coordinate sort with spills failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); +/// Coordinate order oracle: `tid` ascending with no-reference (`tid == -1`) last, +/// then `pos` ascending. This is computed independently of the sort crate from +/// the records actually present in the output. +fn assert_coordinate_ordered(records: &[RecordBuf]) { + let keys: Vec = records.iter().map(record_key).collect(); + let mut expected = keys.clone(); + expected.sort_by(|a, b| { + let a_noref = a.tid < 0; + let b_noref = b.tid < 0; + a_noref + .cmp(&b_noref) // false (has ref) sorts before true (no ref) + .then(a.tid.cmp(&b.tid)) + .then(a.pos.cmp(&b.pos)) + }); + // Compare only the order-determining prefix (tid, pos); names within an + // equal (tid, pos) are an unspecified tie so are not asserted here. + let proj = |k: &RecordKey| (k.tid < 0, k.tid, k.pos); + let got: Vec<_> = keys.iter().map(proj).collect(); + let want: Vec<_> = expected.iter().map(proj).collect(); + assert_eq!(got, want, "output is not coordinate-ordered (independent oracle)"); } -#[test] -#[ignore = "requires samtools"] -fn test_sort_coordinate_many_spills_t4() { - if !samtools_available() { - return; +/// Queryname-lexicographic order oracle: read names compared as raw bytes, +/// non-decreasing down the file. Computed independently of the sort crate. +fn assert_queryname_lex_ordered(records: &[RecordBuf]) { + let names: Vec> = records.iter().map(|r| record_key(r).name).collect(); + for pair in names.windows(2) { + assert!( + pair[0] <= pair[1], + "output is not queryname-lexicographic ordered (independent oracle): {:?} > {:?}", + String::from_utf8_lossy(&pair[0]), + String::from_utf8_lossy(&pair[1]), + ); } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 3000); - let output = dir.path().join("sorted.bam"); +} + +// --------------------------------------------------------------------------- +// samtools cross-check oracle (queryname-natural, template-coordinate) +// --------------------------------------------------------------------------- - // Very small memory with 4 threads = many spills - sort_bam(&input, &output, "coordinate", 4, "30K"); - assert!(verify_sorted(&output, "coordinate"), "coordinate sort many spills failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); +fn samtools_available() -> bool { + which::which("samtools").is_ok() } -#[test] -#[ignore = "requires samtools"] -fn test_sort_coordinate_single_thread() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 1000); - let output = dir.path().join("sorted.bam"); +/// Sort `input` with samtools into `output` using `samtools_args` (e.g. +/// `["-n"]` for queryname-natural, `["--template-coordinate"]` for +/// template-coordinate). Panics on failure. +fn samtools_sort(input: &Path, output: &Path, samtools_args: &[&str]) { + let mut cmd = Command::new("samtools"); + cmd.arg("sort"); + cmd.args(samtools_args); + cmd.args(["-o", output.to_str().unwrap(), input.to_str().unwrap()]); + let status = cmd.status().expect("run samtools sort"); + assert!(status.success(), "samtools sort failed"); +} - sort_bam(&input, &output, "coordinate", 1, "50K"); - assert!(verify_sorted(&output, "coordinate"), "coordinate sort t1 failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); +/// Cross-check fgumi's output read-NAME order against samtools'. Used for +/// queryname-natural, where the read name IS the sort key, so the exact name +/// sequence is the contract being proven independently. +fn assert_name_order_matches(fgumi_out: &Path, samtools_out: &Path) { + let fgumi_names: Vec> = + read_records(fgumi_out).iter().map(|r| record_key(r).name).collect(); + let samtools_names: Vec> = + read_records(samtools_out).iter().map(|r| record_key(r).name).collect(); + assert_eq!(fgumi_names.len(), samtools_names.len(), "fgumi / samtools record count differs"); + assert_eq!( + fgumi_names, samtools_names, + "fgumi read-name order disagrees with samtools (independent natural oracle)" + ); +} + +/// Cross-check fgumi's template-coordinate POSITION-key sequence against +/// samtools'. The template-coordinate primary key is the template's +/// `(tid, pos)` coordinate; the per-record tie-breaker differs by design (fgumi +/// folds the `CB` cell tag + a name hash into its key, which samtools' +/// `--template-coordinate` does not model), so the read-NAME sequence is a +/// legitimately divergent tie order. The contract that IS shared — and the one +/// proven independently here — is that the ordered sequence of `(tid, pos)` keys +/// matches: both tools place every template at the same point in coordinate +/// order. Comparing the position-key stream is tie-agnostic and so isolates the +/// ordering definition from the unspecified tie order. +fn assert_template_position_order_matches(fgumi_out: &Path, samtools_out: &Path) { + let proj = |path: &Path| -> Vec<(i32, i32)> { + read_records(path) + .iter() + .map(|r| { + let k = record_key(r); + (k.tid, k.pos) + }) + .collect() + }; + let fgumi_keys = proj(fgumi_out); + let samtools_keys = proj(samtools_out); + assert_eq!(fgumi_keys.len(), samtools_keys.len(), "fgumi / samtools record count differs"); + assert_eq!( + fgumi_keys, samtools_keys, + "fgumi template-coordinate (tid,pos) order disagrees with samtools \ + (independent template oracle)" + ); } // --------------------------------------------------------------------------- -// Tests: Queryname Lexicographic Sort +// Coordinate sort: full spill matrix, independent in-test oracle // --------------------------------------------------------------------------- -#[test] -#[ignore = "requires samtools"] -fn test_sort_queryname_lex_in_memory() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 500); +#[rstest] +#[case::in_memory(Spill::InMemory)] +#[case::single_spill(Spill::Single)] +#[case::many_spill(Spill::Many)] +#[case::uncompressed_bgzf_spill(Spill::UncompressedBgzf)] +fn coordinate_sort_matrix(#[case] spill: Spill) { + let (dir, input) = build_unsorted_fixture(1500); let output = dir.path().join("sorted.bam"); + run_sort(&input, &output, "coordinate", spill); + + // Write-corruption guard (NOT the order proof). + assert!(verify_sorted(&output, "coordinate"), "coordinate --verify guard failed ({spill:?})"); - sort_bam(&input, &output, "queryname", 2, "100M"); - assert!(verify_sorted(&output, "queryname"), "queryname-lex sort failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); + // Independent order proof + exact count / content preservation. + let out_recs = read_records(&output); + assert_coordinate_ordered(&out_recs); + assert_records_preserved(&input, &output); } -#[test] -#[ignore = "requires samtools"] -fn test_sort_queryname_lex_with_spills() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 2000); +// --------------------------------------------------------------------------- +// Queryname-lexicographic sort: full spill matrix, independent in-test oracle +// --------------------------------------------------------------------------- + +#[rstest] +#[case::in_memory(Spill::InMemory)] +#[case::single_spill(Spill::Single)] +#[case::many_spill(Spill::Many)] +#[case::uncompressed_bgzf_spill(Spill::UncompressedBgzf)] +fn queryname_lex_sort_matrix(#[case] spill: Spill) { + let (dir, input) = build_unsorted_fixture(1500); let output = dir.path().join("sorted.bam"); + run_sort(&input, &output, "queryname", spill); + + assert!(verify_sorted(&output, "queryname"), "queryname-lex --verify guard failed ({spill:?})"); - sort_bam(&input, &output, "queryname", 2, "50K"); - assert!(verify_sorted(&output, "queryname"), "queryname-lex sort with spills failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); + let out_recs = read_records(&output); + assert_queryname_lex_ordered(&out_recs); + assert_records_preserved(&input, &output); } // --------------------------------------------------------------------------- -// Tests: Queryname Natural Sort +// Queryname-natural sort: full spill matrix, samtools cross-check oracle // --------------------------------------------------------------------------- -#[test] -#[ignore = "requires samtools"] -fn test_sort_queryname_natural_in_memory() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 500); +#[rstest] +#[case::in_memory(Spill::InMemory)] +#[case::single_spill(Spill::Single)] +#[case::many_spill(Spill::Many)] +#[case::uncompressed_bgzf_spill(Spill::UncompressedBgzf)] +fn queryname_natural_sort_matrix(#[case] spill: Spill) { + let (dir, input) = build_unsorted_fixture(1500); let output = dir.path().join("sorted.bam"); + run_sort(&input, &output, "queryname::natural", spill); - sort_bam(&input, &output, "queryname::natural", 2, "100M"); - assert!(verify_sorted(&output, "queryname::natural"), "queryname-natural sort failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); -} + // Corruption guard always runs. + assert!( + verify_sorted(&output, "queryname::natural"), + "queryname-natural --verify guard failed ({spill:?})" + ); + assert_records_preserved(&input, &output); -#[test] -#[ignore = "requires samtools"] -fn test_sort_queryname_natural_with_spills() { + // Independent order proof: cross-check the name sequence against samtools. if !samtools_available() { + eprintln!("skipping queryname-natural samtools oracle: samtools not on PATH"); return; } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 2000); + let samtools_out = dir.path().join("samtools_natural.bam"); + samtools_sort(&input, &samtools_out, &["-n"]); + assert_name_order_matches(&output, &samtools_out); +} + +// --------------------------------------------------------------------------- +// Template-coordinate sort: full spill matrix, samtools cross-check oracle +// --------------------------------------------------------------------------- + +#[rstest] +#[case::in_memory(Spill::InMemory)] +#[case::single_spill(Spill::Single)] +#[case::many_spill(Spill::Many)] +#[case::uncompressed_bgzf_spill(Spill::UncompressedBgzf)] +fn template_coordinate_sort_matrix(#[case] spill: Spill) { + let (dir, input) = build_unsorted_fixture(1500); let output = dir.path().join("sorted.bam"); + run_sort(&input, &output, "template-coordinate", spill); - sort_bam(&input, &output, "queryname::natural", 2, "50K"); assert!( - verify_sorted(&output, "queryname::natural"), - "queryname-natural sort with spills failed" + verify_sorted(&output, "template-coordinate"), + "template-coordinate --verify guard failed ({spill:?})" ); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); + assert_records_preserved(&input, &output); + + if !samtools_available() { + eprintln!("skipping template-coordinate samtools oracle: samtools not on PATH"); + return; + } + let samtools_out = dir.path().join("samtools_template.bam"); + samtools_sort(&input, &samtools_out, &["--template-coordinate"]); + assert_template_position_order_matches(&output, &samtools_out); } // --------------------------------------------------------------------------- -// Tests: Template-Coordinate Sort +// Template-coordinate: mate-side / strand key coverage (independent oracle) // --------------------------------------------------------------------------- +// +// The shared `unsorted_records` fixture gives every mapped template the SAME +// orientation (R1 forward, R2 reverse) and a FIXED mate offset (+200), so its +// template-coordinate key collapses to ordinary `(tid, pos)` coordinate order: +// the mate-side (`tid2`/`pos2`) and per-end strand (`neg1`/`neg2`) lanes of the +// samtools-compatible key never independently vary, and a bug confined to them +// would still pass `template_coordinate_sort_matrix`. This fixture instead pins +// several templates to the SAME lower-end `(tid1, pos1)` while varying the +// upper-end chromosome, position, and both ends' strands, so the secondary key +// lanes are the sole discriminator of the correct order. + +/// Reference span of every read in the mate-geometry fixture (all `10M`). +const MATE_GEOM_SPAN: i32 = 10; + +/// A template-coordinate-orderable projection of an output record, mirroring the +/// canonical key normalization in `fgumi_sort`'s `extract_template_key_inline`: +/// the lower end (by `(tid, unclipped-5')`, ties broken so the reverse end is +/// "upper") becomes `(tid1, pos1, neg1)` and the mate becomes `(tid2, pos2, +/// neg2)`. The returned tuple orders fields exactly as the key compares them — +/// `tid1, tid2, pos1, pos2`, the strands, then the final `is_upper` lane — with +/// each strand encoded as `u8::from(!neg)` so the reverse strand (`neg = true`) +/// sorts first, matching samtools. +/// +/// The trailing `is_upper` byte (`0` = lower-of-pair, `1` = upper-of-pair) pins +/// the documented within-template tie-break: both records of a template share +/// the leading six lanes, so without it a deterministic regression that flips +/// the per-record order inside a template would still satisfy the oracle. The +/// production key's penultimate lane is the read-name *hash* (not the literal +/// name, and not lexicographic), which is non-reproducible here; it only decides +/// order between templates that tie on every position/strand lane, and this +/// fixture gives every template a unique position key, so that lane never +/// arbitrates and is intentionally not modeled. Derived purely from record +/// fields (own + mate, plus the `MC` span), independent of the sort crate. +fn template_coord_proj(r: &RecordBuf) -> (i32, i32, i32, i32, u8, u8, u8) { + let zero_based = |p: noodles::core::Position| i32::try_from(usize::from(p)).expect("pos") - 1; + let tid = r.reference_sequence_id().map_or(-1, |id| i32::try_from(id).expect("tid fits i32")); + let pos = zero_based(r.alignment_start().expect("mapped record has alignment start")); + let is_rev = r.flags().is_reverse_complemented(); + let mate_tid = + r.mate_reference_sequence_id().map_or(-1, |id| i32::try_from(id).expect("mate tid")); + let mate_pos = zero_based(r.mate_alignment_start().expect("paired record has mate start")); + let mate_rev = r.flags().is_mate_reverse_complemented(); + + // Unclipped 5' position: alignment start for forward reads, alignment end + // (start + span - 1) for reverse reads. Every fixture read is a clip-free + // `10M` alignment, so the span is constant. + let five_prime = |p: i32, rev: bool| if rev { p + MATE_GEOM_SPAN - 1 } else { p }; + let this5 = five_prime(pos, is_rev); + let mate5 = five_prime(mate_pos, mate_rev); + + // Canonical normalization (extract_template_key_inline): this read is the + // "upper" end when its coordinate exceeds the mate's, or ties and it is the + // reverse strand. + let is_upper = + (tid, this5) > (mate_tid, mate5) || ((tid, this5) == (mate_tid, mate5) && is_rev); + let (tid1, pos1, neg1, tid2, pos2, neg2) = if is_upper { + (mate_tid, mate5, mate_rev, tid, this5, is_rev) + } else { + (tid, this5, is_rev, mate_tid, mate5, mate_rev) + }; + (tid1, tid2, pos1, pos2, u8::from(!neg1), u8::from(!neg2), u8::from(is_upper)) +} -#[test] -#[ignore = "requires samtools"] -fn test_sort_template_coordinate_in_memory() { - if !samtools_available() { - return; +/// Independent template-coordinate order oracle: assert the output record stream +/// is non-decreasing under the full template key projection — the mate-side and +/// strand lanes AND the trailing within-template `is_upper` tie-break. Computed +/// from the records actually present in the output, independently of the sort +/// crate. +fn assert_template_coordinate_ordered(records: &[RecordBuf]) { + let projs: Vec<_> = records.iter().map(template_coord_proj).collect(); + for pair in projs.windows(2) { + assert!( + pair[0] <= pair[1], + "output is not template-coordinate ordered by the full \ + (tid1, tid2, pos1, pos2, neg1, neg2, is_upper) key (independent oracle): \ + {:?} > {:?}", + pair[0], + pair[1], + ); } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 500); - let output = dir.path().join("sorted.bam"); +} - sort_bam(&input, &output, "template-coordinate", 2, "100M"); - assert!(verify_sorted(&output, "template-coordinate"), "template-coordinate sort failed"); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); +/// Build a paired-end template `name` from its two ends `a` and `b`, each given +/// as `(tid, pos0, reverse)` (0-based `pos`), optionally attaching a cell-barcode +/// (`CB`) tag. Both reads are clip-free `10M` alignments carrying the mate's +/// coordinate/strand and an `MC` tag, so the sort can compute the mate's +/// unclipped 5' position from each record alone. +fn push_template_with_cb( + records: &mut Vec, + name: &str, + cb: Option<&[u8]>, + a: (i32, i32, bool), + b: (i32, i32, bool), +) { + let seq = b"ACGTACGTAC"; // 10 bases == 10M + let qual = vec![40u8; seq.len()]; + let cigar = u32::try_from(seq.len()).expect("len fits u32") << 4; // 10M + + let build = |seg_flag: u16, this: (i32, i32, bool), mate: (i32, i32, bool)| { + let (tid, pos, rev) = this; + let (mate_tid, mate_pos, mate_rev) = mate; + let mut f = flags::PAIRED | flags::PROPER_PAIR | seg_flag; + if rev { + f |= flags::REVERSE; + } + if mate_rev { + f |= flags::MATE_REVERSE; + } + let mut sb = SamBuilder::new(); + sb.read_name(name.as_bytes()) + .ref_id(tid) + .pos(pos) + .mapq(60) + .flags(f) + .mate_ref_id(mate_tid) + .mate_pos(mate_pos) + .template_length(0) + .cigar_ops(&[cigar]) + .sequence(seq) + .qualities(&qual) + .add_string_tag(SamTag::RG, b"rg1") + .add_string_tag(SamTag::MC, b"10M"); + if let Some(cb) = cb { + sb.add_string_tag(SamTag::CB, cb); + } + sb.build() + }; + + records.push(build(flags::FIRST_SEGMENT, a, b)); + records.push(build(flags::LAST_SEGMENT, b, a)); +} + +/// Build a paired-end template `name` (no `CB` tag) from its two ends. +fn push_mate_geom_template( + records: &mut Vec, + name: &str, + a: (i32, i32, bool), + b: (i32, i32, bool), +) { + push_template_with_cb(records, name, None, a, b); +} + +/// Templates that share a lower-end `(tid1, pos1)` but diverge in the mate-side +/// and strand key lanes, emitted out of template-coordinate order. The correct +/// order is `t_low_rev, t_pos2_lo, t_up_rev, t_pos2_hi, t_tie, t_tid2, t_tid1`; +/// the records are written scrambled so a real sort has work to do and a +/// primary-`(tid1, pos1)`-only key would leave the shared-lower-end group +/// misordered. +fn mate_geometry_records() -> Vec { + let mut records = Vec::new(); + // Scrambled emission order. Each end is (tid, pos0, reverse); all five + // chr1-lower templates share lower end (tid1=0, pos1=1000) except where a + // reverse lower end is used to vary neg1 at the same 5' coordinate. + push_mate_geom_template(&mut records, "t_pos2_hi", (0, 1000, false), (0, 3000, false)); + push_mate_geom_template(&mut records, "t_tid2", (0, 1000, false), (1, 500, false)); + // endA forward@5000, endB reverse@4991 → both 5'=5000; reverse end is upper. + push_mate_geom_template(&mut records, "t_tie", (0, 5000, false), (0, 4991, true)); + push_mate_geom_template(&mut records, "t_up_rev", (0, 1000, false), (0, 2000, true)); + push_mate_geom_template(&mut records, "t_tid1", (1, 100, false), (1, 200, false)); + push_mate_geom_template(&mut records, "t_pos2_lo", (0, 1000, false), (0, 2000, false)); + // reverse lower end at pos 991 → 5'=1000, same pos1 as the forward-lower + // templates but neg1 differs, so it must sort before t_pos2_lo. + push_mate_geom_template(&mut records, "t_low_rev", (0, 991, true), (0, 2000, false)); + records } +// Runs in-memory only: this fixture is a handful of templates crafted to make +// the mate-side/strand key lanes the sole order discriminator, so it would never +// fill the spill thresholds anyway. The comparator is identical on the in-memory +// and spill/merge paths, and the spill/merge path itself is exercised across the +// `64K`/`200K` budgets by the 1500-template `*_sort_matrix` tests above — so a +// spill `#[case]` matrix here would only re-run the same in-memory sorter and +// falsely advertise merge-path coverage it does not provide. #[test] -#[ignore = "requires samtools"] -fn test_sort_template_coordinate_with_spills() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 2000); +fn template_coordinate_mate_geometry_oracle() { + let dir = TempDir::new().expect("tempdir"); + let input = dir.path().join("unsorted.bam"); + let records = mate_geometry_records(); + write_bam(&input, &unsorted_header(), &records); + let output = dir.path().join("sorted.bam"); + run_sort(&input, &output, "template-coordinate", Spill::InMemory); - sort_bam(&input, &output, "template-coordinate", 2, "50K"); assert!( verify_sorted(&output, "template-coordinate"), - "template-coordinate sort with spills failed" + "template-coordinate --verify guard failed" + ); + assert_records_preserved(&input, &output); + + // The real contract: the output respects the FULL template key, including + // the mate-side and strand lanes that the shared-fixture matrix cannot + // exercise. With several templates pinned to the same lower-end (tid1, pos1), + // a comparator that dropped pos2/neg1/neg2 would leave that group in input + // (scrambled) order and break this assertion. + let out = read_records(&output); + assert_template_coordinate_ordered(&out); + + // Sanity: the fixture genuinely stresses the secondary lanes — more than one + // template shares the lower-end (tid1, pos1), so ordering them requires the + // mate-side/strand key (not just the primary coordinate). + let mut primary_keys: Vec<(i32, i32)> = + out.iter().map(template_coord_proj).map(|p| (p.0, p.2)).collect(); + primary_keys.sort_unstable(); + primary_keys.dedup(); + let shared_lower_end = out.len() / 2 > primary_keys.len(); + assert!( + shared_lower_end, + "fixture no longer shares a lower-end (tid1, pos1) across templates — \ + the mate-side/strand lanes are no longer exercised" ); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); } +// --------------------------------------------------------------------------- +// Template-coordinate: final CB / read-name-hash tie-break lanes +// --------------------------------------------------------------------------- +// +// The mate-geometry oracle and the samtools `(tid,pos)` cross-check both stop at +// the position/strand lanes, and every template in those fixtures has a unique +// position key — so the production key's trailing CB lane (`cb_hash`) and +// read-name-hash lane never arbitrate, and a regression there (dropped lane, +// reversed comparison, wrong key position) would slip through. This test builds +// template *pairs* that collide on `(tid1, tid2, pos1, pos2, neg1, neg2)` and +// differ in EXACTLY ONE trailing lane, then pins fgumi's order against a FROZEN +// expected order (below). The expectation is a precomputed constant, NOT +// recomputed from `fgumi_sort::cb_hasher` / `LibraryLookup::hash_name` at +// runtime: deriving it from the same hashers the sorter uses would let a bad +// seed or hash-wiring regression move both the output and the expectation +// together and pass silently. Freezing makes such a regression flip the actual +// order against a fixed expectation and fail. The frozen values were captured +// from the fixed-seed hashers for these exact inputs; a deliberate seed change +// is itself a sort-order change and is expected to require updating them. +// +// In-memory only (like `template_coordinate_mate_geometry_oracle`): four +// templates never reach the spill thresholds, the comparator is identical on the +// merge path, and spill/merge is covered by the `*_sort_matrix` tests. #[test] -#[ignore = "requires samtools"] -fn test_sort_template_coordinate_many_spills_t4() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 3000); - let output = dir.path().join("sorted.bam"); +fn template_coordinate_cb_and_name_hash_tiebreak() { + let dir = TempDir::new().expect("tempdir"); + let input = dir.path().join("unsorted.bam"); + + // Two colliding pairs at distinct positions so each pair's assertion is + // independent of the other: + // - CB pair @ chr1:1000(F)/2000(F): identical but for the CB tag + // (`cellA`/`cellB`) → the `cb_hash` lane is the sole discriminator. + // - name pair @ chr1:5000(F)/6000(F): identical, NO CB (so `cb_hash` is 0 + // for both), differing only in read name → the name-hash lane decides. + let mut records = Vec::new(); + push_template_with_cb(&mut records, "cb_a", Some(b"cellA"), (0, 1000, false), (0, 2000, false)); + push_template_with_cb(&mut records, "cb_b", Some(b"cellB"), (0, 1000, false), (0, 2000, false)); + push_template_with_cb(&mut records, "name_aaa", None, (0, 5000, false), (0, 6000, false)); + push_template_with_cb(&mut records, "name_zzz", None, (0, 5000, false), (0, 6000, false)); + write_bam(&input, &unsorted_header(), &records); - sort_bam(&input, &output, "template-coordinate", 4, "30K"); + let output = dir.path().join("sorted.bam"); + run_sort(&input, &output, "template-coordinate", Spill::InMemory); + assert!(verify_sorted(&output, "template-coordinate"), "verify guard failed"); + assert_records_preserved(&input, &output); + + let out = read_records(&output); + // Output index of the FIRST record carrying read name `qname` (the lower-end + // mate of that template, `is_upper = 0`). + let first_index = |qname: &[u8]| -> usize { + out.iter() + .position(|r| r.name().is_some_and(|n| AsRef::<[u8]>::as_ref(n) == qname)) + .unwrap_or_else(|| panic!("qname {} not in output", String::from_utf8_lossy(qname))) + }; + + // FROZEN expected order (see the header comment for why it is not recomputed + // from the production hashers). For the fixed-seed `cb_hasher`, `cellA`'s + // hash sorts before `cellB`'s, so the CB pair comes out cb_a then cb_b. For + // `LibraryLookup`'s fixed-seed name hasher, `name_zzz`'s hash sorts before + // `name_aaa`'s, so the name pair comes out name_zzz then name_aaa. assert!( - verify_sorted(&output, "template-coordinate"), - "template-coordinate many spills failed" + first_index(b"cb_a") < first_index(b"cb_b"), + "CB tie-break regressed: expected the cellA template (cb_a) to sort before the \ + cellB template (cb_b) under the frozen cb_hash order", + ); + assert!( + first_index(b"name_zzz") < first_index(b"name_aaa"), + "read-name tie-break regressed: expected name_zzz to sort before name_aaa under \ + the frozen name_hash order", + ); + + // The tie-break order must also be run-to-run deterministic (the fixed seeds + // guarantee it; a regression to a per-run seed would break this). + let output2 = dir.path().join("sorted2.bam"); + run_sort(&input, &output2, "template-coordinate", Spill::InMemory); + assert_eq!( + out, + read_records(&output2), + "template-coordinate CB/name tie-break is not run-to-run deterministic", ); - assert_eq!(count_records(&input), count_records(&output), "record count mismatch"); } // --------------------------------------------------------------------------- -// Tests: Consistency across thread counts +// Thread-count consistency (coordinate, independent oracle) // --------------------------------------------------------------------------- +/// All thread counts must produce the *same* coordinate-ordered output that +/// preserves every record. The same input is sorted at 1, 2, and 4 threads; +/// each run is self-checked (sortedness + record preservation), and then the +/// order-determining `(tid, pos)` key stream is asserted byte-identical across +/// all thread counts. Self-checking each `#[case]` independently would let a +/// thread-count-dependent ordering regression slip through, so the cross-run +/// comparison is the real contract here. A small `-m` forces spills so the +/// external-merge path is exercised at each thread count. #[test] -#[ignore = "requires samtools"] -fn test_sort_coordinate_consistent_across_threads() { - if !samtools_available() { - return; - } - let dir = TempDir::new().unwrap(); - let input = create_test_bam(dir.path(), 1000); - - // Sort with different thread counts using small memory to force spills - let outputs: Vec<_> = [1, 2, 4] - .iter() - .map(|t| { - let output = dir.path().join(format!("sorted_t{t}.bam")); - sort_bam(&input, &output, "coordinate", *t, "50K"); - output - }) - .collect(); - - // All should be correctly sorted - for output in &outputs { - assert!(verify_sorted(output, "coordinate"), "failed for {}", output.display()); - } - - // Record counts should all match - let input_count = count_records(&input); - for output in &outputs { - assert_eq!(input_count, count_records(output), "count mismatch for {}", output.display()); +fn coordinate_sort_consistent_across_threads() { + let (dir, input) = build_unsorted_fixture(1000); + + // The order-determining projection: tid ascending (no-reference `tid == -1` + // last), then pos. Names within an equal `(tid, pos)` are an unspecified tie + // (see `assert_coordinate_ordered`), so they are excluded from the + // cross-thread identity check — only the documented key stream is compared. + let position_stream = |output: &Path| -> Vec<(bool, i32, i32)> { + read_records(output).iter().map(record_key).map(|k| (k.tid < 0, k.tid, k.pos)).collect() + }; + + let mut baseline_stream: Option> = None; + for threads in [1usize, 2, 4] { + let output = dir.path().join(format!("sorted_t{threads}.bam")); + let args: Vec = vec![ + "sort".into(), + "-i".into(), + input.as_os_str().to_owned(), + "-o".into(), + output.as_os_str().to_owned(), + "--order".into(), + "coordinate".into(), + "--threads".into(), + threads.to_string().into(), + "-m".into(), + "200K".into(), + "--temp-compression".into(), + "1".into(), + ]; + fgumi_sort_in_process(&args) + .unwrap_or_else(|e| panic!("fgumi sort failed (threads={threads}): {e:#}")); + + assert!( + verify_sorted(&output, "coordinate"), + "coordinate guard failed (threads={threads})" + ); + assert_coordinate_ordered(&read_records(&output)); + assert_records_preserved(&input, &output); + + // Cross-thread identity: every thread count must emit the *same* + // coordinate key stream, not merely a self-consistent one. + let stream = position_stream(&output); + match &baseline_stream { + None => baseline_stream = Some(stream), + Some(baseline) => assert_eq!( + &stream, baseline, + "coordinate key stream differs at threads={threads} vs the 1-thread baseline", + ), + } } } diff --git a/tests/integration/test_sort_write_index.rs b/tests/integration/test_sort_write_index.rs index 8994b0ae9..bc8c7b2de 100644 --- a/tests/integration/test_sort_write_index.rs +++ b/tests/integration/test_sort_write_index.rs @@ -3,49 +3,97 @@ //! Verifies that BAM index generation produces a valid `.bai` that //! samtools can query, across the coordinate sort code path. //! -//! fgumi sort is invoked in-process via `Sort::execute()`; samtools -//! is invoked as a subprocess (external tool). +//! The input fixture is built in-process with `SamBuilder` + the noodles BAM +//! writer (no samtools), so the fgumi-only tests run by default. The samtools +//! cross-check tests are NO LONGER `#[ignore]`'d: they run by default and are +//! gated only by a runtime `samtools_available()` check, so they execute in CI +//! (samtools is installed in the workflow) and skip gracefully on a local dev +//! box without samtools. use clap::Parser; use fgumi_lib::commands::command::Command as FgumiCommand; use fgumi_lib::commands::sort::Sort; +use fgumi_raw_bam::{RawRecord, SamBuilder, flags}; +use noodles::bam; +use noodles::sam::Header; +use noodles::sam::alignment::io::Write as AlignmentWrite; use rstest::rstest; use std::ffi::OsStr; use std::path::Path; use std::process::Command; use tempfile::TempDir; +use crate::helpers::bam_generator::to_record_buf; + /// Check if samtools is available in PATH. fn samtools_available() -> bool { - Command::new("samtools").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) + which::which("samtools").is_ok() } -/// Create a small test BAM file using samtools. +/// Create a small unsorted multi-chromosome test BAM in-process (no samtools). +/// +/// Six single-end records: five mapped across chr1/chr2 plus one unmapped, in a +/// deliberately unsorted emission order so the coordinate sort has work to do. fn create_test_bam(dir: &Path) -> std::path::PathBuf { + use bstr::BString; + use noodles::sam::header::record::value::map::header::tag::Tag as HeaderTag; + use noodles::sam::header::record::value::map::{Map, ReferenceSequence}; + use std::num::NonZeroUsize; + let bam_path = dir.join("test_input.bam"); - // Create a simple SAM file and convert to BAM - let sam_content = r"@HD VN:1.6 SO:unsorted -@SQ SN:chr1 LN:10000 -@SQ SN:chr2 LN:10000 -read1 0 chr1 100 60 10M * 0 0 ACGTACGTAC IIIIIIIIII -read2 0 chr1 200 60 10M * 0 0 ACGTACGTAC IIIIIIIIII -read3 0 chr1 300 60 10M * 0 0 ACGTACGTAC IIIIIIIIII -read4 0 chr2 100 60 10M * 0 0 ACGTACGTAC IIIIIIIIII -read5 0 chr2 200 60 10M * 0 0 ACGTACGTAC IIIIIIIIII -read6 4 * 0 0 * * 0 0 ACGTACGTAC IIIIIIIIII -"; - - let sam_path = dir.join("test_input.sam"); - std::fs::write(&sam_path, sam_content).expect("Failed to write SAM file"); - - // Convert SAM to BAM using samtools - let status = Command::new("samtools") - .args(["view", "-b", "-o", bam_path.to_str().unwrap(), sam_path.to_str().unwrap()]) - .status() - .expect("Failed to run samtools view"); + let HeaderTag::Other(so_tag) = HeaderTag::from(*b"SO") else { unreachable!() }; + let header_map = Map::::builder() + .insert(so_tag, "unsorted") + .build() + .expect("valid header map"); + let mut hb = Header::builder().set_header(header_map); + for chrom in ["chr1", "chr2"] { + hb = hb.add_reference_sequence( + BString::from(chrom), + Map::::new(NonZeroUsize::new(10_000).expect("non-zero")), + ); + } + let header = hb.build(); + + let seq = b"ACGTACGTAC"; + let qual = vec![40u8; seq.len()]; + let cigar = u32::try_from(seq.len()).expect("seq len fits u32") << 4; // 10M + + let mapped = |name: &str, ref_id: i32, pos: i32| -> RawRecord { + let mut b = SamBuilder::new(); + b.read_name(name.as_bytes()) + .ref_id(ref_id) + .pos(pos) + .mapq(60) + .flags(0) + .cigar_ops(&[cigar]) + .sequence(seq) + .qualities(&qual); + b.build() + }; + + // Emit out of coordinate order on purpose. + let mut records = vec![ + mapped("read4", 1, 99), // chr2:100 + mapped("read2", 0, 199), // chr1:200 + mapped("read5", 1, 199), // chr2:200 + mapped("read1", 0, 99), // chr1:100 + mapped("read3", 0, 299), // chr1:300 + ]; + // One unmapped read. + let mut un = SamBuilder::new(); + un.read_name(b"read6").flags(flags::UNMAPPED).sequence(seq).qualities(&qual); + records.push(un.build()); + + let mut writer = + bam::io::Writer::new(std::fs::File::create(&bam_path).expect("create input BAM")); + writer.write_header(&header).expect("write header"); + for r in &records { + writer.write_alignment_record(&header, &to_record_buf(r)).expect("write record"); + } + writer.try_finish().expect("finish BAM"); - assert!(status.success(), "samtools view failed"); bam_path } @@ -80,7 +128,9 @@ fn run_coordinate_sort(input: &Path, output: &Path, write_index: bool, threads: /// (BAI-indexed) coordinate-sorted BAM. fn region_query_names(bam_path: &Path, region: &str) -> Vec { let output = Command::new("samtools") - .args(["view", bam_path.to_str().unwrap(), region]) + .arg("view") + .arg(bam_path) + .arg(region) .output() .expect("Failed to run samtools view"); assert!( @@ -96,27 +146,18 @@ fn region_query_names(bam_path: &Path, region: &str) -> Vec { names } -/// Verify that a BAM index allows region queries. +/// Verify that a BAM index allows region queries AND returns the correct slice. +/// Asserting record identities (not just a non-zero count) catches a BAI that +/// returns the wrong reads for the interval, which `samtools view -c > 0` would +/// miss. The `chr1:100-300` interval covers read1/read2/read3 in +/// `create_test_bam` (read4/read5 are on chr2). fn verify_index_works(bam_path: &Path) -> bool { - // Try to query a region - this will fail if index is invalid - let output = Command::new("samtools") - .args(["view", "-c", bam_path.to_str().unwrap(), "chr1:100-300"]) - .output() - .expect("Failed to run samtools view"); - - if !output.status.success() { - eprintln!("samtools view failed: {}", String::from_utf8_lossy(&output.stderr)); - return false; - } - - // Should find at least one read - let count: i32 = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); - count > 0 + region_query_names(bam_path, "chr1:100-300") + == vec!["read1".to_string(), "read2".to_string(), "read3".to_string()] } /// Test sort --write-index creates a valid index. #[test] -#[ignore = "requires samtools"] fn test_sort_write_index() { if !samtools_available() { eprintln!("Skipping: samtools not available"); @@ -155,7 +196,6 @@ fn test_sort_write_index() { /// Test that --write-index with multi-threading still produces valid index. #[test] -#[ignore = "requires samtools"] fn test_sort_write_index_multithreaded() { if !samtools_available() { eprintln!("Skipping: samtools not available"); @@ -205,13 +245,10 @@ fn test_sort_write_index_multithreaded() { #[rstest] #[case::threads_1(1)] #[case::threads_4(4)] -#[ignore = "requires samtools"] fn test_sort_write_index_byte_identical_to_off(#[case] threads: usize) { - if !samtools_available() { - eprintln!("Skipping: samtools not available"); - return; - } - + // No `samtools_available()` gate: this is a pure-fgumi regression (both runs + // go through `run_coordinate_sort`), so it must always run — including on + // minimal CI jobs without samtools. let temp_dir = TempDir::new().unwrap(); let input_bam = create_test_bam(temp_dir.path()); @@ -248,7 +285,6 @@ fn test_sort_write_index_byte_identical_to_off(#[case] threads: usize) { #[case::t4_chr1_1_10000(4, "chr1:1-10000")] #[case::t4_chr2(4, "chr2")] #[case::t4_chr2_200_200(4, "chr2:200-200")] -#[ignore = "requires samtools"] fn test_sort_write_index_matches_samtools_bai(#[case] threads: usize, #[case] region: &str) { if !samtools_available() { eprintln!("Skipping: samtools not available"); @@ -265,12 +301,16 @@ fn test_sort_write_index_matches_samtools_bai(#[case] threads: usize, #[case] re // samtools reference: sort the same input, then index it. let sam_bam = temp_dir.path().join("samtools_sorted.bam"); let status = Command::new("samtools") - .args(["sort", "-o", sam_bam.to_str().unwrap(), input_bam.to_str().unwrap()]) + .arg("sort") + .arg("-o") + .arg(&sam_bam) + .arg(&input_bam) .status() .expect("Failed to run samtools sort"); assert!(status.success(), "samtools sort failed"); let status = Command::new("samtools") - .args(["index", sam_bam.to_str().unwrap()]) + .arg("index") + .arg(&sam_bam) .status() .expect("Failed to run samtools index"); assert!(status.success(), "samtools index failed"); diff --git a/tests/integration/test_streaming_input.rs b/tests/integration/test_streaming_input.rs index 0c5f4ba1c..736886ebe 100644 --- a/tests/integration/test_streaming_input.rs +++ b/tests/integration/test_streaming_input.rs @@ -7,8 +7,7 @@ use noodles::bam; use noodles::sam::alignment::io::Write as AlignmentWrite; use rstest::rstest; -use std::fs::{self, File}; -use std::io::{BufReader, Read}; +use std::fs; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -635,6 +634,13 @@ fn test_correct_command_with_sam_input_new_pipeline_with_rejects_matches_bam_bas .unwrap_or_else(|_| panic!("Failed to run correct {label}")); assert!(status.success(), "correct {label} failed"); } + // Non-vacuous guard (S9b-007): this is a SAM-vs-BAM parity check between two + // fgumi outputs, so a both-empty pass is possible if correct silently dropped + // every record on BOTH paths. Assert the BAM baseline kept output is + // non-empty before the parity comparison so the test cannot pass vacuously. + // (The rejects output is legitimately empty here — this fixture corrects all + // UMIs, rejecting none — so only the kept output is guarded.) + assert!(count_bam_records(&out_bam_baseline) > 0, "BAM baseline kept output is empty"); compare_bam_records(&out_bam_baseline, &out_sam); compare_bam_records(&out_bam_rejects_baseline, &out_sam_rejects); } @@ -649,8 +655,7 @@ fn create_unmapped_consensus_bam(path: &PathBuf) { let header = create_minimal_header("chr1", 10000); let records: Vec<_> = ["good1", "good2", "low_depth"] .iter() - .enumerate() - .map(|(i, name)| { + .map(|name| { let depth = if *name == "low_depth" { 1u16 } else { 10u16 }; let mut b = RawSamBuilder::new(); b.read_name(name.as_bytes()) @@ -658,7 +663,6 @@ fn create_unmapped_consensus_bam(path: &PathBuf) { .sequence(b"ACGTACGT") .qualities(&[35; 8]); b.add_array_u16(SamTag::CD_BASES, &[depth; 8]).add_array_u16(SamTag::CE_BASES, &[0; 8]); - let _ = i; // silence unused b.build() }) .collect(); @@ -716,6 +720,15 @@ fn test_filter_command_with_rejects_sam_input_matches_bam_baseline() { .unwrap_or_else(|_| panic!("Failed to run filter {label}")); assert!(status.success(), "filter {label} failed"); } + // Non-vacuous guard (S9b-007): this compares two fgumi outputs, so a + // both-empty pass is possible if filter silently dropped every record on + // BOTH paths. The fixture has two depth-10 reads (kept) and one depth-1 read + // (rejected at --min-reads 3), so both baseline outputs must be non-empty. + assert!(count_bam_records(&out_bam_baseline) > 0, "BAM baseline kept output is empty"); + assert!( + count_bam_records(&out_bam_rejects_baseline) > 0, + "BAM baseline rejects output is empty" + ); compare_bam_records(&out_bam_baseline, &out_sam); compare_bam_records(&out_bam_rejects_baseline, &out_sam_rejects); } @@ -852,16 +865,6 @@ fn test_group_command_with_piped_input_new_pipeline() { compare_bam_records(&output_from_file, &output_from_pipe); } -/// Helper to read file contents for comparison. -#[allow(dead_code)] -fn read_file_contents(path: &PathBuf) -> Vec { - let file = File::open(path).expect("Failed to open file"); - let mut reader = BufReader::new(file); - let mut contents = Vec::new(); - reader.read_to_end(&mut contents).expect("Failed to read file"); - contents -} - /// Helper to compare BAM records (ignoring header differences like @PG command line). /// /// Decodes to eager `RecordBuf`s and compares them for full equality — every @@ -1242,6 +1245,11 @@ fn test_duplex_reads_stdin_once(#[case] threads: Option<&str>) { }); } +/// Unlike codec/duplex/clip, filter has NO single-threaded fast path: `Filter::execute` +/// always routes through `chains::build_for(spec).run()` regardless of `--threads` +/// (see `src/lib/commands/filter.rs`). So there is no no-`--threads` code path to cover — +/// only the explicit `--threads 1` / `--threads 2` cases exist (no +/// `#[case::single_threaded(None)]`). #[rstest] #[case("1")] #[case("2")] From 13c84a9768f9591e3d5918fc490fdb8cb9afa1da Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 24 Jun 2026 10:55:20 -0700 Subject: [PATCH 4/5] test: remaining E2E/unit coverage and small structural fixes Close runall parity/E2E gaps (stdin, FASTQ fusion, reject-discard, floors); cover correct batch routing, header/desync invariants, methylation guards, and coalesce flush; honest ChainTailKind variant, asserted MiKey invariant, testable downsample bound; bench iter_batched. --- benches/core_functions.rs | 36 +- src/lib/commands/downsample.rs | 67 +- src/lib/commands/runall.rs | 124 ++++ src/lib/fastq_parse.rs | 12 +- src/lib/mi_group.rs | 9 +- src/lib/pipeline/chains/builder.rs | 58 +- src/lib/pipeline/steps/align_and_merge.rs | 61 ++ src/lib/pipeline/steps/coalesce.rs | 176 +++++ src/lib/pipeline/steps/correct/tests.rs | 174 +++++ src/lib/pipeline/steps/source/pair_fastq.rs | 124 ++++ tests/integration/helpers/parity.rs | 127 ++++ tests/integration/main.rs | 1 + tests/integration/test_chain_build.rs | 268 +++++++ tests/integration/test_runall_parity.rs | 741 ++++++++++++++++++-- 14 files changed, 1822 insertions(+), 156 deletions(-) create mode 100644 tests/integration/test_chain_build.rs diff --git a/benches/core_functions.rs b/benches/core_functions.rs index 5b0132148..5b3dbb8eb 100644 --- a/benches/core_functions.rs +++ b/benches/core_functions.rs @@ -290,10 +290,14 @@ fn bench_adjacency_assigner(c: &mut Criterion) { &umis, |b, umis| { let assigner = Strategy::Adjacency.new_assigner(1); - b.iter(|| { - let result = assigner.assign(black_box(&umis.clone())); - black_box(result) - }); + b.iter_batched( + || umis.clone(), + |umis| { + let result = assigner.assign(black_box(&umis)); + black_box(result) + }, + criterion::BatchSize::LargeInput, + ); }, ); } @@ -364,10 +368,14 @@ fn bench_vanilla_consensus_caller(c: &mut Criterion) { let options = VanillaUmiConsensusOptions::default(); let mut caller = VanillaUmiConsensusCaller::new("bench".to_string(), "RG1".to_string(), options); - b.iter(|| { - let result = caller.consensus_reads(black_box(raw_reads.clone())); - black_box(result) - }); + b.iter_batched( + || raw_reads.clone(), + |raw_reads| { + let result = caller.consensus_reads(black_box(raw_reads)); + black_box(result) + }, + criterion::BatchSize::LargeInput, + ); }, ); } @@ -391,10 +399,14 @@ fn bench_vanilla_consensus_caller(c: &mut Criterion) { let options = VanillaUmiConsensusOptions::default(); let mut caller = VanillaUmiConsensusCaller::new("bench".to_string(), "RG1".to_string(), options); - b.iter(|| { - let result = caller.consensus_reads(black_box(raw_reads.clone())); - black_box(result) - }); + b.iter_batched( + || raw_reads.clone(), + |raw_reads| { + let result = caller.consensus_reads(black_box(raw_reads)); + black_box(result) + }, + criterion::BatchSize::LargeInput, + ); }, ); } diff --git a/src/lib/commands/downsample.rs b/src/lib/commands/downsample.rs index b6009d48b..0a3a45220 100644 --- a/src/lib/commands/downsample.rs +++ b/src/lib/commands/downsample.rs @@ -89,6 +89,20 @@ pub struct Downsample { pub compression: CompressionOptions, } +/// Validate the `--fraction` argument. +/// +/// The fraction must be greater than 0.0 (exclusive) and at most 1.0 (inclusive). +/// +/// # Errors +/// +/// Returns an error if `fraction` is `<= 0.0` or `> 1.0`. +fn validate_fraction(fraction: f64) -> Result<()> { + if fraction <= 0.0 || fraction > 1.0 { + bail!("--fraction must be between 0.0 (exclusive) and 1.0 (inclusive), got {}", fraction); + } + Ok(()) +} + impl Command for Downsample { fn execute(&self, command_line: &str) -> Result<()> { // Validate inputs. Skip the existence check for stdin (`-` / @@ -101,12 +115,7 @@ impl Command for Downsample { } // Validate fraction - if self.fraction <= 0.0 || self.fraction > 1.0 { - bail!( - "--fraction must be between 0.0 (exclusive) and 1.0 (inclusive), got {}", - self.fraction - ); - } + validate_fraction(self.fraction)?; let timer = OperationTimer::new("Downsampling reads"); @@ -494,51 +503,23 @@ mod tests { #[test] fn test_validate_fraction_too_low() { - let cmd = Downsample { - io: test_bam_io_options(), - fraction: 0.0, - rejects: None, - seed: None, - validate_mi_order: false, - histogram_kept: None, - histogram_rejected: None, - compression: CompressionOptions { compression_level: 1 }, - }; - - // We can't call execute() without a real BAM file, but we can test the validation - assert!(cmd.fraction <= 0.0); + // 0.0 is the exclusive lower bound, so it must be rejected; values below it likewise. + assert!(validate_fraction(0.0).is_err()); + assert!(validate_fraction(-0.1).is_err()); } #[test] fn test_validate_fraction_too_high() { - let cmd = Downsample { - io: test_bam_io_options(), - fraction: 1.5, - rejects: None, - seed: None, - validate_mi_order: false, - histogram_kept: None, - histogram_rejected: None, - compression: CompressionOptions { compression_level: 1 }, - }; - - assert!(cmd.fraction > 1.0); + // Anything strictly greater than the inclusive upper bound of 1.0 must be rejected. + assert!(validate_fraction(1.1).is_err()); + assert!(validate_fraction(1.5).is_err()); } #[test] fn test_validate_fraction_valid() { - let cmd = Downsample { - io: test_bam_io_options(), - fraction: 0.5, - rejects: None, - seed: None, - validate_mi_order: false, - histogram_kept: None, - histogram_rejected: None, - compression: CompressionOptions { compression_level: 1 }, - }; - - assert!(cmd.fraction > 0.0 && cmd.fraction <= 1.0); + // A mid-range value and the inclusive upper bound 1.0 must both be accepted. + assert!(validate_fraction(0.5).is_ok()); + assert!(validate_fraction(1.0).is_ok()); } #[test] diff --git a/src/lib/commands/runall.rs b/src/lib/commands/runall.rs index c77c44587..268629b8a 100644 --- a/src/lib/commands/runall.rs +++ b/src/lib/commands/runall.rs @@ -1851,6 +1851,130 @@ mod tests { ); } + /// Codec consensus does not support methylation calling. The + /// `Stage::Codec` arm of `build_stage_options_bag` rejects a + /// `--methylation-mode` that survived parsing — fail loud rather than + /// silently drop the flag. The guard fires before any `--codec::*` + /// validation, so no codec tuning flags are needed to reach it. + #[test] + fn codec_with_methylation_is_rejected() { + use clap::Parser; + let r = RunAll::try_parse_from([ + "runall", + "--start-from", + "group", + "--stop-after", + "consensus", + "--consensus", + "codec", + "--methylation-mode", + "em-seq", + "--ref", + "/tmp/fgumi-nonexistent-ref.fa", + "--input", + "x.bam", + "--output", + "o.bam", + "--group::strategy", + "paired", + "--threads", + "1", + ]) + .expect("parse"); + let stages = r.derive_stages().expect("derive stages"); + assert!( + stages.contains(&crate::pipeline::chains::Stage::Codec), + "group → consensus with --consensus codec must include the Codec stage: {stages:?}" + ); + let err = match r.build_stage_options_bag(&stages) { + Ok(_) => panic!("--consensus codec + --methylation-mode must be rejected"), + Err(e) => e, + }; + let msg = err.to_string(); + assert!( + msg.contains("methylation-mode") && msg.contains("codec"), + "error must mention methylation-mode and codec, got: {msg}" + ); + } + + /// `--start-from align` (align-and-merge) does not yet support + /// methylation-aware aligning. `validate_align_and_merge` rejects a + /// `--methylation-mode` after confirming `--ref` is present but before + /// the `.dict` existence check, so a nonexistent reference path still + /// reaches the methylation guard. + #[test] + fn align_start_with_methylation_is_rejected() { + use clap::Parser; + let r = RunAll::try_parse_from([ + "runall", + "--start-from", + "align", + "--stop-after", + "consensus", + "--consensus", + "duplex", + "--methylation-mode", + "em-seq", + "--ref", + "/tmp/fgumi-nonexistent-ref.fa", + "--input", + "x.bam", + "--output", + "o.bam", + "--group::strategy", + "paired", + "--threads", + "1", + ]) + .expect("parse"); + let err = r + .validate_align_and_merge() + .expect_err("--start-from align + --methylation-mode must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("methylation-mode") && msg.contains("align"), + "error must mention methylation-mode and align, got: {msg}" + ); + } + + /// On a non-aligner start stage (e.g. `--start-from group`), passing + /// `--ref` without `--methylation-mode` is rejected by `execute` before + /// any stage runs: the ref would otherwise be silently ignored. The + /// guard is checked early — after the (skipped-for-stdin) input + /// existence check but before the pipeline opens — so `--input -` + /// reaches the guard without a real input BAM and without consuming + /// stdin. + #[test] + fn ref_without_methylation_on_group_start_is_rejected() { + use clap::Parser; + let r = RunAll::try_parse_from([ + "runall", + "--start-from", + "group", + "--stop-after", + "group", + "--ref", + "/tmp/fgumi-nonexistent-ref.fa", + "--input", + "-", + "--output", + "o.bam", + "--group::strategy", + "paired", + "--threads", + "1", + ]) + .expect("parse"); + let err = r + .execute("test") + .expect_err("--ref without --methylation-mode on a non-aligner start must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("--ref requires --methylation-mode"), + "error must mention `--ref requires --methylation-mode`, got: {msg}" + ); + } + /// `--filter::max-read-error-rate` / `--filter::max-base-error-rate` are /// `Vec` fields with standalone defaults (`[0.025]` / `[0.1]`) that the /// `#[multi_options]`-generated `validate()` backfills from diff --git a/src/lib/fastq_parse.rs b/src/lib/fastq_parse.rs index 8744444a2..e41b5ba0b 100644 --- a/src/lib/fastq_parse.rs +++ b/src/lib/fastq_parse.rs @@ -346,14 +346,16 @@ mod tests { #[test] fn test_parse_fastq_eof_no_newline_seq_qual_mismatch() { - // Quality shorter than sequence at EOF — should error. + // Quality shorter than sequence at EOF — must error (must not silently + // swallow the malformed record). let data = b"@read1\nACGT\n+\nIII"; let result = parse_fastq_records(data); + assert!(result.is_err(), "expected an error for seq/qual length mismatch at EOF"); + let err = result.unwrap_err(); + let msg = err.to_string(); assert!( - result.is_err() || { - let (recs, leftover) = result.unwrap(); - recs.is_empty() && !leftover.is_empty() - } + msg.contains("Sequence length") && msg.contains("quality length"), + "unexpected error: {msg}" ); } diff --git a/src/lib/mi_group.rs b/src/lib/mi_group.rs index e89d51e8d..3d4ddd880 100644 --- a/src/lib/mi_group.rs +++ b/src/lib/mi_group.rs @@ -190,7 +190,14 @@ impl MiKey { // cell presence always matches the configuration; the mixed arms are // unreachable. (None, None) => true, - _ => false, + (Some(_), None) | (None, Some(_)) => { + debug_assert!( + false, + "cell-tag presence is fixed for the grouper's lifetime, so the stored key's \ + cell presence always matches the record's; this mixed arm is unreachable" + ); + false + } }; Some(mi_eq && cell_eq) } diff --git a/src/lib/pipeline/chains/builder.rs b/src/lib/pipeline/chains/builder.rs index 0176714aa..08f9bb62f 100644 --- a/src/lib/pipeline/chains/builder.rs +++ b/src/lib/pipeline/chains/builder.rs @@ -162,6 +162,17 @@ pub(crate) enum ChainTailKind { /// [`DecodedRecordBatch`]: crate::pipeline::steps::types::DecodedRecordBatch DecodedRecordBatch, + /// The chain tail produces serialized BGZF-ready bytes + /// ([`DecompressedBlock`]) after a Terminal serialize step + /// (`SerializeBamRecords` / `SerializeRecordBatch` / `SerializeGroups`, or + /// the record-aligned block emitted by a terminal consensus stage). No + /// stage downstream of a Terminal stage reads `chain_tail_kind`, so this is + /// a terminal-only marker; it exists so the kind is never a lie. `add_sink` + /// wires `BgzfCompress → WriteBgzfFile` regardless of this value. + /// + /// [`DecompressedBlock`]: crate::pipeline::steps::types::DecompressedBlock + SerializedBytes, + /// The chain tail produces [`RecordBatch`]. Set when Sort is the first /// stage: `add_source` uses `ParseBamRecords` (not `DecodeRecords`). /// @@ -1017,9 +1028,10 @@ impl<'a> ChainBuilder<'a> { /// /// The consensus step emits a record-aligned [`DecompressedBlock`]. For a /// [`StagePosition::Terminal`] consensus stage, that block IS the chain tail - /// (`add_sink` wires `BgzfCompress → Write`); `chain_tail_kind` is left at - /// its sentinel (the enum has no `DecompressedBlock` variant and nothing - /// downstream of a terminal consensus reads it). For a + /// (`add_sink` wires `BgzfCompress → Write`); `chain_tail_kind` is set to + /// [`ChainTailKind::SerializedBytes`] (the honest marker for "serialized + /// bytes ready for sink"; nothing downstream of a terminal consensus reads + /// it). For a /// [`StagePosition::Intermediate`] consensus stage, a downstream stage /// (e.g. filter) consumes records, so append the existing [`DecodeRecords`] /// step — consensus output is already record-aligned, so no @@ -1039,8 +1051,11 @@ impl<'a> ChainBuilder<'a> { ) -> (crate::pipeline::core::topology::StepIdx, crate::pipeline::core::topology::BranchIdx) { match position { - // terminal: tail is DecompressedBlock; chain_tail_kind left as sentinel. - StagePosition::Terminal => tail, + // terminal: tail is DecompressedBlock (serialized bytes) → SerializedBytes. + StagePosition::Terminal => { + self.chain_tail_kind = ChainTailKind::SerializedBytes; + tail + } StagePosition::Intermediate => { use crate::pipeline::steps::parse::decode::DecodeRecords; let group_key_config = self.bam_group_key_config(); @@ -1608,7 +1623,8 @@ impl<'a> ChainBuilder<'a> { process_tail, ); self.current_tail = Some(tail); - self.chain_tail_kind = ChainTailKind::DecodedRecordBatch; // actually DecompressedBlock + // tail is DecompressedBlock (serialized bytes) → SerializedBytes. + self.chain_tail_kind = ChainTailKind::SerializedBytes; } else { // Intermediate: leave the tail as BamTemplateBatch so the next // stage (add_align → GroupByQueryname → AlignAndMergeStep) can @@ -1834,10 +1850,9 @@ impl<'a> ChainBuilder<'a> { .pipeline .append_step(SerializeBamRecords::new(self.tuning.per_step_byte_limit), aam_tail); self.current_tail = Some(tail); - // chain_tail_kind is DecompressedBlock after SerializeBamRecords, but - // we reuse DecodedRecordBatch as the sentinel for "bytes ready for sink" - // (consistent with other Terminal paths). - self.chain_tail_kind = ChainTailKind::DecodedRecordBatch; + // tail is DecompressedBlock (serialized bytes) after SerializeBamRecords + // → SerializedBytes. + self.chain_tail_kind = ChainTailKind::SerializedBytes; } else { self.current_tail = Some(aam_tail); // AAM output is BamTemplateBatch; downstream stages (Sort, Group, @@ -1973,11 +1988,9 @@ impl<'a> ChainBuilder<'a> { merge_tail, ); self.current_tail = Some(tail); - // Tail is now DecompressedBlock (bytes). The chain_tail_kind - // sentinel after SerializeBamRecords is DecodedRecordBatch - // (same convention used by other Terminal paths — see the - // comment on `ChainTailKind` for the "ready for sink" sentinel). - self.chain_tail_kind = ChainTailKind::DecodedRecordBatch; + // tail is DecompressedBlock (serialized bytes) after SerializeBamRecords + // → SerializedBytes. + self.chain_tail_kind = ChainTailKind::SerializedBytes; } else { self.current_tail = Some(merge_tail); // Intermediate: ZipperMergeStep emits BamTemplateBatch so the @@ -2192,8 +2205,8 @@ impl<'a> ChainBuilder<'a> { // For Intermediate: chain_tail_kind = DecodedRecordBatch; // add_group / add_simplex / etc. can proceed normally. // - // For Terminal: chain_tail_kind = DecodedRecordBatch (sentinel for - // "bytes ready for sink") — add_sink appends BgzfCompress → WriteBgzfFile. + // For Terminal: chain_tail_kind = SerializedBytes (DecompressedBlock + // bytes ready for sink) — add_sink appends BgzfCompress → WriteBgzfFile. // // Memory must be `Fixed` — `Auto` cannot be honoured in a // multi-stage pipeline where the memory budget is shared across @@ -2295,9 +2308,9 @@ impl<'a> ChainBuilder<'a> { merge_tail, ); self.current_tail = Some(tail); - // Use DecodedRecordBatch as the sentinel for "bytes ready for sink" - // (consistent with other Terminal paths). - self.chain_tail_kind = ChainTailKind::DecodedRecordBatch; + // tail is DecompressedBlock (serialized bytes) after + // SerializeRecordBatch → SerializedBytes. + self.chain_tail_kind = ChainTailKind::SerializedBytes; // Mirror the Standalone-branch BAI hook registration: when // the spec asks for a sidecar BAI, queue the @@ -2552,8 +2565,9 @@ impl<'a> ChainBuilder<'a> { ); let tail = self.pipeline.append_step(serialize_step, tail); self.current_tail = Some(tail); - // chain_tail_kind remains DecodedRecordBatch (actually DecompressedBlock, - // but that's used only for add_sink detection, not for stage routing). + // tail is DecompressedBlock (serialized bytes) after SerializeGroups + // → SerializedBytes. + self.chain_tail_kind = ChainTailKind::SerializedBytes; } else { // Intermediate: leave tail as BatchedProcessedPositionGroups so the // next stage (add_simplex / add_duplex / add_codec) can wire diff --git a/src/lib/pipeline/steps/align_and_merge.rs b/src/lib/pipeline/steps/align_and_merge.rs index ac3f53f8e..695f89c30 100644 --- a/src/lib/pipeline/steps/align_and_merge.rs +++ b/src/lib/pipeline/steps/align_and_merge.rs @@ -2283,4 +2283,65 @@ mod tests { let result = handle.try_get().expect("set"); assert!(result.is_ok(), "SAM-text header should resolve handle to Ok: {:?}", result.err(),); } + + /// `merge_aligner_header` must keep the *partial* header's `@PG` on a + /// duplicate ID (the aligner's PG with that ID is dropped, not merged) + /// while still appending aligner PGs whose IDs are unique to the + /// aligner. This pins the dedup behavior documented on the function. + #[test] + fn merge_aligner_header_keeps_partial_pg_on_duplicate_id() { + use bstr::BString; + use noodles::sam::header::record::value::Map; + use noodles::sam::header::record::value::map::Program; + use noodles::sam::header::record::value::map::program::tag as pg_tag; + + // Helper: build a @PG map carrying a distinguishing PN field so we + // can tell the partial's PG apart from the aligner's. + let program_with_name = |name: &str| -> Map { + Map::::builder().insert(pg_tag::NAME, name).build().expect("valid @PG") + }; + + // Partial header: a "dup" PG (PN=partial) plus a partial-only PG. + let partial = Header::builder() + .add_program(BString::from("dup"), program_with_name("partial")) + .add_program(BString::from("onlypartial"), program_with_name("partial")) + .build(); + + // Aligner header: a "dup" PG (PN=aligner, must be dropped) plus a + // unique "bwa" PG (must be appended). + let aligner = Header::builder() + .add_program(BString::from("dup"), program_with_name("aligner")) + .add_program(BString::from("bwa"), program_with_name("aligner")) + .build(); + + let merged = merge_aligner_header(&partial, &aligner); + let programs = merged.programs(); + let programs = programs.as_ref(); + + // Exactly three PGs survive: dup (partial's), onlypartial, bwa. + assert_eq!(programs.len(), 3, "expected dup + onlypartial + bwa"); + + // Read back the PN field for a given @PG ID. + let pn_of = |id: &str| -> Option { + programs + .get(&BString::from(id)) + .and_then(|pg| pg.other_fields().get(&pg_tag::NAME).map(ToString::to_string)) + }; + + // The duplicate-ID PG is the PARTIAL's (PN=partial), not the + // aligner's — the aligner's same-ID PG was dropped. + assert_eq!( + pn_of("dup").as_deref(), + Some("partial"), + "duplicate @PG ID must retain the partial's PG, not the aligner's" + ); + // The partial-only PG is preserved. + assert_eq!(pn_of("onlypartial").as_deref(), Some("partial")); + // The aligner-unique PG is appended. + assert_eq!( + pn_of("bwa").as_deref(), + Some("aligner"), + "aligner @PG with a non-duplicate ID must be appended" + ); + } } diff --git a/src/lib/pipeline/steps/coalesce.rs b/src/lib/pipeline/steps/coalesce.rs index 2bbfaf2b7..bcd8a61d0 100644 --- a/src/lib/pipeline/steps/coalesce.rs +++ b/src/lib/pipeline/steps/coalesce.rs @@ -211,6 +211,14 @@ impl Step for CoalesceBytes { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; + + use crate::pipeline::core::item::HeapSize; + use crate::pipeline::core::outputs::Single; + use crate::pipeline::core::{PipelineBuilder, PipelineConfig}; + use super::*; #[test] @@ -227,4 +235,172 @@ mod tests { fn default_threshold_is_256_kib() { assert_eq!(DEFAULT_COALESCE_THRESHOLD_BYTES, 256 * 1024); } + + // ───────────────────────────────────────────────────────────────────────── + // X3-007: drive `CoalesceBytes::try_run`'s flush loop through a real + // pipeline. The two pre-existing tests only assert the profile and the + // default threshold constant — neither exercises the byte-budget flush + // logic (accumulate until `pending` crosses `threshold_bytes`, then emit; + // bound `pending` at ~threshold regardless of input count). These tests + // pin that contract: all input bytes are preserved AND each emitted block + // is bounded (the step flushes at the threshold, never buffering the whole + // stream into one giant block). + // ───────────────────────────────────────────────────────────────────────── + + /// Size of each `DecompressedBlock` the source emits. + const COALESCE_INPUT_BLOCK_BYTES: usize = 1000; + /// Coalesce flush threshold under test. Chosen so several input blocks + /// accumulate per emit (`THRESHOLD / INPUT = 8` blocks per flush) and the + /// flush loop runs many times over the full stream. + const COALESCE_THRESHOLD_BYTES: usize = 8 * COALESCE_INPUT_BLOCK_BYTES; + /// Number of fixed-size input blocks. Sums to `64 * THRESHOLD`, so a + /// regression that buffered everything into one block would emit a single + /// ~512 KB block (caught by the per-block size bound below). + const COALESCE_INPUT_BLOCKS: usize = 64 * 8; + + /// Source emitting `remaining` fixed-size `DecompressedBlock`s via a shared + /// atomic counter (safe for Serial single-worker execution). Each block's + /// `bytes` is `COALESCE_INPUT_BLOCK_BYTES` of a deterministic fill so the + /// sink can verify byte preservation without an ordering assumption. + #[derive(Clone)] + struct BlockSource { + remaining: Arc, + } + impl Step for BlockSource { + type Input = (); + type Outputs = Single; + fn profile(&self) -> StepProfile { + StepProfile { + name: "BlockSource", + kind: StepKind::Serial, + sticky: false, + output_queues: vec![QueueSpec::ByteBounded { limit_bytes: 4 * 1024 }], + branch_ordering: vec![BranchOrdering::None], + } + } + fn try_run(&mut self, ctx: &mut StepCtx<'_, Self>) -> io::Result { + let n = self.remaining.load(AtomicOrd::Acquire); + if n == 0 { + return Ok(StepOutcome::Finished); + } + let block = DecompressedBlock { + batch_serial: n, + bytes: vec![0xCD; COALESCE_INPUT_BLOCK_BYTES], + }; + match ctx.outputs.push(block) { + Ok(()) => { + self.remaining.fetch_sub(1, AtomicOrd::AcqRel); + Ok(StepOutcome::Progress) + } + // Backpressure: keep the count and retry next dispatch. + Err(_) => Ok(StepOutcome::NoProgress), + } + } + fn new_worker_copy(&self) -> Self { + self.clone() + } + } + + /// Sink recording the byte length of every emitted block (so the test can + /// bound the per-block size) plus the running total. + #[derive(Clone)] + struct SizeRecordingSink { + sizes: Arc>>, + } + impl Step for SizeRecordingSink { + type Input = DecompressedBlock; + type Outputs = (); + fn profile(&self) -> StepProfile { + StepProfile { + name: "SizeSink", + kind: StepKind::Serial, + sticky: false, + output_queues: vec![], + branch_ordering: vec![], + } + } + fn try_run(&mut self, ctx: &mut StepCtx<'_, Self>) -> io::Result { + match ctx.input.pop() { + Some(block) => { + // Every byte must be the source's fill — proving the + // concatenation neither drops nor corrupts bytes. + assert!( + block.bytes.iter().all(|&b| b == 0xCD), + "coalesced block carries unexpected bytes" + ); + self.sizes.lock().expect("sink mutex").push(block.bytes.len()); + Ok(StepOutcome::Progress) + } + None if ctx.input.is_drained() => Ok(StepOutcome::Finished), + None => Ok(StepOutcome::NoProgress), + } + } + fn new_worker_copy(&self) -> Self { + self.clone() + } + } + + #[test] + fn coalesce_flushes_at_threshold_and_preserves_bytes() { + let remaining = Arc::new(AtomicU64::new(u64::try_from(COALESCE_INPUT_BLOCKS).unwrap())); + let sizes = Arc::new(Mutex::new(Vec::new())); + + let coalesce = CoalesceBytes::new(COALESCE_THRESHOLD_BYTES, 4 * 1024); + + let builder = PipelineBuilder::new(); + builder + .chain(BlockSource { remaining: Arc::clone(&remaining) }) + .chain(coalesce) + .chain(SizeRecordingSink { sizes: Arc::clone(&sizes) }) + .into_sink_marker(); + + let pipeline = builder.build().unwrap(); + let result = pipeline.run(PipelineConfig { threads: 4, ..Default::default() }); + assert!(result.is_ok(), "coalesce run failed: {:?}", result.err()); + + let emitted = sizes.lock().expect("sink mutex").clone(); + let total_in = COALESCE_INPUT_BLOCKS * COALESCE_INPUT_BLOCK_BYTES; + let total_out: usize = emitted.iter().sum(); + + // Byte conservation: every input byte reaches the sink exactly once. + assert_eq!(total_out, total_in, "coalesce dropped or duplicated bytes"); + + // Memory bound: the step flushes once `pending` reaches the threshold, + // then resets. Each emitted block therefore carries between + // `threshold` and `threshold + (MAX_BATCHES_PER_LOCK - 1) * input` — + // the threshold check fires mid-pull, but the loop may absorb up to one + // lock's worth of inputs before re-checking. A "buffer everything into + // one block" regression emits a single `total_in`-sized block, which + // blows this bound spectacularly. + let per_block_ceiling = + COALESCE_THRESHOLD_BYTES + MAX_BATCHES_PER_LOCK * COALESCE_INPUT_BLOCK_BYTES; + for (i, &sz) in emitted.iter().enumerate() { + let is_last = i + 1 == emitted.len(); + assert!( + sz <= per_block_ceiling, + "emitted block {i} of {sz} bytes exceeds the bound {per_block_ceiling} \ + — pending was not flushed at the threshold" + ); + if !is_last { + // Non-final blocks must have crossed the threshold before + // flushing (only the final drain may emit a sub-threshold + // partial). + assert!( + sz >= COALESCE_THRESHOLD_BYTES, + "non-final block {i} of {sz} bytes is below the threshold \ + {COALESCE_THRESHOLD_BYTES} — premature flush" + ); + } + } + + // The stream is large enough that a correctly-flushing step emits many + // blocks (~64), never one. + assert!( + emitted.len() > 1, + "expected many threshold-sized flushes, got {} block(s)", + emitted.len() + ); + // Sanity-check the helper trait is exercised on the flowing type. + assert!(DecompressedBlock { batch_serial: 0, bytes: vec![0u8; 3] }.heap_size() >= 3); + } } diff --git a/src/lib/pipeline/steps/correct/tests.rs b/src/lib/pipeline/steps/correct/tests.rs index 61412e87f..d3ecefa7f 100644 --- a/src/lib/pipeline/steps/correct/tests.rs +++ b/src/lib/pipeline/steps/correct/tests.rs @@ -3,6 +3,8 @@ use crate::commands::correct::CorrectOptions; use crate::pipeline::core::queues::QueueSpec; use crate::pipeline::core::step::StepKind; use crate::sam::SamTag; +use crate::template::Template; +use fgumi_raw_bam::SamBuilder; fn make_default_cfg() -> CorrectStepConfig { CorrectStepConfig { @@ -39,3 +41,175 @@ fn correct_step_kept_only_profile() { assert!(matches!(profile.kind, StepKind::Parallel)); assert_eq!(profile.output_queues.len(), 1); } + +// --- Batch-routing tests for run_batch_with_rejects / run_batch_kept_only --- + +/// Build a fresh per-worker state matching how the step's `init` closure +/// constructs it for the default (cache-enabled) `CorrectOptions`. +fn make_state() -> CorrectWorkerState { + CorrectWorkerState { + cache: Some(LruCache::new( + NonZero::new(CorrectOptions::default().cache_size).expect("default cache_size > 0"), + )), + } +} + +/// Build a single-record (unpaired) template whose `RX` tag holds `umi`. +/// +/// A 4-base sequence/quality keeps the record comfortably above the 32-byte +/// minimum that `extract_and_validate_template_umi_raw` / `Template::from_records` +/// require, and a distinct `qname` keeps each template independent. +fn make_template_with_rx(qname: &[u8], umi: &str) -> Template { + let mut b = SamBuilder::new(); + b.read_name(qname) + .flags(0) // unmapped, unpaired -> single-record template (R1) + .sequence(b"ACGT") + .qualities(b"IIII") + .add_string_tag(SamTag::RX, umi.as_bytes()); + Template::from_records(vec![b.build()]).expect("single-record template") +} + +/// Read the `RX` tag value back from the first record of a template. +fn template_rx(template: &Template) -> String { + let rx = fgumi_raw_bam::find_string_tag_in_record(&template.records[0], SamTag::RX) + .expect("RX tag present on kept record"); + String::from_utf8(rx.to_vec()).expect("RX is valid UTF-8") +} + +/// Read the `QNAME` (read name) back from the first record of a template. Used +/// to pin *which* templates were routed to a branch by identity — the corrected +/// `RX` collapses both kept templates to `AAAA`, so an RX-only check cannot tell +/// the intended survivor from a regression that kept the wrong record. +fn template_qname(template: &Template) -> String { + let name = fgumi_raw_bam::read_name(template.records[0].as_ref()); + String::from_utf8(name.to_vec()).expect("qname is valid UTF-8") +} + +/// Decode a BAM-framed reject block — `4-byte LE block_size` + record body, +/// repeated — into the `RX` tag of each rejected record. This is an +/// independent inverse of the production `append_framed_raw_record` writer, so +/// a routing regression that serialized the wrong record can't pass a mere +/// non-empty-bytes check. +fn reject_block_rx_tags(bytes: &[u8]) -> Vec { + let mut tags = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + let block_size = u32::from_le_bytes( + bytes[offset..offset + 4].try_into().expect("4-byte block_size prefix"), + ) as usize; + offset += 4; + let body = &bytes[offset..offset + block_size]; + let rx = fgumi_raw_bam::find_string_tag_in_record(body, SamTag::RX) + .expect("RX tag present on rejected record"); + tags.push(String::from_utf8(rx.to_vec()).expect("RX is valid UTF-8")); + offset += block_size; + } + tags +} + +/// With the default config (whitelist `["AAAA"]`, `max_mismatches = 2`): +/// - `RX = "AAAA"` matches exactly -> KEPT, unchanged. +/// - `RX = "AAAT"` is 1 mismatch from `AAAA` (<= 2) -> KEPT, corrected to `AAAA`. +/// - `RX = "TTTT"` is 4 mismatches from `AAAA` (> 2) -> REJECTED. +#[test] +fn run_batch_with_rejects_splits_kept_and_rejected() { + let cfg = Arc::new(make_default_cfg()); + let mut state = make_state(); + + let templates = vec![ + make_template_with_rx(b"exact", "AAAA"), + make_template_with_rx(b"correctable", "AAAT"), + make_template_with_rx(b"offlist", "TTTT"), + ]; + let batch = BamTemplateBatch::new(7, templates); + + let (kept, rejects) = + run_batch_with_rejects(&mut state, &cfg, batch).expect("run_batch_with_rejects"); + + // Kept branch keeps the on-whitelist + correctable templates only. + assert_eq!(kept.batch_serial, 7); + assert_eq!(kept.templates.len(), 2, "exact + correctable kept"); + let mut kept_umis: Vec = kept.templates.iter().map(template_rx).collect(); + kept_umis.sort(); + // Both kept records carry the corrected/whitelisted UMI "AAAA". + assert_eq!(kept_umis, vec!["AAAA".to_string(), "AAAA".to_string()]); + // Pin kept *identity* by QNAME, not just the corrected RX: both kept + // templates normalize to "AAAA", so a regression that kept the wrong record + // (e.g. dropped `correctable` and kept `offlist` rewritten to AAAA) would + // still satisfy the RX check above. + let mut kept_qnames: Vec = kept.templates.iter().map(template_qname).collect(); + kept_qnames.sort(); + assert_eq!(kept_qnames, vec!["correctable".to_string(), "exact".to_string()]); + + // Rejects branch carries exactly the off-whitelist template. Decode the + // framed block and assert the rejected record's identity (its untouched + // `RX = "TTTT"`, 4 mismatches from the `AAAA` whitelist) — a non-empty + // bytes check alone would pass even if the wrong record were routed here. + let rejects = rejects.expect("rejected template routed to rejects branch"); + assert_eq!(rejects.batch_serial, 7); + assert_eq!( + reject_block_rx_tags(&rejects.bytes), + vec!["TTTT".to_string()], + "only the off-whitelist TTTT record is routed to rejects, unchanged" + ); + + // records_emitted counts only the two kept single-record templates. + assert_eq!(cfg.records_emitted.load(Ordering::Relaxed), 2); + + // Metrics: one rejected (mismatched) template, two kept. + let slot = &cfg.metrics.slots()[0]; + let metrics = slot.lock(); + assert_eq!(metrics.mismatched, 1, "one off-whitelist template rejected"); +} + +/// An all-on-whitelist batch produces no rejects (`None`) from the +/// 2-output path, so the framework emits a zero-byte rejects block instead. +#[test] +fn run_batch_with_rejects_no_rejects_when_all_kept() { + let cfg = Arc::new(make_default_cfg()); + let mut state = make_state(); + + let batch = BamTemplateBatch::new(3, vec![make_template_with_rx(b"exact", "AAAA")]); + let (kept, rejects) = + run_batch_with_rejects(&mut state, &cfg, batch).expect("run_batch_with_rejects"); + + assert_eq!(kept.templates.len(), 1); + assert!(rejects.is_none(), "no rejected records -> no rejects block"); + assert_eq!(cfg.records_emitted.load(Ordering::Relaxed), 1); +} + +/// The kept-only path keeps exactly the same templates as the with-rejects +/// path's kept branch, but silently DROPS rejected templates (there is no +/// rejects branch to inspect). +#[test] +fn run_batch_kept_only_drops_rejected() { + let cfg = Arc::new(make_default_cfg()); + let mut state = make_state(); + + let templates = vec![ + make_template_with_rx(b"exact", "AAAA"), + make_template_with_rx(b"correctable", "AAAT"), + make_template_with_rx(b"offlist", "TTTT"), + ]; + let batch = BamTemplateBatch::new(11, templates); + + let (kept, ()) = run_batch_kept_only(&mut state, &cfg, batch).expect("run_batch_kept_only"); + + // Only the AAAA + correctable templates survive; TTTT is dropped silently. + assert_eq!(kept.batch_serial, 11); + assert_eq!(kept.templates.len(), 2, "TTTT dropped, no rejects branch"); + let mut kept_umis: Vec = kept.templates.iter().map(template_rx).collect(); + kept_umis.sort(); + assert_eq!(kept_umis, vec!["AAAA".to_string(), "AAAA".to_string()]); + // Pin kept identity by QNAME: the on-whitelist `exact` and the `correctable` + // template survive; `offlist` (TTTT) is the one dropped. + let mut kept_qnames: Vec = kept.templates.iter().map(template_qname).collect(); + kept_qnames.sort(); + assert_eq!(kept_qnames, vec!["correctable".to_string(), "exact".to_string()]); + + assert_eq!(cfg.records_emitted.load(Ordering::Relaxed), 2); + + let slot = &cfg.metrics.slots()[0]; + let metrics = slot.lock(); + assert_eq!(metrics.mismatched, 1, "rejected template still counted in metrics"); +} diff --git a/src/lib/pipeline/steps/source/pair_fastq.rs b/src/lib/pipeline/steps/source/pair_fastq.rs index 10356c382..4c016b888 100644 --- a/src/lib/pipeline/steps/source/pair_fastq.rs +++ b/src/lib/pipeline/steps/source/pair_fastq.rs @@ -774,4 +774,128 @@ mod tests { "every chunk_serial must be paired exactly once and emitted in order" ); } + + // ───────────────────────────────────────────────────────────────────────── + // S5a2-011: when the two FASTQ streams have UNEQUAL record counts, one + // stream's reader drains while the other still has a chunk for the lowest + // pending serial. `finalize_pairs` must abort with an "out of sync" error + // that names the stream index that ENDED EARLY (the missing one) — and it + // must do so symmetrically, whichever of the two streams is short. The + // `run_desync_harness` helper below drives both directions; the two tests + // assert the stream-1-short and stream-0-short cases respectively. + // + // Wiring mirrors the X3-003 harness above: two `OneStreamSource` leaders + // (no lag — we want clean end-of-stream desync at drain, not mid-stream + // backpressure), one feeding 3 serials and one feeding 2. Serial 2 buffers + // only the long stream's chunk; both readers then drain, `finalize_pairs` + // finds serial 2 holding one side present and the other missing, computes + // `short` from which side is absent, and reports "stream ended + // before chunk_serial 2". The backpressure limit is kept generous so the + // catastrophic-desync hard-abort (2x) never fires first — the one-serial + // skew keeps the partial-pair buffer tiny. + // ───────────────────────────────────────────────────────────────────────── + + /// The long stream emits this many serials. + const DESYNC_LONG_SERIALS: u64 = 3; + /// The short stream emits this many serials (it ends one serial early). + const DESYNC_SHORT_SERIALS: u64 = 2; + + /// Run the two-leader desync harness with `serials0` chunks on stream 0 and + /// `serials1` on stream 1, returning the pipeline run result. Whichever + /// stream is short drains first, leaving the lowest pending serial holding + /// only the long stream's chunk, so `finalize_pairs` must abort with an + /// "out of sync" error naming the SHORT (missing) stream index. Shared by + /// both desync tests so the stream-0-short and stream-1-short cases exercise + /// byte-for-byte the same wiring (only the lengths swap). + fn run_desync_harness(serials0: u64, serials1: u64) -> Result<(), String> { + use std::time::{Duration, Instant}; + + // Both sources are leaders with no lag gate; the shared counter is + // unused for ordering here but required by the constructor signature. + let unused_progress = Arc::new(AtomicUsize::new(0)); + + let progress_for_thread = Arc::clone(&unused_progress); + let worker = std::thread::Builder::new() + .name("pair-fastq-s5a2-011".into()) + .spawn(move || -> Result<(), String> { + let stream0 = OneStreamSource::leader( + 0, + serials0, + X3_SOURCE_QUEUE_BYTES, + Arc::clone(&progress_for_thread), + ); + let stream1 = OneStreamSource::leader( + 1, + serials1, + X3_SOURCE_QUEUE_BYTES, + progress_for_thread, + ); + // Generous backpressure so the catastrophic-desync 2x abort never + // trips before the end-of-stream `finalize_pairs` desync path. + let pair = PairRawFastq::new(64 * 1024); + let sink = PairSink { + seen_serials: Arc::new(Mutex::new(Vec::new())), + count: Arc::new(AtomicUsize::new(0)), + }; + + let builder = PipelineBuilder::new(); + let tail0 = builder.append_source(stream0); + let tail1 = builder.append_source(stream1); + let pair_tail = builder.append_step2(pair, tail0, tail1); + builder.append_step(sink, pair_tail); + + let pipeline = builder.build().map_err(|e| e.to_string())?; + pipeline + .run(PipelineConfig { threads: 3, ..Default::default() }) + .map_err(|e| e.to_string()) + }) + .expect("spawn pipeline worker"); + + let deadline = Instant::now() + Duration::from_secs(30); + while !worker.is_finished() { + assert!( + Instant::now() < deadline, + "PairRawFastq desync pipeline did not finish within 30s — likely a deadlock" + ); + std::thread::sleep(Duration::from_millis(25)); + } + worker.join().expect("pipeline thread panicked") + } + + #[test] + fn finalize_pairs_reports_desync_with_short_stream_index() { + // Stream 0 long (3 serials), stream 1 short (2): serial 2 buffers only + // stream 0's chunk, so `short = usize::from(entry.0.is_some()) = 1` and + // the error must name the MISSING (short) stream, index 1. + let result = run_desync_harness(DESYNC_LONG_SERIALS, DESYNC_SHORT_SERIALS); + let err = result.expect_err("unequal stream lengths must abort the pipeline run"); + assert!( + err.contains("out of sync"), + "desync abort must report an out-of-sync error, got: {err}" + ); + assert!( + err.contains("stream 1 ended before chunk_serial 2"), + "desync error must name the short stream (index 1) and the orphaned \ + serial (2), got: {err}" + ); + } + + #[test] + fn finalize_pairs_reports_desync_when_stream_zero_is_short() { + // Mirror of the above with the lengths swapped: stream 0 short (2 + // serials), stream 1 long (3). Serial 2 now buffers only stream 1's + // chunk, so the missing-stream index is 0 — guards the symmetric branch + // that a stream-1-only test would leave unexercised. + let result = run_desync_harness(DESYNC_SHORT_SERIALS, DESYNC_LONG_SERIALS); + let err = result.expect_err("unequal stream lengths must abort the pipeline run"); + assert!( + err.contains("out of sync"), + "desync abort must report an out-of-sync error, got: {err}" + ); + assert!( + err.contains("stream 0 ended before chunk_serial 2"), + "desync error must name the short stream (index 0) and the orphaned \ + serial (2), got: {err}" + ); + } } diff --git a/tests/integration/helpers/parity.rs b/tests/integration/helpers/parity.rs index 7299bc6af..3297c7264 100644 --- a/tests/integration/helpers/parity.rs +++ b/tests/integration/helpers/parity.rs @@ -62,3 +62,130 @@ pub fn assert_bams_record_equivalent(a: &Path, b: &Path) { assert_eq!(ra, rb, "record {i} differs between {} and {}", a.display(), b.display()); } } + +/// Like [`assert_bams_record_equivalent`], but additionally asserts the +/// record stream is NON-empty. +/// +/// `assert_bams_record_equivalent` is vacuously satisfied when both BAMs +/// emit zero records (`0 == 0`, the per-record loop never runs), so a +/// parity test whose chain is *expected* to retain records could stay +/// green even if the fused pipeline silently emitted a header-only BAM +/// (S9a-001). Use this variant for every parity chain that must carry +/// records; reserve the bare `assert_bams_record_equivalent` (paired with +/// an explicit `assert_eq!(read_bam_records(..).len(), 0, ..)`) for chains +/// that legitimately produce zero records by design (the codec fixtures +/// lack the FR-overlap shape — see the codec parity tests). +/// +/// # Panics +/// +/// Panics if the BAMs are not record-equivalent, or if `a` (and therefore +/// `b`, since the counts were just asserted equal) contains zero records. +pub fn assert_bams_record_equivalent_nonempty(a: &Path, b: &Path) { + assert_bams_record_equivalent(a, b); + let n = read_bam_records(a).len(); + assert!( + n > 0, + "expected a non-empty record stream but {} has 0 records — \ + a header-only BAM would satisfy the equivalence vacuously", + a.display(), + ); +} + +/// Assert the two BAM headers agree on the correctness-relevant fields, +/// ignoring only `@PG` (which records the literal command line and so +/// legitimately diverges between a fused runall and the equivalent staged +/// chain). +/// +/// Compares (S9a-005): +/// * `@HD` sort order / grouping / sub-sort (`SO`/`GO`/`SS`), +/// * the `@SQ` reference-sequence dictionary (names + lengths, in order), +/// * the `@RG` read-group records (id + every field, e.g. `LB`/`SM`, in order). +/// +/// A runall regression that emitted `SO:unsorted` after a sort stage, +/// dropped an `@SQ`, or failed to collapse read groups would be invisible +/// to `assert_bams_record_equivalent` (which ignores the whole header); +/// this closes that gap without re-asserting the `@PG` command-line text. +/// +/// # Panics +/// +/// Panics on the first divergent field. +/// One `@RG` record reduced to `(id, sorted (tag, value) fields)` — the +/// canonical form compared by [`assert_bam_headers_equivalent_ignoring_pg`]. +type ReadGroupRecord = (Vec, Vec<(Vec, bstr::BString)>); + +/// Project a header's `@RG` lines to comparable [`ReadGroupRecord`]s (id plus +/// every field, e.g. `LB`/`SM`), preserving file order. +fn read_group_records(h: &noodles::sam::Header) -> Vec { + h.read_groups() + .iter() + .map(|(id, map)| { + let mut fields: Vec<(Vec, bstr::BString)> = map + .other_fields() + .iter() + .map(|(tag, value)| (tag.as_ref().to_vec(), value.clone())) + .collect(); + fields.sort(); + (id.to_vec(), fields) + }) + .collect() +} + +pub fn assert_bam_headers_equivalent_ignoring_pg(a: &Path, b: &Path) { + fn read_header(path: &Path) -> noodles::sam::Header { + let mut reader = bam::io::Reader::new( + fs::File::open(path).unwrap_or_else(|e| panic!("open {}: {e}", path.display())), + ); + reader.read_header().unwrap_or_else(|e| panic!("read header from {}: {e}", path.display())) + } + + use noodles::sam::header::record::value::map::header::tag; + use noodles::sam::header::record::value::map::header::tag::Standard; + use noodles::sam::header::record::value::map::tag::Other; + + let ha = read_header(a); + let hb = read_header(b); + + // @HD — SO/GO/SS sort-order fields (read from `other_fields`, the only + // place noodles exposes them). VN is intentionally NOT compared (it can + // differ harmlessly across writers). + let hd_field = |h: &noodles::sam::Header, t: Other| -> Option { + h.header().and_then(|m| m.other_fields().get(&t).cloned()) + }; + for (t, name) in [(tag::SORT_ORDER, "SO"), (tag::GROUP_ORDER, "GO"), (tag::SUBSORT_ORDER, "SS")] + { + assert_eq!( + hd_field(&ha, t), + hd_field(&hb, t), + "@HD {name} differs between {} and {}", + a.display(), + b.display(), + ); + } + + // @SQ — reference dictionary (names + lengths, in file order). + let sq = |h: &noodles::sam::Header| -> Vec<(Vec, usize)> { + h.reference_sequences() + .iter() + .map(|(name, map)| (name.to_vec(), map.length().get())) + .collect() + }; + assert_eq!( + sq(&ha), + sq(&hb), + "@SQ dictionary differs between {} and {}", + a.display(), + b.display(), + ); + + // @RG — full read-group records (id plus every field, e.g. LB/SM/PL), in + // file order. Comparing only ids would bless a fused path that altered an + // @RG field that later stages read — `LibraryIndex::from_header` consumes + // @RG LB — so a changed library would diverge downstream yet pass here. + assert_eq!( + read_group_records(&ha), + read_group_records(&hb), + "@RG read-group records differ between {} and {}", + a.display(), + b.display(), + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index de89bd91c..3c77f71f7 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -8,6 +8,7 @@ mod helpers; mod test_async_reader; mod test_bgzf_eof; +mod test_chain_build; mod test_clip_command; #[cfg(feature = "consensus")] mod test_codec_command; diff --git a/tests/integration/test_chain_build.rs b/tests/integration/test_chain_build.rs new file mode 100644 index 000000000..cb519b2dd --- /dev/null +++ b/tests/integration/test_chain_build.rs @@ -0,0 +1,268 @@ +//! Build-level chain-topology tests (finding S5b1-010). +//! +//! These tests call [`fgumi_lib::pipeline::chains::build_for`] directly and +//! assert it returns `Ok` for fused stage permutations — i.e. the chain +//! *constructs and wires* without a build-time error (missing per-stage +//! options, unwired output branch, sort-order rejection, …). They are +//! deliberately **build-only**: command-mode `--aligner::command` lets a +//! `[Correct, AlignAndMerge]` chain build without spawning an aligner. +//! +//! Scope note: a wrong intermediate→terminal *handle type* is NOT caught here. +//! `build_for` erases handles and builds a mis-typed chain successfully; the +//! "input handle downcast failed — chain topology invariant" check is a +//! **run-time** panic raised at the first dispatch in `TypedStep::resolve_input` +//! (see `fgumi_pipeline_core::builder`). That run-time invariant is exercised +//! end-to-end by the fused mock-aligner runs in `test_runall_parity.rs` (e.g. +//! the `correct → … → align → …` parity tests, which drive the same +//! `Correct` emits `BamTemplateBatch` → `Align` consumes `BamTemplateBatch` +//! (skipping `GroupByQueryname`) hand-off through actual dispatch). What these +//! build-only tests add is cheap, aligner-free confirmation that the chain +//! builder accepts the permutation in the first place. + +#![allow(clippy::needless_pass_by_value)] + +use std::fs; +use std::path::{Path, PathBuf}; + +use fgumi_lib::pipeline::chains::{ + ChainSpec, SinkSpec, SourceSpec, Stage, StageOptionsBag, build_for, +}; +use noodles::bam; +use noodles::sam::alignment::io::Write as AlignmentWrite; +use tempfile::TempDir; + +use crate::helpers::bam_generator::{ + create_minimal_header, create_paired_umi_family_at, create_test_reference, to_record_buf, +}; + +/// Writes a tiny template-coordinate-headed, paired-end, UMI-tagged BAM to +/// `dir/input.bam` and returns its path. +/// +/// The header carries `SS:template-coordinate` (via [`create_minimal_header`]) +/// so the `Group` stage's sort-order check accepts it, and the records carry +/// `RX` + `MC` tags so both `Correct` (reads `RX`) and `Group` (needs `MC` for +/// paired-end template spans) build cleanly. `ChainBuilder::new` opens this +/// file to read the header, so it must be a real on-disk BAM. +fn write_input_bam(dir: &Path) -> PathBuf { + let path = dir.join("input.bam"); + let header = create_minimal_header("chr1", 10000); + let mut writer = bam::io::Writer::new(fs::File::create(&path).expect("create input BAM")); + writer.write_header(&header).expect("write header"); + // Two paired-UMI families at distinct positions — enough to exercise the + // chain topology without needing many records (these tests only build, they + // do not run). + let mut records = Vec::new(); + records.extend(create_paired_umi_family_at( + "ACGTACGT", + 2, + "fam_a", + "ACGTACGTACGT", + "TGCATGCATGCA", + 30, + 100, + )); + records.extend(create_paired_umi_family_at( + "TGCATGCA", + 2, + "fam_b", + "ACGTACGTACGT", + "TGCATGCATGCA", + 30, + 500, + )); + for raw in &records { + writer.write_alignment_record(&header, &to_record_buf(raw)).expect("write record"); + } + writer.try_finish().expect("finish input BAM"); + path +} + +/// Builds a complete [`ChainSpec`] with the given stages, source BAM, and +/// pre-filled option bag. Mirrors `runall`'s `ChainSpec` construction +/// (see `commands::runall::execute`) with default threading/compression/etc. +fn spec_for(stages: Vec, source_bam: &Path, stage_opts: StageOptionsBag) -> ChainSpec { + use fgumi_lib::commands::common::{ + CompressionOptions, QueueMemoryOptions, SchedulerOptions, ThreadingOptions, + }; + ChainSpec { + stages, + source: SourceSpec::Bam(source_bam.to_path_buf()), + sink: SinkSpec::Bam(PathBuf::from("/dev/null")), + stage_opts, + threading: ThreadingOptions { threads: Some(1) }, + compression: CompressionOptions::default(), + scheduler: SchedulerOptions::default(), + queue_memory: QueueMemoryOptions::default(), + async_reader: false, + command_line: "fgumi test-chain-build".to_string(), + } +} + +// ─────────────────────── option-bag constructors ─────────────────────── +// +// Each mirrors the corresponding arm of `runall::build_stage_options_bag`: +// fill the required fields and the `#[arg(skip)]` slots the chain builder +// reads, leaving everything else at its `Default`. + +/// `Stage::Sort` options: template-coordinate order (the only order Group +/// accepts downstream), matching runall's forced `SortOrderArg`. +fn sort_opts() -> fgumi_lib::commands::sort::SortOptions { + use fgumi_lib::commands::sort::{SortOptions, SortOrderArg}; + SortOptions { order: SortOrderArg::TemplateCoordinate, ..Default::default() } +} + +/// `Stage::Group` options for the given strategy, with the `#[arg(skip)]` +/// `effective_*` slots populated exactly as `runall` does. +fn group_opts(strategy: fgumi_lib::assigner::Strategy) -> fgumi_lib::commands::group::GroupOptions { + use fgumi_lib::assigner::Strategy; + use fgumi_lib::commands::group::GroupOptions; + let edits = u32::from(!matches!(strategy, Strategy::Identity)); + GroupOptions { + strategy, + edits, + effective_strategy: strategy, + effective_edits: edits, + ..Default::default() + } +} + +/// `Stage::Correct` options with an inline UMI whitelist (`-u`), so no +/// whitelist file is needed; `add_correct` reads the UMIs at build time. +fn correct_opts() -> fgumi_lib::commands::correct::CorrectOptions { + use fgumi_lib::commands::correct::CorrectOptions; + CorrectOptions { + umis: vec!["ACGTACGT".to_string(), "TGCATGCA".to_string()], + ..Default::default() + } +} + +// ─────────────────────────────── tests ─────────────────────────────── + +/// `[Sort, Group]` — the simplest two-stage fusion. Sort emits the +/// intermediate record stream that Group consumes; pins that hand-off. +#[test] +fn chain_build_sort_to_group() { + use fgumi_lib::assigner::Strategy; + let tmp = TempDir::new().unwrap(); + let input = write_input_bam(tmp.path()); + + let bag = StageOptionsBag { + sort: Some(sort_opts()), + group: Some(group_opts(Strategy::Adjacency)), + ..Default::default() + }; + + let spec = spec_for(vec![Stage::Sort, Stage::Group], &input, bag); + build_for(spec).expect("build_for([Sort, Group]) should succeed"); +} + +/// `[Correct, AlignAndMerge]` — the only permutation with no *build-level* +/// coverage elsewhere (parity tests in `test_runall_parity.rs` already exercise +/// the same `Correct → Align` runtime hand-off via fused mock-aligner runs; +/// what is missing is a build-only check). It builds without an aligner: +/// command-mode `--aligner::command` only substitutes `{ref}` into a template, +/// and the reference needs only a `.dict` sibling (provided by +/// `create_test_reference`), not real index files. This pins the `build_for` +/// wiring of the `Correct` (emits `BamTemplateBatch`) → `Align` (consumes +/// `BamTemplateBatch`, skipping `GroupByQueryname`) topology hand-off — exactly +/// the `chain_tail_kind` branch a regression would break at construction time. +#[test] +fn chain_build_correct_to_align() { + use fgumi_lib::aligner::AlignerOptions; + use fgumi_lib::pipeline::chains::options_bag::AlignOptions; + + let tmp = TempDir::new().unwrap(); + let input = write_input_bam(tmp.path()); + let reference = create_test_reference(tmp.path()); + + // Command mode: a do-nothing template that contains the required `{ref}` + // placeholder. `AlignerOptions::resolve` substitutes it and never spawns + // a process at build time, so no aligner binary is required. + let bag = StageOptionsBag { + correct: Some(correct_opts()), + aligner: Some(AlignOptions { + aligner: AlignerOptions { + command: Some("cat {ref} >/dev/null; cat".to_string()), + ..Default::default() + }, + reference, + aligner_bin: None, + }), + ..Default::default() + }; + + let spec = spec_for(vec![Stage::Correct, Stage::Align], &input, bag); + build_for(spec).expect( + "build_for([Correct, Align]) should construct the chain — this covers chain \ + construction/topology wiring only; the typed input-handle downcast is a \ + run-time concern checked by `Pipeline::run` (`TypedStep::resolve_input`), not here", + ); +} + +// Consensus-terminal fusions. Gated on `consensus` exactly like the bag +// slots (`StageOptionsBag::{simplex,duplex,codec}`) so the +// `--no-default-features` build still compiles. + +/// `[Group, Simplex]` — Group's grouped record stream feeds the simplex +/// consensus caller (the terminal stage). Pins that hand-off at build time. +#[cfg(feature = "consensus")] +#[test] +fn chain_build_group_to_simplex() { + use fgumi_lib::assigner::Strategy; + use fgumi_lib::commands::simplex::SimplexOptions; + + let tmp = TempDir::new().unwrap(); + let input = write_input_bam(tmp.path()); + + let bag = StageOptionsBag { + group: Some(group_opts(Strategy::Adjacency)), + simplex: Some(SimplexOptions::default()), + ..Default::default() + }; + + let spec = spec_for(vec![Stage::Group, Stage::Simplex], &input, bag); + build_for(spec).expect("build_for([Group, Simplex]) should succeed"); +} + +/// `[Group, Duplex]` — duplex requires the `Paired` grouping strategy (MIs +/// carry `/A`/`/B` suffixes), enforced by the cross-stage validator. Pins +/// the Group→Duplex hand-off under that constraint. +#[cfg(feature = "consensus")] +#[test] +fn chain_build_group_to_duplex() { + use fgumi_lib::assigner::Strategy; + use fgumi_lib::commands::duplex::DuplexOptions; + + let tmp = TempDir::new().unwrap(); + let input = write_input_bam(tmp.path()); + + let bag = StageOptionsBag { + group: Some(group_opts(Strategy::Paired)), + duplex: Some(DuplexOptions::default()), + ..Default::default() + }; + + let spec = spec_for(vec![Stage::Group, Stage::Duplex], &input, bag); + build_for(spec).expect("build_for([Group, Duplex]) should succeed"); +} + +/// `[Group, Codec]` — Group's grouped stream feeds the CODEC consensus +/// caller. Pins the Group→Codec hand-off at build time. +#[cfg(feature = "consensus")] +#[test] +fn chain_build_group_to_codec() { + use fgumi_lib::assigner::Strategy; + use fgumi_lib::commands::codec::CodecOptions; + + let tmp = TempDir::new().unwrap(); + let input = write_input_bam(tmp.path()); + + let bag = StageOptionsBag { + group: Some(group_opts(Strategy::Adjacency)), + codec: Some(CodecOptions::default()), + ..Default::default() + }; + + let spec = spec_for(vec![Stage::Group, Stage::Codec], &input, bag); + build_for(spec).expect("build_for([Group, Codec]) should succeed"); +} diff --git a/tests/integration/test_runall_parity.rs b/tests/integration/test_runall_parity.rs index af216a523..a914ff2f9 100644 --- a/tests/integration/test_runall_parity.rs +++ b/tests/integration/test_runall_parity.rs @@ -51,7 +51,10 @@ use crate::helpers::cli_runner::{ ParityArgs, Stage, fgumi, fgumi_binary, run_runall, run_runall_consensus_to_filter, run_staged_chain, run_standalone, run_standalone_filter, }; -use crate::helpers::parity::{assert_bams_record_equivalent, read_bam_records}; +use crate::helpers::parity::{ + assert_bam_headers_equivalent_ignoring_pg, assert_bams_record_equivalent, + assert_bams_record_equivalent_nonempty, read_bam_records, +}; // ────────────────────────── Fixtures ────────────────────────── // @@ -311,7 +314,11 @@ fn parity_a_sort_to_sort() { let r = run_standalone(Stage::Sort, &fixture, &standalone_out, &args); assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &standalone_out); + assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); + // S9a-005: the sort-order metadata (`@HD SO/GO/SS`), `@SQ` dictionary, and + // `@RG` set must match too — a fused runall that emitted the wrong sort + // order would be invisible to the record-only equivalence check. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); } /// `Group → Group` parity vs standalone `fgumi group`. Runall delegates to @@ -332,7 +339,9 @@ fn parity_a_group_to_group() { let r = run_standalone(Stage::Group, &fixture, &standalone_out, &args); assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &standalone_out); + assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); + // S9a-005: group preserves the sort metadata + dictionary + read groups. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); } /// `Simplex → Simplex` parity vs standalone `fgumi simplex`. @@ -358,7 +367,13 @@ fn parity_a_simplex_to_simplex() { let r = run_standalone(Stage::Simplex, &fixture, &standalone_out, &args); assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &standalone_out); + // S9a-001: simplex on the grouped fixture emits one fragment consensus per + // MI group, so the stream is non-empty — pin that floor so a regression + // that dropped all consensus records can't pass vacuously. + assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); + // S9a-005: the consensus header collapses read groups; pin that the fused + // and standalone paths agree on the @HD/@SQ/@RG metadata. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); } /// `Duplex → Duplex` parity vs standalone `fgumi duplex`. Runs through @@ -379,15 +394,23 @@ fn parity_a_duplex_to_duplex() { let r = run_standalone(Stage::Duplex, &fixture, &standalone_out, &args); assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &standalone_out); + // S9a-001: the duplex chain emits duplex consensus records — non-empty. + assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); } -/// `Codec → Codec` parity vs standalone `fgumi codec`. Runs through -/// the consensus-only delegation fast -/// path. Uses `grouped_codec_fixture` (paired-end, single-strand -/// UMI, grouped with `--strategy adjacency`) — the documented CODEC -/// input — so this actually exercises the duplex consensus logic -/// instead of degenerating to a no-op on simplex single-end data. +/// `Codec → Codec` parity vs standalone `fgumi codec`. Runs through the +/// consensus-only delegation fast path. Uses `grouped_codec_fixture` +/// (paired-end, single-UMI, grouped with `--strategy adjacency`). +/// +/// NOTE (S9a-001): this fixture lacks the FR-overlap flag shape CODEC +/// consensus requires (R1 `PAIRED|FIRST|MATE_REVERSE` / R2 `PAIRED|LAST|REVERSE` +/// at the same position), so every pair is rejected and BOTH the fused and +/// standalone paths emit **0 consensus records**. The parity therefore holds +/// only because both sides are empty. The explicit `== 0` assertion below makes +/// that deliberate zero VISIBLE: if a future change gives the codec fixture +/// FR-overlap flags (making it non-empty), this test fails loudly and forces an +/// upgrade to a real non-empty parity check (the `zipper_*_codec` smoke tests +/// already cover the non-empty FR-overlap shape via `zipper_codec_fixture`). #[cfg(feature = "consensus")] #[test] fn parity_a_codec_to_codec() { @@ -404,6 +427,11 @@ fn parity_a_codec_to_codec() { assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); assert_bams_record_equivalent(&runall_out, &standalone_out); + assert_eq!( + read_bam_records(&runall_out).len(), + 0, + "codec fixture lacks the FR-overlap shape → 0 consensus by design (S9a-001)" + ); } // ────────────────────────── Class B — multi-stage parity ────────────────────────── @@ -431,7 +459,12 @@ fn parity_b_sort_to_group() { run_staged_chain(&[Stage::Sort, Stage::Group], &fixture, &staged_out, tmp.path(), &args); assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &staged_out); + assert_bams_record_equivalent_nonempty(&runall_out, &staged_out); + // S9a-005: the stack changes header propagation/update paths, so also pin + // @HD/@SQ/@RG parity — a fused-chain header regression (e.g. SO:unsorted + // after sort, a dropped @SQ, uncollapsed @RG) is invisible to the + // record-only comparison above. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); } /// `Sort → Simplex` parity vs staged `fgumi sort | fgumi group | fgumi simplex`. @@ -456,7 +489,12 @@ fn parity_b_sort_to_simplex() { ); assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &staged_out); + assert_bams_record_equivalent_nonempty(&runall_out, &staged_out); + // S9a-005: the stack changes header propagation/update paths, so also pin + // @HD/@SQ/@RG parity — a fused-chain header regression (e.g. SO:unsorted + // after sort, a dropped @SQ, uncollapsed @RG) is invisible to the + // record-only comparison above. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); } /// `Sort → Duplex` parity vs staged `fgumi sort | fgumi group | fgumi duplex`. @@ -484,7 +522,12 @@ fn parity_b_sort_to_duplex() { ); assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &staged_out); + assert_bams_record_equivalent_nonempty(&runall_out, &staged_out); + // S9a-005: the stack changes header propagation/update paths, so also pin + // @HD/@SQ/@RG parity — a fused-chain header regression (e.g. SO:unsorted + // after sort, a dropped @SQ, uncollapsed @RG) is invisible to the + // record-only comparison above. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); } /// `Sort → Codec` parity vs staged `fgumi sort | fgumi group | fgumi codec`. @@ -513,6 +556,13 @@ fn parity_b_sort_to_codec() { assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); assert_bams_record_equivalent(&runall_out, &staged_out); + // S9a-005: header parity holds even for the zero-record codec branch (both + // sides still emit @HD/@SQ/@RG) — assert it before the zero-count pin below. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); + // S9a-001: the codec fixture lacks the FR-overlap shape → 0 consensus by + // design; pin the deliberate zero so a future non-empty fixture forces a + // real parity check. + assert_eq!(read_bam_records(&runall_out).len(), 0, "codec fixture → 0 consensus by design"); } /// `Group → Simplex` parity vs staged `fgumi group | fgumi simplex`. @@ -532,7 +582,12 @@ fn parity_b_group_to_simplex() { run_staged_chain(&[Stage::Group, Stage::Simplex], &fixture, &staged_out, tmp.path(), &args); assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &staged_out); + assert_bams_record_equivalent_nonempty(&runall_out, &staged_out); + // S9a-005: the stack changes header propagation/update paths, so also pin + // @HD/@SQ/@RG parity — a fused-chain header regression (e.g. SO:unsorted + // after sort, a dropped @SQ, uncollapsed @RG) is invisible to the + // record-only comparison above. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); } /// `Group → Duplex` parity vs staged `fgumi group | fgumi duplex`. @@ -553,7 +608,12 @@ fn parity_b_group_to_duplex() { run_staged_chain(&[Stage::Group, Stage::Duplex], &fixture, &staged_out, tmp.path(), &args); assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &staged_out); + assert_bams_record_equivalent_nonempty(&runall_out, &staged_out); + // S9a-005: the stack changes header propagation/update paths, so also pin + // @HD/@SQ/@RG parity — a fused-chain header regression (e.g. SO:unsorted + // after sort, a dropped @SQ, uncollapsed @RG) is invisible to the + // record-only comparison above. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); } /// NEW-002: under `--group::allow-unmapped`, a both-unmapped read pair survives @@ -653,7 +713,12 @@ fn new_002_group_to_duplex_allow_unmapped_parity() { let r = fgumi(&duplex_args); assert!(r.status.success(), "staged duplex: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &staged_out); + assert_bams_record_equivalent_nonempty(&runall_out, &staged_out); + // S9a-005: the stack changes header propagation/update paths, so also pin + // @HD/@SQ/@RG parity — a fused-chain header regression (e.g. SO:unsorted + // after sort, a dropped @SQ, uncollapsed @RG) is invisible to the + // record-only comparison above. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); // Negative oracle: path-equivalence alone would still pass if BOTH paths // wrongly emitted output derived from the both-unmapped family. The contract @@ -702,6 +767,10 @@ fn parity_b_group_to_codec() { assert!(r.status.success(), "staged: {}", String::from_utf8_lossy(&r.stderr)); assert_bams_record_equivalent(&runall_out, &staged_out); + // S9a-005: header parity holds even for the zero-record codec branch. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &staged_out); + // S9a-001: codec fixture lacks FR-overlap → 0 consensus by design. + assert_eq!(read_bam_records(&runall_out).len(), 0, "codec fixture → 0 consensus by design"); } // ──────────────── Fused consensus → filter parity (issue #330 option a) ──────────────── @@ -735,7 +804,12 @@ fn parity_group_to_simplex_to_filter() { let r = run_standalone_filter(&consensus_bam, &staged_out, &args); assert!(r.status.success(), "staged filter: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&fused_out, &staged_out); + // S9a-001 / S5b2-004: simplex consensus at --min-reads 1 survives the + // filter, so the fused consensus→filter stream is non-empty. + assert_bams_record_equivalent_nonempty(&fused_out, &staged_out); + // S9a-005: the fused consensus→filter path also updates the header; pin + // @HD/@SQ/@RG parity against the staged consensus + standalone-filter chain. + assert_bam_headers_equivalent_ignoring_pg(&fused_out, &staged_out); } /// `Group → Duplex → Filter` (fused) vs staged consensus BAM + `fgumi filter`. @@ -758,7 +832,22 @@ fn parity_group_to_duplex_to_filter() { let r = run_standalone_filter(&consensus_bam, &staged_out, &args); assert!(r.status.success(), "staged filter: {}", String::from_utf8_lossy(&r.stderr)); + // S9a-001 / S5b2-004: this pins the fused consensus→filter topology (the + // duplex strand-strip bridge) — fused MUST equal staged. The duplex fixture + // is single-strand (all reads of each family carry the same paired UMI → + // AB depth 4, BA depth 0), so `fgumi filter --min-reads 1` drops every + // duplex consensus read for failing the per-strand depth floor (BA = 0 < 1). + // Both paths therefore yield 0 — assert that deliberate zero explicitly + // (rather than letting the equivalence pass vacuously). `parity_b_group_to_ + // duplex` already pins the NON-empty duplex consensus stream before filtering. assert_bams_record_equivalent(&fused_out, &staged_out); + // S9a-005: header parity holds even for the zero-record duplex→filter branch. + assert_bam_headers_equivalent_ignoring_pg(&fused_out, &staged_out); + assert_eq!( + read_bam_records(&fused_out).len(), + 0, + "single-strand duplex fixture → 0 reads survive `filter --min-reads 1` (BA depth 0)" + ); } /// `Group → Codec → Filter` (fused) vs staged consensus BAM + `fgumi filter`. @@ -781,6 +870,10 @@ fn parity_group_to_codec_to_filter() { assert!(r.status.success(), "staged filter: {}", String::from_utf8_lossy(&r.stderr)); assert_bams_record_equivalent(&fused_out, &staged_out); + // S9a-005: header parity holds even for the zero-record codec→filter branch. + assert_bam_headers_equivalent_ignoring_pg(&fused_out, &staged_out); + // S9a-001: codec fixture → 0 consensus → 0 after filter, by design. + assert_eq!(read_bam_records(&fused_out).len(), 0, "codec fixture → 0 consensus by design"); } // ───────────────────── Run-to-run / thread-count determinism ───────────────────── @@ -877,6 +970,376 @@ fn runall_duplex_record_stream_is_deterministic() { assert_bams_record_equivalent(&out_a, &out_t1); } +/// Build a many-position single-end simplex fixture spanning enough distinct +/// template-coordinate positions that the sort/group/consensus stages run +/// across multiple parallel workers — the regime where a broken +/// `ByItemOrdinal` contract would let output diverge by thread-completion +/// order. Returns a SORTED BAM (so `group` / `simplex` can consume it). +fn deterministic_simplex_fixture(dir: &Path, positions: usize) -> PathBuf { + let unsorted = dir.join("det_simplex_unsorted.bam"); + let header = create_minimal_header("chr1", positions * 200 + 1000); + let mut writer = + bam::io::Writer::new(fs::File::create(&unsorted).expect("create det simplex fixture")); + writer.write_header(&header).expect("write det simplex header"); + let mut all = Vec::new(); + // A small set of valid-DNA UMIs (non-ACGT chars would be rejected by the + // UMI grouping stage); cycle through them so distinct families form across + // positions. `create_umi_family` maps every read at a fixed position, so + // all 200 families share one coordinate but distinct UMIs → distinct MI + // groups → one fragment consensus each. + let umis = ["ACGTACGT", "TGCATGCA", "CCAATTGG", "GGTTAACC", "AACCGGTT", "TTGGCCAA"]; + for p in 0..positions { + let umi = umis[p % umis.len()]; + for r in create_umi_family(umi, 3, &format!("ds{p}_{umi}"), "ACGTACGTACGT", 30) { + all.push(r); + } + } + all.reverse(); + for raw in &all { + writer + .write_alignment_record(&header, &to_record_buf(raw)) + .expect("write det simplex record"); + } + writer.try_finish().expect("finish det simplex fixture"); + + let sorted = dir.join("det_simplex_sorted.bam"); + let args = ParityArgs::for_simplex_like(); + let out = run_standalone(Stage::Sort, &unsorted, &sorted, &args); + assert!(out.status.success(), "det simplex sort: {}", String::from_utf8_lossy(&out.stderr)); + sorted +} + +/// S9a-008: extend the determinism guard beyond `group → duplex` to the +/// streaming `sort` and the `group → simplex` parallel paths — the other +/// stages whose output most plausibly emits in thread-completion order if the +/// `ByItemOrdinal` contract regressed. Each asserts run-to-run equivalence at +/// `--threads 8` AND thread-count invariance (`t8 == t1`). +#[test] +fn runall_sort_record_stream_is_deterministic() { + let tmp = TempDir::new().unwrap(); + // The sort engine itself is the parallel stage under test — feed it the + // UNSORTED, many-position duplex fixture and sort it via runall. + let unsorted = tmp.path().join("sort_det_unsorted.bam"); + { + let positions = 200usize; + let header = create_minimal_header("chr1", positions * 200 + 1000); + let mut writer = + bam::io::Writer::new(fs::File::create(&unsorted).expect("create sort det fixture")); + writer.write_header(&header).expect("write sort det header"); + let mut all = Vec::new(); + for p in 0..positions { + for r in create_paired_umi_family_at( + "AAAA-CCCC", + 3, + &format!("sd{p}"), + "ACGTACGT", + "TTTTAAAA", + 30, + 99 + p * 200, + ) { + all.push(r); + } + } + all.reverse(); + for raw in &all { + writer + .write_alignment_record(&header, &to_record_buf(raw)) + .expect("write sort det record"); + } + writer.try_finish().expect("finish sort det fixture"); + } + + let run = |out: &Path, threads: u32| { + let mut args = ParityArgs::for_simplex_like(); + args.threads = threads; + let r = run_runall(Stage::Sort, Stage::Sort, &unsorted, out, &args); + assert!( + r.status.success(), + "runall sort (threads={threads}): {}", + String::from_utf8_lossy(&r.stderr) + ); + }; + let out_a = tmp.path().join("sort_t8_a.bam"); + let out_b = tmp.path().join("sort_t8_b.bam"); + let out_t1 = tmp.path().join("sort_t1.bam"); + run(&out_a, 8); + run(&out_b, 8); + run(&out_t1, 1); + assert_bams_record_equivalent_nonempty(&out_a, &out_b); + assert_bams_record_equivalent_nonempty(&out_a, &out_t1); +} + +/// S9a-008: `group → simplex` determinism (run-to-run + thread-count invariant). +#[test] +fn runall_simplex_record_stream_is_deterministic() { + let tmp = TempDir::new().unwrap(); + let fixture = deterministic_simplex_fixture(tmp.path(), 200); + + let run = |out: &Path, threads: u32| { + let mut args = ParityArgs::for_simplex_like(); + args.threads = threads; + let r = run_runall(Stage::Group, Stage::Simplex, &fixture, out, &args); + assert!( + r.status.success(), + "runall simplex (threads={threads}): {}", + String::from_utf8_lossy(&r.stderr) + ); + }; + let out_a = tmp.path().join("simplex_t8_a.bam"); + let out_b = tmp.path().join("simplex_t8_b.bam"); + let out_t1 = tmp.path().join("simplex_t1.bam"); + run(&out_a, 8); + run(&out_b, 8); + run(&out_t1, 1); + assert_bams_record_equivalent_nonempty(&out_a, &out_b); + assert_bams_record_equivalent_nonempty(&out_a, &out_t1); +} + +// ───────────────────────── stdin + FASTQ-source coverage ───────────────────────── +// +// X3-001/X3-002: runall is the one command that FUSES the stdin-aware sources, +// yet it had no stdin test and no FASTQ-entry-point (`--start-from extract`) +// end-to-end test. These close both gaps: the "reads the source exactly once" +// invariant (which fusion is most likely to break by re-opening / double-pulling +// a non-seekable `-`) and the FASTQ→consensus fusion path. + +/// X3-001: `fgumi runall --start-from group --stop-after simplex --input -` +/// reading a BAM piped to stdin must produce the SAME (non-empty) record stream +/// as the identical runall reading the BAM from a file path. A regression that +/// made the fused chain pull stdin twice (or seek it) would read an empty stream +/// the second time and diverge; every BAM-source-from-file test would still pass. +#[test] +fn runall_reads_bam_from_stdin_once() { + use std::process::{Command, Stdio}; + + let tmp = TempDir::new().unwrap(); + let fixture = sorted_simplex_fixture(tmp.path()); + let from_file = tmp.path().join("from_file.bam"); + let from_stdin = tmp.path().join("from_stdin.bam"); + let args = ParityArgs::for_simplex_like(); + + // Baseline: file input. + let r = run_runall(Stage::Group, Stage::Simplex, &fixture, &from_file, &args); + assert!(r.status.success(), "runall (file input): {}", String::from_utf8_lossy(&r.stderr)); + + // Piped: `cat fixture.bam | fgumi runall ... --input -`. + let mut cat = Command::new("cat") + .arg(&fixture) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn cat for stdin pipe"); + let cat_stdout = cat.stdout.take().expect("cat stdout"); + let stdin_out = Command::new(fgumi_binary()) + .args([ + "runall", + "--start-from", + "group", + "--stop-after", + "consensus", + "--consensus", + "simplex", + "--input", + "-", + "--output", + from_stdin.to_str().unwrap(), + "--group::strategy", + args.strategy, + "--group::edits", + "1", + "--simplex::min-reads", + args.min_reads, + "--threads", + "1", + ]) + .stdin(cat_stdout) + .output() + .expect("run runall with stdin"); + // Reap the `cat` producer so it isn't left unwaited. + let _ = cat.wait().expect("wait on cat"); + assert!( + stdin_out.status.success(), + "runall (stdin input): {}", + String::from_utf8_lossy(&stdin_out.stderr) + ); + + // Same record stream both ways, and non-empty (so the stdin source was read + // exactly once — a double-pull would yield 0 records on the second read). + assert_bams_record_equivalent_nonempty(&from_file, &from_stdin); +} + +/// Write a gzip-compressed FASTQ from `(name, seq, qual)` records. +fn write_gzip_fastq(path: &Path, records: &[(&str, &str, &str)]) { + use std::io::Write as _; + let file = fs::File::create(path).expect("create gzip fastq"); + let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + for (name, seq, qual) in records { + writeln!(encoder, "@{name}\n{seq}\n+\n{qual}").expect("write fastq record"); + } + encoder.finish().expect("finish gzip fastq"); +} + +/// Build a small paired gzip FASTQ (`r1.fq.gz`, `r2.fq.gz`) for the X3-002 +/// fusion test: 3 families × 3 read-pairs, BOTH mates carrying a 6 bp UMI +/// prefix (read structure `6M16T` each) so extract builds a paired UMI +/// (`-`) — the shape `--group::strategy paired` + duplex +/// consensus consume. Within a family all pairs share the same UMI + template +/// bases so they collapse into one MI group. Returns `(r1, r2)`. +fn write_paired_umi_fastq(dir: &Path) -> (PathBuf, PathBuf) { + let r1 = dir.join("r1.fq.gz"); + let r2 = dir.join("r2.fq.gz"); + // (r1 umi, r2 umi, r1 template (16bp), r2 template (16bp)). + let families = [ + ("AAAAAA", "CCCCCC", "ACGTACGTACGTACGT", "GGTTAACCGGTTAACC"), + ("GGGGGG", "TTTTTT", "TGCATGCATGCATGCA", "AACCGGTTAACCGGTT"), + ("ACACAC", "TGTGTG", "TTGGCCAATTGGCCAA", "CCAATTGGCCAATTGG"), + ]; + let qual = "I".repeat(22); // 6 (UMI) + 16 (template), both mates + let mut r1_owned: Vec<(String, String)> = Vec::new(); + let mut r2_owned: Vec<(String, String)> = Vec::new(); + for (fi, (r1_umi, r2_umi, r1_tmpl, r2_tmpl)) in families.iter().enumerate() { + for i in 0..3 { + let name = format!("fam{fi}_{i}"); + r1_owned.push((name.clone(), format!("{r1_umi}{r1_tmpl}"))); // 6 + 16 = 22 + r2_owned.push((name, format!("{r2_umi}{r2_tmpl}"))); // 6 + 16 = 22 + } + } + let r1_slices: Vec<(&str, &str, &str)> = + r1_owned.iter().map(|(n, s)| (n.as_str(), s.as_str(), qual.as_str())).collect(); + let r2_slices: Vec<(&str, &str, &str)> = + r2_owned.iter().map(|(n, s)| (n.as_str(), s.as_str(), qual.as_str())).collect(); + write_gzip_fastq(&r1, &r1_slices); + write_gzip_fastq(&r2, &r2_slices); + (r1, r2) +} + +/// X3-002: the headline FASTQ→consensus fusion. `fgumi runall --start-from +/// extract` over a paired gzip FASTQ must produce the SAME (non-empty) record +/// stream as the equivalent chain where extract is run STANDALONE and its BAM +/// is fed to `runall --start-from align`. Every other runall fixture is a +/// synthesized BAM; this gives the fused extract source (`SourceSpec::Fastqs`) +/// and the paired-FASTQ pull machinery its only end-to-end coverage. +/// +/// NB: runall AUTO-INSERTS `Stage::Align` after extract (a FASTQ source must be +/// aligned before sort/group), so `--start-from extract` requires `--ref` + an +/// aligner. We use the deterministic mock-aligner shell script (as the AAM +/// tests do) so the test needs no real bwa. The staged side feeds the SAME +/// extracted BAM into `--start-from align` with the SAME mock aligner, isolating +/// the one difference under test: whether extract is fused into the chain. +#[test] +fn runall_extract_to_duplex_fastq_fusion_matches_staged() { + let tmp = TempDir::new().unwrap(); + let reference = crate::helpers::bam_generator::create_test_reference(tmp.path()); + // PAIRED mock aligner: extract emits paired R1/R2 sharing a queryname, so a + // single-end mock (flag 0 on both) would collide on the queryname. The + // paired mock alternates R1(99)/R2(147) as proper pairs with MC tags, the + // shape `GroupByPosition` + duplex consensus need. + let mock = write_paired_mock_aligner_script(tmp.path()); + let aligner_cmd = format!("{} {{ref}}", mock.display()); + + let (r1, r2) = write_paired_umi_fastq(tmp.path()); + + // ── Fused: runall --start-from extract (auto-inserts align) → simplex. ── + let fused_out = tmp.path().join("fused_extract_simplex.bam"); + let fused = fgumi(&[ + "runall".into(), + "--start-from".into(), + "extract".into(), + "--stop-after".into(), + "consensus".into(), + "--consensus".into(), + "duplex".into(), + "--ref".into(), + reference.as_os_str().to_owned(), + "--aligner::command".into(), + aligner_cmd.clone().into(), + "--extract::inputs".into(), + r1.as_os_str().to_owned(), + r2.as_os_str().to_owned(), + "--extract::read-structures".into(), + "6M16T".into(), + "6M16T".into(), + "--extract::sample".into(), + "s".into(), + "--extract::library".into(), + "l".into(), + "--group::strategy".into(), + "paired".into(), + "--group::edits".into(), + "0".into(), + "--duplex::min-reads".into(), + "1,1,0".into(), + "--output".into(), + fused_out.as_os_str().to_owned(), + "--threads".into(), + "1".into(), + ]); + assert!( + fused.status.success(), + "fused extract→duplex: {}", + String::from_utf8_lossy(&fused.stderr) + ); + + // ── Staged: standalone extract, then runall --start-from align on the + // extracted BAM with the SAME mock aligner. ── + let extracted = tmp.path().join("extracted.bam"); + let e = fgumi(&[ + "extract".into(), + "--inputs".into(), + r1.as_os_str().to_owned(), + r2.as_os_str().to_owned(), + "--read-structures".into(), + "6M16T".into(), + "6M16T".into(), + "--sample".into(), + "s".into(), + "--library".into(), + "l".into(), + "--output".into(), + extracted.as_os_str().to_owned(), + ]); + assert!(e.status.success(), "staged extract: {}", String::from_utf8_lossy(&e.stderr)); + + let staged_out = tmp.path().join("staged_align_duplex.bam"); + let staged = fgumi(&[ + "runall".into(), + "--start-from".into(), + "align".into(), + "--stop-after".into(), + "consensus".into(), + "--consensus".into(), + "duplex".into(), + "--input".into(), + extracted.as_os_str().to_owned(), + "--ref".into(), + reference.as_os_str().to_owned(), + "--aligner::command".into(), + aligner_cmd.into(), + "--group::strategy".into(), + "paired".into(), + "--group::edits".into(), + "0".into(), + "--duplex::min-reads".into(), + "1,1,0".into(), + "--output".into(), + staged_out.as_os_str().to_owned(), + "--threads".into(), + "1".into(), + ]); + assert!( + staged.status.success(), + "staged align→duplex: {}", + String::from_utf8_lossy(&staged.stderr) + ); + + // The fused FASTQ→consensus chain must match the standalone-extract + + // align-onward chain, and be non-empty (the FASTQ entry path actually + // produced consensus records through the full extract→align→…→simplex path). + assert_bams_record_equivalent_nonempty(&fused_out, &staged_out); + // S9a-005: the FASTQ-entrypoint chain also builds the output header from + // scratch (extract→align→…), so pin @HD/@SQ/@RG parity against the staged chain. + assert_bam_headers_equivalent_ignoring_pg(&fused_out, &staged_out); +} + // ────────────────────────── CLI-level rejection tests ────────────────────────── // // These pin the validator's structural rules at the binary entry @@ -885,11 +1348,16 @@ fn runall_duplex_record_stream_is_deterministic() { // `commands::runall::tests` wouldn't surface (e.g. a clap-level flag // rename that silently drops `--start-from`). // -// We test three categories: -// * Cross-flavor terminal (`simplex → duplex`) — rejected by -// `RunAllStage::validate_with` rule 2. -// * Reverse order (`duplex → simplex`) — rejected by rule 1. -// * Backwards (`group → sort`) — rejected by rule 1. +// We test three STRUCTURALLY DISTINCT rejection causes, each pinned by its +// OWN stderr fragment (S9a-010 — the shared `"--start-from"` substring did not +// distinguish them; that flag name appears in essentially any error that +// echoes it, so it could not tell a correct rejection from an unrelated one): +// * Invalid start value (`--start-from simplex` / `duplex`) — the +// per-algorithm names are NOT valid stage values (the algorithm moved to +// `--consensus`), so CLAP rejects them with `invalid value ''` +// BEFORE the validator runs. +// * Backwards order (`group → sort`) — `group.ord() > sort.ord()`, rejected +// by `RunAllStage::validate_with` with the `comes after` ordinal message. fn assert_runall_rejects(start: Stage, stop: Stage, expected_stderr_fragment: &str) { let tmp = TempDir::new().unwrap(); @@ -926,27 +1394,34 @@ fn assert_runall_rejects(start: Stage, stop: Stage, expected_stderr_fragment: &s ); } -/// Cross-flavor terminal: `simplex → duplex` is structurally invalid -/// (both are terminal consensus stages, so the pipeline cannot -/// progress past simplex). Rejected by `RunAllStage::validate_with`. +/// `--start-from simplex` is not a valid stage value — the per-algorithm +/// names moved to `--consensus`. Clap rejects it with `invalid value +/// 'simplex'` before any validator rule runs. (S9a-010: pin the clap +/// value-rejection message, not the bare `--start-from` flag name.) #[cfg(feature = "consensus")] #[test] fn rejects_cross_flavor_consensus_pair() { - assert_runall_rejects(Stage::Simplex, Stage::Duplex, "--start-from"); + assert_runall_rejects(Stage::Simplex, Stage::Duplex, "invalid value 'simplex'"); } -/// Reverse order: `duplex → simplex` violates the linear-stage -/// ordering rule. +/// `--start-from duplex` is likewise an invalid stage value → clap +/// `invalid value 'duplex'`. #[cfg(feature = "consensus")] #[test] fn rejects_reverse_order_consensus_to_consensus() { - assert_runall_rejects(Stage::Duplex, Stage::Simplex, "--start-from"); + assert_runall_rejects(Stage::Duplex, Stage::Simplex, "invalid value 'duplex'"); } -/// Backwards: `group → sort` violates the linear-stage ordering rule. +/// Backwards order: `group → sort` has `group.ord() > sort.ord()`, rejected +/// by the validator's ordinal rule. Pin the `comes after` message so this +/// test verifies the ORDER rule fired, not merely that the flag was echoed. #[test] fn rejects_backwards_group_to_sort() { - assert_runall_rejects(Stage::Group, Stage::Sort, "--start-from"); + assert_runall_rejects( + Stage::Group, + Stage::Sort, + "--start-from group comes after --stop-after sort", + ); } /// S5c2-003: runall `--group::*` flag combos that standalone `fgumi group` @@ -1358,7 +1833,8 @@ fn parity_a_zipper_to_zipper() { let r = run_standalone_zipper(&inputs, &standalone_out, &args); assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &standalone_out); + // S9a-001: zipper merges the mapped + unmapped reads → a non-empty stream. + assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); } /// Smoke check — `fgumi runall --start-from zipper --stop-after zipper` @@ -1779,24 +2255,35 @@ fn aam_to_sort_fused_pipeline_with_mock_aligner() { let n_records = reader.record_bufs(&noodles::sam::Header::default()).count(); assert!(n_records >= 1, "expected at least 1 record in sorted BAM, got {n_records}"); - // The fused path's promise: no `aam-merged.bam` tempfile is - // left behind anywhere in the tempdir. (The tempdir itself - // contains the input fixture + reference + mock-aligner; we - // assert no extra BAM file beyond the user's --output.) - let leftover_bams: Vec<_> = fs::read_dir(tmp.path()) + // The fused path's promise: no AAM/sort tempfile bridge BAM is left + // behind. S9a-004: assert the EXACT set of top-level `.bam` files — + // the two `zipper_fixture` inputs plus the user's `--output` — and + // that NO tempfile-bridge name appears. The old `len() <= 3` bound was + // self-defeating: 3 legitimate BAMs + 1 leaked `aam-merged.bam` = 4, + // but a leak of a name already counted (or any off-by-one) could slip + // past a loose count. The exact allow-set + per-name leak check (the + // pattern the simplex companion already uses) catches a single leak. + let bam_names: std::collections::BTreeSet = fs::read_dir(tmp.path()) .unwrap() .filter_map(Result::ok) .filter(|e| e.path().extension().is_some_and(|x| x == "bam")) - .map(|e| e.path()) + .filter_map(|e| e.file_name().to_str().map(str::to_owned)) .collect(); - // Two BAMs expected at the top-level tempdir: the input mapped - // BAM and the output sorted BAM. Anything more means the v1 - // `aam-merged.bam` tempfile leaked into the user-visible - // working dir. - assert!( - leftover_bams.len() <= 3, - "unexpected BAM artifacts left behind (tempfile leak?): {leftover_bams:?}" + let expected: std::collections::BTreeSet = + ["zipper_mapped.bam", "zipper_unmapped.bam", "aam-sorted.bam"] + .into_iter() + .map(str::to_owned) + .collect(); + assert_eq!( + bam_names, expected, + "unexpected top-level BAM set (tempfile leak?): got {bam_names:?}, expected {expected:?}" ); + for tempfile_name in ["aam-merged.bam", "sorted.bam", "after_sort.bam", "after_group.bam"] { + assert!( + !bam_names.contains(tempfile_name), + "fused-pipeline tempfile {tempfile_name} leaked into the user-visible dir: {bam_names:?}" + ); + } } /// Companion to `aam_to_sort_fused_pipeline_with_mock_aligner`: @@ -2311,13 +2798,32 @@ fn aam_fails_cleanly_on_bogus_aligner_output() { let inputs = zipper_fixture(tmp.path()); let out = tmp.path().join("out.bam"); let mut args = aam_base_args(&inputs.unmapped, &inputs.reference, &out); + // `cat > /dev/null` drains the writer's stdin to EOF *before* the bogus + // line is emitted (same guard the sibling `aam_rejects_empty_aligner_output` + // uses). A bare `echo dummy {ref}` exits immediately and closes its stdin, + // which races the AAM writer's flush: under slower runs (e.g. llvm-cov + // coverage) the flush loses the race and surfaces a "Broken pipe" I/O error + // instead of the deterministic `@SQ count` mismatch this test pins. Draining + // stdin first removes the race while leaving stdout (the bogus, non-SAM + // `dummy {ref}` line ⇒ 0 `@SQ` records) unchanged. args.push("--aligner::command".into()); - args.push("echo dummy {ref}".into()); + args.push("cat > /dev/null; echo dummy {ref}".into()); let r = fgumi_with_args(&args); assert!(!r.status.success(), "AAM should fail on bogus aligner output"); let stderr = String::from_utf8_lossy(&r.stderr); assert!(!stderr.contains("panicked"), "unexpected panic: {stderr}"); assert!(!stderr.contains("unreachable"), "unexpected unreachable: {stderr}"); + // S9a-006: a clean non-zero exit is necessary but not sufficient — pin + // that the failure is the EXPECTED AAM-step error, not an incidental clap + // / I/O failure before the aligner is reached. `echo dummy {ref}` emits a + // non-SAM line, so the aligner's parsed header carries 0 `@SQ` records, + // which the AAM `@SQ`-consistency check rejects against the reference + // dict's 1 `@SQ`. The `"AAM"` step marker plus the stable `"@SQ count"` + // fragment confirm the failure happens at the aligner-merge step. + assert!( + stderr.contains("AAM") && stderr.contains("@SQ count"), + "expected the stable AAM @SQ-mismatch error at the merge step, got: {stderr}" + ); } /// `--methylation-mode em-seq` paired with `--start-from align-and-merge` @@ -2393,16 +2899,27 @@ fn aam_rejects_stop_after_align_and_merge() { use crate::helpers::bam_generator::build_aligner_index; -/// Build a small reference + run the supplied aligner's `index` -/// command on it. Returns `Ok((ref_path, _temp_dir))` so the caller -/// can use the path; `_temp_dir` extends the lifetime of the -/// indexed files. Returns `Err(reason)` to signal "skip this test" -/// when the aligner isn't installed locally. -fn build_aam_reference_for(binary_name: &str) -> Result<(PathBuf, TempDir), String> { - let tmp = TempDir::new().map_err(|e| format!("tempdir: {e}"))?; +/// Build a small reference + run the supplied aligner's `index` command on it. +/// Returns `(ref_path, _temp_dir)`; `_temp_dir` extends the lifetime of the +/// indexed files. +/// +/// S9a-002: this is called ONLY after the caller has confirmed the binary is +/// on `PATH`, so EVERY failure here (tempdir creation, ` index` +/// non-zero exit) is a real setup regression and `panic!`s loudly rather than +/// being laundered into a silent skip. A skip that is indistinguishable from a +/// pass would mask an index-build regression; the only legitimate skip +/// condition — a missing binary — is handled by the caller's `which` gate +/// before this runs. +fn build_aam_reference_for(binary_name: &str) -> (PathBuf, TempDir) { + let tmp = TempDir::new().expect("AAM parity: create reference tempdir"); let ref_path = crate::helpers::bam_generator::create_test_reference(tmp.path()); - build_aligner_index(&ref_path, binary_name)?; - Ok((ref_path, tmp)) + build_aligner_index(&ref_path, binary_name).unwrap_or_else(|e| { + panic!( + "AAM parity: `{binary_name} index` failed with the binary present (real setup \ + regression, not a skip): {e}" + ) + }); + (ref_path, tmp) } /// Run an AAM parity test: invoke runall with the supplied @@ -2423,13 +2940,21 @@ fn build_aam_reference_for(binary_name: &str) -> Result<(PathBuf, TempDir), Stri /// * `-t 1` — pin single-threaded aligner output for stable /// record ordering. fn run_aam_parity_test(aligner_binary: &str, aligner_args: &[(&str, &str)]) { - let (ref_path, _ref_temp) = match build_aam_reference_for(aligner_binary) { - Ok(r) => r, - Err(reason) => { - eprintln!("skipping AAM parity test: setup failed: {reason}"); - return; - } - }; + // S9a-002: a MISSING aligner binary is the ONLY condition that skips — + // legitimate for a binary-less dev laptop running with `--include-ignored`. + // Any OTHER setup failure (tempdir, index build with the binary present) is + // a real regression and panics in `build_aam_reference_for` rather than + // green-passing with zero assertions. (CI installs both binaries and + // verifies them on PATH before these `#[ignore]`'d tests run, so this skip + // is unreachable in CI.) + if which::which(aligner_binary).is_err() { + eprintln!( + "skipping AAM parity test: `{aligner_binary}` not found on PATH \ + (install via bioconda for CI)" + ); + return; + } + let (ref_path, _ref_temp) = build_aam_reference_for(aligner_binary); let tmp = TempDir::new().unwrap(); // The fixture uses single-end UNMAPPED reads with RX tags @@ -2645,8 +3170,15 @@ fn parity_a_correct_to_correct() { let r = fgumi(&standalone_args); assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); - assert_bams_record_equivalent(&runall_out, &standalone_out); - assert_bams_record_equivalent(&runall_rejects, &standalone_rejects); + // The fixture is designed to emit both kept reads and rejects, so pin both + // streams non-empty — otherwise a shared-empty regression would silently + // satisfy the record/header equivalence checks on both paths. + assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); + assert_bams_record_equivalent_nonempty(&runall_rejects, &standalone_rejects); + // S9a-005: both the accepted-output and rejects BAMs must carry equivalent + // @HD/@SQ/@RG headers between the fused runall correct stage and standalone. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); + assert_bam_headers_equivalent_ignoring_pg(&runall_rejects, &standalone_rejects); // Metrics TSVs are plain text; compare exactly (both are produced // by the same `UmiCorrectionMetrics::write_metrics` writer, so any // forwarding bug would surface as different counts on one side). @@ -3043,17 +3575,34 @@ fn correct_to_codec_fused_pipeline() { assert!(n_records >= 1, "expected ≥ 1 record in codec BAM, got {n_records}"); } -/// Warning: combining `--rejects` with `--start-from correct -/// --stop-after sort` (or any cross-stage stop past `correct`) does -/// NOT capture correct's UMI rejects — the fused chain uses -/// `correct_step_kept_only`, which has no UMI-rejects branch. The -/// dispatcher emits a warn log explaining this and proceeds; the -/// rejects file collects only downstream rejects (here, none, since -/// sort doesn't filter). +/// X3-006: combining `--rejects` with a cross-stage `--start-from correct +/// --stop-after sort` chain does NOT capture correct's UMI rejects — the fused +/// chain uses `correct_step_kept_only`, which has no UMI-rejects branch, so the +/// rejected reads are silently DROPPED (a near-data-loss the dispatcher guards +/// only with a warn log). +/// +/// The old version of this test used a whitelist containing EVERY family's UMI, +/// so 0 reads were rejected — it could not distinguish "rejects correctly +/// discarded" from "no rejects existed at all" (the vacuous-pass pattern +/// S9a-001 calls out). This version uses a PARTIAL whitelist that genuinely +/// rejects one family, then PROVES the discard is real and bounded: +/// (a) the warn log fires, +/// (b) the fused `--rejects` BAM is EMPTY (correct's rejects were discarded, +/// not routed here), and +/// (c) a standalone `fgumi correct --rejects` on the SAME input produces a +/// NON-empty rejects BAM — i.e. real reads ARE lost, justifying the warning. #[test] fn correct_to_sort_with_rejects_emits_warning() { let tmp = TempDir::new().unwrap(); - let (input, reference, whitelist) = correct_to_aam_fixture(tmp.path()); + // `unsorted_simplex_fixture` carries families ACGTACGT/TGCATGCA/CCAATTGG/ + // GGTTAACC. Drop GGTTAACC from the whitelist so its 4 reads are rejected by + // correct (the remaining whitelist UMIs are all far from GGTTAACC, so it + // cannot be corrected to any of them within `--correct::min-distance 1`). + let input = unsorted_simplex_fixture(tmp.path()); + let reference = crate::helpers::bam_generator::create_test_reference(tmp.path()); + let whitelist = tmp.path().join("partial_whitelist.txt"); + fs::write(&whitelist, "ACGTACGT\nTGCATGCA\nCCAATTGG\n").expect("write partial whitelist"); + let out = tmp.path().join("correct-to-sort-rejects.bam"); let rejects = tmp.path().join("rejects.bam"); let mock = write_mock_aligner_script(tmp.path()); @@ -3080,9 +3629,55 @@ fn correct_to_sort_with_rejects_emits_warning() { String::from_utf8_lossy(&r.stderr) ); + // (a) The warning fires. let stderr = String::from_utf8_lossy(&r.stderr); assert!( stderr.contains("discards correct's UMI rejects"), "expected UMI-rejects-discarded warning, got stderr:\n{stderr}" ); + + // (b) The fused `--rejects` capture is EMPTY — correct's rejects were + // discarded by `correct_step_kept_only`, and sort produces no rejects of its + // own. The file is either absent (sort emitted nothing to it) or a + // header-only BAM; either way, ZERO reject reads were captured. + let fused_reject_count = if rejects.exists() { read_bam_records(&rejects).len() } else { 0 }; + assert_eq!( + fused_reject_count, 0, + "fused correct→sort --rejects must capture 0 reads (correct's rejects are discarded), got {fused_reject_count}" + ); + + // (c) Standalone `fgumi correct --rejects` on the SAME input + whitelist + // captures the rejected family — proving the discard above lost REAL data, + // exactly what the warning claims. The standalone correct path uses the + // 2-output `correct_step_with_rejects` branch. + let standalone_kept = tmp.path().join("standalone-correct.bam"); + let standalone_rejects = tmp.path().join("standalone-correct-rejects.bam"); + let correct_args: Vec = vec![ + "correct".into(), + "--input".into(), + input.as_os_str().to_owned(), + "--output".into(), + standalone_kept.as_os_str().to_owned(), + "--umi-files".into(), + whitelist.as_os_str().to_owned(), + "--min-distance".into(), + "1".into(), + "--rejects".into(), + standalone_rejects.as_os_str().to_owned(), + "--threads".into(), + "1".into(), + ]; + let r = fgumi(&correct_args); + assert!( + r.status.success(), + "standalone correct --rejects failed: {}", + String::from_utf8_lossy(&r.stderr) + ); + let standalone_reject_count = read_bam_records(&standalone_rejects).len(); + assert!( + standalone_reject_count > 0, + "standalone correct --rejects must capture the off-whitelist family that the \ + fused chain discarded — got {standalone_reject_count} rejects (expected the 4 \ + GGTTAACC reads). If this is 0, the partial whitelist is not actually rejecting." + ); } From c13dc23ce51b5847f4834133c43b24a682f127dd Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Wed, 24 Jun 2026 10:55:41 -0700 Subject: [PATCH 5/5] fix: address final dual-review findings Reject non-finite downsample fraction; fix parallel-assigner invalid-UMI parity (invalid reads -> None in both paths); log aligner try_wait errors; mark the terminal extract chain tail as SerializedBytes; strengthen oracles for dedup, coalesce, sort, runall, streaming, and the driver round-robin path. --- benches/core_functions.rs | 10 +- crates/fgumi-consensus/src/base_builder.rs | 9 + crates/fgumi-consensus/src/codec_caller.rs | 7 + .../fgumi-pipeline-core/src/runtime/driver.rs | 80 +++++- src/lib/aligner.rs | 134 ++++++++-- src/lib/commands/downsample.rs | 48 ++-- src/lib/commands/runall.rs | 250 ++++++++++++++---- src/lib/pipeline/chains/builder.rs | 12 +- src/lib/pipeline/steps/coalesce.rs | 105 ++++++-- src/lib/umi/parallel_assigner.rs | 75 +++++- tests/integration/test_dedup_command.rs | 63 ++++- tests/integration/test_runall_parity.rs | 155 ++++++++--- tests/integration/test_sort_correctness.rs | 139 ++++++++-- tests/integration/test_streaming_input.rs | 12 +- 14 files changed, 899 insertions(+), 200 deletions(-) diff --git a/benches/core_functions.rs b/benches/core_functions.rs index 5b3dbb8eb..4d6ef08cd 100644 --- a/benches/core_functions.rs +++ b/benches/core_functions.rs @@ -343,10 +343,12 @@ fn bench_vanilla_consensus_caller(c: &mut Criterion) { .map(|i| { let mut read_seq = seq.clone(); // Introduce a single-base error on every 10th read. NOTE: this - // only fires for `i` a positive multiple of 10, so the smaller - // molecule sizes here (num_reads in {2, 3, 5}) get NO injected - // error and exercise the unanimous fast path; only num_reads - // 10 and 20 actually introduce a mismatch. + // only fires for `i` a positive multiple of 10. Because the loop + // is `0..num_reads`, `i` only reaches 10 when `num_reads == 20`; + // for `num_reads` in {2, 3, 5, 10} the index never hits 10, so + // those sizes get NO injected error and exercise the unanimous + // fast path. Only the `num_reads == 20` case introduces a + // mismatch (at i = 10). if i > 0 && i % 10 == 0 { read_seq[i % read_len] = b"TGCA"[(read_seq[i % read_len] as usize) % 4]; } diff --git a/crates/fgumi-consensus/src/base_builder.rs b/crates/fgumi-consensus/src/base_builder.rs index 477a6dd8b..4194777cb 100644 --- a/crates/fgumi-consensus/src/base_builder.rs +++ b/crates/fgumi-consensus/src/base_builder.rs @@ -866,6 +866,15 @@ mod tests { } // ==================== Observation-count saturation (S7-001 / FU-002) ==================== + // + // NOTE on fgbio parity: clamping `observations_for_base` / `contributions` at + // `u16::MAX` is an INTENTIONAL divergence from fgbio once a family's counts + // exceed the `u16` ceiling — `ConsensusBaseBuilder` uses `u16` counters, so it + // saturates where fgbio (wider counters) keeps counting. This only matters for + // pathologically large families (>65535 observations of a single base, or a + // cross-base contribution sum past the ceiling), well beyond any realistic UMI + // family. The tests below pin the clamp (no wrap toward zero); they are NOT a + // parity bug, so do not "fix" the saturation to match fgbio. #[test] fn test_observations_saturate_at_u16_max() { diff --git a/crates/fgumi-consensus/src/codec_caller.rs b/crates/fgumi-consensus/src/codec_caller.rs index 0e598fd33..0050ebd1c 100644 --- a/crates/fgumi-consensus/src/codec_caller.rs +++ b/crates/fgumi-consensus/src/codec_caller.rs @@ -4299,6 +4299,13 @@ mod tests { } } + // NOTE (no fgbio oracle here, by design): the cD/cM/cE and ad/ae/bd/be + // saturation behavior is pinned by the helper-clamp tests below plus the + // public BAM-tag surface exercised elsewhere; we do NOT add an fgbio + // dependency to cross-check serialized tags. fgbio's codec consensus + // operates on raw bytes (established in S7-002-bitenc-perf-analysis.md), and + // the saturation at u16::MAX is an intentional u16-counter divergence for + // pathological families — not a parity bug to validate against fgbio. #[test] fn test_duplex_depth_error_saturate_to_u16_at_high_counts() { let options = CodecConsensusOptions::default(); diff --git a/crates/fgumi-pipeline-core/src/runtime/driver.rs b/crates/fgumi-pipeline-core/src/runtime/driver.rs index 2f59230e4..2d7c61b46 100644 --- a/crates/fgumi-pipeline-core/src/runtime/driver.rs +++ b/crates/fgumi-pipeline-core/src/runtime/driver.rs @@ -511,6 +511,42 @@ mod tests { } } + /// A non-sticky `Exclusive` sink that deliberately stays live for one extra + /// round-robin pass: it ignores its input-drain status and finishes purely + /// on an internal tick counter — `NoProgress` on the first `try_run`, + /// `Finished` after. Keeping a second step alive for one more outer + /// iteration *after* the sticky source is removed is what makes + /// `sticky_owner_removed_via_round_robin_and_loop_exits` branch-specific: a + /// plain `SinkStep` finishes in the same round-robin pass as the source + /// (its input is already drained), emptying `live` so the loop exits via + /// `live.is_empty()` even if the `removed_sticky_owner` branch had failed to + /// clear `sticky_live`. Lingering forces the extra iteration on which a + /// stale `sticky_live` would re-enter the sticky fast-path and re-invoke the + /// already-removed source. + struct LingerThenFinish { + ticks: usize, + } + impl Step for LingerThenFinish { + type Input = u32; + type Outputs = (); + fn profile(&self) -> StepProfile { + StepProfile { + name: "Linger", + kind: StepKind::Exclusive, + sticky: false, + output_queues: vec![], + branch_ordering: vec![], + } + } + fn try_run(&mut self, ctx: &mut StepCtx<'_, Self>) -> io::Result { + while ctx.input.pop().is_some() {} + self.ticks += 1; + // Stay live for exactly one extra round-robin pass before finishing, + // regardless of input-drain status. + if self.ticks >= 2 { Ok(StepOutcome::Finished) } else { Ok(StepOutcome::NoProgress) } + } + } + /// Build `Src → Finish → Sink` (Finish having the given kind) and return the /// erased steps + the wired graph. The `Finish` step's `try_run` counter is /// returned so tests can assert how many times it actually ran. @@ -643,23 +679,35 @@ mod tests { /// the round-robin removal branch (lines around `outcome.removed_sticky_owner`), /// not just the sticky fast-path removal exercised by /// `sticky_owner_completes_and_loop_exits`. + /// + /// A second `LingerThenFinish` step is kept alive for one extra round-robin + /// pass *after* the source is removed, so the worker loop must run one more + /// outer iteration. That iteration is where a stale `sticky_live` would + /// wrongly re-enter the sticky fast-path and re-invoke the + /// already-removed-from-`live` source — making the `== 2` source-call + /// assertion below uniquely diagnostic of the `removed_sticky_owner` branch. + /// (Without the linger, a plain sink would finish in the same pass as the + /// source, emptying `live` so the loop exits via `live.is_empty()` whether + /// or not `sticky_live` was cleared — and `== 2` would not be branch-specific.) #[test] fn sticky_owner_removed_via_round_robin_and_loop_exits() { let mut graph = ChainGraph::new(); let src = graph.register_step("SrcIdleThenFinish", 1); - let sink = graph.register_step("Sink", 0); - graph.wire(src, BranchIdx(0), sink); + let linger = graph.register_step("Linger", 0); + graph.wire(src, BranchIdx(0), linger); let calls = Arc::new(AtomicUsize::new(0)); let steps: Vec> = vec![ Box::new(TypedStep::new(SrcIdleThenFinish { calls: Arc::clone(&calls) })), - Box::new(TypedStep::new(SinkStep)), + Box::new(TypedStep::new(LingerThenFinish { ticks: 0 })), ]; let contexts = Arc::new(build_chain_contexts(&steps, &graph)); let mut entries: Vec = vec![ WorkerStepEntry::Exclusive { step: steps.into_iter().next().unwrap() }, - WorkerStepEntry::Exclusive { step: Box::new(TypedStep::new(SinkStep)) }, + WorkerStepEntry::Exclusive { + step: Box::new(TypedStep::new(LingerThenFinish { ticks: 0 })), + }, ]; let drain_counters = vec![StepDrainCounter::new(1), StepDrainCounter::new(1)]; let signal = PipelineSignal::new(); @@ -668,14 +716,26 @@ mod tests { // First sticky call → NoProgress (yield to round-robin); the source then // returns Finished during a round-robin pass, which must remove it and // disable the sticky fast-path so the loop terminates rather than hangs. + // The `Linger` step stays alive for one more iteration, forcing the + // post-removal outer iteration that exercises the cleared fast path. run_worker_loop(&mut worker, &mut entries, &contexts, &drain_counters, &signal, None); - // The source must have been called at least twice: once idle (sticky), - // then again to Finished (round-robin). - assert!( - calls.load(Ordering::Relaxed) >= 2, - "source should idle once then finish via round-robin, got {} call(s)", - calls.load(Ordering::Relaxed) + // The source must have been called EXACTLY twice: call 1 = idle in the + // sticky fast-path (`NoProgress`, which does NOT remove it there — that + // block only reaps a `Finished`), call 2 = `Finished` during the + // round-robin pass. The lingering second step guarantees one more outer + // iteration after that removal, so `== 2` is the branch-specific signal + // for the `removed_sticky_owner` path: if that branch had failed to clear + // `sticky_live`, the extra iteration's sticky fast-path would re-invoke + // the (already-removed-from-`live`) source — the sticky block dispatches + // `entries[owned_idx]` directly, not gated on `live` membership — + // producing a third call. `== 2` therefore proves removal happened via + // round-robin AND that it correctly disabled the fast path. + assert_eq!( + calls.load(Ordering::Relaxed), + 2, + "source must be called exactly twice (sticky idle, then round-robin finish); \ + a different count means the removed_sticky_owner branch did not gate the fast path", ); } } diff --git a/src/lib/aligner.rs b/src/lib/aligner.rs index 41c3c1064..ea624c527 100644 --- a/src/lib/aligner.rs +++ b/src/lib/aligner.rs @@ -210,19 +210,50 @@ impl AlignerProcess { // Wait for the process to actually exit so its resources are cleaned up. let deadline = std::time::Instant::now() + Duration::from_secs(1); // Tracks whether the loop gave up because the 1s deadline elapsed while - // the process was still alive. Only that case warrants a warning; a - // successful reap or a non-leak `try_wait` error (e.g. ECHILD) does not. + // the process was still alive. Only that case warrants the leak warning. let mut deadline_exceeded = false; - // `Ok(None)` means still alive — keep polling. Any other result - // (`Ok(Some)` = reaped, or `Err` such as ECHILD = already reaped) is a - // non-leak: exit without warning. Only hitting the deadline while still - // alive sets `deadline_exceeded`. - while let Ok(None) = self.child.try_wait() { - if std::time::Instant::now() >= deadline { - deadline_exceeded = true; - break; + // Set only when exit is *positively confirmed*: a clean reap + // (`Ok(Some)`) or an `ECHILD` error (already reaped by a SIGCHLD + // handler). A deadline timeout or an unclassified `try_wait` errno + // leaves this `false`, so the caller must NOT follow up with an + // unbounded `wait()`. + let mut reaped = false; + loop { + match self.child.try_wait() { + // Reaped cleanly: resources cleaned up, no warning. + Ok(Some(_)) => { + reaped = true; + break; + } + // Still alive: keep polling until the deadline. + Ok(None) => { + if std::time::Instant::now() >= deadline { + deadline_exceeded = true; + break; + } + thread::sleep(Duration::from_millis(50)); + } + // `try_wait` failed. `ECHILD` (the child was already reaped, + // e.g. by a SIGCHLD handler) is a benign non-leak that confirms + // exit. Any *other* errno is unexpected — the process may still + // be around — so we do NOT report it as reaped; we cannot make + // progress on a `try_wait` error either way, so stop polling. + Err(err) => { + if is_already_reaped(&err) { + log::debug!( + "aligner process (pid {pid}) try_wait returned ECHILD after \ + SIGKILL; already reaped" + ); + reaped = true; + } else { + log::warn!( + "aligner process (pid {pid}) try_wait failed after SIGKILL: {err}; \ + unable to confirm exit, treating as not reaped" + ); + } + break; + } } - thread::sleep(Duration::from_millis(50)); } if deadline_exceeded { log::warn!( @@ -230,6 +261,10 @@ impl AlignerProcess { stuck (e.g. uninterruptible I/O) and left unreaped" ); } + // Report reaped only when exit was positively confirmed (clean reap or + // `ECHILD`). A timeout on a still-running child or an unclassified + // `try_wait` errno reports `false` so the caller avoids a follow-up + // unbounded `wait()`. reaped } @@ -240,17 +275,61 @@ impl AlignerProcess { } } +/// `ECHILD` errno. POSIX assigns it the value 10 on every Unix fgumi runs an +/// external aligner on (Linux, macOS, the BSDs); `std::io` exposes no +/// `ErrorKind` for it, and `nix`/`libc` are not available on all targets here, +/// so we match the raw errno directly. On non-Unix targets nothing returns this +/// value, so [`is_already_reaped`] simply never matches — the conservative +/// "not confirmed reaped" default. +const ECHILD: i32 = 10; + +/// Whether a `try_wait` error means the child was *already reaped* (and so its +/// exit is confirmed). Only `ECHILD` qualifies — it is what the OS returns once +/// a SIGCHLD handler (or a prior wait) has already collected the child. Any +/// other errno is unexpected and must NOT be treated as a confirmed exit. +fn is_already_reaped(err: &std::io::Error) -> bool { + err.raw_os_error() == Some(ECHILD) +} + impl Drop for AlignerProcess { fn drop(&mut self) { - // If the child process is still running when the struct is - // dropped (e.g. due to a panic or early return), kill it to - // prevent orphaned processes. - if let Ok(None) = self.child.try_wait() { - self.kill(); - } - // Always join the stderr relay thread to avoid resource leaks. + // Determine whether the child's exit is *confirmed*. Only then is it + // safe to join the stderr relay thread: that thread blocks reading the + // child's stderr fd, which stays open while the child lives, so joining + // a still-alive child would hang teardown forever — the very hang the + // bounded `kill()` exists to prevent. + let exit_confirmed = match self.child.try_wait() { + // Already exited: stderr fd is closed, relay will finish. + Ok(Some(_)) => true, + // Still running: kill() reports `true` only on a confirmed reap + // (clean exit or ECHILD); `false` means it may still be alive. + Ok(None) => self.kill(), + // `try_wait` errored. ECHILD (already reaped) confirms exit. For any + // other errno the child may still be alive, so fall back to the + // bounded `kill()` — returning `is_already_reaped` alone here would + // drop the handle without ever attempting SIGKILL and leak the + // subprocess. `kill()` re-classifies ECHILD as a confirmed reap. + Err(err) => { + if is_already_reaped(&err) { + true + } else { + log::warn!( + "aligner process (pid {}) try_wait failed in Drop: {err}; \ + attempting bounded kill", + self.child.id() + ); + self.kill() + } + } + }; + // Join the stderr relay only when exit is confirmed. If the child could + // not be confirmed dead (kill timed out / unclassified errno), skip the + // join: leaking a detached relay thread is the lesser evil versus + // hanging Drop indefinitely on a still-open stderr fd. if let Some(handle) = self.stderr_thread.take() { - let _ = handle.join(); + if exit_confirmed { + let _ = handle.join(); + } } } } @@ -823,6 +902,23 @@ mod tests { assert!(proc.kill(), "killing an already-reaped child must still report reaped"); } + /// `is_already_reaped` confirms exit only for `ECHILD`. An unrelated errno + /// (e.g. `EINVAL`) must NOT be treated as a confirmed reap, so `kill()` + /// reports `false` and the caller avoids a follow-up unbounded `wait()`. + #[test] + fn test_is_already_reaped_only_for_echild() { + let echild = std::io::Error::from_raw_os_error(ECHILD); + assert!(is_already_reaped(&echild), "ECHILD must count as already reaped"); + + // EINVAL (22) stands in for any unrelated errno: it must NOT count. + let einval = std::io::Error::from_raw_os_error(22); + assert!(!is_already_reaped(&einval), "an unrelated errno must not count as reaped"); + + // A non-OS error (no errno at all) is likewise not a confirmed reap. + let not_os = std::io::Error::other("synthetic non-os error"); + assert!(!is_already_reaped(¬_os), "a non-OS error must not count as reaped"); + } + /// `bwa mem` command string with default-PATH binary. #[test] fn test_bwa_mem_command() { diff --git a/src/lib/commands/downsample.rs b/src/lib/commands/downsample.rs index 0a3a45220..ecb6787fe 100644 --- a/src/lib/commands/downsample.rs +++ b/src/lib/commands/downsample.rs @@ -95,9 +95,11 @@ pub struct Downsample { /// /// # Errors /// -/// Returns an error if `fraction` is `<= 0.0` or `> 1.0`. +/// Returns an error if `fraction` is non-finite (NaN/±inf), `<= 0.0`, or `> 1.0`. fn validate_fraction(fraction: f64) -> Result<()> { - if fraction <= 0.0 || fraction > 1.0 { + // Reject non-finite values (NaN/±inf) first, since NaN comparisons are always + // false and would otherwise slip past the range test below. + if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 { bail!("--fraction must be between 0.0 (exclusive) and 1.0 (inclusive), got {}", fraction); } Ok(()) @@ -386,6 +388,7 @@ fn get_mi_tag(record: &RawRecord) -> Result { mod tests { use super::*; use fgumi_raw_bam::SamBuilder as RawSamBuilder; + use rstest::rstest; /// Create a test record with a string MI tag. fn create_test_record(name: &str, mi: &str) -> RawRecord { @@ -501,25 +504,28 @@ mod tests { assert!(family.is_none()); } - #[test] - fn test_validate_fraction_too_low() { - // 0.0 is the exclusive lower bound, so it must be rejected; values below it likewise. - assert!(validate_fraction(0.0).is_err()); - assert!(validate_fraction(-0.1).is_err()); - } - - #[test] - fn test_validate_fraction_too_high() { - // Anything strictly greater than the inclusive upper bound of 1.0 must be rejected. - assert!(validate_fraction(1.1).is_err()); - assert!(validate_fraction(1.5).is_err()); - } - - #[test] - fn test_validate_fraction_valid() { - // A mid-range value and the inclusive upper bound 1.0 must both be accepted. - assert!(validate_fraction(0.5).is_ok()); - assert!(validate_fraction(1.0).is_ok()); + /// `validate_fraction` accepts `(0.0, 1.0]` and rejects everything else. + /// + /// The boundary matrix is kept in one table so new edge values land in a + /// single place: + /// - too low: `0.0` is the exclusive lower bound, and negatives are below it; + /// - too high: anything strictly above the inclusive upper bound of `1.0`; + /// - valid: a mid-range value and the inclusive upper bound `1.0`; + /// - non-finite: NaN/±inf, which must be rejected first since NaN + /// comparisons are always false and would otherwise slip past the + /// `<= 0.0 || > 1.0` range test. + #[rstest] + #[case::too_low_zero(0.0, false)] + #[case::too_low_negative(-0.1, false)] + #[case::too_high_just_over(1.1, false)] + #[case::too_high(1.5, false)] + #[case::valid_mid(0.5, true)] + #[case::valid_upper_bound(1.0, true)] + #[case::non_finite_nan(f64::NAN, false)] + #[case::non_finite_inf(f64::INFINITY, false)] + #[case::non_finite_neg_inf(f64::NEG_INFINITY, false)] + fn test_validate_fraction(#[case] fraction: f64, #[case] expect_ok: bool) { + assert_eq!(validate_fraction(fraction).is_ok(), expect_ok); } #[test] diff --git a/src/lib/commands/runall.rs b/src/lib/commands/runall.rs index 268629b8a..e2103f61f 100644 --- a/src/lib/commands/runall.rs +++ b/src/lib/commands/runall.rs @@ -429,10 +429,14 @@ pub struct RunAll { /// Path to the reference FASTA file. Required when `--methylation-mode` /// is set (consensus stages), when `--start-from zipper` is used (zipper /// requires the FASTA + `.dict` to build the output BAM header from the - /// reference dictionary), and when `--start-from align` is used (the - /// aligner reference; see `validate_align_and_merge`). It is also accepted - /// without `--methylation-mode` for `--start-from correct` (the - /// `ref_is_aligner_only` exemption in `execute`), where it is not consumed. + /// reference dictionary), and whenever the derived stage chain includes + /// `Stage::Align` (the aligner reference; see `validate_align_and_merge`) — + /// not only `--start-from align`, but also fused runs that reach align from + /// an upstream start (e.g. `--start-from extract`/`--start-from correct` + /// with a `--stop-after` past zipper). For those align-bearing upstream + /// starts the reference is consumed by the align stage and is accepted + /// without `--methylation-mode` (the `ref_is_aligner_only` exemption in + /// `execute`). #[arg(long = "ref")] pub reference: Option, @@ -459,12 +463,16 @@ pub struct RunAll { #[command(flatten)] pub correct_opts: crate::commands::correct::MultiCorrectOptions, - // ───────── aligner-side options (used only when --start-from=align) ───────── + // ───────── aligner-side options (used whenever the chain includes Stage::Align) ───────── /// Per-stage aligner tuning, exposed as `--aligner::preset`, /// `--aligner::command`, `--aligner::threads`, `--aligner::chunk-size` /// via the `MultiAlignerOptions` companion struct (generated by /// `#[multi_options]` on `AlignerOptions` in `crate::aligner`). - /// Ignored when `--start-from` is not `align`. + /// Used whenever the derived stage chain includes `Stage::Align` — not + /// only on `--start-from=align`, but also on fused runs that reach align + /// from an upstream start (e.g. `--start-from extract` or + /// `--start-from correct` with a `--stop-after` past zipper). Ignored only + /// when the chain has no align stage. #[command(flatten)] pub aligner_opts: crate::aligner::MultiAlignerOptions, @@ -472,7 +480,8 @@ pub struct RunAll { /// unset, the preset's binary (`bwa-mem3` / `bwa`) is found via /// `which::which()` on `PATH`. Rejected with a clear error if /// `--aligner::command` is used (command mode owns its own - /// binary). Ignored when `--start-from` is not `align`. + /// binary). Used whenever the chain includes `Stage::Align` (see + /// `--aligner::*` above); ignored only when the chain has no align stage. /// /// Note: this flag is at the runall top level (`--aligner-bin`), /// NOT inside the `--aligner::*` family. The `--aligner::*` flags @@ -551,15 +560,47 @@ pub struct RunAll { /// template-coordinate-sorted BAM for `group`; a grouped MI-tagged /// BAM for `consensus`; and so on). /// - /// **Input-state hazard:** the validator checks that - /// `start_from.ord() <= stop_after.ord()` but cannot verify that the - /// on-disk input actually matches the declared stage. Passing, say, a - /// raw-unsorted BAM to `--start-from group` will not raise a clear - /// error; the chain runs on whatever it finds. Make sure the input - /// matches the declared start stage. + /// * `sort` — input is raw unsorted BAM; the chain runs the + /// in-pipeline sort step, then continues into group + consensus + /// unless `--stop-after` truncates it earlier (e.g. `--stop-after + /// sort` writes the sorted BAM and stops). + /// * `group` — input is sorted by template-coordinate (output of + /// `fgumi sort`); the chain skips sort and runs group, then + /// continues into consensus unless `--stop-after` truncates it + /// earlier (e.g. `--stop-after group` writes the grouped BAM and + /// stops). + /// * `consensus` — input is sorted and grouped (MI-tagged, output + /// of `fgumi group`); the chain runs the consensus caller selected + /// by `--consensus {simplex,duplex,codec}` (optionally followed by + /// `filter`). The algorithm is chosen by `--consensus`, NOT by the + /// start value — `simplex` / `duplex` / `codec` are not valid + /// `--start-from` values. See the consensus-start notes below. /// - /// Must satisfy `start_from.ord() <= stop_after.ord()` (see - /// [`RunAllStage::validate_with`]). + /// **Consensus-start chain:** `--start-from consensus` (with + /// `--consensus {simplex,duplex,codec}`) runs the standalone consensus + /// chain, which groups by the *existing* `MI` tag via `GroupByMi`. It does + /// NOT add a position-grouping `Stage::Group` (no `GroupByPosition` / + /// `ProcessGroups` / `MiAssign`) — doing so would double-group an + /// already-grouped input (see [`derive_stages_for`]'s consensus-self-pair + /// exception). On already-grouped input the output is record- and + /// header-equivalent to the standalone consensus command (`fgumi simplex` / + /// `fgumi duplex` / `fgumi codec`), ignoring `@PG` provenance — runall and the + /// standalone command record different `@PG` chains, so the output is not + /// byte-identical. + /// + /// **Input-state hazard:** the validator cannot check whether the on-disk + /// BAM actually matches the declared `start_from`. Because + /// `--start-from consensus` groups by the existing `MI` tag and adds no + /// grouping stage, the input must already be grouped/MI-tagged — i.e. the + /// output of a prior `group`. A merely *sorted-but-not-grouped* BAM (no `MI` + /// tags), or a *raw-unsorted* BAM, is NOT grouped in-pipeline and will + /// silently produce wrong/empty output rather than a clear error. (The + /// consensus algorithm itself is chosen by `--consensus + /// {simplex,duplex,codec}`, not by the start value.) + /// + /// Case-insensitive. Must satisfy `start_from.ord() <= + /// stop_after.ord()` and the consensus-terminal rules + /// (see [`RunAllStage::validate_with`]). #[arg(long = "start-from", value_enum, ignore_case = true)] pub start_from: RunAllStage, @@ -674,32 +715,6 @@ impl Command for RunAll { // and is opened once via `InputSource::open` (BGZF/SAM auto-detected, // stdin-aware). runall reads the source exactly once per run, so a // streamed `-` works for every start stage. - // `--ref` typically pairs with `--methylation-mode` for the - // consensus stages, but several upstream start-stages also - // legitimately need a reference without methylation OR don't - // consume it at all (which would otherwise produce a - // confusing "--ref requires --methylation-mode" error for a - // ref the stage simply ignores): - // * `--start-from correct` — does not use `--ref` itself - // today, but cross-stage chaining (correct → align → ...) - // in the follow-up will need it; accept it here so users - // can pre-stage the flag in templates. - // * `--start-from zipper` — FASTA + `.dict` for the - // output BAM header. - // * `--start-from align-and-merge` — aligner reference + - // index files. - // Only enforce the methylation pairing on non-upstream start - // stages. - let ref_is_aligner_only = matches!( - self.start_from, - RunAllStage::Extract - | RunAllStage::Correct - | RunAllStage::Zipper - | RunAllStage::AlignAndMerge - ); - if !ref_is_aligner_only && self.reference.is_some() && self.methylation_mode.is_none() { - bail!("--ref requires --methylation-mode to be set"); - } self.validate_stages()?; @@ -716,6 +731,62 @@ impl Command for RunAll { // Derive the ordered stage list for this (start_from, stop_after) pair. let stages = self.derive_stages()?; + // `--ref` pairs with `--methylation-mode` for the consensus stages, but + // it is also legitimately consumed *without* methylation when the + // derived chain includes `Stage::Align` (the aligner reference) or + // starts at `zipper` (the FASTA + `.dict` for the output BAM header). + // For any other chain `--ref` is dead, so reject it without + // `--methylation-mode` rather than silently ignore it. Keyed on the + // derived chain — not the start stage — so a fused `extract`/`correct → + // … → align` run is exempted while a non-aligning chain (e.g. + // `correct → correct`) is not. This must run after `derive_stages()`. + let ref_is_consumed_without_methylation = + self.start_from == RunAllStage::Zipper || stages.contains(&Stage::Align); + if !ref_is_consumed_without_methylation + && self.reference.is_some() + && self.methylation_mode.is_none() + { + bail!("--ref requires --methylation-mode to be set"); + } + + // Symmetric guard for the *other* flag: in runall, `--methylation-mode` + // is wired only into the simplex/duplex consensus stages (see the + // `simplex_opts`/`duplex_opts` bag population below; codec and align + // reject it with their own messages). On a chain that reaches no + // consensus stage (e.g. `group → group`, `correct → sort`) it is dead — + // silently ignored — so reject it rather than mislead. Align chains are + // exempt here: `validate_align_and_merge` (run just below for any chain + // containing `Stage::Align`) owns the align-specific EM-seq message, + // which is more actionable than this generic one. + let chain_reaches_consensus = stages.iter().any(|s| s.is_consensus()); + let chain_includes_align = stages.contains(&Stage::Align); + // `--methylation-mode` on a simplex/duplex chain requires `--ref`: the + // consensus stage consumes the FASTA (via `consensus_reference()`), and + // the standalone contract (`--methylation-mode` doc, "requires --ref") + // demands it. Without this guard a non-align `group → simplex` run with + // `--methylation-mode` but no `--ref` slips through (the dead-consensus + // guard below does not fire because the chain *does* reach consensus), + // and `build_stage_options_bag()` then threads `methylation_mode = + // Some(...)` with `reference = None` into the stage — deferring the + // failure to a later, less actionable path. Codec is excluded (it + // rejects `--methylation-mode` outright below), and align chains are + // exempt (`validate_align_and_merge` owns the more specific message). + let chain_uses_methylation_capable_consensus = + stages.iter().any(|s| matches!(s, Stage::Simplex | Stage::Duplex)); + if self.methylation_mode.is_some() + && chain_uses_methylation_capable_consensus + && !chain_includes_align + && self.reference.is_none() + { + bail!("--methylation-mode requires --ref to be set"); + } + if self.methylation_mode.is_some() && !chain_reaches_consensus && !chain_includes_align { + bail!( + "--methylation-mode is only consumed by the consensus stage; \ + it is dead on a runall chain that stops before consensus" + ); + } + // D12: log auto-inserted stages for extract-fed chains so users // understand the chain expansion rules. if self.start_from == RunAllStage::Extract { @@ -1179,8 +1250,8 @@ impl RunAll { fn validate_align_and_merge(&self) -> Result<()> { let reference = self.reference.as_ref().ok_or_else(|| { anyhow::anyhow!( - "--start-from align requires --ref (the aligner reference FASTA \ - with its index files alongside)" + "a runall chain that includes align requires --ref (the aligner \ + reference FASTA with its index files alongside)" ) })?; // `--methylation-mode` drives the *consensus* stage (which AAM @@ -1190,8 +1261,8 @@ impl RunAll { // follow-up PR per the design doc. if self.methylation_mode.is_some() { bail!( - "--methylation-mode is not yet supported with --start-from align. \ - For EM-seq today, use `--aligner::command \"bwameth.py ...\"` (or \ + "--methylation-mode is not yet supported for runall chains that include \ + align. For EM-seq today, use `--aligner::command \"bwameth.py ...\"` (or \ `bwa-mem3 --methylation-mode em-seq ...`) in command mode and apply \ methylation downstream as a separate step." ); @@ -1205,7 +1276,7 @@ impl RunAll { "no sequence-dictionary file found next to --ref {} \ (expected `.dict` or `.dict`). \ Generate one with `samtools dict {} -o .dict` before running \ - `--start-from align`.", + a runall chain that includes align.", reference.display(), reference.display(), ); @@ -1975,6 +2046,93 @@ mod tests { ); } + /// Symmetric to `ref_without_methylation_on_group_start_is_rejected`: + /// `--methylation-mode` on a chain that never reaches consensus (here + /// `group → group`) is dead — no stage consumes it — so `execute` rejects it + /// before the pipeline opens rather than silently dropping it. `--ref` is + /// supplied too (so the existing `--ref requires --methylation-mode` guard + /// does NOT fire and the methylation guard is the one under test), and + /// `--input -` reaches the guard without consuming a real BAM. The error + /// must name `--methylation-mode` and `consensus` (not the align-specific + /// message, which `validate_align_and_merge` owns for align chains). + #[test] + fn methylation_mode_without_consensus_stage_is_rejected() { + use clap::Parser; + let r = RunAll::try_parse_from([ + "runall", + "--start-from", + "group", + "--stop-after", + "group", + "--methylation-mode", + "em-seq", + "--ref", + "/tmp/fgumi-nonexistent-ref.fa", + "--input", + "-", + "--output", + "o.bam", + "--group::strategy", + "paired", + "--threads", + "1", + ]) + .expect("parse"); + let err = r.execute("test").expect_err( + "--methylation-mode on a non-consensus group → group chain must be rejected", + ); + let msg = err.to_string(); + assert!( + msg.contains("--methylation-mode") && msg.contains("consensus"), + "error must mention `--methylation-mode` and `consensus`, got: {msg}" + ); + assert!( + !msg.contains("align"), + "non-align chain must not get the align-specific message, got: {msg}" + ); + } + + /// `--methylation-mode` on a simplex/duplex consensus chain requires `--ref`: + /// the consensus stage consumes the FASTA. A non-align `group → simplex` run + /// with `--methylation-mode` but no `--ref` reaches consensus (so the + /// dead-consensus guard does NOT fire) yet would otherwise thread + /// `methylation_mode = Some(...)` / `reference = None` into the stage. The + /// `execute` guard must reject it up front with a clear message. No + /// `--ref`/`--simplex::*` flags are supplied because the guard fires before + /// `build_stage_options_bag()`; `--input -` reaches it without opening a BAM. + #[test] + fn methylation_mode_without_ref_on_consensus_chain_is_rejected() { + use clap::Parser; + let r = RunAll::try_parse_from([ + "runall", + "--start-from", + "group", + "--stop-after", + "consensus", + "--consensus", + "simplex", + "--methylation-mode", + "em-seq", + "--input", + "-", + "--output", + "o.bam", + "--group::strategy", + "paired", + "--threads", + "1", + ]) + .expect("parse"); + let err = r.execute("test").expect_err( + "--methylation-mode on a group → simplex chain without --ref must be rejected", + ); + let msg = err.to_string(); + assert!( + msg.contains("--methylation-mode requires --ref"), + "error must mention `--methylation-mode requires --ref`, got: {msg}" + ); + } + /// `--filter::max-read-error-rate` / `--filter::max-base-error-rate` are /// `Vec` fields with standalone defaults (`[0.025]` / `[0.1]`) that the /// `#[multi_options]`-generated `validate()` backfills from diff --git a/src/lib/pipeline/chains/builder.rs b/src/lib/pipeline/chains/builder.rs index 08f9bb62f..8a633c375 100644 --- a/src/lib/pipeline/chains/builder.rs +++ b/src/lib/pipeline/chains/builder.rs @@ -1423,7 +1423,11 @@ impl<'a> ChainBuilder<'a> { .pipeline .append_step(SerializeBamRecords::new(self.tuning.per_step_byte_limit), tail); self.current_tail = Some(tail); - self.chain_tail_kind = ChainTailKind::DecodedRecordBatch; + // SerializeBamRecords emits DecompressedBlock (serialized bytes), not + // DecodedRecordBatch. Mark the tail honestly as SerializedBytes, matching + // every other terminal serialize path (e.g. add_dedup); nothing downstream + // of a Terminal stage reads this, but the kind must never lie. + self.chain_tail_kind = ChainTailKind::SerializedBytes; } else { self.current_tail = Some(tail); self.chain_tail_kind = ChainTailKind::BamTemplateBatch; @@ -1447,9 +1451,9 @@ impl<'a> ChainBuilder<'a> { /// /// For [`StagePosition::Intermediate`], `SerializeBamRecords` is **not** /// appended: the chain tail is left as `BamTemplateBatch` so the next stage - /// (`add_align` → `GroupByQueryname → AlignAndMergeStep`) can consume the - /// correct step's kept output (branch 0) directly. This is the correct→align - /// fused path. + /// (`add_align` → `AlignAndMergeStep`) can consume the correct step's kept + /// output (branch 0) directly; `add_align` skips `GroupByQueryname` on an + /// incoming `BamTemplateBatch`. This is the correct→align fused path. /// /// When `--rejects` is set, branch 1 of the correct step carries pre-framed /// `DecompressedBlock` bytes and is wired here directly to its own diff --git a/src/lib/pipeline/steps/coalesce.rs b/src/lib/pipeline/steps/coalesce.rs index bcd8a61d0..cf8472a6c 100644 --- a/src/lib/pipeline/steps/coalesce.rs +++ b/src/lib/pipeline/steps/coalesce.rs @@ -258,10 +258,33 @@ mod tests { /// ~512 KB block (caught by the per-block size bound below). const COALESCE_INPUT_BLOCKS: usize = 64 * 8; + /// Deterministic, per-block-unique fill for the block with the given + /// `batch_serial`. The bytes vary by position (a tiny LCG seeded by the + /// serial) so two distinct blocks never share a byte pattern — this is what + /// lets the full-stream oracle catch reorder/drop/duplicate bugs, not just + /// total-byte conservation. Both the source and the independently-built + /// expected stream call this, so the only way the streams match is if + /// coalesce concatenates every block exactly once, in order. + fn block_fill(batch_serial: u64) -> Vec { + // Seed off the serial; +1 avoids a degenerate all-zero seed for serial 0. + let mut state = batch_serial.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1); + (0..COALESCE_INPUT_BLOCK_BYTES) + .map(|_| { + // SplitMix64-style step for a well-mixed per-byte sequence. + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + // Keep the low byte; truncation is intentional (we want a u8 fill). + ((z ^ (z >> 31)) & 0xFF) as u8 + }) + .collect() + } + /// Source emitting `remaining` fixed-size `DecompressedBlock`s via a shared /// atomic counter (safe for Serial single-worker execution). Each block's - /// `bytes` is `COALESCE_INPUT_BLOCK_BYTES` of a deterministic fill so the - /// sink can verify byte preservation without an ordering assumption. + /// `bytes` is a per-block-unique `block_fill(batch_serial)` so the sink can + /// reconstruct and verify the exact concatenated byte stream. #[derive(Clone)] struct BlockSource { remaining: Arc, @@ -283,10 +306,7 @@ mod tests { if n == 0 { return Ok(StepOutcome::Finished); } - let block = DecompressedBlock { - batch_serial: n, - bytes: vec![0xCD; COALESCE_INPUT_BLOCK_BYTES], - }; + let block = DecompressedBlock { batch_serial: n, bytes: block_fill(n) }; match ctx.outputs.push(block) { Ok(()) => { self.remaining.fetch_sub(1, AtomicOrd::AcqRel); @@ -302,10 +322,12 @@ mod tests { } /// Sink recording the byte length of every emitted block (so the test can - /// bound the per-block size) plus the running total. + /// bound the per-block size) plus the exact concatenated byte stream (so the + /// test can compare it against an independently-built expected stream). #[derive(Clone)] struct SizeRecordingSink { sizes: Arc>>, + stream: Arc>>, } impl Step for SizeRecordingSink { type Input = DecompressedBlock; @@ -322,13 +344,12 @@ mod tests { fn try_run(&mut self, ctx: &mut StepCtx<'_, Self>) -> io::Result { match ctx.input.pop() { Some(block) => { - // Every byte must be the source's fill — proving the - // concatenation neither drops nor corrupts bytes. - assert!( - block.bytes.iter().all(|&b| b == 0xCD), - "coalesced block carries unexpected bytes" - ); + // Record the per-block size for the memory bound, and append + // the bytes verbatim so the test can compare the full stream + // against the expected concatenation (catches reorder / + // drop / duplicate, not just total-byte conservation). self.sizes.lock().expect("sink mutex").push(block.bytes.len()); + self.stream.lock().expect("stream mutex").extend_from_slice(&block.bytes); Ok(StepOutcome::Progress) } None if ctx.input.is_drained() => Ok(StepOutcome::Finished), @@ -342,16 +363,33 @@ mod tests { #[test] fn coalesce_flushes_at_threshold_and_preserves_bytes() { - let remaining = Arc::new(AtomicU64::new(u64::try_from(COALESCE_INPUT_BLOCKS).unwrap())); + let n_blocks = u64::try_from(COALESCE_INPUT_BLOCKS).unwrap(); + let remaining = Arc::new(AtomicU64::new(n_blocks)); let sizes = Arc::new(Mutex::new(Vec::new())); + let stream = Arc::new(Mutex::new(Vec::new())); - let coalesce = CoalesceBytes::new(COALESCE_THRESHOLD_BYTES, 4 * 1024); + // Cap the transport queue at the per-block ceiling — the largest block + // coalesce can emit — so the run exercises the threshold-flush contract + // under a real byte bound instead of relying on the queue accepting + // oversized items. Memory stays a function of config, not input size. + // + // The real overshoot bound is `threshold + one input block`: `try_run` + // re-checks the threshold after *every* input it absorbs and breaks the + // moment `pending` crosses it (see step 3), so a flush carries at most + // one input block beyond the threshold — NOT a full `MAX_BATCHES_PER_LOCK` + // batch. Using the loose `MAX_BATCHES_PER_LOCK * input` bound would let a + // "flush a whole lock late" regression slip through. + let per_block_ceiling = COALESCE_THRESHOLD_BYTES + COALESCE_INPUT_BLOCK_BYTES; + let coalesce = CoalesceBytes::new( + COALESCE_THRESHOLD_BYTES, + u64::try_from(per_block_ceiling).expect("per-block ceiling fits u64"), + ); let builder = PipelineBuilder::new(); builder .chain(BlockSource { remaining: Arc::clone(&remaining) }) .chain(coalesce) - .chain(SizeRecordingSink { sizes: Arc::clone(&sizes) }) + .chain(SizeRecordingSink { sizes: Arc::clone(&sizes), stream: Arc::clone(&stream) }) .into_sink_marker(); let pipeline = builder.build().unwrap(); @@ -365,15 +403,34 @@ mod tests { // Byte conservation: every input byte reaches the sink exactly once. assert_eq!(total_out, total_in, "coalesce dropped or duplicated bytes"); + // Byte-stream identity: independently build the expected concatenation in + // the source's emission order (`BlockSource` counts the shared atomic + // DOWN from `n_blocks` to 1, so it emits `batch_serial = N, N-1, …, 1`) + // and require the sink's flat byte stream to match it exactly. Because + // each block's fill is per-block-unique (`block_fill`), this fails if any + // block is reordered, dropped, or duplicated — a far stronger oracle than + // the aggregate-count check above. + let expected_stream: Vec = (1..=n_blocks).rev().flat_map(block_fill).collect(); + let actual_stream = stream.lock().expect("stream mutex").clone(); + assert_eq!( + actual_stream.len(), + expected_stream.len(), + "coalesced stream length differs from expected" + ); + assert!( + actual_stream == expected_stream, + "coalesced byte stream diverges from the expected in-order concatenation \ + (reorder/drop/duplicate)" + ); + // Memory bound: the step flushes once `pending` reaches the threshold, - // then resets. Each emitted block therefore carries between - // `threshold` and `threshold + (MAX_BATCHES_PER_LOCK - 1) * input` — - // the threshold check fires mid-pull, but the loop may absorb up to one - // lock's worth of inputs before re-checking. A "buffer everything into - // one block" regression emits a single `total_in`-sized block, which - // blows this bound spectacularly. - let per_block_ceiling = - COALESCE_THRESHOLD_BYTES + MAX_BATCHES_PER_LOCK * COALESCE_INPUT_BLOCK_BYTES; + // then resets. The threshold check fires after each input is absorbed + // and breaks immediately, so an emitted block carries between + // `threshold` and `threshold + one input block` — never more. A "buffer + // everything into one block" regression emits a single `total_in`-sized + // block, which blows this bound spectacularly; so would a "flush a whole + // lock late" regression. `per_block_ceiling` (also the transport queue + // cap above) is the upper limit on any emitted block. for (i, &sz) in emitted.iter().enumerate() { let is_last = i + 1 == emitted.len(); assert!( diff --git a/src/lib/umi/parallel_assigner.rs b/src/lib/umi/parallel_assigner.rs index e2e52d15c..4ea784e2f 100644 --- a/src/lib/umi/parallel_assigner.rs +++ b/src/lib/umi/parallel_assigner.rs @@ -484,10 +484,12 @@ impl UmiAssigner for ParallelAdjacencyAssigner { }) .collect(); - // Handle edge case: no valid UMIs + // Handle edge case: no valid UMIs. Reads whose UMI failed to encode keep + // `MoleculeId::None`, matching the sequential `AdjacencyUmiAssigner` (which + // returns `vec![MoleculeId::None; len]` when every UMI is invalid). Assigning + // a fresh `Single` here would diverge from the sequential path (parity bug). if sorted_umis.is_empty() { - // All UMIs were invalid - each gets its own molecule ID - return (0..raw_umis.len()).map(|i| MoleculeId::Single(i as u64)).collect(); + return vec![MoleculeId::None; raw_umis.len()]; } // Sort by count descending, then by the RAW first-seen UMI string — exactly @@ -559,17 +561,15 @@ impl UmiAssigner for ParallelAdjacencyAssigner { .map(|(i, (umi, _, _, _))| (umi.as_str(), mol_ids[i])) .collect(); - // Map back to original UMI order + // Map back to original UMI order. Reads whose UMI failed to encode are not + // present in `str_to_mol` and keep `MoleculeId::None`, mirroring the + // sequential `AdjacencyUmiAssigner` (which maps invalid reads to `None`). + // Assigning a fresh `Single` here would be a sequential/parallel parity bug. raw_umis .iter() .map(|umi| { let upper = umi.to_uppercase(); - str_to_mol.get(upper.as_str()).copied().unwrap_or_else(|| { - // Invalid UMI gets its own molecule ID (consistent with Edit assigner) - let id = MoleculeId::Single(next_mol_id); - next_mol_id += 1; - id - }) + str_to_mol.get(upper.as_str()).copied().unwrap_or(MoleculeId::None) }) .collect() } @@ -1464,6 +1464,12 @@ mod tests { /// between the two paths (parallel keyed on the uppercased string, sequential on raw /// bytes) and capture a shared neighbor into different groups. UMIs are drawn from a small /// alphabet (mixed case) over a short fixed length to maximize ties and 1-edit adjacency. + /// + /// No fgbio oracle (by design): the case-SENSITIVE raw-bytes tie-break IS fgbio's + /// `strnum`-style order, established in S7-002-bitenc-perf-analysis.md (fgbio = raw bytes). + /// The DIRECTION test below (`adjacency_tie_break_is_case_sensitive_raw_not_uppercased`, + /// aAAA/CAAA/GAAA) non-vacuously pins that direction, so we cross-check the two fgumi paths + /// against each other rather than adding an fgbio dependency. #[test] fn proptest_adjacency_parallel_matches_sequential( indices in prop::collection::vec(0usize..16, 1..40), @@ -1516,6 +1522,55 @@ mod tests { } } + /// Invalid-UMI parity: a read whose UMI fails to encode (e.g. contains an `N`) + /// must map to `MoleculeId::None` in BOTH the sequential `AdjacencyUmiAssigner` + /// and the parallel `ParallelAdjacencyAssigner`, never to a fresh + /// `MoleculeId::Single`. The sequential path has long preserved `None`; the + /// parallel path previously minted a `Single` per invalid read, which broke + /// sequential/parallel parity and would silently fold invalid reads into their + /// own consensus families. This pins the fix for both the mixed case and the + /// all-invalid case. + /// + /// The `None` expectations below ARE the fgbio oracle, not a mere + /// sequential-vs-parallel cross-check: fgbio's `GroupReadsByUmi` drops reads + /// whose UMI contains an `N` (`.filter(r => !umi.contains('N'))`), assigning + /// them no molecule — exactly `MoleculeId::None` here. (That fgbio-compatible + /// UMI validation is documented on `fgumi_umi`'s UMI validator, which mirrors + /// the same Scala filter.) Because these are absolute expected values, a + /// shared drift from fgbio — e.g. both paths minting a fresh `Single` for an + /// invalid UMI — FAILS the asserts rather than slipping through. No fgbio + /// dependency is added, consistent with the no-oracle-by-design rationale on + /// `proptest_adjacency_parallel_matches_sequential` above. + #[test] + fn adjacency_invalid_umi_maps_to_none_in_both_paths() { + // One valid UMI (twice) + one invalid UMI (contains `N`). + let mixed: Vec = ["ACGT", "ACGT", "ACGN"].iter().map(|s| (*s).to_string()).collect(); + for (label, mol) in [ + ("sequential", AdjacencyUmiAssigner::new(1, 1, DEFAULT_INDEX_THRESHOLD).assign(&mixed)), + ("parallel", ParallelAdjacencyAssigner::new(1, 4).assign(&mixed)), + ] { + assert_eq!(mol[0], mol[1], "{label}: the two valid reads share one molecule"); + assert!(matches!(mol[0], MoleculeId::Single(_)), "{label}: valid reads get a Single"); + assert_eq!(mol[2], MoleculeId::None, "{label}: invalid read must be None, not Single"); + } + + // All-invalid input: every read must be `None` (no fresh `Single` ids). + let all_invalid: Vec = + ["ACGN", "NNNN", "TTNT"].iter().map(|s| (*s).to_string()).collect(); + for (label, mol) in [ + ( + "sequential", + AdjacencyUmiAssigner::new(1, 1, DEFAULT_INDEX_THRESHOLD).assign(&all_invalid), + ), + ("parallel", ParallelAdjacencyAssigner::new(1, 4).assign(&all_invalid)), + ] { + assert!( + mol.iter().all(|id| *id == MoleculeId::None), + "{label}: all-invalid input must map every read to None, got {mol:?}", + ); + } + } + // NOTE on the paired strategy (S7-002 / FU-004 scope): the sequential `PairedUmiAssigner` // tie-break is the case-SENSITIVE canonical string (`canonicalize_paired` normalizes // orientation only, not case) — raw/fgbio-compatible, matching the single-UMI raw tie-break. diff --git a/tests/integration/test_dedup_command.rs b/tests/integration/test_dedup_command.rs index 9f065d341..a37970c01 100644 --- a/tests/integration/test_dedup_command.rs +++ b/tests/integration/test_dedup_command.rs @@ -176,13 +176,66 @@ fn test_dedup_command_remove_duplicates() { assert!(output_bam.exists(), "Output BAM not created"); // With --remove-duplicates, exactly one pair (2 records) survives: the 3 - // duplicate pairs (6 records) collapse to the single best representative - // pair, so the other 2 pairs (4 records) are dropped. Assert the exact - // expected count rather than loose bounds (S9b-008). + // duplicate pairs (6 records) collapse to a single representative pair, so + // the other 2 pairs (4 records) are dropped. The 3 input templates are + // byte-identical except for their qname suffix (`dup1_0/1/2`; see + // `create_duplicate_group`), so which one is retained is an implementation- + // defined tie-break, NOT an independently oracle-able contract — this test + // therefore does not pin the exact surviving qname. What it DOES enforce, + // beyond the count, is that the survivor is a well-formed representative + // *pair*: one R1 + one R2 sharing a single input qname, at the original + // mapped positions, and not duplicate-flagged. That catches a dropped-mate, + // split-pair, or malformed-survivor regression (S9b-008). let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap()); let _header = reader.read_header().unwrap(); - let count = reader.records().count(); - assert_eq!(count, 2, "remove-duplicates over 3 duplicate pairs must keep exactly one pair"); + let surviving: Vec = + reader.records().map(|r| r.expect("read surviving record")).collect(); + assert_eq!( + surviving.len(), + 2, + "remove-duplicates over 3 duplicate pairs must keep exactly one pair" + ); + + // Project each surviving record onto stable, order-independent fields and + // assert the exact set: one R1 + one R2 with a shared qname (one of the + // input names `dup1_0/1/2`), the original mapped positions, and NOT flagged + // as a duplicate (representatives are kept, duplicates are removed). + let name0 = String::from_utf8(surviving[0].name().expect("R1 has a name").to_vec()).unwrap(); + let name1 = String::from_utf8(surviving[1].name().expect("R2 has a name").to_vec()).unwrap(); + assert_eq!(name0, name1, "the surviving pair's two mates must share one qname"); + assert!( + ["dup1_0", "dup1_1", "dup1_2"].contains(&name0.as_str()), + "surviving qname must be one of the input templates, got {name0}" + ); + + // Project (is_first, is_last, is_reverse, pos) for each mate; the set must be + // exactly the forward R1 (first, not last) at pos 100 and the reverse R2 (last, + // not first) at pos 200 (1-based). Asserting both the first- and last-segment + // flags pins the exact "one R1 + one R2" contract — a malformed record that is + // merely "not first" at pos 200 would otherwise slip through. + let mut projected: Vec<(bool, bool, bool, i32)> = surviving + .iter() + .map(|rec| { + let flags = rec.flags(); + let pos = i32::try_from(rec.alignment_start().unwrap().unwrap().get()).unwrap(); + ( + flags.is_first_segment(), + flags.is_last_segment(), + flags.is_reverse_complemented(), + pos, + ) + }) + .collect(); + projected.sort_unstable(); + assert_eq!( + projected, + vec![(false, true, true, 200), (true, false, false, 100)], + "surviving pair must be the forward R1 (first, pos 100) + reverse R2 (last, pos 200), 1-based" + ); + assert!( + surviving.iter().all(|rec| !rec.flags().is_duplicate()), + "surviving representatives must not be flagged as duplicates" + ); } /// SAM-input parity: dedup's typed-step path accepts both BAM and SAM via diff --git a/tests/integration/test_runall_parity.rs b/tests/integration/test_runall_parity.rs index a914ff2f9..73e41cb2f 100644 --- a/tests/integration/test_runall_parity.rs +++ b/tests/integration/test_runall_parity.rs @@ -45,7 +45,7 @@ use tempfile::TempDir; use crate::helpers::bam_generator::{ create_both_unmapped_pair, create_minimal_header, create_paired_umi_family, - create_paired_umi_family_at, create_umi_family, to_record_buf, + create_paired_umi_family_at, create_umi_family, create_umi_family_at, to_record_buf, }; use crate::helpers::cli_runner::{ ParityArgs, Stage, fgumi, fgumi_binary, run_runall, run_runall_consensus_to_filter, @@ -396,6 +396,7 @@ fn parity_a_duplex_to_duplex() { // S9a-001: the duplex chain emits duplex consensus records — non-empty. assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); } /// `Codec → Codec` parity vs standalone `fgumi codec`. Runs through the @@ -427,6 +428,9 @@ fn parity_a_codec_to_codec() { assert!(r.status.success(), "standalone: {}", String::from_utf8_lossy(&r.stderr)); assert_bams_record_equivalent(&runall_out, &standalone_out); + // Header parity matters most in this empty-stream case: a wrong fused + // @HD/@SQ/@RG would otherwise stay green behind the zero-record assertion. + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); assert_eq!( read_bam_records(&runall_out).len(), 0, @@ -620,6 +624,75 @@ fn parity_b_group_to_duplex() { /// `group` but must be dropped by the duplex `record_filter` on BOTH the fused /// `runall group→duplex` path and the staged `fgumi group | fgumi duplex` path. /// +/// Assert that a `group --allow-unmapped` output BAM retains the fixture's sole +/// both-unmapped template (`fam_unmapped`, UMI `TTAA-GGCC`; see +/// `sorted_duplex_with_unmapped_fixture`) by IDENTITY, not just by count. A +/// count-only check would still pass if the group stage retained two of some +/// *other* record while dropping a `fam_unmapped` mate. +fn assert_group_retains_both_unmapped_pair(group_bam: &Path) { + use noodles::sam::alignment::record::data::field::Tag; + use noodles::sam::alignment::record_buf::data::field::Value; + let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); + let rx_tag = Tag::from(fgumi_lib::sam::SamTag::RX); + let group_records = read_bam_records(group_bam); + let retained: Vec<_> = group_records + .iter() + .filter(|rec| rec.flags().is_unmapped() && rec.data().get(&mi_tag).is_some()) + .collect(); + assert_eq!( + retained.len(), + 2, + "group --allow-unmapped output must retain BOTH mates of the one both-unmapped \ + template (= 2 MI-tagged unmapped records); a count other than 2 means a mate was \ + dropped or duplicated. Found {} such records in {}", + retained.len(), + group_bam.display() + ); + // Both retained records must be the `fam_unmapped` pair, carrying its UMI. + for rec in &retained { + let qname: &[u8] = rec.name().expect("retained record has a read name").as_ref(); + assert_eq!( + qname, b"fam_unmapped", + "retained MI-tagged unmapped record has an unexpected qname (not fam_unmapped)" + ); + let Some(Value::String(rx)) = rec.data().get(&rx_tag) else { + panic!("retained unmapped record is missing its RX (UMI) tag"); + }; + let rx_bytes: &[u8] = rx.as_ref(); + assert_eq!( + rx_bytes, b"TTAA-GGCC", + "retained unmapped record carries the wrong UMI (not the fam_unmapped UMI)" + ); + } + // Both retained mates must share ONE molecule id: presence alone (the filter + // above) would still pass if a regression split `fam_unmapped` into two + // different MI values, changing the grouped-template identity. + let mi_values: std::collections::BTreeSet> = retained + .iter() + .map(|rec| { + let Some(Value::String(mi)) = rec.data().get(&mi_tag) else { + panic!("retained unmapped record is missing its MI tag"); + }; + let mi_bytes: &[u8] = mi.as_ref(); + mi_bytes.to_vec() + }) + .collect(); + assert_eq!( + mi_values.len(), + 1, + "the retained both-unmapped pair must share one MI value; got {mi_values:?}" + ); + // ...and they are exactly one R1 + one R2 (no mate dropped or duplicated). + let first = retained.iter().filter(|r| r.flags().is_first_segment()).count(); + let last = retained.iter().filter(|r| r.flags().is_last_segment()).count(); + assert_eq!( + (first, last), + (1, 1), + "the retained both-unmapped pair must be exactly one R1 + one R2, got \ + ({first} first, {last} last) segments" + ); +} + /// Before the fix the fused `templates_to_mi_step` bridge did not apply the /// duplex filter, so the both-unmapped pair leaked into the consensus caller on /// the fused path only — diverging from the staged path. This test fails on the @@ -679,25 +752,12 @@ fn new_002_group_to_duplex_allow_unmapped_parity() { assert!(r.status.success(), "staged group: {}", String::from_utf8_lossy(&r.stderr)); // Independent intermediate check: the group stage with --allow-unmapped must - // retain the both-unmapped template (MI-tagged) BEFORE duplex runs. This - // pins the bridge/filter contract directly — without it, a regression that - // drops the unmapped template at the group stage could still pass the final - // fused-vs-staged comparison if both paths dropped it identically. - { - use noodles::sam::alignment::record::data::field::Tag; - let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); - let group_records = read_bam_records(&group_bam); - let unmapped_mi_count = group_records - .iter() - .filter(|rec| rec.flags().is_unmapped() && rec.data().get(&mi_tag).is_some()) - .count(); - assert!( - unmapped_mi_count > 0, - "group --allow-unmapped output must retain the MI-tagged unmapped template; \ - found {unmapped_mi_count} such records in {}", - group_bam.display() - ); - } + // retain the both-unmapped template (MI-tagged, by identity) BEFORE duplex + // runs. This pins the bridge/filter contract directly — without it, a + // regression that drops the unmapped template at the group stage could still + // pass the final fused-vs-staged comparison if both paths dropped it + // identically. + assert_group_retains_both_unmapped_pair(&group_bam); let duplex_args: Vec = vec![ "duplex".into(), @@ -984,13 +1044,15 @@ fn deterministic_simplex_fixture(dir: &Path, positions: usize) -> PathBuf { let mut all = Vec::new(); // A small set of valid-DNA UMIs (non-ACGT chars would be rejected by the // UMI grouping stage); cycle through them so distinct families form across - // positions. `create_umi_family` maps every read at a fixed position, so - // all 200 families share one coordinate but distinct UMIs → distinct MI - // groups → one fragment consensus each. + // positions. Each family is mapped to its own template-coordinate position + // (100 bp apart) via `create_umi_family_at`, so the families genuinely span + // many coordinates — exercising the sort/group/consensus path across + // multiple parallel workers rather than collapsing onto one position. let umis = ["ACGTACGT", "TGCATGCA", "CCAATTGG", "GGTTAACC", "AACCGGTT", "TTGGCCAA"]; for p in 0..positions { let umi = umis[p % umis.len()]; - for r in create_umi_family(umi, 3, &format!("ds{p}_{umi}"), "ACGTACGTACGT", 30) { + let pos = i32::try_from(100 + p * 100).expect("family position fits in i32"); + for r in create_umi_family_at(pos, umi, 3, &format!("ds{p}_{umi}"), "ACGTACGTACGT", 30) { all.push(r); } } @@ -1835,6 +1897,7 @@ fn parity_a_zipper_to_zipper() { // S9a-001: zipper merges the mapped + unmapped reads → a non-empty stream. assert_bams_record_equivalent_nonempty(&runall_out, &standalone_out); + assert_bam_headers_equivalent_ignoring_pg(&runall_out, &standalone_out); } /// Smoke check — `fgumi runall --start-from zipper --stop-after zipper` @@ -3673,11 +3736,43 @@ fn correct_to_sort_with_rejects_emits_warning() { "standalone correct --rejects failed: {}", String::from_utf8_lossy(&r.stderr) ); - let standalone_reject_count = read_bam_records(&standalone_rejects).len(); - assert!( - standalone_reject_count > 0, - "standalone correct --rejects must capture the off-whitelist family that the \ - fused chain discarded — got {standalone_reject_count} rejects (expected the 4 \ - GGTTAACC reads). If this is 0, the partial whitelist is not actually rejecting." + let standalone_reject_records = read_bam_records(&standalone_rejects); + assert_eq!( + standalone_reject_records.len(), + 4, + "standalone correct --rejects must capture the ENTIRE off-whitelist family that the \ + fused chain discarded — expected exactly the 4 GGTTAACC reads, got {}. A non-4 count \ + means only part of the family was rejected (or the wrong reads were captured).", + standalone_reject_records.len() ); + // Pin the captured family by IDENTITY, not just count: every rejected read + // must be a `fam_d` read carrying the off-whitelist UMI `GGTTAACC`, and the + // four must be exactly fam_d_0..fam_d_3 — proving the discarded data was the + // specific off-whitelist family, not some unrelated four reads. + { + use noodles::sam::alignment::record::data::field::Tag; + use noodles::sam::alignment::record_buf::data::field::Value; + let rx_tag = Tag::from(fgumi_lib::sam::SamTag::RX); + let mut reject_qnames: Vec> = Vec::new(); + for rec in &standalone_reject_records { + let Some(Value::String(rx)) = rec.data().get(&rx_tag) else { + panic!("rejected record is missing its RX (UMI) tag"); + }; + let rx_bytes: &[u8] = rx.as_ref(); + assert_eq!( + rx_bytes, b"GGTTAACC", + "rejected record carries the wrong UMI — only the off-whitelist GGTTAACC \ + family should be rejected" + ); + let qname: &[u8] = rec.name().expect("rejected record has a read name").as_ref(); + reject_qnames.push(qname.to_vec()); + } + reject_qnames.sort(); + let expected_qnames: Vec> = + (0..4).map(|i| format!("fam_d_{i}").into_bytes()).collect(); + assert_eq!( + reject_qnames, expected_qnames, + "rejected reads must be exactly the four GGTTAACC family members fam_d_0..fam_d_3" + ); + } } diff --git a/tests/integration/test_sort_correctness.rs b/tests/integration/test_sort_correctness.rs index 7817b0645..55e89213d 100644 --- a/tests/integration/test_sort_correctness.rs +++ b/tests/integration/test_sort_correctness.rs @@ -352,6 +352,19 @@ fn assert_records_preserved(input: &Path, output: &Path) { /// then `pos` ascending. This is computed independently of the sort crate from /// the records actually present in the output. fn assert_coordinate_ordered(records: &[RecordBuf]) { + // fgumi's coordinate sort key is `(tid << 34) | ((pos+1) << 1) | reverse` + // (see `RawCoordinateKey`), so the REVERSE strand flag (0x10) is a real, + // reliably-ordered tertiary component of the key — forward-strand records + // precede reverse-strand at the same (tid, pos). Including it here catches a + // comparator/merge regression that reshuffles equal-coordinate records by + // strand, which a `(tid, pos)`-only projection would miss. The remaining + // within-(tid, pos, strand) order is the stable input-order tie-break (no + // name tie-break, matching samtools); it is not asserted here because the + // parallel/spill merge does not independently guarantee global input order + // for fully-equal keys, and record integrity is proven separately by + // `assert_records_preserved`. + const REVERSE_FLAG: u16 = 0x10; + let strand = |k: &RecordKey| (k.flags & REVERSE_FLAG) != 0; let keys: Vec = records.iter().map(record_key).collect(); let mut expected = keys.clone(); expected.sort_by(|a, b| { @@ -361,25 +374,44 @@ fn assert_coordinate_ordered(records: &[RecordBuf]) { .cmp(&b_noref) // false (has ref) sorts before true (no ref) .then(a.tid.cmp(&b.tid)) .then(a.pos.cmp(&b.pos)) + .then(strand(a).cmp(&strand(b))) // forward (false) before reverse (true) }); - // Compare only the order-determining prefix (tid, pos); names within an - // equal (tid, pos) are an unspecified tie so are not asserted here. - let proj = |k: &RecordKey| (k.tid < 0, k.tid, k.pos); + let proj = |k: &RecordKey| (k.tid < 0, k.tid, k.pos, strand(k)); let got: Vec<_> = keys.iter().map(proj).collect(); let want: Vec<_> = expected.iter().map(proj).collect(); - assert_eq!(got, want, "output is not coordinate-ordered (independent oracle)"); + assert_eq!( + got, want, + "output is not coordinate-ordered by (tid, pos, strand) (independent oracle)" + ); } -/// Queryname-lexicographic order oracle: read names compared as raw bytes, -/// non-decreasing down the file. Computed independently of the sort crate. +/// Queryname-lexicographic order oracle. The lex sort key is the read name +/// compared as raw bytes; the sort is documented stable, so within an +/// equal-name run the records keep their input order — and the fixture emits +/// every template's R1 (`FIRST_SEGMENT`) before its R2 (`LAST_SEGMENT`). The order +/// is therefore FULLY determined, so this oracle uses an order-preserving +/// per-record projection `(name, is_last_segment)` rather than collapsing +/// duplicates by name: a name-only `<=` would pass even if R1/R2 were swapped +/// within a name, but the full projection catches per-record reordering within +/// an equal-name run. Computed independently of the sort crate. fn assert_queryname_lex_ordered(records: &[RecordBuf]) { - let names: Vec> = records.iter().map(|r| record_key(r).name).collect(); - for pair in names.windows(2) { + let proj = |r: &RecordBuf| -> (Vec, bool) { + let name = record_key(r).name; + // FIRST_SEGMENT (0x40) precedes LAST_SEGMENT (0x80) under the stable + // sort; `is_last_segment` (false < true) encodes that secondary order. + let is_last = r.flags().is_last_segment(); + (name, is_last) + }; + let keys: Vec<(Vec, bool)> = records.iter().map(proj).collect(); + for pair in keys.windows(2) { assert!( pair[0] <= pair[1], - "output is not queryname-lexicographic ordered (independent oracle): {:?} > {:?}", - String::from_utf8_lossy(&pair[0]), - String::from_utf8_lossy(&pair[1]), + "output is not queryname-lexicographic ordered (independent oracle): \ + ({:?}, last={}) > ({:?}, last={})", + String::from_utf8_lossy(&pair[0].0), + pair[0].1, + String::from_utf8_lossy(&pair[1].0), + pair[1].1, ); } } @@ -399,7 +431,9 @@ fn samtools_sort(input: &Path, output: &Path, samtools_args: &[&str]) { let mut cmd = Command::new("samtools"); cmd.arg("sort"); cmd.args(samtools_args); - cmd.args(["-o", output.to_str().unwrap(), input.to_str().unwrap()]); + cmd.arg("-o"); + cmd.arg(output); + cmd.arg(input); let status = cmd.status().expect("run samtools sort"); assert!(status.success(), "samtools sort failed"); } @@ -544,12 +578,33 @@ fn template_coordinate_sort_matrix(#[case] spill: Spill) { ); assert_records_preserved(&input, &output); + // Run-to-run determinism: fgumi's template-coordinate tie-break folds the + // CB cell tag + a read-name hash into its key and legitimately DIVERGES from + // samtools' per-record tie order, so we do NOT cross-check the full record + // stream against samtools (that would assert a false equivalence — see + // `assert_template_position_order_matches`). But fgumi's OWN tie-break is + // deterministic, so sorting the same input twice must yield the identical + // full ordered record sequence. This pins the complete record order + // (including ties) internally, which the position-key samtools cross-check + // below cannot. + let output2 = dir.path().join("sorted2.bam"); + run_sort(&input, &output2, "template-coordinate", spill); + let recs1 = read_records(&output); + let recs2 = read_records(&output2); + assert_eq!( + recs1, recs2, + "template-coordinate sort is not run-to-run deterministic ({spill:?}): \ + the full ordered record sequence (including tie order) differs between two runs" + ); + if !samtools_available() { eprintln!("skipping template-coordinate samtools oracle: samtools not on PATH"); return; } let samtools_out = dir.path().join("samtools_template.bam"); samtools_sort(&input, &samtools_out, &["--template-coordinate"]); + // Cross-check only the (tid, pos) position-key stream against samtools — the + // per-record tie order is intentionally divergent (documented above). assert_template_position_order_matches(&output, &samtools_out); } @@ -815,28 +870,60 @@ fn template_coordinate_cb_and_name_hash_tiebreak() { assert_records_preserved(&input, &output); let out = read_records(&output); - // Output index of the FIRST record carrying read name `qname` (the lower-end - // mate of that template, `is_upper = 0`). - let first_index = |qname: &[u8]| -> usize { + // The ordered `(name, is_first_segment)` slice of just the records whose + // name is in `names`, in output order. Each colliding template emits two + // records sharing a name: the FIRST_SEGMENT end is the lower 5' mate + // (`is_upper = 0`), the LAST_SEGMENT end the upper (`is_upper = 1`). Because + // the `cb_hash` / `name_hash` lane is MORE significant than the trailing + // `is_upper` lane, BOTH records of the earlier-hashing template must precede + // BOTH of the later one — never interleaved. Asserting only which template's + // first record appears earlier (a bare `first_index` check) would miss an + // interleaving that still violates the frozen lane order, so pin the full + // four-record slice. + let pair_slice = |names: &[&[u8]]| -> Vec<(Vec, bool)> { out.iter() - .position(|r| r.name().is_some_and(|n| AsRef::<[u8]>::as_ref(n) == qname)) - .unwrap_or_else(|| panic!("qname {} not in output", String::from_utf8_lossy(qname))) + .filter(|r| { + r.name().is_some_and(|n| { + let nb = AsRef::<[u8]>::as_ref(n); + names.contains(&nb) + }) + }) + .map(|r| { + let name = AsRef::<[u8]>::as_ref(&r.name().expect("named record")).to_vec(); + (name, r.flags().is_first_segment()) + }) + .collect() }; // FROZEN expected order (see the header comment for why it is not recomputed // from the production hashers). For the fixed-seed `cb_hasher`, `cellA`'s // hash sorts before `cellB`'s, so the CB pair comes out cb_a then cb_b. For // `LibraryLookup`'s fixed-seed name hasher, `name_zzz`'s hash sorts before - // `name_aaa`'s, so the name pair comes out name_zzz then name_aaa. - assert!( - first_index(b"cb_a") < first_index(b"cb_b"), - "CB tie-break regressed: expected the cellA template (cb_a) to sort before the \ - cellB template (cb_b) under the frozen cb_hash order", + // `name_aaa`'s, so the name pair comes out name_zzz then name_aaa. Within + // each template the lower-end (FIRST_SEGMENT) record precedes the upper-end + // (LAST_SEGMENT) one. + assert_eq!( + pair_slice(&[b"cb_a", b"cb_b"]), + vec![ + (b"cb_a".to_vec(), true), + (b"cb_a".to_vec(), false), + (b"cb_b".to_vec(), true), + (b"cb_b".to_vec(), false), + ], + "CB tie-break regressed: expected both records of the cellA template (cb_a) to \ + sort before both of the cellB template (cb_b), lower end first, under the frozen \ + cb_hash order", ); - assert!( - first_index(b"name_zzz") < first_index(b"name_aaa"), - "read-name tie-break regressed: expected name_zzz to sort before name_aaa under \ - the frozen name_hash order", + assert_eq!( + pair_slice(&[b"name_zzz", b"name_aaa"]), + vec![ + (b"name_zzz".to_vec(), true), + (b"name_zzz".to_vec(), false), + (b"name_aaa".to_vec(), true), + (b"name_aaa".to_vec(), false), + ], + "read-name tie-break regressed: expected both records of name_zzz to sort before \ + both of name_aaa, lower end first, under the frozen name_hash order", ); // The tie-break order must also be run-to-run deterministic (the fixed seeds diff --git a/tests/integration/test_streaming_input.rs b/tests/integration/test_streaming_input.rs index 736886ebe..745b7cade 100644 --- a/tests/integration/test_streaming_input.rs +++ b/tests/integration/test_streaming_input.rs @@ -213,7 +213,10 @@ fn test_downsample_command_with_piped_input() { "downsample with direct input failed; stderr: {}", String::from_utf8_lossy(&direct.stderr) ); - crate::helpers::parity::assert_bams_record_equivalent(&output_bam, &direct_out); + // Use the non-empty variant so a both-empty pass (e.g. `downsample` silently + // dropping every record on both paths) cannot satisfy the equivalence + // vacuously — `--fraction 1.0` must retain the full record stream. + crate::helpers::parity::assert_bams_record_equivalent_nonempty(&output_bam, &direct_out); } /// Test simplex command with piped input. @@ -833,6 +836,13 @@ fn test_group_command_with_piped_input_new_pipeline() { .expect("Failed to run group with file input"); assert!(status.success(), "group (file) failed"); assert!(output_from_file.exists(), "file-input output not created"); + // Non-empty baseline: the file-vs-stdin parity check below (`compare_bam_records`) + // would pass vacuously if `group` emitted zero records on BOTH paths. Assert the + // file-path baseline actually produced records so the parity comparison is meaningful. + assert!( + count_bam_records(&output_from_file) > 0, + "file-input baseline produced no records; the stdin parity check would be vacuous" + ); let cat_child = Command::new("cat") .arg(input_bam.to_str().unwrap())