Skip to content

Commit 495f749

Browse files
committed
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.
1 parent 4a88568 commit 495f749

14 files changed

Lines changed: 317 additions & 77 deletions

File tree

benches/core_functions.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,12 @@ fn bench_vanilla_consensus_caller(c: &mut Criterion) {
343343
.map(|i| {
344344
let mut read_seq = seq.clone();
345345
// Introduce a single-base error on every 10th read. NOTE: this
346-
// only fires for `i` a positive multiple of 10, so the smaller
347-
// molecule sizes here (num_reads in {2, 3, 5}) get NO injected
348-
// error and exercise the unanimous fast path; only num_reads
349-
// 10 and 20 actually introduce a mismatch.
346+
// only fires for `i` a positive multiple of 10. Because the loop
347+
// is `0..num_reads`, `i` only reaches 10 when `num_reads == 20`;
348+
// for `num_reads` in {2, 3, 5, 10} the index never hits 10, so
349+
// those sizes get NO injected error and exercise the unanimous
350+
// fast path. Only the `num_reads == 20` case introduces a
351+
// mismatch (at i = 10).
350352
if i > 0 && i % 10 == 0 {
351353
read_seq[i % read_len] = b"TGCA"[(read_seq[i % read_len] as usize) % 4];
352354
}

crates/fgumi-consensus/src/base_builder.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,15 @@ mod tests {
821821
}
822822

823823
// ==================== Observation-count saturation (S7-001 / FU-002) ====================
824+
//
825+
// NOTE on fgbio parity: clamping `observations_for_base` / `contributions` at
826+
// `u16::MAX` is an INTENTIONAL divergence from fgbio once a family's counts
827+
// exceed the `u16` ceiling — `ConsensusBaseBuilder` uses `u16` counters, so it
828+
// saturates where fgbio (wider counters) keeps counting. This only matters for
829+
// pathologically large families (>65535 observations of a single base, or a
830+
// cross-base contribution sum past the ceiling), well beyond any realistic UMI
831+
// family. The tests below pin the clamp (no wrap toward zero); they are NOT a
832+
// parity bug, so do not "fix" the saturation to match fgbio.
824833

825834
#[test]
826835
fn test_observations_saturate_at_u16_max() {

crates/fgumi-consensus/src/codec_caller.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4299,6 +4299,13 @@ mod tests {
42994299
}
43004300
}
43014301

4302+
// NOTE (no fgbio oracle here, by design): the cD/cM/cE and ad/ae/bd/be
4303+
// saturation behavior is pinned by the helper-clamp tests below plus the
4304+
// public BAM-tag surface exercised elsewhere; we do NOT add an fgbio
4305+
// dependency to cross-check serialized tags. fgbio's codec consensus
4306+
// operates on raw bytes (established in S7-002-bitenc-perf-analysis.md), and
4307+
// the saturation at u16::MAX is an intentional u16-counter divergence for
4308+
// pathological families — not a parity bug to validate against fgbio.
43024309
#[test]
43034310
fn test_duplex_depth_error_saturate_to_u16_at_high_counts() {
43044311
let options = CodecConsensusOptions::default();

crates/fgumi-pipeline-core/src/runtime/driver.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -670,12 +670,20 @@ mod tests {
670670
// disable the sticky fast-path so the loop terminates rather than hangs.
671671
run_worker_loop(&mut worker, &mut entries, &contexts, &drain_counters, &signal, None);
672672

673-
// The source must have been called at least twice: once idle (sticky),
674-
// then again to Finished (round-robin).
675-
assert!(
676-
calls.load(Ordering::Relaxed) >= 2,
677-
"source should idle once then finish via round-robin, got {} call(s)",
678-
calls.load(Ordering::Relaxed)
673+
// The source must have been called EXACTLY twice: call 1 = idle in the
674+
// sticky fast-path (`NoProgress`, which does NOT remove it there — that
675+
// block only reaps a `Finished`), call 2 = `Finished` during the
676+
// round-robin pass. An exact count is the branch-specific signal for the
677+
// `removed_sticky_owner` path: if that round-robin branch had failed to
678+
// clear `sticky_live`, the next outer iteration's sticky fast-path would
679+
// re-invoke the (already-removed-from-`live`) source, producing a third
680+
// call (or the loop would spin / hang). `== 2` therefore proves removal
681+
// happened via round-robin AND that it correctly disabled the fast path.
682+
assert_eq!(
683+
calls.load(Ordering::Relaxed),
684+
2,
685+
"source must be called exactly twice (sticky idle, then round-robin finish); \
686+
a different count means the removed_sticky_owner branch did not gate the fast path",
679687
);
680688
}
681689
}

src/lib/aligner.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -203,19 +203,34 @@ impl AlignerProcess {
203203
// Wait for the process to actually exit so its resources are cleaned up.
204204
let deadline = std::time::Instant::now() + Duration::from_secs(1);
205205
// Tracks whether the loop gave up because the 1s deadline elapsed while
206-
// the process was still alive. Only that case warrants a warning; a
207-
// successful reap or a non-leak `try_wait` error (e.g. ECHILD) does not.
206+
// the process was still alive. Only that case warrants the leak warning.
208207
let mut deadline_exceeded = false;
209-
// `Ok(None)` means still alive — keep polling. Any other result
210-
// (`Ok(Some)` = reaped, or `Err` such as ECHILD = already reaped) is a
211-
// non-leak: exit without warning. Only hitting the deadline while still
212-
// alive sets `deadline_exceeded`.
213-
while let Ok(None) = self.child.try_wait() {
214-
if std::time::Instant::now() >= deadline {
215-
deadline_exceeded = true;
216-
break;
208+
loop {
209+
match self.child.try_wait() {
210+
// Reaped cleanly: resources cleaned up, no warning.
211+
Ok(Some(_)) => break,
212+
// Still alive: keep polling until the deadline.
213+
Ok(None) => {
214+
if std::time::Instant::now() >= deadline {
215+
deadline_exceeded = true;
216+
break;
217+
}
218+
thread::sleep(Duration::from_millis(50));
219+
}
220+
// `try_wait` failed. The most common case here is `ECHILD`
221+
// (the child was already reaped, e.g. by a SIGCHLD handler),
222+
// which is a benign non-leak. But we do NOT silently bypass
223+
// the leak path for *any* error — an unexpected errno could
224+
// mean the process is still around. Log it with context and
225+
// stop polling (we cannot make progress on a `try_wait` error).
226+
Err(err) => {
227+
log::warn!(
228+
"aligner process (pid {pid}) try_wait failed after SIGKILL: {err}; \
229+
assuming already reaped (e.g. ECHILD) but unable to confirm exit"
230+
);
231+
break;
232+
}
217233
}
218-
thread::sleep(Duration::from_millis(50));
219234
}
220235
if deadline_exceeded {
221236
log::warn!(

src/lib/commands/downsample.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ pub struct Downsample {
9595
///
9696
/// # Errors
9797
///
98-
/// Returns an error if `fraction` is `<= 0.0` or `> 1.0`.
98+
/// Returns an error if `fraction` is non-finite (NaN/±inf), `<= 0.0`, or `> 1.0`.
9999
fn validate_fraction(fraction: f64) -> Result<()> {
100-
if fraction <= 0.0 || fraction > 1.0 {
100+
// Reject non-finite values (NaN/±inf) first, since NaN comparisons are always
101+
// false and would otherwise slip past the range test below.
102+
if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
101103
bail!("--fraction must be between 0.0 (exclusive) and 1.0 (inclusive), got {}", fraction);
102104
}
103105
Ok(())
@@ -522,6 +524,15 @@ mod tests {
522524
assert!(validate_fraction(1.0).is_ok());
523525
}
524526

527+
#[test]
528+
fn test_validate_fraction_non_finite() {
529+
// NaN and ±inf must be rejected: NaN comparisons are always false and
530+
// would otherwise slip past the `<= 0.0 || > 1.0` range test.
531+
assert!(validate_fraction(f64::NAN).is_err());
532+
assert!(validate_fraction(f64::INFINITY).is_err());
533+
assert!(validate_fraction(f64::NEG_INFINITY).is_err());
534+
}
535+
525536
#[test]
526537
fn test_write_histogram() {
527538
use tempfile::NamedTempFile;

src/lib/commands/runall.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -456,20 +456,25 @@ pub struct RunAll {
456456
#[command(flatten)]
457457
pub correct_opts: crate::commands::correct::MultiCorrectOptions,
458458

459-
// ───────── aligner-side options (used only when --start-from=align) ─────────
459+
// ───────── aligner-side options (used whenever the chain includes Stage::Align) ─────────
460460
/// Per-stage aligner tuning, exposed as `--aligner::preset`,
461461
/// `--aligner::command`, `--aligner::threads`, `--aligner::chunk-size`
462462
/// via the `MultiAlignerOptions` companion struct (generated by
463463
/// `#[multi_options]` on `AlignerOptions` in `crate::aligner`).
464-
/// Ignored when `--start-from` is not `align`.
464+
/// Used whenever the derived stage chain includes `Stage::Align` — not
465+
/// only on `--start-from=align`, but also on fused runs that reach align
466+
/// from an upstream start (e.g. `--start-from extract` or
467+
/// `--start-from correct` with a `--stop-after` past zipper). Ignored only
468+
/// when the chain has no align stage.
465469
#[command(flatten)]
466470
pub aligner_opts: crate::aligner::MultiAlignerOptions,
467471

468472
/// Override path for the aligner binary (preset mode only). When
469473
/// unset, the preset's binary (`bwa-mem3` / `bwa`) is found via
470474
/// `which::which()` on `PATH`. Rejected with a clear error if
471475
/// `--aligner::command` is used (command mode owns its own
472-
/// binary). Ignored when `--start-from` is not `align`.
476+
/// binary). Used whenever the chain includes `Stage::Align` (see
477+
/// `--aligner::*` above); ignored only when the chain has no align stage.
473478
///
474479
/// Note: this flag is at the runall top level (`--aligner-bin`),
475480
/// NOT inside the `--aligner::*` family. The `--aligner::*` flags
@@ -569,12 +574,16 @@ pub struct RunAll {
569574
///
570575
/// **Input-state hazard:** the validator cannot check whether
571576
/// the on-disk BAM actually matches the declared `start_from`.
572-
/// Passing a raw-unsorted BAM to `--start-from consensus`, or a
573-
/// sorted-but-not-grouped BAM to `--start-from consensus`, will
574-
/// not produce a clear error today; the chain will run on
575-
/// whatever it finds. Make sure the input matches the declared
576-
/// start stage. (The consensus algorithm itself is chosen by
577-
/// `--consensus {simplex,duplex,codec}`, not by the start value.)
577+
/// Because `--start-from consensus` reuses the `group → consensus`
578+
/// chain (see the Executor honesty note above), a *sorted-but-not-grouped*
579+
/// BAM is handled correctly — it is grouped in-pipeline before the
580+
/// consensus caller. The remaining hazard is a *raw-unsorted* BAM:
581+
/// `--start-from consensus` does not sort, so the position-grouping
582+
/// step will see unsorted input and silently produce wrong groups
583+
/// rather than a clear error. Make sure a `consensus` start is given
584+
/// at least template-coordinate-sorted input. (The consensus algorithm
585+
/// itself is chosen by `--consensus {simplex,duplex,codec}`, not by the
586+
/// start value.)
578587
///
579588
/// Case-insensitive. Must satisfy `start_from.ord() <=
580589
/// stop_after.ord()` and the consensus-terminal rules

src/lib/pipeline/chains/builder.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1407,7 +1407,11 @@ impl<'a> ChainBuilder<'a> {
14071407
.pipeline
14081408
.append_step(SerializeBamRecords::new(self.tuning.per_step_byte_limit), tail);
14091409
self.current_tail = Some(tail);
1410-
self.chain_tail_kind = ChainTailKind::DecodedRecordBatch;
1410+
// SerializeBamRecords emits DecompressedBlock (serialized bytes), not
1411+
// DecodedRecordBatch. Mark the tail honestly as SerializedBytes, matching
1412+
// every other terminal serialize path (e.g. add_dedup); nothing downstream
1413+
// of a Terminal stage reads this, but the kind must never lie.
1414+
self.chain_tail_kind = ChainTailKind::SerializedBytes;
14111415
} else {
14121416
self.current_tail = Some(tail);
14131417
self.chain_tail_kind = ChainTailKind::BamTemplateBatch;

src/lib/pipeline/steps/coalesce.rs

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,33 @@ mod tests {
258258
/// ~512 KB block (caught by the per-block size bound below).
259259
const COALESCE_INPUT_BLOCKS: usize = 64 * 8;
260260

261+
/// Deterministic, per-block-unique fill for the block with the given
262+
/// `batch_serial`. The bytes vary by position (a tiny LCG seeded by the
263+
/// serial) so two distinct blocks never share a byte pattern — this is what
264+
/// lets the full-stream oracle catch reorder/drop/duplicate bugs, not just
265+
/// total-byte conservation. Both the source and the independently-built
266+
/// expected stream call this, so the only way the streams match is if
267+
/// coalesce concatenates every block exactly once, in order.
268+
fn block_fill(batch_serial: u64) -> Vec<u8> {
269+
// Seed off the serial; +1 avoids a degenerate all-zero seed for serial 0.
270+
let mut state = batch_serial.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1);
271+
(0..COALESCE_INPUT_BLOCK_BYTES)
272+
.map(|_| {
273+
// SplitMix64-style step for a well-mixed per-byte sequence.
274+
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
275+
let mut z = state;
276+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
277+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
278+
// Keep the low byte; truncation is intentional (we want a u8 fill).
279+
((z ^ (z >> 31)) & 0xFF) as u8
280+
})
281+
.collect()
282+
}
283+
261284
/// Source emitting `remaining` fixed-size `DecompressedBlock`s via a shared
262285
/// atomic counter (safe for Serial single-worker execution). Each block's
263-
/// `bytes` is `COALESCE_INPUT_BLOCK_BYTES` of a deterministic fill so the
264-
/// sink can verify byte preservation without an ordering assumption.
286+
/// `bytes` is a per-block-unique `block_fill(batch_serial)` so the sink can
287+
/// reconstruct and verify the exact concatenated byte stream.
265288
#[derive(Clone)]
266289
struct BlockSource {
267290
remaining: Arc<AtomicU64>,
@@ -283,10 +306,7 @@ mod tests {
283306
if n == 0 {
284307
return Ok(StepOutcome::Finished);
285308
}
286-
let block = DecompressedBlock {
287-
batch_serial: n,
288-
bytes: vec![0xCD; COALESCE_INPUT_BLOCK_BYTES],
289-
};
309+
let block = DecompressedBlock { batch_serial: n, bytes: block_fill(n) };
290310
match ctx.outputs.push(block) {
291311
Ok(()) => {
292312
self.remaining.fetch_sub(1, AtomicOrd::AcqRel);
@@ -302,10 +322,12 @@ mod tests {
302322
}
303323

304324
/// Sink recording the byte length of every emitted block (so the test can
305-
/// bound the per-block size) plus the running total.
325+
/// bound the per-block size) plus the exact concatenated byte stream (so the
326+
/// test can compare it against an independently-built expected stream).
306327
#[derive(Clone)]
307328
struct SizeRecordingSink {
308329
sizes: Arc<Mutex<Vec<usize>>>,
330+
stream: Arc<Mutex<Vec<u8>>>,
309331
}
310332
impl Step for SizeRecordingSink {
311333
type Input = DecompressedBlock;
@@ -322,13 +344,12 @@ mod tests {
322344
fn try_run(&mut self, ctx: &mut StepCtx<'_, Self>) -> io::Result<StepOutcome> {
323345
match ctx.input.pop() {
324346
Some(block) => {
325-
// Every byte must be the source's fill — proving the
326-
// concatenation neither drops nor corrupts bytes.
327-
assert!(
328-
block.bytes.iter().all(|&b| b == 0xCD),
329-
"coalesced block carries unexpected bytes"
330-
);
347+
// Record the per-block size for the memory bound, and append
348+
// the bytes verbatim so the test can compare the full stream
349+
// against the expected concatenation (catches reorder /
350+
// drop / duplicate, not just total-byte conservation).
331351
self.sizes.lock().expect("sink mutex").push(block.bytes.len());
352+
self.stream.lock().expect("stream mutex").extend_from_slice(&block.bytes);
332353
Ok(StepOutcome::Progress)
333354
}
334355
None if ctx.input.is_drained() => Ok(StepOutcome::Finished),
@@ -342,16 +363,18 @@ mod tests {
342363

343364
#[test]
344365
fn coalesce_flushes_at_threshold_and_preserves_bytes() {
345-
let remaining = Arc::new(AtomicU64::new(u64::try_from(COALESCE_INPUT_BLOCKS).unwrap()));
366+
let n_blocks = u64::try_from(COALESCE_INPUT_BLOCKS).unwrap();
367+
let remaining = Arc::new(AtomicU64::new(n_blocks));
346368
let sizes = Arc::new(Mutex::new(Vec::new()));
369+
let stream = Arc::new(Mutex::new(Vec::new()));
347370

348371
let coalesce = CoalesceBytes::new(COALESCE_THRESHOLD_BYTES, 4 * 1024);
349372

350373
let builder = PipelineBuilder::new();
351374
builder
352375
.chain(BlockSource { remaining: Arc::clone(&remaining) })
353376
.chain(coalesce)
354-
.chain(SizeRecordingSink { sizes: Arc::clone(&sizes) })
377+
.chain(SizeRecordingSink { sizes: Arc::clone(&sizes), stream: Arc::clone(&stream) })
355378
.into_sink_marker();
356379

357380
let pipeline = builder.build().unwrap();
@@ -365,6 +388,26 @@ mod tests {
365388
// Byte conservation: every input byte reaches the sink exactly once.
366389
assert_eq!(total_out, total_in, "coalesce dropped or duplicated bytes");
367390

391+
// Byte-stream identity: independently build the expected concatenation in
392+
// the source's emission order (`BlockSource` counts the shared atomic
393+
// DOWN from `n_blocks` to 1, so it emits `batch_serial = N, N-1, …, 1`)
394+
// and require the sink's flat byte stream to match it exactly. Because
395+
// each block's fill is per-block-unique (`block_fill`), this fails if any
396+
// block is reordered, dropped, or duplicated — a far stronger oracle than
397+
// the aggregate-count check above.
398+
let expected_stream: Vec<u8> = (1..=n_blocks).rev().flat_map(block_fill).collect();
399+
let actual_stream = stream.lock().expect("stream mutex").clone();
400+
assert_eq!(
401+
actual_stream.len(),
402+
expected_stream.len(),
403+
"coalesced stream length differs from expected"
404+
);
405+
assert!(
406+
actual_stream == expected_stream,
407+
"coalesced byte stream diverges from the expected in-order concatenation \
408+
(reorder/drop/duplicate)"
409+
);
410+
368411
// Memory bound: the step flushes once `pending` reaches the threshold,
369412
// then resets. Each emitted block therefore carries between
370413
// `threshold` and `threshold + (MAX_BATCHES_PER_LOCK - 1) * input` —

0 commit comments

Comments
 (0)