Skip to main content

poa_consensus/
graph.rs

1use crate::config::{AlignmentMode, ConsensusMode, PoaConfig};
2use crate::error::PoaError;
3use crate::types::{BubbleSite, Consensus, CoverageGap, GapKind, GraphStats};
4use std::collections::HashMap;
5
6#[cfg(test)]
7use std::cell::Cell as StdCell;
8
9// Per-thread diagonal-skip counters, only compiled in test builds.
10// SKIP_COUNTER: total diagonal skips fired; NODE_COUNTER: total non-source nodes.
11#[cfg(test)]
12thread_local! {
13    pub(crate) static SKIP_COUNTER: StdCell<u64> = StdCell::new(0);
14    pub(crate) static NODE_COUNTER: StdCell<u64> = StdCell::new(0);
15}
16
17#[cfg(test)]
18pub(crate) fn reset_skip_counters() {
19    SKIP_COUNTER.with(|c| c.set(0));
20    NODE_COUNTER.with(|c| c.set(0));
21}
22
23#[cfg(test)]
24pub(crate) fn skip_rate() -> f64 {
25    let skips = SKIP_COUNTER.with(|c| c.get());
26    let nodes = NODE_COUNTER.with(|c| c.get());
27    if nodes == 0 {
28        0.0
29    } else {
30        skips as f64 / nodes as f64
31    }
32}
33
34// ─── Sentinel ────────────────────────────────────────────────────────────────
35
36/// "Not yet filled" sentinel.
37const UNSET: i32 = i32::MIN / 2;
38
39/// Half-width of the per-node j-window given to a locked winning arm node.
40/// Wide enough to absorb ~5 cumulative indels at 5% ONT error rate.
41const LOCK_EPS: usize = 5;
42
43/// Half-width of the local-population window used by `coverage_threshold`'s
44/// absolute-floor fallback (see `local_population_profile` and its call site
45/// in `consensus()`).
46///
47/// Empirically tuned, not derived from first principles -- both a much
48/// smaller and a much larger radius were tried and rejected:
49///
50/// - A read-length-scaled radius (the intuitive first choice: a read can
51///   only contribute evidence within roughly one read-length of where it
52///   starts) recovers almost none of the true partial-population fix --
53///   see the call site's comment.
54/// - A *small* radius (in this file's usual small-scale-jitter range, e.g.
55///   `LOCK_EPS = 5` / `MINI_EPS_BASE = 3`) gives the best recovery on the
56///   partial-read scenario this fix targets, but regresses three
57///   pre-existing majority-length-wins tests on real repeat data
58///   (`diag_sca8_real_sequences_no_flank`,
59///   `diag_dab1_sca37_attttc_lookahead_arm_length_bias`,
60///   `structural_phasing_no_contamination_on_noisy_periodic_repeat`): in a
61///   tandem repeat, a genuine minority's extra repeat units sit only a few
62///   bases past the majority's true boundary, so a small window bridges
63///   from the minority's own low coverage straight into the adjacent
64///   majority peak and wrongly rescues it -- reproducing the exact bug
65///   pattern (Known Bugs #1-#10) this crate has spent the most effort
66///   fixing, just via a new mechanism.
67/// - A sweep against those three regressions found their exact crossover
68///   radius at 18-20 (`diag_sca8`) and 40-45 (the periodic-phasing test);
69///   `LOCAL_POP_RADIUS = 50` sits safely above both with margin, confirmed
70///   against the full test suite (zero regressions) rather than living
71///   right at the measured edge.
72/// - This is a real, measured trade-off, not a free lunch: at radius=50 the
73///   partial-read regression scenario (`gen_short_reads_long_region_ont_r10`
74///   in `bench/compare_callers.py --general`) improves from edit=352 to
75///   edit=282 against truth (vs external tools' 250/234) -- better, but it
76///   does not fully close the gap the way a smaller, unsafe radius would.
77///   Closing it further needs a mechanism that distinguishes repeat-driven
78///   column proximity from genuine partial-population sparsity, which a
79///   single fixed window cannot do; out of scope here.
80const LOCAL_POP_RADIUS: usize = 50;
81
82// ─── Minimizer anchoring constants ───────────────────────────────────────────
83
84/// k-mer length for spine/read minimizer seeding.
85const MINI_K: usize = 15;
86/// Minimizer window width: select the minimum-hash k-mer from each window of
87/// this many consecutive k-mers.  Density ≈ 1/MINI_W anchors per base.
88const MINI_W: usize = 10;
89/// Minimum per-anchor j-window half-width (absorbs indel errors near anchors).
90/// Covers ~3 cumulative indels; keeps the correct j inside the intersection when
91/// the spine and read have small local alignment offsets at the anchor position.
92const MINI_EPS_BASE: usize = 3;
93/// Minimum anchor chain length required before applying any anchor refinement.
94/// Chosen to be above the expected number of error-induced wrong anchors in a
95/// repetitive read (~1-3 for typical ONT depths), but below the expected number
96/// of correct anchors in a non-repetitive read (≈read_len × 0.46 / MINI_W for
97/// 5% error / k=15; ~23 for a 500bp read).  Flank-only reads (200bp of flank
98/// from 100bp ANCHOR_PAD × 2) yield ≈9 flank anchors — below this threshold,
99/// so anchors are safely disabled when the spine is repetitive and only flank
100/// k-mers would match.
101const MINI_MIN_CHAIN: usize = 15;
102// Minimum chain density (anchors per base × MINI_W) to trust the chain.
103// In non-repetitive sequence, density ≈ 1.0 (one anchor per MINI_W bases).
104// In repetitive sequence, error-induced coincidences produce a sparse chain
105// (density << 0.5) that is dominated by wrong anchors and must be ignored.
106// Threshold: chain_len * MINI_W * 2 >= read_len  (≡ density ≥ 0.5 / MINI_W).
107// Equivalently: require chain_len >= read_len / (2 * MINI_W).
108
109// ─── Align scratch ───────────────────────────────────────────────────────────
110
111/// Per-call scratch reused across `align()` calls to avoid per-call heap
112/// allocation for the lock-window tables.  Both vecs are sorted by node_idx
113/// and binary-searched; they are empty when no lock fired, so the check is
114/// O(1) in the common case.
115struct AlignScratch {
116    /// Locked winning arm nodes: (node_idx, j_center as u32).
117    lock_node_j: Vec<(usize, u32)>,
118    /// Locked exit nodes: (node_idx, j_center as u32).
119    lock_exit_j: Vec<(usize, u32)>,
120}
121
122impl AlignScratch {
123    fn new() -> Self {
124        Self {
125            lock_node_j: Vec::new(),
126            lock_exit_j: Vec::new(),
127        }
128    }
129
130    fn clear(&mut self) {
131        self.lock_node_j.clear();
132        self.lock_exit_j.clear();
133    }
134
135    fn get_node_j(&self, node_idx: usize) -> Option<usize> {
136        self.lock_node_j
137            .binary_search_by_key(&node_idx, |&(idx, _)| idx)
138            .ok()
139            .map(|pos| self.lock_node_j[pos].1 as usize)
140    }
141
142    fn get_exit_j(&self, node_idx: usize) -> Option<usize> {
143        self.lock_exit_j
144            .binary_search_by_key(&node_idx, |&(idx, _)| idx)
145            .ok()
146            .map(|pos| self.lock_exit_j[pos].1 as usize)
147    }
148}
149
150/// Current weight of the edge `from -> to`, or 0 if no such edge exists yet.
151///
152/// Used to break DP score ties in favour of the already-better-supported
153/// transition.  Without this, ties are broken by `in_edges` iteration order
154/// (effectively read-arrival order), which lets content-identical repeat
155/// registers (e.g. two phase-shifted encodings of the same homopolymer run)
156/// split support roughly evenly across reads instead of converging onto one
157/// path — silently fragmenting the graph in periodic sequence.
158///
159/// Returns the *total* (matched + deleted) traversal count, not matched-only.
160/// This is a live-alignment tie-break ("which predecessor is more
161/// established"), a different question from `heaviest_path`'s final
162/// path-selection scoring. Open Question #2 (design/graph_data_model_rework.md
163/// Phase 1): tried both matched-only and total here, A/B against the full
164/// `cargo test` suite and `bench/validate.py` -- neither discriminated between
165/// them (every currently-passing case stayed passing, and the 3 scenarios
166/// `bench/validate.py` fails are pre-existing, present identically at the
167/// unmodified `HEAD` in an isolated worktree, unrelated to this choice). With
168/// no evidence favouring a change, kept at total weight -- unchanged from
169/// before the Match/Delete edge-weight split.
170#[inline]
171fn edge_weight(nodes: &[Node], from: usize, to: usize) -> i32 {
172    nodes[from]
173        .out_edges
174        .iter()
175        .find(|&&(t, _)| t == to)
176        .map(|&(_, ew)| ew.total())
177        .unwrap_or(0)
178}
179
180#[inline]
181fn safe_add(a: i32, b: i32) -> i32 {
182    if a == UNSET {
183        UNSET
184    } else {
185        a.saturating_add(b)
186    }
187}
188
189// ─── Graph types ─────────────────────────────────────────────────────────────
190
191/// Per-edge traversal weight, split by how the read traversed it.
192///
193/// A single conflated `i32` weight cannot tell "N reads confirmed this base"
194/// (`Match`) apart from "N reads skipped past whatever's here" (`Delete`) --
195/// and both increment the *same* edge when they share a predecessor. See
196/// `design/graph_data_model_rework.md` for the audit that motivated this
197/// split (confirmed on real RFC1 data: nodes reached by one pure-delete
198/// in-edge and one pure-match in-edge to the same target).
199///
200/// `Insert` traffic is folded into `matched`, not tracked separately: once a
201/// node exists (whether created by `Insert` or already present), every later
202/// read that agrees with its base routes through `Match`, so `Insert`'s own
203/// founding traversal is accounting-identical to a `Match` -- both mean "a
204/// read confirms this exact base occurs here." A three-way split would add a
205/// bucket that never needs to be read differently from `matched`.
206#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
207struct EdgeWeight {
208    /// Match traversals, plus the founding Insert that created the target node.
209    matched: i32,
210    /// Delete traversals (read skipped past the target node without consuming a base).
211    deleted: i32,
212}
213
214impl EdgeWeight {
215    fn total(&self) -> i32 {
216        self.matched + self.deleted
217    }
218}
219
220struct Node {
221    base: u8,
222    /// (successor_node_index, edge_weight)
223    out_edges: Vec<(usize, EdgeWeight)>,
224    /// predecessor node indices
225    in_edges: Vec<usize>,
226    /// reads that produced a Match op at this node (not Delete ops)
227    coverage: u32,
228    /// reads that produced a Delete op at this node (traversed without consuming a base)
229    delete_count: u32,
230    /// Cached `(fork, arm_entry)`: the nearest ancestor with 2+ out-edges (a
231    /// real fork) and the specific child of that fork this node's own
232    /// single-successor chain descends from -- `None` if no such ancestor
233    /// exists (this node has never had a fork anywhere back along its
234    /// creation chain). `arm_entry` is cached alongside `fork` because the
235    /// interior filter's plurality comparison needs to know *which* of the
236    /// fork's out-edges this node's arm belongs to, not just that a fork
237    /// exists.
238    ///
239    /// Populated incrementally in `add_to_graph`/`propagate_fork_if_new` --
240    /// see design/graph_data_model_rework.md Phase 4. Can go *stale in one
241    /// direction only*: pointing farther back than the true nearest fork,
242    /// never invalid (a node that is a fork never stops being one, since
243    /// out-edges are only ever added, and `arm_entry` is only ever set
244    /// alongside a `fork` that was genuinely a fork when set). Reconvergence
245    /// points (2+ in-edges) and anything past them are deliberately left
246    /// untouched by propagation -- ambiguous which incoming arm "owns" them,
247    /// so whatever was established at their own creation stays authoritative.
248    nearest_fork: Option<(usize, usize)>,
249}
250
251pub struct PoaGraph {
252    nodes: Vec<Node>,
253    config: PoaConfig,
254    /// number of reads integrated (seed + subsequent)
255    n_reads: usize,
256    /// original reads stored for per-allele sub-graph reconstruction
257    reads: Vec<Vec<u8>>,
258    /// (from_node, to_node) → sorted list of read indices that genuinely
259    /// confirmed this edge's target (Match, or the founding Insert that
260    /// created it). Does NOT include reads that merely deleted through the
261    /// target. Mirrors the `coverage`/`delete_count` idiom at the node level
262    /// (see design/graph_data_model_rework.md Phase 2).
263    edge_reads: HashMap<(usize, usize), Vec<u32>>,
264    /// **Vestigial under pure bypass (revised Phase 1 of
265    /// design/bypass_edge_delete_rework.md).** Formerly `(from, to)` → read
266    /// indices that Deleted through an edge; it was always write-only (no
267    /// production consumer ever read it -- confirmed by exhaustive grep). Under
268    /// pure bypass a deleting read touches the skipped node only via
269    /// `delete_count`, never via an edge, so this map is no longer populated at
270    /// all; it stays initialised-empty. Left in place rather than removed to
271    /// keep this phase's delta minimal; removable in a later cleanup.
272    #[allow(dead_code)]
273    edge_delete_reads: HashMap<(usize, usize), Vec<u32>>,
274    /// Per-node bypass edges: from-node index → list of `(to_node, weight)`.
275    /// A bypass edge records that a read skipped one or more nodes via a run
276    /// of consecutive `Delete` ops and then reconnected -- by a clean Match --
277    /// onto a node already in the graph (the run's entry predecessor bypasses
278    /// straight to that resume node). This is a *separate* structural edge
279    /// around the skipped node(s) -- exactly how abPOA and SPOA represent a
280    /// deletion (see `design/architecture_comparison_abpoa_spoa.md` Finding 1,
281    /// and `design/bypass_edge_delete_rework.md` for the full phased plan).
282    ///
283    /// **Pure-bypass representation (revised Phase 1).** The skipped nodes are
284    /// touched only by `delete_count`; no laundered matched through-edge is
285    /// created (that is the delta from the superseded dual-bookkeeping Phase 1
286    /// commit). A resume that instead *diverges into new structure*
287    /// (mismatch/insert) is recorded as an ordinary minority `out_edge`, not a
288    /// bypass -- so bypass edges here are exactly "rejoined the existing main
289    /// path after a skip."
290    ///
291    /// **Phase 1 status: written but not yet read by any consumer.**
292    /// `heaviest_path` (the intended reader) does not consult it until Phase 2;
293    /// the interior filter, `find_bubbles`/`find_structural_bubbles`,
294    /// `compute_stats`, and `verify_reuse_chain` never will (they iterate
295    /// `Node.out_edges`, which a bypass edge is deliberately kept out of).
296    ///
297    /// Weight is a plain `i32`, deliberately *not* an [`EdgeWeight`]: a bypass
298    /// edge is definitionally delete-traffic, and keeping it in this separate
299    /// structure (never in `out_edges`) is what makes it invisible to every
300    /// `matched`-based out-edge consumer for free -- so it cannot manufacture a
301    /// false bubble, at any deletion rate.
302    bypass_edges: HashMap<usize, Vec<(usize, i32)>>,
303    /// number of times the long-unbanded warning was emitted
304    warnings: usize,
305    /// Cached heaviest-path spine, recomputed adaptively rather than every read.
306    cached_spine: Vec<(usize, u8, i32)>,
307    /// `n_reads` at the time the spine was last recomputed.
308    spine_updated_at: usize,
309    /// Current recompute interval; doubles when the spine is stable, resets
310    /// to 1 when it changes significantly.
311    spine_interval: usize,
312    /// Reusable scratch for lock-window tables; avoids per-call heap allocation.
313    align_scratch: AlignScratch,
314    /// Minimizer index over the cached spine sequence; rebuilt whenever
315    /// `cached_spine` is refreshed.  Maps k-mer hash → spine rank (index into
316    /// `cached_spine`).  Only hashes that appear exactly once in the spine are
317    /// stored — duplicate k-mers cannot serve as unambiguous anchors.
318    spine_mers: HashMap<u64, u32>,
319    /// Per-fork content-addressable arm index: fork node index → (full
320    /// characterized edit's bases → existing arm's start node index).
321    /// Populated in `add_to_graph` whenever a new divergence arm is created;
322    /// consulted before creating a new arm so an exact-duplicate edit (same
323    /// fork, same complete base sequence) reuses the existing node chain
324    /// instead of fragmenting into a new one. Scoped per-fork (not global) so
325    /// reuse can only ever happen among arms of the *same* divergence point;
326    /// see design/graph_data_model_rework.md Phase 3. Does NOT solve fuzzy
327    /// near-duplicates (edits with the same effect but different incidental
328    /// length/shape) -- only byte-identical edits hash-match.
329    fork_arm_index: HashMap<usize, HashMap<Vec<u8>, usize>>,
330    /// Set when any `add_read` call needed `align_with_retry`'s pass 2 or 3
331    /// (i.e. the configured band was too narrow for at least one read).
332    /// Read by `build_graph` (`src/lib.rs`): a graph built from a *mix* of
333    /// some reads succeeding on the configured (narrow) band and others
334    /// only succeeding after a wider retry is not the same as building the
335    /// whole graph with a single, consistent band from the start -- in a
336    /// periodic/repetitive locus, different reads can settle on different,
337    /// individually-plausible diagonals (Known Bug #3's mechanism),
338    /// fragmenting bubble structure that a uniformly-unbanded build would
339    /// not. `build_graph` uses this flag to rebuild the whole graph
340    /// unbanded from scratch when set, rather than trusting a
341    /// mixed-band graph as-is.
342    used_band_retry: bool,
343}
344
345// ─── DP cell ─────────────────────────────────────────────────────────────────
346
347/// Sentinel topo index meaning "came from the virtual start node".
348const VIRTUAL: u32 = u32::MAX;
349
350#[derive(Clone, Copy, PartialEq, Eq)]
351enum State {
352    M,
353    I,
354    D,
355}
356
357/// DP cell: 8 bytes (i32 score + u32 pred_t, no padding).
358/// pred_t stores the topo-order row of the best predecessor, or VIRTUAL.
359#[derive(Clone, Copy)]
360struct Cell {
361    score: i32,
362    pred_t: u32,
363}
364
365impl Cell {
366    fn unset() -> Self {
367        Cell {
368            score: UNSET,
369            pred_t: 0,
370        }
371    }
372}
373
374/// Alignment operation produced by traceback, in forward read order.
375#[derive(Clone, Copy, Debug)]
376pub enum AlignOp {
377    /// Base matched or mismatched against this graph node index.
378    Match(usize),
379    /// Inserted base from the read, no corresponding graph node.
380    Insert(u8),
381    /// Graph node skipped by the read (gap in read).
382    Delete(usize),
383}
384
385// ─── Graph construction helpers ──────────────────────────────────────────────
386
387fn push_node(nodes: &mut Vec<Node>, base: u8) -> usize {
388    let idx = nodes.len();
389    nodes.push(Node {
390        base,
391        out_edges: Vec::new(),
392        in_edges: Vec::new(),
393        coverage: 0,
394        delete_count: 0,
395        nearest_fork: None,
396    });
397    idx
398}
399
400/// Bounded depth for forward propagation when a predecessor transitions from
401/// a single out-edge to a fork (2+ out-edges), fixing up the *pre-existing*
402/// arm's descendants whose cached `nearest_fork` now points farther back than
403/// this newly-forked predecessor. Mirrors `ARM_MAX_DEPTH`'s existing bound
404/// (the same order of magnitude already accepted elsewhere in this file for
405/// walking a single-successor chain) rather than inventing a new, unproven
406/// limit -- see design/graph_data_model_rework.md Phase 4 for why an
407/// *unbounded* walk here would repeat the exact class of mistake Phase 3
408/// found (nodes/walks with no depth limit blowing up on long unbranched runs).
409const FORK_PROPAGATE_MAX_DEPTH: usize = ARM_MAX_DEPTH;
410
411/// Called immediately after a new out-edge is added from `from` to some
412/// node, whether that node is brand new or already existed. If this is
413/// `from`'s *second* out-edge (a fresh 1 -> 2 transition, i.e. `from` just
414/// became a fork), walks forward along `from`'s *original* out-edge (the one
415/// that existed before this new one -- `out_edges[0]`) updating every
416/// descendant's cached `nearest_fork` to `Some((from, orig_target))`, until
417/// hitting a node that is itself already a fork (anything past it already
418/// has a closer fork of its own to be attributed to) or a reconvergence
419/// point (2+ in-edges -- ambiguous which incoming arm "owns" it, left
420/// untouched), or the depth bound. A no-op if `from` was already a fork
421/// before this edge (its pre-existing arms' descendants are already
422/// correctly attributed) or is still not one (this was its first edge).
423fn propagate_fork_if_new(nodes: &mut [Node], from: usize) {
424    if nodes[from].out_edges.len() != 2 {
425        return;
426    }
427    let orig_target = nodes[from].out_edges[0].0;
428    let mut cur = orig_target;
429    for _ in 0..FORK_PROPAGATE_MAX_DEPTH {
430        if nodes[cur].in_edges.len() > 1 {
431            break;
432        }
433        nodes[cur].nearest_fork = Some((from, orig_target));
434        match nodes[cur].out_edges.as_slice() {
435            [(next, _)] => cur = *next,
436            _ => break,
437        }
438    }
439}
440
441/// Sets a freshly-created node's own `nearest_fork`. Does NOT propagate to
442/// `p`'s *pre-existing* arm -- that has to be deferred until the current
443/// read's entire traceback has been processed (see `add_to_graph`'s
444/// `to_propagate` list), not run eagerly here. Confirmed empirically (not
445/// assumed) that eager, mid-read propagation is wrong: a single read's own
446/// later ops can turn a node the propagation already updated into a
447/// reconvergence point (2+ in-edges) a few steps later in that *same*
448/// read's own processing -- at the moment of the eager update, that
449/// downstream node still looks like an ordinary single-predecessor node, so
450/// the "don't touch reconvergence points" guard in `propagate_fork_if_new`
451/// can't see the problem coming. Running propagation only after the whole
452/// read is processed avoids this: by then, its own reconvergence, if any, has
453/// already happened, so `in_edges.len()` reflects the read's final effect.
454fn set_new_node_own_fork(nodes: &mut [Node], p: usize, new_idx: usize) {
455    if nodes[p].out_edges.len() >= 2 {
456        // p is a fork (whether it just became one or already was) --
457        // new_idx starts a fresh arm directly off it.
458        nodes[new_idx].nearest_fork = Some((p, new_idx));
459    } else {
460        nodes[new_idx].nearest_fork = nodes[p].nearest_fork;
461    }
462}
463
464/// Adds a brand-new edge with weight 1, attributed as genuine confirmation
465/// (`matched: 1, deleted: 0`). Used only for the seed's initial linear chain,
466/// where the seed read's own bases are by definition confirmed. Calls
467/// `propagate_fork_if_new` directly (not deferred): the seed's entire chain
468/// is built in one uninterrupted pass with exactly one out-edge per node, so
469/// there is no "this read's own later ops" to race against -- the deferred-
470/// propagation subtlety only applies within a single `add_to_graph` call.
471fn add_edge(nodes: &mut [Node], from: usize, to: usize) {
472    nodes[from].out_edges.push((
473        to,
474        EdgeWeight {
475            matched: 1,
476            deleted: 0,
477        },
478    ));
479    nodes[to].in_edges.push(from);
480    propagate_fork_if_new(nodes, from);
481}
482
483/// Increments an existing edge's weight, or creates it at weight 1, routing
484/// the `+1` to `.matched` or `.deleted` depending on how this read traversed
485/// it (`is_delete`). Returns `true` if a genuinely new edge was created (as
486/// opposed to an existing one being incremented) -- callers in `add_to_graph`
487/// use this to defer `propagate_fork_if_new(nodes, from)` until the current
488/// read's traceback has been fully processed, not run it immediately.
489#[must_use]
490fn increment_or_add_edge(nodes: &mut [Node], from: usize, to: usize, is_delete: bool) -> bool {
491    debug_assert_ne!(
492        from, to,
493        "increment_or_add_edge: refusing to create/increment a literal self-loop"
494    );
495    for (succ, ew) in nodes[from].out_edges.iter_mut() {
496        if *succ == to {
497            if is_delete {
498                ew.deleted += 1;
499            } else {
500                ew.matched += 1;
501            }
502            return false;
503        }
504    }
505    let ew = if is_delete {
506        EdgeWeight {
507            matched: 0,
508            deleted: 1,
509        }
510    } else {
511        EdgeWeight {
512            matched: 1,
513            deleted: 0,
514        }
515    };
516    nodes[from].out_edges.push((to, ew));
517    nodes[to].in_edges.push(from);
518    true
519}
520
521// ─── Topological sort ────────────────────────────────────────────────────────
522
523fn topological_order(nodes: &[Node]) -> (Vec<usize>, Vec<usize>) {
524    let n = nodes.len();
525    let mut in_deg: Vec<usize> = nodes.iter().map(|nd| nd.in_edges.len()).collect();
526    let mut queue: std::collections::VecDeque<usize> = (0..n).filter(|&i| in_deg[i] == 0).collect();
527    let mut topo: Vec<usize> = Vec::with_capacity(n);
528
529    while let Some(u) = queue.pop_front() {
530        topo.push(u);
531        for &(v, _) in &nodes[u].out_edges {
532            in_deg[v] -= 1;
533            if in_deg[v] == 0 {
534                queue.push_back(v);
535            }
536        }
537    }
538
539    let mut rank_of = vec![0usize; n];
540    for (t, &node_idx) in topo.iter().enumerate() {
541        rank_of[node_idx] = t;
542    }
543    (topo, rank_of)
544}
545
546/// Measure the length of an arm starting at `start` by walking single-successor chains.
547///
548/// Stops at `max_depth`, a fork (multiple out-edges), or a reconvergence point
549/// (a node reached from multiple in-edges, after the first step).
550/// The reconvergence node itself is NOT counted.
551fn materialize_arm_len(nodes: &[Node], start: usize, max_depth: usize) -> usize {
552    let mut len = 0;
553    let mut cur = start;
554    for _ in 0..max_depth {
555        // After the first step, a node with multiple in-edges is a reconvergence.
556        if len >= 1 && nodes[cur].in_edges.len() > 1 {
557            break;
558        }
559        len += 1;
560        match nodes[cur].out_edges.as_slice() {
561            [(next, _)] => cur = *next,
562            _ => break,
563        }
564    }
565    len
566}
567
568/// Number of `node`'s in-edges whose own source-side weight (matched-only, the
569/// same axis `find_structural_bubbles`/`find_bubbles` already score arms on)
570/// clears `sig_threshold`. Used by [`materialize_arm_len_tolerant`] to tell a
571/// genuine reconvergence (2+ real paths meeting) apart from one real path plus
572/// incidental noise (e.g. one read's stray Delete-turned-edge, or a single
573/// misaligned base creating a low-weight side edge).
574fn real_in_edge_count(nodes: &[Node], node: usize, sig_threshold: i32) -> usize {
575    nodes[node]
576        .in_edges
577        .iter()
578        .filter(|&&p| {
579            nodes[p]
580                .out_edges
581                .iter()
582                .find(|&&(to, _)| to == node)
583                .is_some_and(|&(_, ew)| ew.matched >= sig_threshold)
584        })
585        .count()
586}
587
588/// Like [`materialize_arm_len`], but tolerant of minor internal noise: a fork
589/// or reconvergence only ends the arm when it is a *real* one -- i.e. when
590/// 2+ of the edges involved individually clear `sig_threshold` -- rather than
591/// on any branch at all, no matter how small.
592///
593/// # Why this exists (see `find_structural_bubbles`)
594///
595/// A genuinely long structural difference between two alleles (e.g. GAA×30
596/// vs GAA×100) does not, in real data, materialise as one perfectly clean
597/// unbranched chain of matches for its entire length: ordinary per-read
598/// sequencing noise creates small side-branches and reconvergences scattered
599/// throughout it (a single read's substitution error creating a 1-weight
600/// alternate node; a single read's Delete creating a low-weight direct edge
601/// past a node others still Match through). The strict, non-tolerant walk
602/// stops at the *first* such disruption, so it measures only the distance to
603/// the first read's noise, not the arm's true span -- confirmed empirically
604/// on real data (RFC1 hap2's whole "delete-driven forkless gap" class of
605/// known bugs is this same phenomenon in a different codepath) and on a
606/// synthetic 150 bp non-repetitive structural-insertion control at realistic
607/// (~6%) per-read error rates, where the strict walk also undercounts span
608/// for a perfectly clean, unambiguous, non-periodic difference.
609///
610/// # What counts as "minor" and why the bound is safe
611///
612/// A branch or reconvergence is "minor" exactly when it does NOT clear
613/// `sig_threshold` -- the identical `min_allele_freq`-derived vote count
614/// `find_bubbles`/`find_structural_bubbles` already use to decide "is this
615/// arm significant enough to be a competing-allele candidate" everywhere
616/// else in this file. This is deliberate, not a new, separately-tuned bar:
617/// anything this function is willing to walk through is, by the SAME
618/// definition already governing every other bubble decision in this module,
619/// not significant enough to be treated as a second real path. It cannot
620/// silently swallow a genuine second haplotype nested inside the arm being
621/// measured, because a genuine one clears the same bar used to find it in
622/// the first place -- if 2+ of the candidate edges at a fork (or 2+ of a
623/// node's in-edges) each clear `sig_threshold` independently, that is by
624/// construction a real branch/reconvergence, and the walk stops there exactly
625/// as the strict version would.
626///
627/// Bounded to `max_depth` steps, identical to the caller's existing bound
628/// (`phasing_bubble_min_span + 1`): the caller only ever needs to know
629/// whether the span reaches `phasing_bubble_min_span`, so once the walk has
630/// gone that far without hitting a REAL fork or reconvergence the answer is
631/// already known, and there is no reason -- or additional risk -- in
632/// continuing further. This keeps the same bounded-walk shape as this file's
633/// existing patterns (`ARM_MAX_DEPTH`, `FORK_SEARCH_HOPS`) rather than
634/// introducing an open-ended search; unlike reusing the whole-graph
635/// `compute_bubble_ranges` path-budget algorithm (which tracks true
636/// reconvergence of *every* out-edge, including noise ones, and can mark an
637/// entire open-ended tail of the graph as "one giant unresolved bubble" when
638/// a noise branch never reconverges before the graph ends), this walk only
639/// ever looks at the single arm being measured and never grows unbounded.
640fn materialize_arm_len_tolerant(
641    nodes: &[Node],
642    start: usize,
643    max_depth: usize,
644    sig_threshold: i32,
645) -> usize {
646    let mut len = 0;
647    let mut cur = start;
648    for _ in 0..max_depth {
649        // After the first step, only a REAL reconvergence (2+ in-edges each
650        // individually clearing sig_threshold) ends the arm.
651        if len >= 1 && real_in_edge_count(nodes, cur, sig_threshold) > 1 {
652            break;
653        }
654        len += 1;
655        match nodes[cur].out_edges.as_slice() {
656            [] => break,
657            [(next, _)] => cur = *next,
658            edges => {
659                let real_arms = edges
660                    .iter()
661                    .filter(|&&(_, ew)| ew.matched >= sig_threshold)
662                    .count();
663                if real_arms > 1 {
664                    break; // a genuine nested fork -- this arm ends here.
665                }
666                // At most one edge is significant; the rest are noise. A
667                // significant edge's weight is by definition >= sig_threshold
668                // and thus >= any noise edge's weight, so max_by_key always
669                // picks it when one exists, and picks an arbitrary (but
670                // deterministic) noise edge when none do.
671                let (next, _) = edges.iter().max_by_key(|&&(_, ew)| ew.matched).unwrap();
672                cur = *next;
673            }
674        }
675    }
676    len
677}
678
679// ─── Bubble detection ────────────────────────────────────────────────────────
680
681/// Find the topo rank of the exit node for a bubble starting at `entry_t`.
682///
683/// Uses the path-budget algorithm: budget starts at `out_degree(entry)` — one open
684/// path per arm.  For each subsequent node (in topo order) that is reachable from
685/// entry within the bubble, budget decrements by the number of in-edges arriving
686/// from already-seen bubble nodes, then increments by the node's own out-degree.
687/// When budget reaches 0 all paths have reconverged — that node is the exit.
688///
689/// Returns `None` when the graph ends before reconvergence (open-ended bubble).
690/// For each topo rank, compute the bubble it belongs to as `Some((entry_t, exit_t))`,
691/// or `None` for spine nodes outside any bubble.
692///
693/// Uses a generation-counter scratch (`gen_mark`) so the inner bubble search never
694/// allocates: each new bubble increments the generation and marks nodes with it
695/// instead of resetting a bool array.
696fn compute_bubble_ranges(nodes: &[Node], topo: &[usize]) -> Vec<Option<(usize, usize)>> {
697    let n = topo.len();
698    let nn = nodes.len();
699    let mut ranges: Vec<Option<(usize, usize)>> = vec![None; n];
700    // gen_mark[node_idx] == current_gen  ⟺  node is inside the active bubble search.
701    // Allocated once; never cleared between bubbles.
702    let mut gen_mark = vec![0u32; nn];
703    let mut current_gen = 0u32;
704
705    let mut t = 0;
706    while t < n {
707        if nodes[topo[t]].out_edges.len() >= 2 {
708            // Start a new bubble search from entry topo rank t.
709            current_gen = current_gen.wrapping_add(1);
710            let entry_node = topo[t];
711            gen_mark[entry_node] = current_gen;
712            let mut outstanding = nodes[entry_node].out_edges.len();
713            let mut exit_t = None;
714
715            for (tt, &node_idx) in topo.iter().enumerate().skip(t + 1) {
716                let bubble_in = nodes[node_idx]
717                    .in_edges
718                    .iter()
719                    .filter(|&&p| gen_mark[p] == current_gen)
720                    .count();
721                if bubble_in == 0 {
722                    continue;
723                }
724                gen_mark[node_idx] = current_gen;
725                outstanding -= bubble_in;
726                if outstanding == 0 {
727                    exit_t = Some(tt);
728                    break;
729                }
730                outstanding += nodes[node_idx].out_edges.len();
731            }
732
733            if let Some(et) = exit_t {
734                ranges[t..=et].fill(Some((t, et)));
735                t = et + 1;
736            } else {
737                // Open-ended bubble: mark everything remaining conservatively.
738                ranges[t..n].fill(Some((t, n - 1)));
739                break;
740            }
741        } else {
742            t += 1;
743        }
744    }
745    ranges
746}
747
748// ─── Stale-spine helpers ─────────────────────────────────────────────────────
749
750/// Base-level differences between two spines: length delta + per-position
751/// mismatches over the shared prefix.  Returns `usize::MAX` when `old` is
752/// empty (forces the first recompute to be treated as fully unstable).
753fn spine_diff(old: &[(usize, u8, i32)], new: &[(usize, u8, i32)]) -> usize {
754    if old.is_empty() {
755        return usize::MAX;
756    }
757    let len_diff = old.len().abs_diff(new.len());
758    let base_diffs = old[..old.len().min(new.len())]
759        .iter()
760        .zip(new.iter())
761        .filter(|(o, n)| o.1 != n.1)
762        .count();
763    base_diffs + len_diff
764}
765
766/// Spine is "stable" if it changed by this many bases or fewer.
767const SPINE_STABLE_THRESHOLD: usize = 3;
768/// Maximum recompute interval (reads between spine refreshes).
769const SPINE_MAX_INTERVAL: usize = 32;
770
771// ─── Lookahead resolve ───────────────────────────────────────────────────────
772
773/// Query bases scored per arm when resolving a bubble entry.
774/// Set to one repeat unit (5 bases covers AAGGG, GGGGCC, CAG etc.).
775/// Larger windows increase false-positive risk at typical ONT error rates
776/// because a single read error in the window can flip the decision.
777const LOOKAHEAD_K: usize = 5;
778/// Winning arm must beat the runner-up by at least this many score points.
779/// One match+mismatch swing = 2 points, so MARGIN=2 requires one net
780/// advantage position after accounting for any tie positions.
781const LOOKAHEAD_MARGIN: i32 = 2;
782/// Maximum nodes walked when materialising a single bubble arm.
783/// 4096 covers AAGGG × 800 (one arm) and similar long STR bubbles.
784const ARM_MAX_DEPTH: usize = 4096;
785
786/// Walks one bubble arm from `start_idx` forward, collecting node indices
787/// until the bubble exit (topo rank `exit_t`) is reached.  The exit node
788/// itself is NOT included — it runs its own windowed DP normally.
789///
790/// Returns `None` if the arm has internal branching (a nested bubble) or
791/// exceeds the depth cap; both are signals to fall back to windowed DP.
792/// Walk one bubble arm from `start_idx` to (but not including) the exit node,
793/// appending node indices into `out`.  Clears `out` first.
794/// Returns `false` if the arm has an internal branch or exceeds `ARM_MAX_DEPTH`.
795fn collect_arm_nodes(
796    nodes: &[Node],
797    rank_of: &[usize],
798    start_idx: usize,
799    exit_t: usize,
800    out: &mut Vec<usize>,
801) -> bool {
802    out.clear();
803    let mut cur = start_idx;
804    for _ in 0..ARM_MAX_DEPTH {
805        if rank_of[cur] == exit_t {
806            return true;
807        }
808        out.push(cur);
809        match nodes[cur].out_edges.as_slice() {
810            [(next, _)] => cur = *next,
811            _ => return false,
812        }
813    }
814    false
815}
816
817/// Scores the first `min(LOOKAHEAD_K, arm.len(), query[j..].len())` bases of
818/// `arm` against `query` starting at position `j`.
819fn score_arm_prefix(
820    arm: &[usize],
821    nodes: &[Node],
822    query: &[u8],
823    j: usize,
824    match_score: i32,
825    mismatch_score: i32,
826) -> i32 {
827    let len = arm
828        .len()
829        .min(LOOKAHEAD_K)
830        .min(query.len().saturating_sub(j));
831    arm[..len]
832        .iter()
833        .enumerate()
834        .map(|(i, &nidx)| {
835            if nodes[nidx].base == query[j + i] {
836                match_score
837            } else {
838                mismatch_score
839            }
840        })
841        .sum()
842}
843
844// ─── Slide-and-lock ──────────────────────────────────────────────────────────
845
846/// Number of consecutive positions where one arm uniquely matches the query
847/// before we commit to it.  Two positions guards against single-base read
848/// errors flipping the decision in repetitive sequence.
849const SLIDE_MIN_CONSEC: usize = 2;
850
851/// Slide all arms against `query[j_entry..]` simultaneously, base by base,
852/// until one arm can be uniquely identified as the correct path.
853///
854/// **Lock conditions** (first to fire wins):
855/// - *Exhaustion*: an arm runs out of nodes while the query has remaining
856///   bases and at least one other arm can still continue.  The exhausted arm
857///   cannot account for the remaining query and is eliminated.
858/// - *Unique mismatch*: exactly one alive arm matches the query base at the
859///   current position for `SLIDE_MIN_CONSEC` consecutive steps.  All others
860///   are eliminated.
861/// - *Single survivor*: only one arm remains after eliminations above.
862///
863/// Returns the winning arm index into `all_arms`, or `None` if no lock fires
864/// within the arm bounds.  `None` falls back to windowed DP, which is always
865/// correct.
866///
867/// **No retroactive gap-state fill is required.**  Because slide-and-lock is
868/// decided at the bubble *entry* node (before arm nodes are processed in topo
869/// order), the winning arm's nodes proceed through the normal windowed DP and
870/// accumulate correct M/I/D cells for traceback.
871fn slide_lock(
872    all_arms: &[Vec<usize>],
873    nodes: &[Node],
874    query: &[u8],
875    j_entry: usize,
876) -> Option<usize> {
877    let n_arms = all_arms.len();
878    let remaining = query.len().saturating_sub(j_entry);
879    if remaining == 0 || n_arms < 2 {
880        return None;
881    }
882
883    // Only slide when ALL arms are at least LOOKAHEAD_K nodes long.
884    // Short arms are produced by individual read errors (1-4 nodes):
885    //   - insertion bubbles have one empty arm (0 nodes, direct edge to exit)
886    //     which exhausts immediately, incorrectly eliminating the no-insertion path
887    //   - 2-3 consecutive substitution errors produce 2-3 node arms where a
888    //     single read error can produce SLIDE_MIN_CONSEC false unique matches
889    // Requiring all arms ≥ LOOKAHEAD_K ensures we only slide on structural
890    // bubbles of at least one full repeat unit.
891    let min_arm_len = all_arms.iter().map(|a| a.len()).min().unwrap_or(0);
892    if min_arm_len < LOOKAHEAD_K {
893        return None;
894    }
895
896    let max_steps = all_arms
897        .iter()
898        .map(|a| a.len())
899        .max()
900        .unwrap_or(0)
901        .min(remaining);
902
903    let mut alive = vec![true; n_arms];
904    let mut alive_count = n_arms;
905    let mut consec_unique = 0usize;
906    let mut consec_winner: Option<usize> = None;
907
908    for i in 0..max_steps {
909        // ── Exhaustion ────────────────────────────────────────────────────────
910        // Eliminate arms that have run out of nodes while at least one other arm
911        // can still continue.  If ALL alive arms exhaust simultaneously the query
912        // continues past the bubble at the shared exit — no lock from exhaustion.
913        let any_continuing = (0..n_arms).any(|idx| alive[idx] && all_arms[idx].len() > i);
914        if any_continuing {
915            for idx in 0..n_arms {
916                if alive[idx] && all_arms[idx].len() <= i {
917                    alive[idx] = false;
918                    alive_count -= 1;
919                }
920            }
921        }
922        if alive_count <= 1 {
923            break;
924        }
925
926        // ── Unique mismatch ───────────────────────────────────────────────────
927        let q = query[j_entry + i];
928        // Bounds check on arm index: alive arms always have len > i here (ensured
929        // by the exhaustion block above which eliminates len <= i arms first).
930        let matched: Vec<bool> = (0..n_arms)
931            .map(|idx| alive[idx] && all_arms[idx].len() > i && nodes[all_arms[idx][i]].base == q)
932            .collect();
933        let match_count = matched.iter().filter(|&&m| m).count();
934        let mismatch_count = (0..n_arms)
935            .filter(|&idx| alive[idx] && !matched[idx])
936            .count();
937
938        if match_count == 1 && mismatch_count > 0 {
939            let candidate = matched.iter().position(|&m| m).unwrap();
940            if consec_winner == Some(candidate) {
941                consec_unique += 1;
942            } else {
943                consec_unique = 1;
944                consec_winner = Some(candidate);
945            }
946            if consec_unique >= SLIDE_MIN_CONSEC {
947                for idx in 0..n_arms {
948                    if alive[idx] && !matched[idx] {
949                        alive[idx] = false;
950                        alive_count -= 1;
951                    }
952                }
953                break;
954            }
955        } else {
956            consec_unique = 0;
957            consec_winner = None;
958        }
959    }
960
961    if alive_count == 1 {
962        alive.iter().position(|&a| a)
963    } else {
964        None
965    }
966}
967
968// ─── Minimizer anchoring ─────────────────────────────────────────────────────
969
970#[inline]
971fn encode_base(b: u8) -> Option<u64> {
972    match b {
973        b'A' | b'a' => Some(0),
974        b'C' | b'c' => Some(1),
975        b'G' | b'g' => Some(2),
976        b'T' | b't' => Some(3),
977        _ => None,
978    }
979}
980
981/// Compute minimizers for `seq`: for each sliding window of `w` consecutive
982/// k-mers, select the (hash, start-position) pair with the minimum hash.
983/// Returns deduplicated (hash, seq_position) pairs in order of appearance.
984/// K-mers containing non-ACGT bases are skipped (treated as invalid).
985fn compute_minimizers(seq: &[u8], k: usize, w: usize) -> Vec<(u64, usize)> {
986    let n = seq.len();
987    if n < k {
988        return vec![];
989    }
990    let n_kmers = n - k + 1;
991    let mask = if k * 2 < 64 {
992        (1u64 << (k * 2)) - 1
993    } else {
994        u64::MAX
995    };
996
997    // Rolling 2-bit kmer hash; None at positions containing non-ACGT bases.
998    let mut kmer_hashes: Vec<Option<u64>> = Vec::with_capacity(n_kmers);
999    let mut hash: u64 = 0;
1000    let mut valid: usize = 0;
1001    for (i, &b) in seq.iter().enumerate() {
1002        match encode_base(b) {
1003            Some(bits) => {
1004                hash = ((hash << 2) | bits) & mask;
1005                valid += 1;
1006            }
1007            None => {
1008                hash = 0;
1009                valid = 0;
1010            }
1011        }
1012        if i + 1 >= k {
1013            kmer_hashes.push(if valid >= k { Some(hash) } else { None });
1014        }
1015    }
1016
1017    // Slide a window of w k-mer positions; pick the minimum-hash position.
1018    // win = w.min(n_kmers) ensures the loop is safe when the sequence is short.
1019    let win = w.min(n_kmers);
1020    let mut result: Vec<(u64, usize)> = Vec::new();
1021    let mut last: Option<(u64, usize)> = None;
1022    for start in 0..=(n_kmers - win) {
1023        let mut best_hash = u64::MAX;
1024        let mut best_pos = start;
1025        for (off, entry) in kmer_hashes[start..start + win].iter().enumerate() {
1026            if let Some(h) = entry {
1027                if *h < best_hash {
1028                    best_hash = *h;
1029                    best_pos = start + off;
1030                }
1031            }
1032        }
1033        if best_hash == u64::MAX {
1034            continue;
1035        }
1036        let entry = (best_hash, best_pos);
1037        if last != Some(entry) {
1038            last = Some(entry);
1039            result.push(entry);
1040        }
1041    }
1042    result
1043}
1044
1045/// Build the minimizer index for the cached spine.
1046/// Maps k-mer hash → spine rank (index in `spine`).
1047/// Only k-mers that appear exactly once in the spine are kept.
1048fn build_spine_mers(spine: &[(usize, u8, i32)], k: usize, w: usize) -> HashMap<u64, u32> {
1049    let spine_seq: Vec<u8> = spine.iter().map(|&(_, b, _)| b).collect();
1050    let mers = compute_minimizers(&spine_seq, k, w);
1051    // Count occurrences and record the first-seen spine rank per hash.
1052    let mut counts: HashMap<u64, (u32, u32)> = HashMap::new();
1053    for (hash, pos) in mers {
1054        let e = counts.entry(hash).or_insert((0, pos as u32));
1055        e.0 += 1;
1056    }
1057    counts
1058        .into_iter()
1059        .filter(|(_, (c, _))| *c == 1)
1060        .map(|(h, (_, p))| (h, p))
1061        .collect()
1062}
1063
1064/// Return the indices into `vals` that form the longest strictly increasing
1065/// subsequence.  O(n log n) patience-sort with predecessor backtracking.
1066fn lis_indices(vals: &[usize]) -> Vec<usize> {
1067    let n = vals.len();
1068    if n == 0 {
1069        return vec![];
1070    }
1071    let mut tails: Vec<usize> = Vec::new();
1072    let mut tail_idx: Vec<usize> = Vec::new();
1073    let mut pred: Vec<Option<usize>> = vec![None; n];
1074    for i in 0..n {
1075        let v = vals[i];
1076        let p = tails.partition_point(|&t| t < v);
1077        if p == tails.len() {
1078            tails.push(v);
1079            tail_idx.push(i);
1080        } else {
1081            tails[p] = v;
1082            tail_idx[p] = i;
1083        }
1084        pred[i] = if p > 0 { Some(tail_idx[p - 1]) } else { None };
1085    }
1086    let lis_len = tails.len();
1087    let mut chain = Vec::with_capacity(lis_len);
1088    let mut cur = Some(tail_idx[lis_len - 1]);
1089    while let Some(i) = cur {
1090        chain.push(i);
1091        cur = pred[i];
1092    }
1093    chain.reverse();
1094    chain
1095}
1096
1097/// Build the colinear anchor chain for one read against the current spine.
1098///
1099/// Matches each read minimizer against `spine_mers` to produce candidate
1100/// `(read_pos, topo_rank)` pairs, then runs LIS to keep the longest
1101/// monotonically-increasing sub-sequence (guaranteeing colinearity).
1102/// Returns the chain sorted by topo_rank.
1103fn build_anchor_chain(
1104    read_mers: &[(u64, usize)],
1105    spine_mers: &HashMap<u64, u32>,
1106    spine: &[(usize, u8, i32)],
1107    rank_of: &[usize],
1108) -> Vec<(usize, usize)> {
1109    let mut candidates: Vec<(usize, usize)> = read_mers
1110        .iter()
1111        .filter_map(|&(hash, read_pos)| {
1112            spine_mers.get(&hash).map(|&sr| {
1113                let node_idx = spine[sr as usize].0;
1114                (read_pos, rank_of[node_idx])
1115            })
1116        })
1117        .collect();
1118    if candidates.is_empty() {
1119        return vec![];
1120    }
1121    // Sort by topo_rank; LIS on read_pos then gives a colinear monotone chain.
1122    candidates.sort_unstable_by_key(|&(_, t)| t);
1123    let read_positions: Vec<usize> = candidates.iter().map(|&(r, _)| r).collect();
1124    let idxs = lis_indices(&read_positions);
1125    idxs.iter().map(|&i| candidates[i]).collect()
1126}
1127
1128/// Compute the anchor-derived j-window for topo row `t`.
1129///
1130/// Returns `Some((lo, hi))` only when `t` is the exact topo rank of an anchor.
1131/// Interpolation between anchors is intentionally omitted: in repetitive
1132/// sequence, error k-mers at different repeat positions can produce coincident
1133/// k-mer hashes (same error type, same cycle position → identical k-mer), which
1134/// would create wrong anchors that are colinear with the correct flank anchors
1135/// and survive the LIS filter.  An exact-match-only policy is safe because a
1136/// wrong anchor centered far from the correct j produces an empty intersection
1137/// with the existing window → the existing window is used unchanged.
1138fn anchor_j_bounds(
1139    t: usize,
1140    anchors: &[(usize, usize)], // (read_pos, topo_rank), sorted by topo_rank
1141    l: usize,
1142) -> Option<(usize, usize)> {
1143    if anchors.is_empty() {
1144        return None;
1145    }
1146    // Binary search for an exact topo_rank match.
1147    let pos = anchors.partition_point(|&(_, at)| at <= t);
1148    if pos > 0 && anchors[pos - 1].1 == t {
1149        let r = anchors[pos - 1].0;
1150        let lo = r.saturating_sub(MINI_EPS_BASE).max(1);
1151        let hi = (r + MINI_EPS_BASE).min(l);
1152        Some((lo, hi))
1153    } else {
1154        None
1155    }
1156}
1157
1158/// Intersect `(j_lo, j_hi)` with the anchor-derived window for topo row `t`.
1159///
1160/// Only applied to spine nodes that are outside bubble regions and not already
1161/// constrained by slide-lock windows.  Returns the original window unchanged
1162/// when anchors provide no constraint or the intersection is empty (wrong
1163/// anchor — correctness fallback to existing window).
1164///
1165/// Also skips the anchor when its upper bound falls below `j_center`
1166/// (j_pred_max + 1): a shifted anchor that would exclude the expected
1167/// diagonal causes the diagonal skip to write outside the valid band,
1168/// silently discarding the correct alignment path.
1169#[allow(clippy::too_many_arguments)]
1170#[inline]
1171fn anchor_refine_spine(
1172    j_lo: usize,
1173    j_hi: usize,
1174    t: usize,
1175    anchors: &[(usize, usize)],
1176    l: usize,
1177    is_source: bool,
1178    is_spine: bool,
1179    in_bubble: bool,
1180    is_locked: bool,
1181    j_center: usize,
1182) -> (usize, usize) {
1183    if anchors.is_empty() || is_source || !is_spine || in_bubble || is_locked {
1184        return (j_lo, j_hi);
1185    }
1186    match anchor_j_bounds(t, anchors, l) {
1187        Some((alo, ahi)) => {
1188            // A shifted anchor whose upper bound sits below the expected diagonal
1189            // would exclude the correct alignment path.  Discard it.
1190            if ahi < j_center {
1191                return (j_lo, j_hi);
1192            }
1193            let nlo = j_lo.max(alo);
1194            let nhi = j_hi.min(ahi);
1195            if nlo <= nhi { (nlo, nhi) } else { (j_lo, j_hi) }
1196        }
1197        None => (j_lo, j_hi),
1198    }
1199}
1200
1201// ─── Bubble-aware DP alignment ────────────────────────────────────────────────
1202
1203/// Align `query` against the graph using spine-guided affine-gap DAG DP.
1204///
1205/// **Diagonal skip** — O(1) fast path for consecutive exact matches along the
1206/// heaviest-path spine.  Fires whenever the spine node's base matches the query
1207/// base AND the M score at the predecessor dominates any open I/D state.  After
1208/// each read is added the spine converges toward the true consensus, so later
1209/// reads align almost entirely via diagonal skip with only small local-DP
1210/// bursts at divergence points.
1211///
1212/// **j-range restriction** — spine nodes get a narrow ±[`SPINE_MARGIN`] window;
1213/// bubble nodes get a window anchored on the bubble entry's j position extended
1214/// by the bubble span.  Cells outside the window remain UNSET and are invisible
1215/// to traceback.
1216// Bounds-safe read from a banded matrix (m or ins).  j=0 is always UNSET
1217// for these two matrices.  Returns UNSET when j falls outside [j_lo, j_hi].
1218#[inline(always)]
1219fn gs(mat: &[Cell], t: usize, j: usize, j_lo: usize, j_hi: usize, rw: usize) -> i32 {
1220    if j == 0 || j < j_lo || j > j_hi {
1221        UNSET
1222    } else {
1223        mat[t * rw + (j - j_lo)].score
1224    }
1225}
1226
1227// Bounds-safe read for the del matrix.  j=0 is stored in del0 (separate array).
1228#[inline(always)]
1229fn gsd(
1230    del: &[Cell],
1231    del0: &[Cell],
1232    t: usize,
1233    j: usize,
1234    j_lo: usize,
1235    j_hi: usize,
1236    rw: usize,
1237) -> i32 {
1238    if j == 0 {
1239        del0[t].score
1240    } else if j < j_lo || j > j_hi {
1241        UNSET
1242    } else {
1243        del[t * rw + (j - j_lo)].score
1244    }
1245}
1246
1247#[allow(clippy::too_many_arguments)]
1248fn align(
1249    nodes: &[Node],
1250    topo: &[usize],
1251    rank_of: &[usize],
1252    spine: &[(usize, u8, i32)],
1253    query: &[u8],
1254    cfg: &PoaConfig,
1255    scratch: &mut AlignScratch,
1256    anchors: &[(usize, usize)], // (read_pos, topo_rank) colinear chain from minimizer seeding
1257) -> Result<Vec<AlignOp>, PoaError> {
1258    scratch.clear();
1259    let n = topo.len();
1260    let l = query.len();
1261
1262    // band_width=0 with adaptive_band=false is genuinely unbanded (O(V*L) DP,
1263    // see spine_margin below) -- warn once per call on long reads so callers
1264    // aren't surprised by the memory/time cost.  Never blocks the call; set
1265    // PoaConfig::warn_on_long_unbanded = false to suppress.
1266    if cfg.warn_on_long_unbanded && cfg.band_width == 0 && !cfg.adaptive_band && l > 1000 {
1267        eprintln!(
1268            "poa-consensus: warning: unbanded alignment (band_width=0, \
1269             adaptive_band=false) on a {l} bp read -- this scales as \
1270             O(read_len * graph_len); consider a banded or adaptive PoaConfig \
1271             for large graphs, or set warn_on_long_unbanded=false to suppress"
1272        );
1273    }
1274
1275    let go = cfg.gap_open;
1276    let ge = cfg.gap_extend;
1277    let semi = cfg.alignment_mode == AlignmentMode::SemiGlobal;
1278
1279    // Spine membership: for each node index, the previous spine node (if on spine).
1280    // Used to gate the relaxed diagonal skip.
1281    let nn = nodes.len();
1282    let mut on_spine = vec![false; nn];
1283    let mut spine_prev: Vec<Option<usize>> = vec![None; nn];
1284    for (sp, &(node_idx, _, _)) in spine.iter().enumerate() {
1285        on_spine[node_idx] = true;
1286        if sp > 0 {
1287            spine_prev[node_idx] = Some(spine[sp - 1].0);
1288        }
1289    }
1290
1291    // Bubble membership: Some((entry_t, exit_t)) or None for spine.
1292    let bubble_ranges = compute_bubble_ranges(nodes, topo);
1293
1294    // ── Diagonal-skip gating ──────────────────────────────────────────────────
1295    // The O(1) diagonal-skip fast path is a greedy forward-match: at a spine
1296    // node it commits to matching when the base equals the read's next base,
1297    // never evaluating the insert/delete alternatives. In a single-allele
1298    // periodic repeat this force-matches reads THROUGH phantom extra repeat
1299    // units (every unit base matches), inflating their coverage and over-calling
1300    // the length. In multi-allele mode the same greedy match is PROTECTIVE -- it
1301    // locks each read onto its own length-allele track and stops short-allele
1302    // reads drifting onto the longer allele's extra units. So diagonal-skip is
1303    // disabled for single-allele consensus (accuracy) and kept for multi-allele
1304    // (allele separation). See design/vntr_overcall_delete_edge_visibility.md.
1305    let diag_skip_disabled = !cfg.multi_allele;
1306
1307    // j position of each bubble's entry node's predecessors — filled dynamically.
1308    let mut bubble_entry_j = vec![0usize; n];
1309
1310    // Track best_j per row for diagonal skip.
1311    let mut best_j_per_t = vec![0usize; n];
1312
1313    // Nodes marked here are on a losing bubble arm identified by lookahead
1314    // resolve.  Their entire windowed DP is skipped; their cells stay UNSET so
1315    // the exit node naturally reads only from the winning arm's predecessors.
1316    let mut lookahead_skip = vec![false; nn];
1317
1318    // Spine margin: matches the DP band width so memory usage scales with the
1319    // configured band rather than always allocating SPINE_MARGIN_MIN columns.
1320    // Uses the same adaptive formula as main (b + f*L).  band_width=0 with
1321    // adaptive_band=false is the documented "unbanded / full NW over DAG" case
1322    // (see PoaConfig::band_width): spine_margin is set to the full query length
1323    // so row_width below covers [1, l] for every row, with no artificial cap.
1324    // This is deliberately expensive at long read lengths (see CLAUDE.md scale
1325    // table) -- callers who explicitly ask for unbanded get real O(V*L) DP, not
1326    // a silently narrow fallback band.
1327    let spine_margin: usize = if cfg.adaptive_band {
1328        let w = cfg.adaptive_band_b + (cfg.adaptive_band_f * l as f32).ceil() as usize;
1329        let w = if cfg.band_width > 0 {
1330            w.max(cfg.band_width)
1331        } else {
1332            w
1333        };
1334        w.max(4)
1335    } else if cfg.band_width > 0 {
1336        cfg.band_width
1337    } else {
1338        l
1339    };
1340
1341    // Banded DP tables.  Each row stores j = j_lo_arr[t]..=j_hi_arr[t] (j >= 1).
1342    // Spine rows use ±spine_margin around the diagonal; bubble rows widen by the
1343    // bubble span.  The j=0 del column is stored separately in del0 because
1344    // m[t][0] and ins[t][0] are structurally UNSET and never stored.
1345    // +2 ensures the left-edge j-1 access from the next row stays in range.
1346    let row_width = (2 * spine_margin + 2).min(l).max(1);
1347
1348    let mut del0 = vec![Cell::unset(); n];
1349    let bsize = n * row_width;
1350    let mut m = vec![Cell::unset(); bsize];
1351    let mut ins = vec![Cell::unset(); bsize];
1352    let mut del = vec![Cell::unset(); bsize];
1353    // Per-row band: j_lo_arr[t] is the lowest stored j (>= 1).
1354    // j_hi_arr[t] is set when the row is processed; reads before then return UNSET.
1355    let mut j_lo_arr = vec![1usize; n];
1356    let mut j_hi_arr = vec![0usize; n];
1357
1358    // Reusable scratch for bubble-arm materialisation (avoids per-bubble alloc).
1359    let mut arm_scratch: Vec<usize> = Vec::new();
1360    let mut all_arms: Vec<Vec<usize>> = Vec::new();
1361
1362    for (t, &node_idx) in topo.iter().enumerate() {
1363        // Lookahead: skip nodes on losing bubble arms entirely.
1364        if lookahead_skip[node_idx] {
1365            continue;
1366        }
1367
1368        let node_base = nodes[node_idx].base;
1369        let is_source = nodes[node_idx].in_edges.is_empty();
1370
1371        // ── j range: compute window FIRST (needed for diagonal skip write) ─────
1372        // Spine node  : j_center = predecessor's best_j + 1  (±SPINE_MARGIN)
1373        // Bubble node : j_center = bubble-entry's predecessor j, hi widened by span
1374        // Source node : clamped full range [1, row_width] (large-j cells are UNSET
1375        //               in predecessors so they're never winners; safe to clamp).
1376        let j_pred_max = if is_source {
1377            0
1378        } else {
1379            nodes[node_idx]
1380                .in_edges
1381                .iter()
1382                .map(|&p| best_j_per_t[rank_of[p]])
1383                .max()
1384                .unwrap_or(0)
1385        };
1386
1387        let (j_lo, j_hi) = if is_source {
1388            (1usize, row_width.min(l))
1389        } else if let Some(j_center) = scratch.get_node_j(node_idx) {
1390            // Locked winning arm node: tight per-node window centred at the
1391            // exact query column this depth corresponds to.
1392            let lo = j_center.saturating_sub(LOCK_EPS).max(1);
1393            let hi = (j_center + LOCK_EPS).min(l);
1394            (lo, hi)
1395        } else if let Some(j_center) = scratch.get_exit_j(node_idx) {
1396            // Locked exit node: spine-width window centred at winning arm's
1397            // terminal query column, so it reads the arm's actual best cells.
1398            let lo = j_center.saturating_sub(spine_margin).max(1);
1399            let hi = (j_center + spine_margin).min(l);
1400            (lo, hi)
1401        } else {
1402            match bubble_ranges[t] {
1403                Some((entry_t, exit_t)) => {
1404                    if t == entry_t {
1405                        bubble_entry_j[entry_t] = j_pred_max;
1406                    }
1407                    let bej = bubble_entry_j[entry_t];
1408                    let bubble_span = exit_t.saturating_sub(entry_t) + 1;
1409                    let lo = bej.saturating_sub(spine_margin).max(1);
1410                    let hi = (bej + bubble_span.min(spine_margin) + spine_margin).min(l);
1411                    (lo, hi)
1412                }
1413                None => {
1414                    let j_center = j_pred_max.saturating_add(1);
1415                    let lo = j_center.saturating_sub(spine_margin).max(1);
1416                    let hi = (j_center + spine_margin).min(l);
1417                    (lo, hi)
1418                }
1419            }
1420        };
1421        // Clamp hi to the banded row width.
1422        let j_hi = j_hi.min(j_lo + row_width - 1);
1423        // Narrow with minimizer anchor constraint for spine nodes that are
1424        // outside bubble regions and not already pinned by slide-lock windows.
1425        // Never widens the window; falls back to (j_lo, j_hi) when the anchor
1426        // constraint is absent, would produce an empty interval, or would
1427        // exclude the expected diagonal (shifted-anchor safety guard).
1428        let (j_lo, j_hi) = anchor_refine_spine(
1429            j_lo,
1430            j_hi,
1431            t,
1432            anchors,
1433            l,
1434            is_source,
1435            on_spine[node_idx],
1436            bubble_ranges[t].is_some(),
1437            scratch.get_node_j(node_idx).is_some() || scratch.get_exit_j(node_idx).is_some(),
1438            j_pred_max.saturating_add(1),
1439        );
1440        j_lo_arr[t] = j_lo;
1441        j_hi_arr[t] = j_hi;
1442
1443        // ── Diagonal skip ──────────────────────────────────────────────────────
1444        // O(1) fast path for consecutive exact matches along the spine.
1445        //
1446        // Extended to fire at bubble entries: if a spine node has multiple
1447        // out-edges (e.g. a low-weight error arm), a 1-base pre-resolve checks
1448        // whether query[bj+1] uniquely matches the spine successor's first base.
1449        // If so, losing arms are marked lookahead_skip and the skip fires as if
1450        // the node had a single out-edge.  Error arms never block spine sliding.
1451        //
1452        // In-edge guard is relaxed: predecessors already in lookahead_skip (from
1453        // a previously resolved bubble) are ignored, so exit nodes of resolved
1454        // bubbles re-enter the skip path immediately.
1455        #[cfg(test)]
1456        if !is_source {
1457            NODE_COUNTER.with(|c| c.set(c.get() + 1));
1458        }
1459
1460        if !is_source && on_spine[node_idx] && !diag_skip_disabled {
1461            if let Some(prev_sp) = spine_prev[node_idx] {
1462                // Relaxed in-edge check: fast for the common single-predecessor case;
1463                // falls through to a loop only when there are extra predecessors that
1464                // might all be lookahead_skip (exit nodes of resolved bubbles).
1465                let active_pred_ok = match nodes[node_idx].in_edges.as_slice() {
1466                    [p] => *p == prev_sp,
1467                    _ => {
1468                        nodes[node_idx]
1469                            .in_edges
1470                            .iter()
1471                            .all(|&p| p == prev_sp || lookahead_skip[p])
1472                            && nodes[node_idx].in_edges.contains(&prev_sp)
1473                    }
1474                };
1475
1476                if active_pred_ok {
1477                    let pt = rank_of[prev_sp];
1478                    let bj = best_j_per_t[pt];
1479                    // bj+1 must land inside this row's own band, or the write at
1480                    // line ~1472 (`bj + 1 - j_lo`) underflows: j_lo here is this
1481                    // node's window, which can start after bj+1 (e.g. a locked
1482                    // per-node window centred elsewhere), independent of the
1483                    // predecessor's diagonal position.
1484                    if bj < l && bj + 1 >= j_lo && bj < j_hi && node_base == query[bj] {
1485                        let m_prev = gs(&m, pt, bj, j_lo_arr[pt], j_hi_arr[pt], row_width);
1486                        let i_prev = gs(&ins, pt, bj, j_lo_arr[pt], j_hi_arr[pt], row_width);
1487                        let d_prev =
1488                            gsd(&del, &del0, pt, bj, j_lo_arr[pt], j_hi_arr[pt], row_width);
1489                        let pred_is_source = nodes[topo[pt]].in_edges.is_empty();
1490                        let i_ok = i_prev == UNSET || (pred_is_source && m_prev > i_prev);
1491                        let d_ok = d_prev == UNSET || (pred_is_source && m_prev > d_prev);
1492                        if m_prev != UNSET && i_ok && d_ok {
1493                            // For a bubble entry: 1-base pre-resolve against query[bj+1].
1494                            // Mark losing arms by setting only their first node as skip;
1495                            // the UNSET cascade through the arm is correct and avoids the
1496                            // alloc inside collect_arm_nodes on the hot path.
1497                            let do_skip = match nodes[node_idx].out_edges.as_slice() {
1498                                [_] => true,
1499                                _ if bj + 1 < l => {
1500                                    let next_q = query[bj + 1];
1501                                    let mut spine_succ = None;
1502                                    let mut resolved = true;
1503                                    for &(s, _) in &nodes[node_idx].out_edges {
1504                                        if on_spine[s] && spine_prev[s] == Some(node_idx) {
1505                                            if nodes[s].base == next_q {
1506                                                spine_succ = Some(s);
1507                                            } else {
1508                                                resolved = false;
1509                                                break;
1510                                            }
1511                                        } else if !lookahead_skip[s] && nodes[s].base == next_q {
1512                                            resolved = false;
1513                                            break;
1514                                        }
1515                                    }
1516                                    if resolved {
1517                                        if let Some(ss) = spine_succ {
1518                                            for &(s, _) in &nodes[node_idx].out_edges {
1519                                                if s != ss && !lookahead_skip[s] {
1520                                                    lookahead_skip[s] = true;
1521                                                    // Walk the rest of this losing arm, not
1522                                                    // just its first node: a multi-node
1523                                                    // insertion (e.g. a 2+ base indel) left
1524                                                    // every node past the first one neither
1525                                                    // on-spine nor marked, so it fell through
1526                                                    // to real windowed DP on every subsequent
1527                                                    // read, and -- worse -- kept the arm's
1528                                                    // reconvergence node's active_pred_ok
1529                                                    // check failing (it's still a live,
1530                                                    // unresolved in-edge), forcing the entire
1531                                                    // rest of that read into full DP too.
1532                                                    // Stops at the spine (correct
1533                                                    // reconvergence) or a further branch
1534                                                    // (left to its own resolution).
1535                                                    let mut cur = s;
1536                                                    for _ in 0..ARM_MAX_DEPTH {
1537                                                        match nodes[cur].out_edges.as_slice() {
1538                                                            [(next, _)]
1539                                                                if !on_spine[*next]
1540                                                                    && !lookahead_skip[*next] =>
1541                                                            {
1542                                                                lookahead_skip[*next] = true;
1543                                                                cur = *next;
1544                                                            }
1545                                                            _ => break,
1546                                                        }
1547                                                    }
1548                                                }
1549                                            }
1550                                            true
1551                                        } else {
1552                                            false
1553                                        }
1554                                    } else {
1555                                        false
1556                                    }
1557                                }
1558                                _ => false,
1559                            };
1560
1561                            if do_skip {
1562                                let score = m_prev + cfg.match_score;
1563                                // bj+1 is the centre of this row's band: within [j_lo, j_hi].
1564                                m[t * row_width + (bj + 1 - j_lo)] = Cell {
1565                                    score,
1566                                    pred_t: pt as u32,
1567                                };
1568                                best_j_per_t[t] = bj + 1;
1569                                #[cfg(test)]
1570                                SKIP_COUNTER.with(|c| c.set(c.get() + 1));
1571                                continue;
1572                            }
1573                        }
1574                    }
1575                }
1576            }
1577        }
1578
1579        // ── j = 0: delete-only ──────────────────────────────────────────────────
1580        // m[t][0] and ins[t][0] are structurally UNSET (never stored); only del[t][0]
1581        // (del0[t]) needs initialisation here.
1582        {
1583            let (mut best, mut best_pred) = (UNSET, 0u32);
1584            if is_source {
1585                let val = go + ge;
1586                if val > best {
1587                    best = val;
1588                    best_pred = VIRTUAL;
1589                }
1590            }
1591            for &p in &nodes[node_idx].in_edges {
1592                let pt = rank_of[p];
1593                // m[pt][0] is always UNSET; only del0 carries the j=0 chain.
1594                let vd = safe_add(del0[pt].score, ge);
1595                if vd > best {
1596                    best = vd;
1597                    best_pred = pt as u32;
1598                }
1599            }
1600            if best != UNSET {
1601                del0[t] = Cell {
1602                    score: best,
1603                    pred_t: best_pred,
1604                };
1605            }
1606        }
1607
1608        // ── j = j_lo..=j_hi ───────────────────────────────────────────────────
1609        let mut row_best_j = 0usize;
1610        let mut row_best_score = UNSET;
1611
1612        for j in j_lo..=j_hi {
1613            let q_base = query[j - 1];
1614            let sc = if node_base == q_base {
1615                cfg.match_score
1616            } else {
1617                cfg.mismatch_score
1618            };
1619            let ixcur = t * row_width + (j - j_lo);
1620
1621            // M[t][j]
1622            {
1623                let (mut best, mut best_pred) = (UNSET, VIRTUAL);
1624                // Free-start at j=1: applies when j_lo==1 (source or bubble nodes).
1625                if j == 1 && (is_source || semi) && sc > best {
1626                    best = sc;
1627                    best_pred = VIRTUAL;
1628                }
1629                if is_source && j > 1 {
1630                    let val = safe_add(go + (j as i32 - 1) * ge, sc);
1631                    if val != UNSET && val > best {
1632                        best = val;
1633                        best_pred = VIRTUAL;
1634                    }
1635                }
1636                // Ties across predecessors prefer the heavier-weighted edge (see
1637                // `edge_weight`); ties within the same predecessor keep the
1638                // Match > Insert > Delete order via the pre-existing `>` checks.
1639                // MAX so a pre-loop free-start `best` is never displaced by a tie.
1640                let mut best_edge_w = i32::MAX;
1641                for &p in &nodes[node_idx].in_edges {
1642                    let pt = rank_of[p];
1643                    let ew = edge_weight(nodes, p, node_idx);
1644                    let vm = safe_add(gs(&m, pt, j - 1, j_lo_arr[pt], j_hi_arr[pt], row_width), sc);
1645                    if vm != UNSET && (vm > best || (vm == best && ew > best_edge_w)) {
1646                        best = vm;
1647                        best_pred = pt as u32;
1648                        best_edge_w = ew;
1649                    }
1650                    let vi = safe_add(
1651                        gs(&ins, pt, j - 1, j_lo_arr[pt], j_hi_arr[pt], row_width),
1652                        sc,
1653                    );
1654                    if vi != UNSET && (vi > best || (vi == best && ew > best_edge_w)) {
1655                        best = vi;
1656                        best_pred = pt as u32;
1657                        best_edge_w = ew;
1658                    }
1659                    let vd = safe_add(
1660                        gsd(
1661                            &del,
1662                            &del0,
1663                            pt,
1664                            j - 1,
1665                            j_lo_arr[pt],
1666                            j_hi_arr[pt],
1667                            row_width,
1668                        ),
1669                        sc,
1670                    );
1671                    if vd != UNSET && (vd > best || (vd == best && ew > best_edge_w)) {
1672                        best = vd;
1673                        best_pred = pt as u32;
1674                        best_edge_w = ew;
1675                    }
1676                }
1677                if best != UNSET {
1678                    m[ixcur] = Cell {
1679                        score: best,
1680                        pred_t: best_pred,
1681                    };
1682                    if best > row_best_score {
1683                        row_best_score = best;
1684                        row_best_j = j;
1685                    }
1686                }
1687            }
1688
1689            // I[t][j] — reads from same row at j-1.
1690            // When j == j_lo, j-1 is outside the banded window → treat as UNSET.
1691            {
1692                let (mut best, mut best_pred) = (UNSET, VIRTUAL);
1693                if is_source && j == 1 {
1694                    let val = go + ge;
1695                    if val > best {
1696                        best = val;
1697                        best_pred = VIRTUAL;
1698                    }
1699                }
1700                if j > j_lo {
1701                    let ixprev = t * row_width + (j - 1 - j_lo);
1702                    let vm = safe_add(m[ixprev].score, go + ge);
1703                    if vm != UNSET && vm > best {
1704                        best = vm;
1705                        best_pred = t as u32;
1706                    }
1707                    let vi = safe_add(ins[ixprev].score, ge);
1708                    if vi != UNSET && vi > best {
1709                        best = vi;
1710                        best_pred = t as u32;
1711                    }
1712                }
1713                if best != UNSET {
1714                    ins[ixcur] = Cell {
1715                        score: best,
1716                        pred_t: best_pred,
1717                    };
1718                }
1719            }
1720
1721            // D[t][j]
1722            {
1723                let (mut best, mut best_pred) = (UNSET, 0u32);
1724                // See the M[t][j] block above: ties across predecessors prefer
1725                // the heavier-weighted edge instead of in_edges iteration order.
1726                let mut best_edge_w = i32::MAX;
1727                for &p in &nodes[node_idx].in_edges {
1728                    let pt = rank_of[p];
1729                    let ew = edge_weight(nodes, p, node_idx);
1730                    let vm = safe_add(
1731                        gs(&m, pt, j, j_lo_arr[pt], j_hi_arr[pt], row_width),
1732                        go + ge,
1733                    );
1734                    if vm != UNSET && (vm > best || (vm == best && ew > best_edge_w)) {
1735                        best = vm;
1736                        best_pred = pt as u32;
1737                        best_edge_w = ew;
1738                    }
1739                    let vi = safe_add(
1740                        gs(&ins, pt, j, j_lo_arr[pt], j_hi_arr[pt], row_width),
1741                        go + ge,
1742                    );
1743                    if vi != UNSET && (vi > best || (vi == best && ew > best_edge_w)) {
1744                        best = vi;
1745                        best_pred = pt as u32;
1746                        best_edge_w = ew;
1747                    }
1748                    let vd = safe_add(
1749                        gsd(&del, &del0, pt, j, j_lo_arr[pt], j_hi_arr[pt], row_width),
1750                        ge,
1751                    );
1752                    if vd != UNSET && (vd > best || (vd == best && ew > best_edge_w)) {
1753                        best = vd;
1754                        best_pred = pt as u32;
1755                        best_edge_w = ew;
1756                    }
1757                }
1758                if best != UNSET {
1759                    del[ixcur] = Cell {
1760                        score: best,
1761                        pred_t: best_pred,
1762                    };
1763                }
1764            }
1765        }
1766
1767        if row_best_score != UNSET {
1768            best_j_per_t[t] = row_best_j;
1769        } else {
1770            // No M cell set: estimate j from predecessor + 1 (diagonal advance).
1771            best_j_per_t[t] = if is_source {
1772                1
1773            } else {
1774                j_pred_max.saturating_add(1).min(l)
1775            };
1776        }
1777
1778        // ── Lookahead resolve + slide-and-lock ───────────────────────────────
1779        // At a bubble entry node, attempt to identify the correct arm before
1780        // processing its nodes.  Losing arm nodes are marked in `lookahead_skip`
1781        // and their windowed DP is entirely skipped; the winning arm proceeds
1782        // through normal windowed DP, producing correct M/I/D cells for
1783        // traceback without any retroactive gap-state fill.
1784        //
1785        // Dispatch order:
1786        //   1. Lookahead  — score first LOOKAHEAD_K bases; fast, reliable for
1787        //                   structural bubbles (interruption motifs, allele SNPs)
1788        //                   where one arm is clearly better within one repeat unit.
1789        //   2. Slide-and-lock — slide base-by-base past LOOKAHEAD_K until an arm
1790        //                   exhausts or uniquely matches for SLIDE_MIN_CONSEC steps;
1791        //                   handles long repetitive bubbles (RFC1, C9orf72) where
1792        //                   arms share a long identical prefix.
1793        //   3. Windowed DP fallback — always correct; used when arms are complex
1794        //                   (internally branched) or no lock fires.
1795        if let Some((entry_t, exit_t)) = bubble_ranges[t] {
1796            if t == entry_t && nodes[node_idx].out_edges.len() >= 2 {
1797                let j_entry = best_j_per_t[t];
1798                if j_entry < l {
1799                    // Materialise each arm.  Arms with internal branches (nested
1800                    // bubbles) or exceeding ARM_MAX_DEPTH return None → complex=true
1801                    // → fall through to windowed DP.
1802                    all_arms.clear();
1803                    let mut complex = false;
1804                    for &(arm_start, _) in &nodes[node_idx].out_edges {
1805                        if collect_arm_nodes(nodes, rank_of, arm_start, exit_t, &mut arm_scratch) {
1806                            all_arms.push(arm_scratch.clone());
1807                        } else {
1808                            complex = true;
1809                            break;
1810                        }
1811                    }
1812
1813                    if !complex && all_arms.len() >= 2 {
1814                        // ── 1. Lookahead ──────────────────────────────────────
1815                        // Only fires when EVERY arm provides all LOOKAHEAD_K bases,
1816                        // so score_arm_prefix compares the same number of bases on
1817                        // each arm.  Gating on the longest arm (as opposed to the
1818                        // shortest) let a short arm's score — capped at its own
1819                        // length — lose to a long arm's score purely because it
1820                        // was compared over fewer bases, regardless of which arm
1821                        // actually matched the query better.  A short arm (e.g. a
1822                        // length-1 "no insertion" shortcut against a many-node
1823                        // insertion arm) always lost this way, even when it was
1824                        // the correct one.  Short arms (< K) fall through to
1825                        // slide-and-lock's exhaustion rule or windowed DP, both of
1826                        // which score fairly.
1827                        let min_scored = all_arms
1828                            .iter()
1829                            .map(|arm| {
1830                                arm.len()
1831                                    .min(LOOKAHEAD_K)
1832                                    .min(query.len().saturating_sub(j_entry))
1833                            })
1834                            .min()
1835                            .unwrap_or(0);
1836
1837                        let winner = if min_scored >= LOOKAHEAD_K {
1838                            let scores: Vec<i32> = all_arms
1839                                .iter()
1840                                .map(|arm| {
1841                                    score_arm_prefix(
1842                                        arm,
1843                                        nodes,
1844                                        query,
1845                                        j_entry,
1846                                        cfg.match_score,
1847                                        cfg.mismatch_score,
1848                                    )
1849                                })
1850                                .collect();
1851                            let best = *scores.iter().max().unwrap();
1852                            let winner_count = scores.iter().filter(|&&s| s == best).count();
1853                            if winner_count == 1 {
1854                                let second_best = scores
1855                                    .iter()
1856                                    .copied()
1857                                    .filter(|&s| s != best)
1858                                    .max()
1859                                    .unwrap_or(i32::MIN);
1860                                if best - second_best >= LOOKAHEAD_MARGIN {
1861                                    scores.iter().position(|&s| s == best)
1862                                } else {
1863                                    None
1864                                }
1865                            } else {
1866                                None
1867                            }
1868                        } else {
1869                            None
1870                        };
1871
1872                        // ── 2. Slide-and-lock ─────────────────────────────────
1873                        let winner =
1874                            winner.or_else(|| slide_lock(&all_arms, nodes, query, j_entry));
1875
1876                        // ── Mark losers + lock winning arm windows ────────────
1877                        if let Some(w) = winner {
1878                            for (arm_idx, arm) in all_arms.iter().enumerate() {
1879                                if arm_idx != w {
1880                                    for &losing_node in arm {
1881                                        lookahead_skip[losing_node] = true;
1882                                    }
1883                                }
1884                            }
1885                            // Give winning arm nodes tight per-depth j-windows
1886                            // so cells at the correct query column are always
1887                            // filled even when arm depth > spine_margin.
1888                            let arm = &all_arms[w];
1889                            let arm_len = arm.len();
1890                            for (d, &win_node) in arm.iter().enumerate() {
1891                                let j_c = (j_entry + d + 1).min(l) as u32;
1892                                scratch.lock_node_j.push((win_node, j_c));
1893                            }
1894                            scratch.lock_node_j.sort_unstable_by_key(|&(idx, _)| idx);
1895                            // Exit node: centred at the arm's expected terminal j.
1896                            let exit_node = topo[exit_t];
1897                            let exit_j = (j_entry + arm_len).min(l) as u32;
1898                            // Insert sorted (exit_node may already be present from
1899                            // a previous bubble in the same align() call).
1900                            match scratch
1901                                .lock_exit_j
1902                                .binary_search_by_key(&exit_node, |&(idx, _)| idx)
1903                            {
1904                                Ok(pos) => {
1905                                    scratch.lock_exit_j[pos].1 =
1906                                        exit_j.max(scratch.lock_exit_j[pos].1)
1907                                }
1908                                Err(pos) => scratch.lock_exit_j.insert(pos, (exit_node, exit_j)),
1909                            }
1910                        }
1911                    }
1912                }
1913            }
1914        }
1915    }
1916
1917    // ── Find best terminal cell at column l ─────────────────────────────────────
1918    let terminal_best = (0..n)
1919        .flat_map(|t| {
1920            let jlo = j_lo_arr[t];
1921            let jhi = j_hi_arr[t];
1922            let sm = gs(&m, t, l, jlo, jhi, row_width);
1923            let si = gs(&ins, t, l, jlo, jhi, row_width);
1924            let sd = gsd(&del, &del0, t, l, jlo, jhi, row_width);
1925            let best_sc = [sm, si, sd].into_iter().filter(|&s| s != UNSET).max();
1926            best_sc.map(|sc| {
1927                let st = if sm == sc {
1928                    State::M
1929                } else if si == sc {
1930                    State::I
1931                } else {
1932                    State::D
1933                };
1934                (t, st, sc)
1935            })
1936        })
1937        .max_by_key(|&(_, _, sc)| sc)
1938        .map(|(t, s, _)| (t, s));
1939
1940    let (best_t, best_state) = match terminal_best {
1941        Some(result) => result,
1942        None => {
1943            // No node's banded row-window reached query column `l` at all --
1944            // the DP could not complete within the configured band. This
1945            // used to fall back to `(0, State::M)`, a fabricated starting
1946            // point nowhere near a real cell, which made the traceback
1947            // below immediately hit an UNSET cell and silently return an
1948            // empty `Vec<AlignOp>` -- no error, no warning, just a no-op
1949            // alignment that `add_to_graph` then applies as if the read
1950            // contributed nothing at all. Confirmed on real data
1951            // (`tests/adaptive_band_collapse.rs`): every non-seed read in a
1952            // population wider than the effective band hits this
1953            // identically, leaving the graph as just the seed's own linear
1954            // chain, which `consensus()`'s boundary trim then collapses to
1955            // a single base.
1956            //
1957            // `required` is estimated from `best_j_per_t`, which every row
1958            // already updates with the furthest query column its own
1959            // window reached (used elsewhere for the diagonal-skip
1960            // machinery, reused here rather than tracked separately): the
1961            // row that got closest to `l` fell short by
1962            // `l - max(best_j_per_t)`, so widening the margin by that same
1963            // amount is the natural single-shot estimate of what would
1964            // have worked. This is a heuristic, not a proof of
1965            // sufficiency -- see `align_with_retry`'s 3-pass caller, which
1966            // does not trust this estimate alone and escalates to a
1967            // mathematically-guaranteed-sufficient fully unbanded retry
1968            // if it turns out to still be too narrow.
1969            let max_best_j = best_j_per_t.iter().copied().max().unwrap_or(0);
1970            let shortfall = l.saturating_sub(max_best_j);
1971            let required = spine_margin.saturating_add(shortfall);
1972            return Err(PoaError::BandTooNarrow {
1973                configured: spine_margin,
1974                required,
1975            });
1976        }
1977    };
1978
1979    // ── Traceback ─────────────────────────────────────────────────────────────
1980    let mut ops: Vec<AlignOp> = Vec::with_capacity(l + n / 4);
1981    let mut t = best_t;
1982    let mut j = l;
1983    let mut cur_state = best_state;
1984
1985    // Defensive bound: a traceback over a well-formed DAG can never produce
1986    // more than `l` Insert/Match steps (each consumes one query base) plus
1987    // `n` Match/Delete steps (each consumes one graph node), so `l + n` is a
1988    // hard ceiling for any *correct* alignment. This crate has a documented
1989    // history of node-graph self-loops corrupting exactly this kind of
1990    // predecessor-pointer walk (see `design/graph_data_model_rework.md`
1991    // Phase 3's account of a literal node self-loop hanging
1992    // `heaviest_path`'s own traceback) -- if that class of bug is ever
1993    // reached from this traceback too, looping forever while `ops` grows
1994    // without bound is an OOM/crash risk, not just a slow answer. Treating
1995    // an over-long traceback as `BandTooNarrow` (rather than a silent
1996    // infinite loop) is conservative: it costs nothing in the overwhelming
1997    // majority of calls that never approach this bound, and turns a
1998    // process-ending crash into a catchable, retriable error for the rare
1999    // case that does.
2000    let max_ops = l.saturating_add(n).saturating_add(16);
2001
2002    loop {
2003        if ops.len() > max_ops {
2004            return Err(PoaError::BandTooNarrow {
2005                configured: spine_margin,
2006                required: spine_margin.saturating_mul(2).max(l),
2007            });
2008        }
2009        let cell = {
2010            let jlo = j_lo_arr[t];
2011            let jhi = j_hi_arr[t];
2012            match cur_state {
2013                State::M => {
2014                    if j < jlo || j > jhi {
2015                        Cell::unset()
2016                    } else {
2017                        m[t * row_width + (j - jlo)]
2018                    }
2019                }
2020                State::I => {
2021                    if j < jlo || j > jhi {
2022                        Cell::unset()
2023                    } else {
2024                        ins[t * row_width + (j - jlo)]
2025                    }
2026                }
2027                State::D => {
2028                    if j == 0 {
2029                        del0[t]
2030                    } else if j < jlo || j > jhi {
2031                        Cell::unset()
2032                    } else {
2033                        del[t * row_width + (j - jlo)]
2034                    }
2035                }
2036            }
2037        };
2038
2039        if cell.score == UNSET {
2040            break;
2041        }
2042
2043        match cur_state {
2044            State::M => {
2045                ops.push(AlignOp::Match(topo[t]));
2046                if cell.pred_t == VIRTUAL {
2047                    for k in (1..j).rev() {
2048                        ops.push(AlignOp::Insert(query[k - 1]));
2049                    }
2050                    break;
2051                }
2052                t = cell.pred_t as usize;
2053                j -= 1;
2054                cur_state = best_prev_state_banded(
2055                    &m, &ins, &del, &del0, t, j, &j_lo_arr, &j_hi_arr, row_width,
2056                );
2057            }
2058            State::I => {
2059                ops.push(AlignOp::Insert(query[j - 1]));
2060                j -= 1;
2061                if cell.pred_t == VIRTUAL {
2062                    for k in (1..j).rev() {
2063                        ops.push(AlignOp::Insert(query[k - 1]));
2064                    }
2065                    break;
2066                }
2067                let sm = gs(&m, t, j, j_lo_arr[t], j_hi_arr[t], row_width);
2068                let si = gs(&ins, t, j, j_lo_arr[t], j_hi_arr[t], row_width);
2069                cur_state = if sm >= si { State::M } else { State::I };
2070            }
2071            State::D => {
2072                ops.push(AlignOp::Delete(topo[t]));
2073                if cell.pred_t == VIRTUAL {
2074                    break;
2075                }
2076                t = cell.pred_t as usize;
2077                cur_state = best_prev_state_banded(
2078                    &m, &ins, &del, &del0, t, j, &j_lo_arr, &j_hi_arr, row_width,
2079                );
2080            }
2081        }
2082
2083        if j == 0 && cur_state != State::D {
2084            break;
2085        }
2086    }
2087
2088    ops.reverse();
2089    Ok(ops)
2090}
2091
2092/// Wraps [`align`] with a bounded, guaranteed-terminating retry for
2093/// [`PoaError::BandTooNarrow`].
2094///
2095/// `TODO.md` has long described a "smart retry (3-pass)" here -- pass 1 at
2096/// the configured width, pass 2 at the error's own `required` estimate,
2097/// pass 3 fully unbanded -- as already implemented. It was not: `align`
2098/// never returned `Err` at all before the fix that added the
2099/// `BandTooNarrow` path above (confirmed by exhaustive search), so no
2100/// caller ever exercised a retry, and none existed. Several existing
2101/// tests (`band_too_narrow_fallback_to_unbanded`,
2102/// `tracking_band_survives_phase_shift`) already asserted the *outcome*
2103/// this retry produces, and passed anyway, for the wrong reason -- not
2104/// because a retry recovered a wide-enough band, but because the missing
2105/// `BandTooNarrow` path meant `align` silently returned a degenerate
2106/// (possibly empty) alignment that the assertions happened not to notice
2107/// was wrong. This is that design, actually built:
2108///
2109/// - **Pass 1**: the caller's own config, unmodified. This is the
2110///   overwhelmingly common path and costs nothing extra when it succeeds.
2111/// - **Pass 2**: on `BandTooNarrow`, retry once with `band_width` set to
2112///   the error's own `required` estimate and `adaptive_band` forced off
2113///   (so `required` is the literal effective margin used, not further
2114///   changed by the adaptive formula), and with `anchors` cleared -- an
2115///   anchor chain computed for the original, too-narrow window is not
2116///   necessarily valid for a wider one, and `align_read_ops`/
2117///   `align_read_ops_unbanded` already always call with no anchors for
2118///   exactly this reason (a "just get a correct alignment" call, not a
2119///   performance-sensitive incremental one).
2120/// - **Pass 3**: if pass 2 *also* returns `BandTooNarrow` (the pass-1
2121///   heuristic `required` estimate under-shot -- `real_in_edge_count`-style
2122///   estimates elsewhere in this file are deliberately conservative, but
2123///   this one is a single-shot approximation, not a proven lower bound),
2124///   retry with a fully unbanded config (`band_width = 0, adaptive_band =
2125///   false`, i.e. `spine_margin = l`) -- mathematically guaranteed
2126///   sufficient (full NW over the DAG), not another estimate. If this
2127///   *still* errors, that is a genuine, unexpected failure and is
2128///   propagated rather than looped on again.
2129///
2130/// Returns `(ops, retried)`: `retried` is `true` whenever pass 1 (the
2131/// caller's own config) did not succeed on its own, so the caller can tell
2132/// a clean single-band alignment from one that only succeeded after
2133/// widening -- see `PoaGraph::used_band_retry` for why that distinction
2134/// matters beyond just "did it eventually succeed."
2135#[allow(clippy::too_many_arguments)]
2136fn align_with_retry(
2137    nodes: &[Node],
2138    topo: &[usize],
2139    rank_of: &[usize],
2140    spine: &[(usize, u8, i32)],
2141    query: &[u8],
2142    cfg: &PoaConfig,
2143    scratch: &mut AlignScratch,
2144    anchors: &[(usize, usize)],
2145) -> Result<(Vec<AlignOp>, bool), PoaError> {
2146    match align(nodes, topo, rank_of, spine, query, cfg, scratch, anchors) {
2147        Ok(ops) => Ok((ops, false)),
2148        Err(PoaError::BandTooNarrow { required, .. }) => {
2149            let mut cfg2 = cfg.clone();
2150            cfg2.band_width = required;
2151            cfg2.adaptive_band = false;
2152            match align(nodes, topo, rank_of, spine, query, &cfg2, scratch, &[]) {
2153                Ok(ops) => Ok((ops, true)),
2154                Err(PoaError::BandTooNarrow { .. }) => {
2155                    let mut cfg3 = cfg.clone();
2156                    cfg3.band_width = 0;
2157                    cfg3.adaptive_band = false;
2158                    align(nodes, topo, rank_of, spine, query, &cfg3, scratch, &[])
2159                        .map(|ops| (ops, true))
2160                }
2161                Err(other) => Err(other),
2162            }
2163        }
2164        Err(other) => Err(other),
2165    }
2166}
2167
2168#[allow(clippy::too_many_arguments)]
2169#[inline]
2170fn best_prev_state_banded(
2171    m: &[Cell],
2172    ins: &[Cell],
2173    del: &[Cell],
2174    del0: &[Cell],
2175    t: usize,
2176    j: usize,
2177    j_lo_arr: &[usize],
2178    j_hi_arr: &[usize],
2179    row_width: usize,
2180) -> State {
2181    let jlo = j_lo_arr[t];
2182    let jhi = j_hi_arr[t];
2183    let sm = gs(m, t, j, jlo, jhi, row_width);
2184    let si = gs(ins, t, j, jlo, jhi, row_width);
2185    let sd = gsd(del, del0, t, j, jlo, jhi, row_width);
2186    if sm != UNSET && sm >= si && sm >= sd {
2187        State::M
2188    } else if si != UNSET && si >= sd {
2189        State::I
2190    } else {
2191        State::D
2192    }
2193}
2194
2195// ─── Graph update ─────────────────────────────────────────────────────────────
2196
2197/// Verifies (read-only) that the existing chain recorded at `start` still
2198/// spells exactly `edit`: every one of the `edit.len() - 1` remaining hops
2199/// must follow a single out-edge (no internal branching since the chain was
2200/// recorded) to a node whose base matches the corresponding byte of `edit`.
2201/// Returns the full chain of node indices on success, or `None` if the chain
2202/// has since branched or drifted -- the caller falls back to creating a
2203/// fresh arm exactly as before this phase existed.
2204fn verify_reuse_chain(nodes: &[Node], edit: &[u8], start: usize) -> Option<Vec<usize>> {
2205    debug_assert!(!edit.is_empty());
2206    let mut chain = Vec::with_capacity(edit.len());
2207    let mut cur = start;
2208    if nodes[cur].base != edit[0] {
2209        return None;
2210    }
2211    chain.push(cur);
2212    for &b in &edit[1..] {
2213        match nodes[cur].out_edges.as_slice() {
2214            [(next, _)] if nodes[*next].base == b => {
2215                cur = *next;
2216                chain.push(cur);
2217            }
2218            _ => return None,
2219        }
2220    }
2221    Some(chain)
2222}
2223
2224/// Returns `true` if any node in `chain` is referenced again anywhere in
2225/// `rest` (as a `Match` or `Delete` target).
2226///
2227/// This is the safety check that closes the gap the naive first attempt at
2228/// this feature missed and this phase's own first implementation attempt
2229/// also missed: `align()` computes the *entire* traceback for a read in one
2230/// pass, before `add_to_graph` runs at all, and its `Match`/`Delete` ops
2231/// reference specific existing node indices that the DP decided this read
2232/// visits -- independently of whatever `add_to_graph` does. If content-address
2233/// reuse redirects `prev` to an existing node, and that *same* node is also
2234/// named later in this read's own traceback (which was computed without any
2235/// knowledge that reuse would happen), committing the reuse creates a second,
2236/// unrelated edge into that node from `prev` -- up to and including a literal
2237/// self-loop when the very next op names the node reuse just landed on.
2238/// Confirmed concretely on real data (DAB1 SCA37, seed=25): node 49 ended up
2239/// with `in_edges=[17, 49]` and `out_edges=[18, 99, 49]`, a genuine self-loop,
2240/// which corrupts `topological_order`'s Kahn's-algorithm bookkeeping (in-degree
2241/// can never reach zero) and hangs `heaviest_path`'s traceback (an unbounded
2242/// walk over a predecessor-pointer array that develops its own cycle once
2243/// `rank_of` assigns the same rank to two different nodes). Skipping reuse
2244/// whenever the candidate chain reappears later in this same read's ops is a
2245/// cheap (bounded by `ops.len()`), always-correct guard against exactly this.
2246fn reuse_would_collide(chain: &[usize], rest: &[AlignOp]) -> bool {
2247    rest.iter().any(|op| match *op {
2248        AlignOp::Match(idx) | AlignOp::Delete(idx) => chain.contains(&idx),
2249        AlignOp::Insert(_) => false,
2250    })
2251}
2252
2253/// Returns `true` if committing this reuse would create a back edge relative
2254/// to the *rest of this same read's own traceback*, not just relative to the
2255/// fork itself (that narrower check lives in `try_reuse_arm`, comparing only
2256/// `fork` against `chain[0]`).
2257///
2258/// `align()`'s own traceback is provably rank-monotonic in the *original*
2259/// node identities it names: reading its `Match`/`Delete` ops in forward
2260/// order, the referenced existing-node rank strictly increases throughout
2261/// (each step in the backward-built traceback moves to a strictly smaller
2262/// DP row, so after `ops.reverse()` the forward sequence strictly
2263/// increases). Confirmed empirically by dumping a real failing read's ops
2264/// end to end: no adjacent pair of `Match`/`Delete` references ever regresses
2265/// in rank.
2266///
2267/// Reuse breaks this guarantee from the *outside*: it substitutes a
2268/// *different*, already-existing node (`chain`'s last element, at whatever
2269/// rank that node happens to have) in place of whatever fresh identity
2270/// `align()`'s own traceback implicitly expected at this position. The very
2271/// next `Match`/`Delete` op in `rest` (an ordinary, unprotected
2272/// `increment_or_add_edge` call in `add_to_graph`, ordinary edges never
2273/// having needed a rank check before reuse existed) then wires an edge from
2274/// this substitute straight to whatever existing node `align()` visits next
2275/// -- which is only ever safe if the substitute's rank is still *less than*
2276/// that next node's rank. `chain`'s internal edges are all real, pre-existing
2277/// graph edges (verified by `verify_reuse_chain`'s single-out-edge walk), so
2278/// rank strictly increases along `chain` too; checking only the *last*
2279/// element (the one `prev` becomes) against the first subsequent existing-
2280/// node reference is therefore sufficient -- if that holds, every earlier
2281/// `chain` element (smaller rank) holds too.
2282///
2283/// Confirmed concretely via a `cag50_hifi`-class read set once the
2284/// `BandTooNarrow` retry started actually widening bands for some reads: a
2285/// substitution reuse landed on an existing node whose rank happened to sit
2286/// *after* the very next node `align()`'s own traceback visited, splicing in
2287/// a real back edge and corrupting the graph into a cycle -- silently
2288/// hanging `heaviest_path`'s traceback in an unbounded loop (the same
2289/// failure class `reuse_would_collide` already guards, reached by a
2290/// different route: rank inversion rather than direct index reappearance).
2291fn reuse_would_create_back_edge(rank_of: &[usize], chain: &[usize], rest: &[AlignOp]) -> bool {
2292    let Some(&last_rank) = chain.last().and_then(|&idx| rank_of.get(idx)) else {
2293        return false;
2294    };
2295    for op in rest {
2296        if let AlignOp::Match(idx) | AlignOp::Delete(idx) = *op {
2297            return match rank_of.get(idx) {
2298                Some(&r) => last_rank >= r,
2299                // `idx` isn't in this read's entry-snapshot rank_of at all,
2300                // meaning it's a node this same read created after the
2301                // snapshot was taken -- Match/Delete ops from align()'s own
2302                // traceback never reference such nodes (they only name
2303                // nodes that existed before this read started), so this
2304                // shouldn't happen; bail out defensively rather than reject.
2305                None => false,
2306            };
2307        }
2308    }
2309    false
2310}
2311
2312/// Commits a verified reuse chain: increments edge weight/`edge_reads` and
2313/// bumps `coverage` on every reused node -- the same effect a genuine `Match`
2314/// against each of these nodes would have had. Only ever called after both
2315/// `verify_reuse_chain` and `reuse_would_collide` have cleared the chain.
2316fn commit_reuse_chain(
2317    nodes: &mut [Node],
2318    edge_reads: &mut HashMap<(usize, usize), Vec<u32>>,
2319    fork: usize,
2320    chain: &[usize],
2321    read_idx: u32,
2322) -> usize {
2323    let mut prev_node = fork;
2324    for &node in chain {
2325        // Reuse only ever traverses an edge that `verify_reuse_chain` already
2326        // confirmed exists (that's what makes it a "reuse" and not a fresh
2327        // node creation) -- this call should always increment, never create.
2328        // A brand-new edge appearing here would mean a reused arm's fork
2329        // propagation was never run when the arm was first built, which
2330        // would be a real bug elsewhere, not something to paper over here.
2331        let created = increment_or_add_edge(nodes, prev_node, node, false);
2332        debug_assert!(
2333            !created,
2334            "reuse chain traversed an edge that did not already exist"
2335        );
2336        edge_reads
2337            .entry((prev_node, node))
2338            .or_default()
2339            .push(read_idx);
2340        nodes[node].coverage += 1;
2341        prev_node = node;
2342    }
2343    *chain.last().unwrap()
2344}
2345
2346/// Looks up, verifies, and safety-checks a candidate reuse in one call;
2347/// returns the reused chain's last node index on success. See
2348/// `verify_reuse_chain` and `reuse_would_collide` for what "success" means.
2349///
2350/// `rank_of` is the topological rank snapshot taken at the *start* of the
2351/// current `add_read` call (before this read's own ops are applied). Reuse
2352/// splices a brand-new edge `fork -> chain[0]` between two nodes that both
2353/// already existed in the graph -- unlike an ordinary Match/Insert/Delete op,
2354/// which only ever connects nodes along `align()`'s own traceback and is
2355/// therefore guaranteed rank-monotonic by construction (each step moves to a
2356/// strictly smaller DP row), this edge is *not* derived from that traceback
2357/// at all. It comes from `fork_arm_index`, a lookup keyed only by content
2358/// (the fork node and the edit's bytes), with no reference to where either
2359/// node sits in the graph's real topological order relative to the other.
2360///
2361/// `reuse_would_collide` guards one specific way this can go wrong (the
2362/// reused chain reappearing later in this same read's own remaining ops),
2363/// but not this one: if `fork` is not actually an ancestor of `chain[0]` in
2364/// the graph's existing structure (i.e. `chain[0]` already sits at or before
2365/// `fork` in topological order), splicing in `fork -> chain[0]` creates a
2366/// genuine multi-node cycle, not a same-read self-loop. `reuse_would_collide`
2367/// cannot see this because it only ever looks within the current read's own
2368/// ops; it has no way to know the reused chain's actual position in the rest
2369/// of the graph.
2370///
2371/// Confirmed concretely via a `cag50_hifi`-class read set once the
2372/// `BandTooNarrow` retry (see `align_with_retry`) started actually firing:
2373/// a retried alignment pass (wider band, anchors cleared) can settle on a
2374/// different fork/traceback shape than the pass that originally populated
2375/// `fork_arm_index` for this position, so by the time a later read's ops
2376/// reach this same fork, `chain[0]` may already be topologically upstream of
2377/// `fork` rather than downstream. Committing the reuse in that case corrupts
2378/// the graph into a real cycle: `topological_order`'s Kahn's-algorithm queue
2379/// never reaches in-degree zero for the cyclic nodes, silently excluding them
2380/// from `topo` (and leaving their `rank_of` entries at the default `0`,
2381/// colliding with whatever node is legitimately first), which in turn hangs
2382/// `heaviest_path`'s own predecessor-pointer traceback in an unbounded loop
2383/// -- the same failure class already documented on `reuse_would_collide`,
2384/// just reached by a different path. Rejecting the reuse whenever it would
2385/// not be rank-increasing is cheap (an O(1) rank comparison) and always
2386/// safe: falling through to fresh-node creation, exactly like the existing
2387/// `None`-returning cases, can never introduce a cycle since a brand-new node
2388/// has no edges to anywhere except `fork` and, later, its own single
2389/// successor.
2390#[allow(clippy::too_many_arguments)]
2391fn try_reuse_arm(
2392    nodes: &mut [Node],
2393    edge_reads: &mut HashMap<(usize, usize), Vec<u32>>,
2394    fork_arm_index: &HashMap<usize, HashMap<Vec<u8>, usize>>,
2395    rank_of: &[usize],
2396    fork: usize,
2397    edit: &[u8],
2398    rest_ops: &[AlignOp],
2399    read_idx: u32,
2400) -> Option<usize> {
2401    let start = *fork_arm_index.get(&fork)?.get(edit)?;
2402    if fork >= rank_of.len() || start >= rank_of.len() || rank_of[fork] >= rank_of[start] {
2403        return None;
2404    }
2405    let chain = verify_reuse_chain(nodes, edit, start)?;
2406    if reuse_would_collide(&chain, rest_ops) {
2407        return None;
2408    }
2409    if reuse_would_create_back_edge(rank_of, &chain, rest_ops) {
2410        return None;
2411    }
2412    Some(commit_reuse_chain(
2413        nodes, edge_reads, fork, &chain, read_idx,
2414    ))
2415}
2416
2417/// Record (or increment) a bypass edge `from -> to`. See
2418/// `PoaGraph.bypass_edges`. Weight is a running count of reads that took this
2419/// bypass; a self-loop (`from == to`) is refused defensively for symmetry
2420/// with `increment_or_add_edge`, though the caller's own guards should make
2421/// it unreachable.
2422fn record_bypass_edge(
2423    bypass_edges: &mut HashMap<usize, Vec<(usize, i32)>>,
2424    from: usize,
2425    to: usize,
2426) {
2427    if from == to {
2428        debug_assert_ne!(from, to, "record_bypass_edge: refusing a self-loop bypass");
2429        return;
2430    }
2431    let entry = bypass_edges.entry(from).or_default();
2432    for (t, w) in entry.iter_mut() {
2433        if *t == to {
2434            *w += 1;
2435            return;
2436        }
2437    }
2438    entry.push((to, 1));
2439}
2440
2441// `edge_delete_reads` is intentionally NOT a parameter: under pure bypass a
2442// deleting read touches the skipped node only via `delete_count`, never via an
2443// edge, so there is nothing to record in that (write-only, unread) map. The
2444// `PoaGraph.edge_delete_reads` field is left in place (initialised empty,
2445// never populated) as a removable-later cleanup, out of scope here.
2446#[allow(clippy::too_many_arguments)]
2447fn add_to_graph(
2448    nodes: &mut Vec<Node>,
2449    edge_reads: &mut HashMap<(usize, usize), Vec<u32>>,
2450    bypass_edges: &mut HashMap<usize, Vec<(usize, i32)>>,
2451    fork_arm_index: &mut HashMap<usize, HashMap<Vec<u8>, usize>>,
2452    rank_of: &[usize],
2453    query: &[u8],
2454    ops: &[AlignOp],
2455    read_idx: u32,
2456) {
2457    let mut prev: Option<usize> = None;
2458    let mut q_idx: usize = 0;
2459    let mut i = 0usize;
2460
2461    // Bypass-edge tracking (Phase 1 of design/bypass_edge_delete_rework.md).
2462    // `bypass_pending` is `Some(entry_pred)` while inside a run of consecutive
2463    // `Delete` ops, where `entry_pred` is `prev` as it stood *just before* the
2464    // run's first Delete (itself an `Option`: `None` when the read begins with
2465    // a Delete run, i.e. there is no predecessor to bypass *from*). It is set
2466    // on the first Delete of a run, left untouched by subsequent Deletes in
2467    // the same run, and consumed by the next `Match`/`Insert` op -- which, by
2468    // the time it finishes, has advanced `prev` to the node the read resumed
2469    // at (the bypass edge's `to`). Capturing the resume node this way, rather
2470    // than peeking at `ops[i]`, is what makes it correct in the mismatch and
2471    // insert cases too: those create/reuse a *new* node whose index isn't
2472    // known until the op is processed, but `prev` names it correctly
2473    // afterward regardless. A run left pending at end-of-ops (a terminal
2474    // Delete run, e.g. a semi-global read that runs out before the graph does)
2475    // is simply dropped -- there is no resume node, so no bypass edge to
2476    // nowhere is created.
2477    let mut bypass_pending: Option<Option<usize>> = None;
2478
2479    // Nodes that gained a new out-edge while processing *this* read.
2480    // `propagate_fork_if_new` is deliberately NOT called inline as each edge
2481    // is added -- confirmed empirically (see `set_new_node_own_fork`'s doc
2482    // comment) that doing so races against this same read's own later ops:
2483    // a node can look like an ordinary single-predecessor node at the moment
2484    // a fork upstream is created, then gain a second in-edge (reconvergence)
2485    // a few ops later in this exact read's own traceback. Collecting
2486    // candidates here and propagating once, after the whole read's ops are
2487    // processed, means `in_edges.len()` already reflects this read's final
2488    // effect on the graph by the time any propagation walk inspects it.
2489    let mut newly_forked: Vec<usize> = Vec::new();
2490
2491    while i < ops.len() {
2492        // The node this iteration's op reconnects the read to (its first, in
2493        // read order -- not `prev`'s final value, which for a multi-base
2494        // Insert is the run's *last* node). Set (to `Some`) by the
2495        // `Match`/`Insert` arms to signal "a non-Delete op ran, close any
2496        // pending bypass run"; left `None` by `Delete` so a run stays open
2497        // across its whole span.
2498        let mut resume_node: Option<usize> = None;
2499        // Whether this iteration's op was a clean Match onto a node ALREADY in
2500        // the graph (base agrees) -- the only resume shape that records a
2501        // bypass edge under pure bypass. A mismatch/insert (new or reused
2502        // structure) instead keeps its ordinary real out-edge and records no
2503        // bypass (see the resolution block after the match).
2504        let mut resume_is_bypass = false;
2505        match ops[i] {
2506            AlignOp::Match(node_idx) => {
2507                let q_base = query[q_idx];
2508                q_idx += 1;
2509                let cur = if nodes[node_idx].base == q_base {
2510                    nodes[node_idx].coverage += 1;
2511                    if bypass_pending.is_some() {
2512                        // Pure bypass (revised Phase 1): this clean Match is
2513                        // resuming a Delete run onto a node already in the
2514                        // graph. Record NO matched out-edge (from the entry
2515                        // predecessor or anywhere) and NO `edge_reads` entry --
2516                        // that edge is exactly the "laundering" this rework
2517                        // removes (it let the skipped node's downstream matched
2518                        // weight re-inflate its arm). `coverage` is still bumped
2519                        // (above); the reconnection is represented solely by the
2520                        // bypass edge recorded at the resolution block below.
2521                        resume_is_bypass = true;
2522                    } else if let Some(p) = prev {
2523                        if increment_or_add_edge(nodes, p, node_idx, false) {
2524                            newly_forked.push(p);
2525                        }
2526                        edge_reads.entry((p, node_idx)).or_default().push(read_idx);
2527                    }
2528                    node_idx
2529                } else {
2530                    // Single-base substitution edit. Try exact-duplicate reuse
2531                    // at this fork before creating a new node.
2532                    let edit = [q_base];
2533                    let reused = prev.and_then(|p| {
2534                        try_reuse_arm(
2535                            nodes,
2536                            edge_reads,
2537                            fork_arm_index,
2538                            rank_of,
2539                            p,
2540                            &edit,
2541                            &ops[i + 1..],
2542                            read_idx,
2543                        )
2544                    });
2545                    if let Some(reused_idx) = reused {
2546                        reused_idx
2547                    } else {
2548                        let new_idx = push_node(nodes, q_base);
2549                        nodes[new_idx].coverage = 1;
2550                        if let Some(p) = prev {
2551                            nodes[p].out_edges.push((
2552                                new_idx,
2553                                EdgeWeight {
2554                                    matched: 1,
2555                                    deleted: 0,
2556                                },
2557                            ));
2558                            nodes[new_idx].in_edges.push(p);
2559                            edge_reads.entry((p, new_idx)).or_default().push(read_idx);
2560                            fork_arm_index
2561                                .entry(p)
2562                                .or_default()
2563                                .insert(edit.to_vec(), new_idx);
2564                            set_new_node_own_fork(nodes, p, new_idx);
2565                            newly_forked.push(p);
2566                        }
2567                        new_idx
2568                    }
2569                };
2570                // A Match reconnects at exactly one node (`cur`), whether a
2571                // clean match, a reused single-base substitution (chain
2572                // length 1, so first == last), or a freshly created
2573                // substitute -- so `cur` is unambiguously the resume node.
2574                resume_node = Some(cur);
2575                prev = Some(cur);
2576                i += 1;
2577            }
2578            AlignOp::Insert(first_base) => {
2579                // Collect the full run of consecutive Insert ops: the
2580                // "characterized edit" is only fully known once the run
2581                // ends, so the content-address lookup/insert has to happen
2582                // for the whole run at once, not per base.
2583                let run_start = i;
2584                let mut edit = vec![first_base];
2585                let mut j = i + 1;
2586                while j < ops.len() {
2587                    if let AlignOp::Insert(b) = ops[j] {
2588                        edit.push(b);
2589                        j += 1;
2590                    } else {
2591                        break;
2592                    }
2593                }
2594
2595                let reused = prev.and_then(|p| {
2596                    try_reuse_arm(
2597                        nodes,
2598                        edge_reads,
2599                        fork_arm_index,
2600                        rank_of,
2601                        p,
2602                        &edit,
2603                        &ops[j..],
2604                        read_idx,
2605                    )
2606                });
2607                if let Some(reused_idx) = reused {
2608                    // An Insert creates/rejoins DIVERGENT structure, not the
2609                    // existing main path, so under pure bypass it keeps its
2610                    // ordinary real edges (created inside `try_reuse_arm`) and
2611                    // records NO bypass edge -- `resume_is_bypass` stays false.
2612                    // `resume_node` just needs to be `Some` to signal that a
2613                    // non-Delete op ran and any pending bypass run should close.
2614                    resume_node = Some(reused_idx);
2615                    prev = Some(reused_idx);
2616                } else {
2617                    let fork = prev;
2618                    let mut chain_start = None;
2619                    for &b in &edit {
2620                        let new_idx = push_node(nodes, b);
2621                        nodes[new_idx].coverage = 1;
2622                        if let Some(p) = prev {
2623                            nodes[p].out_edges.push((
2624                                new_idx,
2625                                EdgeWeight {
2626                                    matched: 1,
2627                                    deleted: 0,
2628                                },
2629                            ));
2630                            nodes[new_idx].in_edges.push(p);
2631                            edge_reads.entry((p, new_idx)).or_default().push(read_idx);
2632                            set_new_node_own_fork(nodes, p, new_idx);
2633                            newly_forked.push(p);
2634                        }
2635                        chain_start.get_or_insert(new_idx);
2636                        prev = Some(new_idx);
2637                    }
2638                    if let (Some(f), Some(start)) = (fork, chain_start) {
2639                        fork_arm_index
2640                            .entry(f)
2641                            .or_default()
2642                            .insert(edit.clone(), start);
2643                    }
2644                    // New structure, not the existing main path: keep the real
2645                    // edges just created, record no bypass (`resume_is_bypass`
2646                    // stays false); `resume_node` only signals run closure.
2647                    resume_node = chain_start;
2648                }
2649                q_idx += edit.len();
2650                i = run_start + edit.len();
2651                let _ = j; // j == i; kept named for clarity above
2652            }
2653            AlignOp::Delete(node_idx) => {
2654                // Open a bypass run on the first Delete: capture `prev` (the
2655                // entry predecessor). `Some(None)` marks a run that begins with
2656                // no predecessor (read starts on a Delete under semi-global),
2657                // which resolves to no bypass edge.
2658                if bypass_pending.is_none() {
2659                    bypass_pending = Some(prev);
2660                }
2661                // Pure bypass (revised Phase 1): increment the skipped node's
2662                // own `delete_count` (this is all `mean_column_entropy` and
2663                // `majority_frequency` need -- they read per-node
2664                // coverage/delete_count only) and do NOTHING else. In
2665                // particular do NOT:
2666                //   - create/increment a `p -> node_idx` edge
2667                //     (`increment_or_add_edge(.., true)`), nor push
2668                //     `edge_delete_reads` (that map is write-only -- no
2669                //     production consumer reads it -- so under pure bypass it
2670                //     simply stops being populated; the field is left in place,
2671                //     removable in a later cleanup);
2672                //   - advance `prev` through the skipped node.
2673                // Leaving `prev` at the entry predecessor is what makes the
2674                // read's next real op reconnect via a bypass edge (existing
2675                // resume) or a fresh minority out-edge (new resume) instead of
2676                // laundering the skipped node's downstream matched edge.
2677                nodes[node_idx].delete_count += 1;
2678                i += 1;
2679            }
2680        }
2681
2682        // Resolve a pending bypass run at the first non-Delete op. A `Delete`
2683        // leaves `resume_node` `None`, so the run stays open across its whole
2684        // span and is only closed here by the Match/Insert that ends it. A
2685        // bypass edge is recorded only when that resume was a clean Match onto
2686        // an EXISTING node (`resume_is_bypass`) AND the run had an entry
2687        // predecessor to bypass *from* (a leading Delete run has none). A
2688        // mismatch/insert resume closes the run but records no bypass -- its
2689        // real out-edge already represents the reconnection.
2690        if bypass_pending.is_some() && resume_node.is_some() {
2691            if resume_is_bypass {
2692                if let (Some(Some(from)), Some(to)) = (bypass_pending, resume_node) {
2693                    // `rank_of` is the pre-read topological snapshot. A bypass
2694                    // run whose entry predecessor `from` is a node this SAME
2695                    // read created earlier (e.g. an Insert that ran before the
2696                    // Delete run) has `from >= rank_of.len()` and cannot be
2697                    // rank-checked against the snapshot. Such a bypass is
2698                    // forward by construction -- ops are applied in alignment
2699                    // order, so an Insert-created `from` precedes the
2700                    // later-matched existing `to` -- so only assert the
2701                    // topological order when both endpoints are in the
2702                    // snapshot. Mirrors `try_reuse_arm`'s own `>= rank_of.len()`
2703                    // guard. Debug-only: the recorded bypass edge is identical
2704                    // either way (this was a spurious `debug_assert` panic in
2705                    // debug/CI builds; release builds, where `debug_assert` is
2706                    // compiled out, always recorded the correct edge).
2707                    debug_assert!(
2708                        from >= rank_of.len() || to >= rank_of.len() || rank_of[from] < rank_of[to],
2709                        "bypass edge {from}->{to} between in-snapshot nodes must respect \
2710                         topological order -- it is redundant with the real \
2711                         entry-pred->...->resume path through the skipped nodes"
2712                    );
2713                    // Anti-laundering guard: a bypass resume must NOT have
2714                    // created a matched edge into the resume node for this
2715                    // read. If it had, this read would appear as the last
2716                    // pushed reader on that edge -- the exact laundering the
2717                    // pure-bypass Delete arm removes.
2718                    debug_assert!(
2719                        edge_reads
2720                            .get(&(from, to))
2721                            .is_none_or(|v| v.last() != Some(&read_idx)),
2722                        "laundering guard: deleting read {read_idx} created a matched \
2723                         edge {from}->{to} into its bypass resume node"
2724                    );
2725                    record_bypass_edge(bypass_edges, from, to);
2726                }
2727            }
2728            bypass_pending = None;
2729        }
2730    }
2731
2732    // A bypass run still open here is a terminal Delete run (the read ran out
2733    // of query before the graph ended, e.g. a semi-global right-flank gap):
2734    // no resume node ever followed, so there is no bypass edge to record.
2735    // Dropping `bypass_pending` unresolved is the correct handling -- a bypass
2736    // edge to a nonexistent "next" node would be malformed.
2737    let _ = bypass_pending;
2738
2739    // Now that this read's entire traceback has been applied and the graph
2740    // reflects its final shape (including any reconvergence this same read
2741    // itself created), it's safe to propagate fork-context to descendants.
2742    // Dedup first: a fork can appear multiple times (e.g. an Insert run's
2743    // per-base loop calls this for the same `p` only once, but a read could
2744    // still revisit the same fork node as `p` more than once via separate
2745    // ops), and `propagate_fork_if_new` is cheap but there's no reason to
2746    // redo the same walk twice.
2747    newly_forked.sort_unstable();
2748    newly_forked.dedup();
2749    for p in newly_forked {
2750        propagate_fork_if_new(nodes, p);
2751    }
2752}
2753
2754// ─── Heaviest path ────────────────────────────────────────────────────────────
2755
2756/// Cumulative-weight DP over the DAG, picking the heaviest incoming edge at
2757/// each node to build the consensus spine.
2758///
2759/// Scores on `edge.matched` only -- `deleted` traversals contribute nothing,
2760/// not even a discount. A read that deletes through a node provides zero
2761/// evidence that the node's *base* is correct (it skipped confirming it
2762/// entirely); scoring on total (matched + deleted) traffic let a node reached
2763/// mostly by Delete out-compete a genuinely-Match-confirmed alternative arm
2764/// at the same fork, confirmed on a forced case (6 reads deleting through a
2765/// reference base vs. 4 reads genuinely matching a SNP base at the same
2766/// position -- see `heaviest_path_prefers_matched_over_delete_inflated_arm`
2767/// in src/tests.rs). This mirrors the interior filter's own existing
2768/// philosophy (`coverage > delete_count`): Delete already counts as zero
2769/// evidence for node inclusion there, so it would be inconsistent for this
2770/// function's arm-*selection* to treat it as partial evidence instead.
2771///
2772/// **Bypass edges (Phase 2 of `design/bypass_edge_delete_rework.md`) are the
2773/// mechanism that lets the DP route around a widely-skipped node.** A run of
2774/// reads that Deleted past a node reconnected via a `bypass_edges` entry
2775/// `from -> to` (recorded in Phase 1); here each such entry is relaxed as an
2776/// additional competing outgoing option of `from`, scored `(weight - 1)` on
2777/// the plain `i32` bypass weight directly -- NOT via `EdgeWeight.matched`, a
2778/// bypass weight does not live in an `EdgeWeight` at all. So a node most reads
2779/// skip is out-competed by the bypass around it, exactly as abPOA/SPOA's plain
2780/// heaviest-bundling does, and there is no laundering to counteract: under pure
2781/// bypass (Phase 1) the deleting reads never touch the skipped node's
2782/// downstream matched edge, so that edge cannot re-inflate the weak node's arm
2783/// at a reconvergence point. This is why the old `credibility_penalty` (which
2784/// discounted net-Delete-dominant nodes to counter exactly that laundering) is
2785/// **retired** here -- Audit item 1's redundancy proof holds once the laundered
2786/// edge is gone. (`heaviest_path_prefers_matched_over_delete_inflated_arm` is
2787/// the ground truth: it now lands on the SNP arm via the bypass edge + no
2788/// penalty, rather than via the penalty on a de-laundered graph.)
2789///
2790/// A bypass edge `(from, to)` always satisfies `rank_of[from] < rank_of[to]`
2791/// (it is redundant with the real `from -> ...skipped... -> to` path that
2792/// still exists in the graph, so it can introduce no cycle) -- asserted below.
2793/// The DP is forward-relaxation in topological order, so relaxing `from`'s
2794/// bypass edges into `cum[rank_of[to]]` when `from` is processed correctly
2795/// makes the bypass an incoming option for `to` (its `cum` is not finalized
2796/// until every strictly-earlier rank, `from` included, has been processed).
2797fn heaviest_path(
2798    nodes: &[Node],
2799    topo: &[usize],
2800    rank_of: &[usize],
2801    bypass_edges: &HashMap<usize, Vec<(usize, i32)>>,
2802) -> Vec<(usize, u8, i32)> {
2803    let n = topo.len();
2804    let mut cum: Vec<(i64, Option<usize>, i32)> = vec![(0, None, 0); n];
2805
2806    for t in 0..n {
2807        let node_idx = topo[t];
2808        let node = &nodes[node_idx];
2809        let curr = cum[t].0;
2810        for &(succ_idx, ew) in &node.out_edges {
2811            let succ_t = rank_of[succ_idx];
2812            let candidate = curr + (ew.matched - 1) as i64;
2813            if candidate > cum[succ_t].0 {
2814                cum[succ_t] = (candidate, Some(t), ew.matched);
2815            }
2816        }
2817        // Bypass edges are relaxed AFTER out-edges, so an exact tie between a
2818        // bypass and a through-edge to the same target keeps the through-edge
2819        // (strict `>` below) -- i.e. prefer keeping a node over skipping it
2820        // when the evidence is exactly balanced, leaving such a node on the
2821        // spine for the interior filter to judge (pre-Phase-4 behaviour).
2822        if let Some(bypasses) = bypass_edges.get(&node_idx) {
2823            for &(succ_idx, weight) in bypasses {
2824                let succ_t = rank_of[succ_idx];
2825                debug_assert!(
2826                    t < succ_t,
2827                    "bypass edge {node_idx}->{succ_idx} (rank {t}->{succ_t}) must respect \
2828                     topological order; it is redundant with the real \
2829                     from->...->to path through the skipped nodes, so a violation means \
2830                     the graph is not a DAG"
2831                );
2832                let candidate = curr + (weight - 1) as i64;
2833                if candidate > cum[succ_t].0 {
2834                    cum[succ_t] = (candidate, Some(t), weight);
2835                }
2836            }
2837        }
2838    }
2839
2840    let max_cum = (0..n).map(|t| cum[t].0).max().unwrap_or(0);
2841    let best_t = (0..n).find(|&t| cum[t].0 == max_cum).unwrap_or(0);
2842
2843    let mut path: Vec<(usize, u8, i32)> = Vec::new();
2844    let mut t = best_t;
2845    loop {
2846        let node_idx = topo[t];
2847        let w = if cum[t].1.is_none() {
2848            nodes[node_idx].coverage as i32
2849        } else {
2850            cum[t].2
2851        };
2852        path.push((node_idx, nodes[node_idx].base, w));
2853        match cum[t].1 {
2854            None => break,
2855            Some(pred_t) => t = pred_t,
2856        }
2857    }
2858    path.reverse();
2859    path
2860}
2861
2862/// abPOA-style greedy heaviest bundling for consensus path selection.
2863///
2864/// At each node (processed in reverse topological order) pick the single
2865/// heaviest outgoing option -- an ordinary out-edge (scored on `matched`) or a
2866/// deletion bypass edge (scored on its weight) -- tie-broken by the downstream
2867/// `score`; `score[t] = max_w + score[max_child]`. Then walk the `max_child`
2868/// chain forward from the source.
2869///
2870/// This deliberately does NOT use the cumulative `(weight-1)` longest-weighted
2871/// path of [`heaviest_path`] (still used for the alignment-centering spine
2872/// cache). That normalization carries a length bias -- every extra node whose
2873/// in/out edges each clear weight 2 adds positive cumulative score -- so in a
2874/// periodic locus the longest path threads through phantom repeat-unit nodes
2875/// and over-calls the length. The greedy max-out-edge chain has no such reward
2876/// and stays on the modal path. See
2877/// `design/vntr_overcall_delete_edge_visibility.md`.
2878fn greedy_heaviest_walk(
2879    nodes: &[Node],
2880    topo: &[usize],
2881    rank_of: &[usize],
2882    bypass_edges: &HashMap<usize, Vec<(usize, i32)>>,
2883) -> Vec<(usize, u8, i32)> {
2884    let n = topo.len();
2885    let mut score = vec![0i64; n];
2886    let mut max_child = vec![usize::MAX; n];
2887    let mut chosen_w = vec![0i32; n];
2888    for t in (0..n).rev() {
2889        let node_idx = topo[t];
2890        let out_candidates = nodes[node_idx]
2891            .out_edges
2892            .iter()
2893            .map(|&(succ, ew)| (rank_of[succ], ew.matched));
2894        let bypass_candidates = bypass_edges
2895            .get(&node_idx)
2896            .into_iter()
2897            .flatten()
2898            .map(|&(succ, w)| (rank_of[succ], w));
2899        let mut best: Option<(i32, usize)> = None; // (weight, child_rank)
2900        for (child_t, w) in out_candidates.chain(bypass_candidates) {
2901            let better = match best {
2902                None => true,
2903                Some((bw, bc)) => w > bw || (w == bw && score[child_t] > score[bc]),
2904            };
2905            if better {
2906                best = Some((w, child_t));
2907            }
2908        }
2909        if let Some((w, child_t)) = best {
2910            score[t] = w as i64 + score[child_t];
2911            max_child[t] = child_t;
2912            chosen_w[t] = w;
2913        }
2914    }
2915    // Source = the node with no in-edges (seed start); fall back to topo[0].
2916    let src_t = (0..n)
2917        .find(|&t| nodes[topo[t]].in_edges.is_empty())
2918        .unwrap_or(0);
2919    let mut path = Vec::new();
2920    let mut t = src_t;
2921    // Report the INCOMING edge weight per node (matching `heaviest_path`'s
2922    // convention). The source has no incoming edge, so use its coverage.
2923    let mut incoming_w = nodes[topo[src_t]].coverage as i32;
2924    loop {
2925        let node_idx = topo[t];
2926        path.push((node_idx, nodes[node_idx].base, incoming_w));
2927        if max_child[t] == usize::MAX {
2928            break;
2929        }
2930        incoming_w = chosen_w[t];
2931        t = max_child[t];
2932    }
2933    path
2934}
2935
2936// ─── Majority-frequency consensus ────────────────────────────────────────────
2937
2938fn majority_frequency(nodes: &[Node], topo: &[usize], min_cov: u32) -> Vec<(usize, u8, i32)> {
2939    topo.iter()
2940        .copied()
2941        .filter(|&idx| {
2942            let cov = nodes[idx].coverage;
2943            let del = nodes[idx].delete_count;
2944            let total = cov + del;
2945            total >= min_cov && cov * 2 >= total
2946        })
2947        .map(|idx| (idx, nodes[idx].base, nodes[idx].coverage as i32))
2948        .collect()
2949}
2950
2951// ─── Graph statistics ─────────────────────────────────────────────────────────
2952
2953fn compute_stats(nodes: &[Node], min_allele_freq: f64, n_reads: usize) -> GraphStats {
2954    let node_count = nodes.len();
2955    let edge_count: usize = nodes.iter().map(|nd| nd.out_edges.len()).sum();
2956
2957    let coverages: Vec<f64> = nodes.iter().map(|nd| nd.coverage as f64).collect();
2958    let coverage_mean = if node_count == 0 {
2959        0.0
2960    } else {
2961        coverages.iter().sum::<f64>() / node_count as f64
2962    };
2963    let coverage_variance = if node_count == 0 {
2964        0.0
2965    } else {
2966        coverages
2967            .iter()
2968            .map(|&c| (c - coverage_mean).powi(2))
2969            .sum::<f64>()
2970            / node_count as f64
2971    };
2972
2973    let single_support = nodes.iter().filter(|nd| nd.coverage == 1).count();
2974    let single_support_fraction = if node_count == 0 {
2975        0.0
2976    } else {
2977        single_support as f64 / node_count as f64
2978    };
2979
2980    // Phase 2 territory (design/graph_data_model_rework.md): kept on total
2981    // (matched + deleted) weight here, unchanged from pre-split behaviour.
2982    // Splitting this by traversal type is deferred to the GraphStats/
2983    // find_bubbles rework, not part of this pass.
2984    let mut weights: Vec<f64> = nodes
2985        .iter()
2986        .flat_map(|nd| nd.out_edges.iter().map(|&(_, ew)| ew.total() as f64))
2987        .collect();
2988    weights.sort_by(|a, b| a.partial_cmp(b).unwrap());
2989    let edge_weight_gini = if weights.len() < 2 {
2990        0.0
2991    } else {
2992        let n = weights.len() as f64;
2993        let sum: f64 = weights.iter().sum();
2994        if sum == 0.0 {
2995            0.0
2996        } else {
2997            let numerator: f64 = weights
2998                .iter()
2999                .enumerate()
3000                .map(|(i, &w)| (2.0 * (i as f64 + 1.0) - n - 1.0) * w)
3001                .sum::<f64>();
3002            numerator / (n * sum)
3003        }
3004    };
3005
3006    let threshold = (n_reads as f64 * min_allele_freq).ceil() as i32;
3007    let mut bubble_count = 0usize;
3008    let mut max_bubble_depth = 0usize;
3009    let mut longest_bubble_span = 0usize;
3010    // Phase 2 territory: same total-weight threshold as before the split.
3011    for nd in nodes {
3012        let qualifying: Vec<(usize, i32)> = nd
3013            .out_edges
3014            .iter()
3015            .filter(|&&(_, ew)| ew.total() >= threshold)
3016            .map(|&(to, ew)| (to, ew.total()))
3017            .collect();
3018        if qualifying.len() >= 2 {
3019            bubble_count += 1;
3020            let mut weights: Vec<i32> = qualifying.iter().map(|&(_, w)| w).collect();
3021            weights.sort_unstable_by(|a, b| b.cmp(a));
3022            max_bubble_depth = max_bubble_depth.max(weights[1] as usize);
3023            for &(arm_start, _) in &qualifying {
3024                let span = if nodes[arm_start].in_edges.len() > 1 {
3025                    0 // direct edge to exit: 0-length arm
3026                } else {
3027                    materialize_arm_len(nodes, arm_start, ARM_MAX_DEPTH)
3028                };
3029                longest_bubble_span = longest_bubble_span.max(span);
3030            }
3031        }
3032    }
3033
3034    let mean_column_entropy = {
3035        let mut sum = 0.0f64;
3036        let mut count = 0usize;
3037        for nd in nodes {
3038            let cov = nd.coverage as f64;
3039            let del = nd.delete_count as f64;
3040            let total = cov + del;
3041            if total > 0.0 {
3042                let h = binary_entropy(cov / total);
3043                sum += h;
3044                count += 1;
3045            }
3046        }
3047        if count == 0 { 0.0 } else { sum / count as f64 }
3048    };
3049
3050    GraphStats {
3051        node_count,
3052        edge_count,
3053        bubble_count,
3054        max_bubble_depth,
3055        coverage_mean,
3056        coverage_variance,
3057        edge_weight_gini,
3058        single_support_fraction,
3059        mean_column_entropy,
3060        longest_bubble_span,
3061        median_input_read_len: 0, // caller fills this in from self.reads
3062    }
3063}
3064
3065fn median_read_len(reads: &[Vec<u8>]) -> usize {
3066    if reads.is_empty() {
3067        return 0;
3068    }
3069    let mut lens: Vec<usize> = reads.iter().map(|r| r.len()).collect();
3070    lens.sort_unstable();
3071    lens[lens.len() / 2]
3072}
3073
3074#[inline]
3075fn binary_entropy(p: f64) -> f64 {
3076    if p <= 0.0 || p >= 1.0 {
3077        0.0
3078    } else {
3079        let q = 1.0 - p;
3080        -(p * p.log2() + q * q.log2())
3081    }
3082}
3083
3084// ─── Coverage gap detection ───────────────────────────────────────────────────
3085
3086fn detect_coverage_gaps(coverage: &[u32]) -> Vec<CoverageGap> {
3087    let first = coverage.iter().position(|&c| c >= 2);
3088    let last = coverage.iter().rposition(|&c| c >= 2);
3089    let (first, last) = match (first, last) {
3090        (Some(f), Some(l)) if f < l => (f, l),
3091        _ => return vec![],
3092    };
3093    let mut gaps = Vec::new();
3094    let mut gap_start: Option<usize> = None;
3095    for (offset, &cov) in coverage[(first + 1)..last].iter().enumerate() {
3096        let i = first + 1 + offset;
3097        if cov < 2 {
3098            gap_start.get_or_insert(i);
3099        } else if let Some(s) = gap_start.take() {
3100            gaps.push(CoverageGap {
3101                start: s,
3102                end: i,
3103                kind: GapKind::Spanning,
3104            });
3105        }
3106    }
3107    if let Some(s) = gap_start {
3108        gaps.push(CoverageGap {
3109            start: s,
3110            end: last,
3111            kind: GapKind::Spanning,
3112        });
3113    }
3114    gaps
3115}
3116
3117// ─── Multi-allele helpers ─────────────────────────────────────────────────────
3118
3119fn find_bubbles(
3120    nodes: &[Node],
3121    topo: &[usize],
3122    n_reads: usize,
3123    min_allele_freq: f64,
3124) -> Vec<(usize, Vec<usize>)> {
3125    let threshold = ((n_reads as f64 * min_allele_freq).ceil() as i32).max(1);
3126    // Thresholded on matched-only weight: a delete-heavy pseudo-arm (reads
3127    // that skip past this position, not reads that confirm a different
3128    // base) should not qualify as a competing allele candidate.
3129    topo.iter()
3130        .copied()
3131        .filter_map(|node_idx| {
3132            let arms: Vec<usize> = nodes[node_idx]
3133                .out_edges
3134                .iter()
3135                .filter(|&&(_, ew)| ew.matched >= threshold)
3136                .map(|&(to, _)| to)
3137                .collect();
3138            if arms.len() >= 2 {
3139                Some((node_idx, arms))
3140            } else {
3141                None
3142            }
3143        })
3144        .collect()
3145}
3146
3147/// Finds bubbles where at least one arm has structural size (span ≥ cfg.phasing_bubble_min_span).
3148/// These represent genuine length variants or SVs rather than SNPs / short indels.
3149/// Used for cross-bubble compatibility phasing in consensus_multi.
3150fn find_structural_bubbles(
3151    nodes: &[Node],
3152    topo: &[usize],
3153    n_reads: usize,
3154    cfg: &PoaConfig,
3155) -> Vec<(usize, Vec<usize>)> {
3156    let threshold = ((n_reads as f64 * cfg.min_allele_freq).ceil() as i32).max(1);
3157    let max_check = cfg.phasing_bubble_min_span.saturating_add(1);
3158
3159    topo.iter()
3160        .copied()
3161        .filter_map(|entry_node| {
3162            // Matched-only, same reasoning as find_bubbles above.
3163            let arms: Vec<usize> = nodes[entry_node]
3164                .out_edges
3165                .iter()
3166                .filter(|&&(_, ew)| ew.matched >= threshold)
3167                .map(|&(to, _)| to)
3168                .collect();
3169
3170            if arms.len() < 2 {
3171                return None;
3172            }
3173
3174            // An arm whose start node already has a REAL (significant)
3175            // second in-edge is a direct edge to the bubble exit (arm span
3176            // 0); a start node with only NOISE extra in-edges is not
3177            // actually a reconvergence and its span is still measured
3178            // normally (see materialize_arm_len_tolerant's doc comment).
3179            let max_span = arms
3180                .iter()
3181                .map(|&start| {
3182                    if real_in_edge_count(nodes, start, threshold) > 1 {
3183                        0
3184                    } else {
3185                        materialize_arm_len_tolerant(nodes, start, max_check, threshold)
3186                    }
3187                })
3188                .max()
3189                .unwrap_or(0);
3190
3191            if max_span >= cfg.phasing_bubble_min_span {
3192                Some((entry_node, arms))
3193            } else {
3194                None
3195            }
3196        })
3197        .collect()
3198}
3199
3200// ─── Bubble site extraction ───────────────────────────────────────────────────
3201
3202/// Maximum arm sequence length captured in [`BubbleSite::arm_sequences`].
3203/// Arms longer than this return an empty `Vec`; callers use `arm_read_counts`
3204/// to assess support for large structural variants.
3205const ARM_SEQUENCE_CAP: usize = 256;
3206
3207/// Collect the base sequence of one bubble arm from `start` up to (but not
3208/// including) the exit/reconvergence node.
3209///
3210/// Returns an empty `Vec` when:
3211/// - `start` is itself the exit (multiple in-edges → 0-length deletion arm).
3212/// - The arm exceeds `ARM_SEQUENCE_CAP` nodes.
3213/// - The arm has an internal branch (nested bubble).
3214fn arm_sequence(nodes: &[Node], start: usize) -> Vec<u8> {
3215    if nodes[start].in_edges.len() > 1 {
3216        return vec![];
3217    }
3218    let mut seq = Vec::new();
3219    let mut cur = start;
3220    loop {
3221        if seq.len() >= ARM_SEQUENCE_CAP {
3222            return vec![];
3223        }
3224        seq.push(nodes[cur].base);
3225        match nodes[cur].out_edges.as_slice() {
3226            [(next, _)] => {
3227                if nodes[*next].in_edges.len() > 1 {
3228                    break; // next is the exit node
3229                }
3230                cur = *next;
3231            }
3232            _ => break, // dead end or internal branch; return what we have
3233        }
3234    }
3235    seq
3236}
3237
3238/// Build the [`BubbleSite`] list for a consensus path.
3239///
3240/// Scans the graph for nodes with two or more outgoing arms each meeting the
3241/// `min_allele_freq` threshold.  Only nodes that appear on `filtered` (the
3242/// trimmed consensus path) are included; off-path bubble entries are skipped.
3243fn collect_bubble_sites(
3244    nodes: &[Node],
3245    topo: &[usize],
3246    filtered: &[(usize, u8, i32)],
3247    edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3248    min_allele_freq: f64,
3249    n_reads: usize,
3250    phasing_bubble_min_span: usize,
3251) -> Vec<BubbleSite> {
3252    let path_pos: HashMap<usize, usize> = filtered
3253        .iter()
3254        .enumerate()
3255        .map(|(i, &(node_idx, _, _))| (node_idx, i))
3256        .collect();
3257
3258    // Same threshold and noise-tolerant span measurement as
3259    // find_structural_bubbles, so `BubbleSite.is_structural` always agrees
3260    // with the classification actually used for phasing -- see that
3261    // function's and materialize_arm_len_tolerant's doc comments for why.
3262    let threshold = ((n_reads as f64 * min_allele_freq).ceil() as i32).max(1);
3263
3264    find_bubbles(nodes, topo, n_reads, min_allele_freq)
3265        .into_iter()
3266        .filter_map(|(entry_node, arm_starts)| {
3267            let consensus_pos = *path_pos.get(&entry_node)?;
3268
3269            let arm_read_counts: Vec<u32> = arm_starts
3270                .iter()
3271                .map(|&a| {
3272                    edge_reads
3273                        .get(&(entry_node, a))
3274                        .map_or(0, |v| v.len() as u32)
3275                })
3276                .collect();
3277
3278            // Mirror the guard in find_structural_bubbles: a 0-length arm
3279            // (arm_start == exit) is not structural regardless of what follows.
3280            let is_structural = arm_starts.iter().any(|&a| {
3281                let len = if real_in_edge_count(nodes, a, threshold) > 1 {
3282                    0
3283                } else {
3284                    materialize_arm_len_tolerant(nodes, a, phasing_bubble_min_span, threshold)
3285                };
3286                len >= phasing_bubble_min_span
3287            });
3288
3289            let arm_sequences: Vec<Vec<u8>> =
3290                arm_starts.iter().map(|&a| arm_sequence(nodes, a)).collect();
3291
3292            Some(BubbleSite {
3293                consensus_pos,
3294                arm_read_counts,
3295                arm_sequences,
3296                is_structural,
3297            })
3298        })
3299        .collect()
3300}
3301
3302// ─── Structural-split validity check ──────────────────────────────────────────
3303//
3304// `find_structural_bubbles`'s own per-bubble vote-count threshold only checks
3305// whether a single bubble's arm split looks significant; it cannot tell a
3306// genuine second haplotype apart from a column that happens to split reads
3307// close to evenly by coincidence (a periodic-repeat phase-registration tie --
3308// Known Bugs #3/#4/#9's family). Confirmed on real GAA×30/GAA×100 data: once
3309// `find_structural_bubbles`'s noise-tolerant span measurement (above) started
3310// correctly finding real structural bubbles instead of none, `phasing_groups`
3311// began correctly refusing to bridge conflicting reads together (see its own
3312// doc comment) -- but that, in turn, revealed several small clusters driven by
3313// nothing more than a handful of reads agreeing on one coincidental tie
3314// column. A genuine second allele's read population should ALSO show a
3315// distinct read-length mode (the two alleles' own lengths differ) and hold up
3316// on its own vote weight; a coincidental tie column produces reads whose
3317// lengths are indistinguishable from the group they were spuriously split out
3318// of.
3319
3320/// Minimum read-length gap (bp) between two candidate groups' medians before
3321/// a length-based split is trusted at all. Guards against a near-zero
3322/// absolute gap being amplified into an apparently "significant" separation
3323/// ratio purely because both groups happen to have very tight internal
3324/// spread (e.g. two 1-read groups always have zero spread).
3325const MIN_LENGTH_GAP_BP: f64 = 8.0;
3326
3327/// A length gap must be at least this many multiples of the pooled spread
3328/// (median absolute deviation) to count as genuine bimodal separation rather
3329/// than ordinary scatter around one shared mode. 3 MADs is a conservative
3330/// bar (roughly analogous to ~2 standard deviations for a normal-ish
3331/// distribution): real independent haplotype length populations in the
3332/// validated CAG/GAA scenarios differ by tens to hundreds of bp against a
3333/// MAD on the order of single-digit bp (ordinary indel/substitution read
3334/// noise), while the confirmed spurious clusters' medians sit within a few
3335/// bp of the group they were actually noise from.
3336const LENGTH_SEPARATION_MADS: f64 = 3.0;
3337
3338/// Floor on the per-group spread estimate, so two groups that both happen to
3339/// have very tight (near-identical) internal lengths -- including the
3340/// degenerate 1-read-group case, spread 0 -- don't produce an artificially
3341/// inflated separation ratio from a near-zero denominator.
3342const MIN_SPREAD_FLOOR_BP: f64 = 3.0;
3343
3344/// Median of `vals`, treated as `f64`. Empty input returns 0.0 (callers only
3345/// ever pass non-empty read-length lists here).
3346fn median_usize(vals: &[usize]) -> f64 {
3347    if vals.is_empty() {
3348        return 0.0;
3349    }
3350    let mut v: Vec<f64> = vals.iter().map(|&x| x as f64).collect();
3351    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
3352    let n = v.len();
3353    if n % 2 == 1 {
3354        v[n / 2]
3355    } else {
3356        (v[n / 2 - 1] + v[n / 2]) / 2.0
3357    }
3358}
3359
3360/// Median absolute deviation of `vals` around `median`.
3361fn mad_usize(vals: &[usize], median: f64) -> f64 {
3362    let dev: Vec<usize> = vals
3363        .iter()
3364        .map(|&x| (x as f64 - median).abs().round() as usize)
3365        .collect();
3366    median_usize(&dev)
3367}
3368
3369/// True when two groups' read-length distributions are genuinely bimodally
3370/// separated (real length-differing alleles) rather than ordinary scatter
3371/// around one shared mode (see the constants above for the exact bar).
3372fn length_separated(lens_a: &[usize], lens_b: &[usize]) -> bool {
3373    if lens_a.is_empty() || lens_b.is_empty() {
3374        return false;
3375    }
3376    let med_a = median_usize(lens_a);
3377    let med_b = median_usize(lens_b);
3378    let gap = (med_a - med_b).abs();
3379    if gap < MIN_LENGTH_GAP_BP {
3380        return false;
3381    }
3382    let spread_a = mad_usize(lens_a, med_a).max(MIN_SPREAD_FLOOR_BP);
3383    let spread_b = mad_usize(lens_b, med_b).max(MIN_SPREAD_FLOOR_BP);
3384    gap >= LENGTH_SEPARATION_MADS * spread_a.max(spread_b)
3385}
3386
3387/// Validates candidate haplotype groups (as produced by [`phasing_groups`],
3388/// before any of its own below-`min_reads` folding is undone) against a
3389/// minimum-depth vote-weight floor, plus -- when 2+ structural bubbles were
3390/// involved -- read-length bimodality as the primary discriminating signal.
3391/// Any candidate that fails merges into whichever already-accepted group its
3392/// own read lengths are closest to.
3393///
3394/// # Why the vote-weight bar is `min_reads`, not `min_allele_freq`
3395///
3396/// An earlier version used the same `min_allele_freq`-derived threshold
3397/// `find_bubbles`/`find_structural_bubbles` use to decide whether a single
3398/// bubble's own arm is significant. That is the wrong bar to apply to a
3399/// *cluster*'s final size here: `phasing_groups`'s whole point is to keep
3400/// only reads that agree *consistently across every bubble they have a
3401/// recorded choice at* (see its own doc comment on why plain union-find
3402/// bridging is unsound), which is a strictly harder bar to clear than any
3403/// single bubble's own arm weight -- a read that is noisy at just one of
3404/// several bubbles is correctly excluded from the clean cluster even though
3405/// it still supports the same haplotype overall. Reusing the un-attrited
3406/// `min_allele_freq * n_reads` threshold against this already-attrited count
3407/// double-penalises exactly the reads `phasing_groups` is supposed to filter
3408/// once, rejecting genuine minority alleles outright: confirmed on real data
3409/// (`multi_skew_cag20_40`, a true ~20%-frequency CAG×40 minority allele) where
3410/// the cleanly cross-bubble-consistent cluster (5 reads) cleared its own
3411/// bubbles' individual arm-weight thresholds fine but fell under the
3412/// pool-wide `min_allele_freq` bar purely from that attrition, even though it
3413/// was clearly read-length-separated from the majority allele. `min_reads`
3414/// (the same absolute depth floor `phasing_groups` already uses for ITS OWN
3415/// below-floor folding, and the same floor `PoaGraph` requires before
3416/// attempting a consensus at all) is the bar actually being asked here --
3417/// "is there enough depth to build a consensus from this cluster at all" --
3418/// so reusing it, rather than the separate significance-of-a-single-arm bar,
3419/// is the correct comparison.
3420///
3421/// # Why length separation is required only when 2+ bubbles are in play
3422///
3423/// A genuine haplotype split is frequently the SAME length on both arms by
3424/// construction -- not just same-length SNP-bubble-fallback haplotypes
3425/// (`structural_bubble_phasing_ignores_snp_bubbles`, handled by a completely
3426/// separate code path anyway), but same-length *structural* bubbles too: a
3427/// single long homopolymer run that differs by base rather than length
3428/// (confirmed by `locked_arm_deep_bubble_alleles_lost`, a G×30-vs-A×30 arm,
3429/// which regressed under an earlier version of this function that required
3430/// length separation unconditionally). Requiring length separation whenever
3431/// there is only one structural bubble in the whole graph would reject that
3432/// class of real call outright.
3433///
3434/// The confirmed failure mode this function targets -- a coincidental
3435/// phase-registration tie in a periodic repeat producing a column whose vote
3436/// split looks significant but carries no real length signal -- was only
3437/// ever observed (and is only plausible) when 2+ structural bubbles exist:
3438/// `find_structural_bubbles` evaluates every fork in the graph and keeps
3439/// only those whose (now noise-tolerant) span clears the threshold, so
3440/// multiple surviving bubbles means multiple independent candidates were
3441/// compared, and a coincidental near-even split becomes far more likely to
3442/// slip through *some* one of them than when there is only a single
3443/// candidate in the entire graph. Gating the length requirement on bubble
3444/// count is therefore not an arbitrary threshold but a direct response to
3445/// that multiple-candidates dynamic: one bubble carries no such selection
3446/// risk (its own span survived on genuine structural grounds, not on being
3447/// picked as the most-balanced-looking of many), so the depth floor alone is
3448/// trusted; two or more reintroduces exactly the risk length separation is
3449/// meant to catch.
3450///
3451/// # Why a clean distinguishing bubble is *also* required (Phase 3 of
3452/// `design/bypass_edge_delete_rework.md`)
3453///
3454/// Length separation alone became insufficient once Delete ops were
3455/// represented as bypass edges (pure bypass). A single allele's deletion-heavy
3456/// reads no longer register at the structural bubbles they skipped (their
3457/// bypass edges are invisible to `edge_reads`, which the per-read arm signature
3458/// is built from), and a deletion elsewhere frame-shifts a read onto the
3459/// *wrong* (shorter) arm at a downstream bubble. `phasing_groups` then splits
3460/// that one allele into sub-groups that happen to be narrowly length-separated
3461/// from each other -- confirmed on `multi_skew_cag20_40`, where the true CAG×40
3462/// allele over-split into ~39- and ~32-unit sub-groups whose 16bp median gap
3463/// just cleared the 12bp length-separation bar. Length separation cannot tell a
3464/// genuinely bimodal two-allele length distribution from a unimodal one that
3465/// `phasing_groups` bisected, because it only ever compares the two
3466/// already-tight halves.
3467///
3468/// The added requirement is orthogonal to length: a candidate is a genuinely
3469/// distinct allele only if it also has a **clean distinguishing bubble** versus
3470/// every already-confirmed group -- a structural bubble where a clear majority
3471/// (>= `CLEAR_MAJORITY`) of each group, over enough reads (>= `MIN_ARM_COV`),
3472/// takes a *different* arm. Measured (see the design doc's Phase 3 spike):
3473/// every genuine two-allele case -- `multi_gaa30_100`, `multi_cag15_25`,
3474/// `multi_cag20_50`, `structural_phasing_no_contamination_on_noisy_periodic_repeat`,
3475/// `structural_phasing_small_gap_bridge_candidate_stress` -- has such a bubble
3476/// at majority fractions 0.80-1.00, while `multi_skew_cag20_40`'s two CAG×40
3477/// sub-groups take the *same* majority arm at every bubble (no distinguishing
3478/// bubble at all), so they fail and merge back to one allele. A candidate that
3479/// clears length separation but fails this check is the same allele split by
3480/// deletion noise; it merges wholesale into the length-nearest confirmed group
3481/// it is structurally indistinguishable from. This check, like length
3482/// separation, is gated on 2+ structural bubbles (with 0-1 there is nothing to
3483/// distinguish on beyond length, and the SNP-bubble fallback is a separate
3484/// path). The `else` per-read length-routing branch (the `multi_gaa30_100`
3485/// contamination fix, for candidates that fail *length* separation) is
3486/// untouched.
3487fn validate_and_merge_groups(
3488    groups: Vec<Vec<usize>>,
3489    reads: &[Vec<u8>],
3490    min_reads: usize,
3491    bubbles: &[(usize, Vec<usize>)],
3492    edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3493) -> Vec<Vec<usize>> {
3494    if groups.len() < 2 {
3495        return groups;
3496    }
3497    let mut groups = groups;
3498    groups.sort_unstable_by_key(|g| std::cmp::Reverse(g.len()));
3499
3500    let n_bubbles = bubbles.len();
3501    let require_length_separation = n_bubbles >= 2;
3502
3503    // Clear-majority threshold and minimum arm coverage for the "clean
3504    // distinguishing bubble" check (validated in the Phase 3 spike; the
3505    // outcome is robust across 0.60-0.70, 0.60 is what was measured).
3506    const CLEAR_MAJORITY: f64 = 0.60;
3507    const MIN_ARM_COV: usize = 2;
3508
3509    // Per-bubble `read -> arm index` map -- the same arm signal `phasing_groups`
3510    // clustered on, rebuilt here from `edge_reads` (cheap: O(reads x bubbles),
3511    // and only when 2+ bubbles gate the check below).
3512    let arm_of: Vec<HashMap<usize, usize>> = bubbles
3513        .iter()
3514        .map(|(entry, arm_starts)| {
3515            let mut m: HashMap<usize, usize> = HashMap::new();
3516            for (k, &a) in arm_starts.iter().enumerate() {
3517                if let Some(rs) = edge_reads.get(&(*entry, a)) {
3518                    for &r in rs {
3519                        m.entry(r as usize).or_insert(k);
3520                    }
3521                }
3522            }
3523            m
3524        })
3525        .collect();
3526
3527    // (majority arm, majority fraction, coverage) for a group at bubble `b`.
3528    let group_bubble_stats = |grp: &[usize], b: usize| -> (Option<usize>, f64, usize) {
3529        let mut counts: HashMap<usize, usize> = HashMap::new();
3530        for &r in grp {
3531            if let Some(&k) = arm_of[b].get(&r) {
3532                *counts.entry(k).or_default() += 1;
3533            }
3534        }
3535        let cov: usize = counts.values().sum();
3536        match counts.into_iter().max_by_key(|&(_, c)| c) {
3537            Some((arm, c)) => (Some(arm), c as f64 / cov as f64, cov),
3538            None => (None, 0.0, 0),
3539        }
3540    };
3541
3542    // True iff some structural bubble has both groups' clear majorities on
3543    // *different* arms (with enough coverage on each side).
3544    let clean_distinguishing = |ga: &[usize], gb: &[usize]| -> bool {
3545        (0..n_bubbles).any(|b| {
3546            let (ma, fa, ca) = group_bubble_stats(ga, b);
3547            let (mb, fb, cb) = group_bubble_stats(gb, b);
3548            ma.is_some()
3549                && mb.is_some()
3550                && ma != mb
3551                && fa >= CLEAR_MAJORITY
3552                && fb >= CLEAR_MAJORITY
3553                && ca >= MIN_ARM_COV
3554                && cb >= MIN_ARM_COV
3555        })
3556    };
3557
3558    // The largest group is always trusted as the baseline/reference -- same
3559    // philosophy as every other bubble-selection heuristic in this file
3560    // (e.g. partition_reads_by_bubble's own largest-group fallback target).
3561    let mut confirmed: Vec<Vec<usize>> = vec![groups[0].clone()];
3562    let mut confirmed_lens: Vec<Vec<usize>> =
3563        vec![groups[0].iter().map(|&r| reads[r].len()).collect()];
3564
3565    for cand in groups.into_iter().skip(1) {
3566        let cand_lens: Vec<usize> = cand.iter().map(|&r| reads[r].len()).collect();
3567
3568        let significant = cand.len() >= min_reads;
3569        let separated_from_all = !require_length_separation
3570            || confirmed_lens
3571                .iter()
3572                .all(|existing| length_separated(&cand_lens, existing));
3573
3574        if significant && separated_from_all {
3575            // Length-separated -- but a genuinely distinct allele must ALSO
3576            // have a clean distinguishing bubble vs every confirmed group
3577            // (gated on 2+ bubbles, same as length separation). A candidate
3578            // that clears length but fails this is one allele's
3579            // deletion-noise-split sub-group; merge it wholesale into the
3580            // length-nearest confirmed group it can't be distinguished from.
3581            let structurally_distinct = !require_length_separation
3582                || confirmed.iter().all(|c| clean_distinguishing(&cand, c));
3583            if structurally_distinct {
3584                confirmed.push(cand);
3585                confirmed_lens.push(cand_lens);
3586            } else {
3587                let cand_med = median_usize(&cand_lens);
3588                let target = (0..confirmed.len())
3589                    .filter(|&c| !clean_distinguishing(&cand, &confirmed[c]))
3590                    .min_by(|&a, &b| {
3591                        let da = (median_usize(&confirmed_lens[a]) - cand_med).abs();
3592                        let db = (median_usize(&confirmed_lens[b]) - cand_med).abs();
3593                        da.partial_cmp(&db).unwrap()
3594                    })
3595                    .expect("structurally_distinct false => at least one indistinguishable group");
3596                confirmed_lens[target].extend(cand_lens);
3597                confirmed[target].extend(cand);
3598            }
3599        } else if confirmed.len() == 1 {
3600            // Only one confirmed group exists so far -- nothing to route
3601            // individual members toward yet, merge wholesale (this is also
3602            // the common, harmless case: a small or non-separated candidate
3603            // whose members really do all belong together).
3604            confirmed_lens[0].extend(cand_lens);
3605            confirmed[0].extend(cand);
3606        } else {
3607            // Not trustworthy as its own allele. Route each *member*
3608            // individually to whichever confirmed group its own length is
3609            // closest to, rather than moving the whole candidate as one
3610            // block to wherever the candidate's *aggregate* median lands.
3611            //
3612            // Why per-read, not per-group: a rejected candidate can itself
3613            // be an internally mixed cluster -- confirmed on real
3614            // GAA×30/GAA×100 HiFi data (`multi_gaa30_100`): a 5-read
3615            // candidate contained 4 reads of one true allele and 1 read of
3616            // the other, all sharing the exact same cross-bubble arm-choice
3617            // pattern by coincidence (see `phasing_groups`'s doc comment on
3618            // this same underlying alignment-tie mechanism). The
3619            // candidate's own aggregate median length was pulled toward the
3620            // 4-read majority and the whole block -- including the 1
3621            // genuinely different read -- got merged into that majority's
3622            // confirmed group, misassigning the minority read. Each read's
3623            // own length is not similarly diluted, and the two confirmed
3624            // groups here are, by construction, already separated on
3625            // length (that is what qualified them as confirmed in the
3626            // first place), so per-read nearest-length routing recovers
3627            // the minority read correctly without affecting the majority.
3628            let snapshot_medians: Vec<f64> =
3629                confirmed_lens.iter().map(|l| median_usize(l)).collect();
3630            let mut per_read_targets: Vec<(usize, usize)> = Vec::with_capacity(cand.len());
3631            for (&r, &len) in cand.iter().zip(cand_lens.iter()) {
3632                let len = len as f64;
3633                let target = snapshot_medians
3634                    .iter()
3635                    .enumerate()
3636                    .min_by(|&(_, &a), &(_, &b)| {
3637                        let da = (a - len).abs();
3638                        let db = (b - len).abs();
3639                        da.partial_cmp(&db).unwrap()
3640                    })
3641                    .map(|(i, _)| i)
3642                    .unwrap_or(0);
3643                per_read_targets.push((r, target));
3644            }
3645            for (r, target) in per_read_targets {
3646                confirmed_lens[target].push(reads[r].len());
3647                confirmed[target].push(r);
3648            }
3649        }
3650    }
3651
3652    confirmed
3653}
3654
3655/// Groups reads by arm-choice compatibility across all structural bubbles.
3656///
3657/// For each structural bubble, edge_reads tells us which reads entered each arm.
3658/// Two reads are in the same haplotype group when they agree on every bubble
3659/// where both have a recorded arm choice. Reads with no arm assignment at any
3660/// structural bubble (pre-dating all bubbles, or not spanning them) are folded
3661/// into the largest group, as are reads whose partial information can't
3662/// distinguish between two or more existing groups (see below). Groups below
3663/// min_reads are also folded into the largest.
3664///
3665/// # Why this is not a plain pairwise union-find
3666///
3667/// An earlier version unioned any two reads whenever they never *conflicted*
3668/// (treating a missing arm choice at either read as an automatic pass) and
3669/// took the transitive closure of that relation. That is unsound whenever a
3670/// read has partial information (an arm choice at some bubbles but not
3671/// others): such a read can be pairwise-"compatible" with two *mutually
3672/// contradictory* fully-specified reads at once (e.g. read X entered arm 0 at
3673/// bubble A and has no recorded choice at bubble B; reads Y and Z both entered
3674/// arm 0 at bubble A but *disagree* at bubble B) -- plain union-find, being
3675/// purely pairwise, transitively merges Y and Z into one group through X even
3676/// though Y and Z themselves directly conflict and would never be unioned if
3677/// compared to each other. Confirmed on real data (a periodic GAA repeat
3678/// where `find_structural_bubbles`' noise-tolerant span measurement now
3679/// correctly detects 2-3 real structural bubbles instead of 0): reads with
3680/// only partial bubble coverage bridged two haplotypes with genuinely
3681/// conflicting votes into a single collapsed group, when they should have
3682/// stayed apart.
3683///
3684/// Fixed by building clusters incrementally, most-informative-read first,
3685/// and only ever merging a read into a cluster when it is compatible with
3686/// *exactly one* existing cluster -- never letting a read's own missing
3687/// information be used to bridge two clusters that would conflict with each
3688/// other. A read compatible with zero clusters starts a new one (this is
3689/// what correctly produces 3+ groups for nested bubbles, e.g. a short/
3690/// medium/long three-allele split: the short reads never reach the inner
3691/// bubble at all, so they are informationally *incompatible* with both the
3692/// medium and long clusters at the outer bubble -- a real conflict, not
3693/// ambiguity -- and correctly form their own third group). A read compatible
3694/// with two or more clusters carries no information that distinguishes them,
3695/// so -- exactly like a read with no recorded arm choice anywhere -- it is
3696/// folded into the largest final group rather than risking a wrong merge.
3697fn phasing_groups(
3698    edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3699    bubbles: &[(usize, Vec<usize>)],
3700    n_reads: usize,
3701    min_reads: usize,
3702    reads: &[Vec<u8>],
3703) -> Vec<Vec<usize>> {
3704    let n_bubbles = bubbles.len();
3705
3706    // sig[read][bubble] = Some(arm_idx) if that read entered that bubble arm, else None.
3707    let mut sig: Vec<Vec<Option<usize>>> = vec![vec![None; n_bubbles]; n_reads];
3708    for (b, (entry, arm_starts)) in bubbles.iter().enumerate() {
3709        for (arm_idx, &arm_start) in arm_starts.iter().enumerate() {
3710            if let Some(reads) = edge_reads.get(&(*entry, arm_start)) {
3711                for &r in reads {
3712                    let r = r as usize;
3713                    if r < n_reads {
3714                        sig[r][b] = Some(arm_idx);
3715                    }
3716                }
3717            }
3718        }
3719    }
3720
3721    // Split reads into those with at least one recorded arm choice and those with none.
3722    let mut assigned: Vec<usize> = Vec::new();
3723    let mut unassigned: Vec<usize> = Vec::new();
3724    for (r, row) in sig.iter().enumerate() {
3725        if row.iter().any(|s| s.is_some()) {
3726            assigned.push(r);
3727        } else {
3728            unassigned.push(r);
3729        }
3730    }
3731
3732    // Process the most-informative reads (fewest missing bubble choices)
3733    // first, ties broken by read index for determinism, so cluster
3734    // "templates" are established from the strongest evidence before any
3735    // partial-information read is considered.
3736    let mut order = assigned.clone();
3737    order.sort_by_key(|&r| {
3738        let known = sig[r].iter().filter(|s| s.is_some()).count();
3739        (std::cmp::Reverse(known), r)
3740    });
3741
3742    // clusters[c][b] = this cluster's observed arm choice at bubble b (None
3743    // if no member has reached it yet). Grown incrementally: once a cluster
3744    // observes a value at a bubble, that becomes a hard requirement for
3745    // every subsequent read considered for that cluster.
3746    let mut clusters: Vec<Vec<Option<usize>>> = Vec::new();
3747    let mut cluster_members: Vec<Vec<usize>> = Vec::new();
3748    // Reads compatible with 2+ clusters at once: their partial information
3749    // can't tell those clusters apart on its own. Each carries the list of
3750    // clusters it's actually compatible with, so the resolution pass below
3751    // can pick among *those* rather than guessing globally.
3752    let mut bridge_candidates: Vec<(usize, Vec<usize>)> = Vec::new();
3753
3754    for r in order {
3755        let row = &sig[r];
3756        let compatible_clusters: Vec<usize> = clusters
3757            .iter()
3758            .enumerate()
3759            .filter(|(_, known)| {
3760                (0..n_bubbles).all(|b| match (row[b], known[b]) {
3761                    (Some(a), Some(bv)) => a == bv,
3762                    _ => true,
3763                })
3764            })
3765            .map(|(ci, _)| ci)
3766            .collect();
3767
3768        match compatible_clusters.as_slice() {
3769            [] => {
3770                clusters.push(row.clone());
3771                cluster_members.push(vec![r]);
3772            }
3773            [only] => {
3774                cluster_members[*only].push(r);
3775                for b in 0..n_bubbles {
3776                    if clusters[*only][b].is_none() {
3777                        clusters[*only][b] = row[b];
3778                    }
3779                }
3780            }
3781            _ => bridge_candidates.push((r, compatible_clusters)),
3782        }
3783    }
3784
3785    // Resolve bridge candidates and fully-unassigned reads against the
3786    // clusters formed above, using each read's own length as a
3787    // corroborating signal -- but only when that read's length is itself
3788    // plausible evidence, not an artifact of a partial (non-spanning) read.
3789    //
3790    // Why length at all: the previous design dumped both kinds of
3791    // leftover read unconditionally into whichever cluster was globally
3792    // largest, on the theory that "no unique bubble-arm signal" meant "no
3793    // signal at all," so the majority was the least-bad guess. That holds
3794    // for a read whose bubble-derived compatible set spans clusters that
3795    // all end up on the *same* true side. Confirmed wrong on real
3796    // GAA×30/GAA×100 HiFi data (`multi_gaa30_100`): a bridge candidate can
3797    // be compatible with clusters on *opposite* true sides purely because
3798    // one of its bubbles happens to overlap both (e.g. compatible with a
3799    // small cluster representing a genuine cross-bubble alignment tie --
3800    // see `validate_and_merge_groups`'s doc comment -- and also with the
3801    // far larger pure-majority cluster on the *other* allele). Dumping such
3802    // a read into the globally largest cluster is then actively wrong, not
3803    // a low-information default. Read length is a signal largely
3804    // orthogonal to bubble topology (a structural bubble is detected
3805    // *because* of a length difference), so it can break this kind of tie
3806    // even when the bubble evidence itself was misleading for this one
3807    // read.
3808    //
3809    // Why length is gated on plausibility, not applied unconditionally: a
3810    // first version of this fix compared every leftover read's length
3811    // against *all* clusters unconditionally and regressed immediately --
3812    // partial (non-spanning) reads that never reached any bubble have a
3813    // truncated length reflecting how much of the flank+repeat they
3814    // happened to cover, not their true allele's length, so several
3815    // partial reads of the *same* true allele clustered together purely
3816    // because they were all similarly short, fabricating a spurious extra
3817    // "allele" that `validate_and_merge_groups` then couldn't tell apart
3818    // from a real one (confirmed: `multi_gaa30_100` went from 2 output
3819    // alleles to a spurious 3rd, a 4-read all-partial, all-same-true-origin
3820    // group, once this fix's first version routed them by raw length
3821    // alone). A read's length is only trustworthy as a discriminating
3822    // signal when it is at least roughly as long as the *shortest*
3823    // well-supported (>= min_reads) cluster's own median -- i.e. plausible
3824    // as a genuine full instance of *some* known allele, not obviously
3825    // truncated relative to every one of them. `PLAUSIBLE_LEN_FRACTION`
3826    // (0.85) leaves room for a moderate deletion/error in an otherwise
3827    // full-length short-allele read without being so loose it accepts an
3828    // actually-partial read.
3829    //   - A bridge candidate that fails the plausibility check falls back
3830    //     to its own largest *bubble-compatible* cluster (still better than
3831    //     the global default: at least respects the partial bubble
3832    //     evidence it does have, which -- for a short/partial read -- is
3833    //     more trustworthy than its truncated length).
3834    //   - A fully unassigned read that fails the check has no compatible
3835    //     set to fall back to at all (zero recorded arm choices anywhere),
3836    //     so it defaults to the single largest cluster overall, exactly as
3837    //     before this fix.
3838    const PLAUSIBLE_LEN_FRACTION: f64 = 0.85;
3839
3840    if !cluster_members.is_empty() {
3841        let snapshot_lens: Vec<f64> = cluster_members
3842            .iter()
3843            .map(|members| {
3844                median_usize(&members.iter().map(|&r| reads[r].len()).collect::<Vec<_>>())
3845            })
3846            .collect();
3847        let snapshot_sizes: Vec<usize> = cluster_members.iter().map(|m| m.len()).collect();
3848        let min_full_len = (0..cluster_members.len())
3849            .filter(|&c| snapshot_sizes[c] >= min_reads)
3850            .map(|c| snapshot_lens[c])
3851            .fold(f64::INFINITY, f64::min);
3852
3853        let nearest_cluster_among = |read_idx: usize, candidates: &[usize]| -> usize {
3854            let len = reads[read_idx].len() as f64;
3855            candidates
3856                .iter()
3857                .copied()
3858                .min_by(|&a, &b| {
3859                    let da = (snapshot_lens[a] - len).abs();
3860                    let db = (snapshot_lens[b] - len).abs();
3861                    da.partial_cmp(&db)
3862                        .unwrap()
3863                        // Tie-break toward the larger (more-evidenced) cluster, then
3864                        // lowest index, for determinism.
3865                        .then_with(|| snapshot_sizes[b].cmp(&snapshot_sizes[a]))
3866                        .then_with(|| a.cmp(&b))
3867                })
3868                .unwrap()
3869        };
3870        let largest_overall = (0..cluster_members.len())
3871            .max_by_key(|&c| snapshot_sizes[c])
3872            .unwrap();
3873        let all_clusters: Vec<usize> = (0..cluster_members.len()).collect();
3874        // Fallback for a bridge candidate whose own length isn't trustworthy:
3875        // pick the largest cluster among its bubble-compatible set *by
3876        // member count*, not by length -- a read whose length we've just
3877        // decided not to trust must not turn around and use that same
3878        // length to break the tie among its fallback candidates.
3879        let largest_compatible = |candidates: &[usize]| -> usize {
3880            *candidates
3881                .iter()
3882                .max_by_key(|&&c| snapshot_sizes[c])
3883                .unwrap()
3884        };
3885
3886        let mut resolved: Vec<(usize, usize)> = Vec::new();
3887        for (r, compat) in &bridge_candidates {
3888            let len = reads[*r].len() as f64;
3889            let target = if min_full_len.is_finite() && len >= PLAUSIBLE_LEN_FRACTION * min_full_len
3890            {
3891                nearest_cluster_among(*r, &all_clusters)
3892            } else {
3893                largest_compatible(compat)
3894            };
3895            resolved.push((*r, target));
3896        }
3897        for &r in &unassigned {
3898            let len = reads[r].len() as f64;
3899            let target = if min_full_len.is_finite() && len >= PLAUSIBLE_LEN_FRACTION * min_full_len
3900            {
3901                nearest_cluster_among(r, &all_clusters)
3902            } else {
3903                largest_overall
3904            };
3905            resolved.push((r, target));
3906        }
3907        for (r, target) in resolved {
3908            cluster_members[target].push(r);
3909        }
3910    } else {
3911        // No cluster exists at all (no read anywhere had a recorded arm
3912        // choice) -- nothing to compare lengths against, so every read is
3913        // definitionally in one, undifferentiated group.
3914        cluster_members.push(Vec::new());
3915        cluster_members[0].extend(bridge_candidates.into_iter().map(|(r, _)| r));
3916        cluster_members[0].extend(unassigned);
3917    }
3918
3919    let mut groups: Vec<Vec<usize>> = cluster_members;
3920    groups.sort_unstable_by_key(|g| std::cmp::Reverse(g.len()));
3921
3922    if groups.is_empty() {
3923        groups.push(Vec::new());
3924    }
3925
3926    // Fold groups below min_reads into the largest group.
3927    let mut i = 1;
3928    while i < groups.len() {
3929        if groups[i].len() < min_reads {
3930            let g = groups.remove(i);
3931            groups[0].extend(g);
3932        } else {
3933            i += 1;
3934        }
3935    }
3936    groups.retain(|g| !g.is_empty());
3937    groups
3938}
3939
3940fn partition_reads_by_bubble(
3941    edge_reads: &HashMap<(usize, usize), Vec<u32>>,
3942    entry_node: usize,
3943    arm_starts: &[usize],
3944    n_reads: usize,
3945) -> Vec<Vec<usize>> {
3946    let mut arm_order: Vec<usize> = (0..arm_starts.len()).collect();
3947    arm_order.sort_unstable_by(|&a, &b| {
3948        let wa = edge_reads
3949            .get(&(entry_node, arm_starts[a]))
3950            .map_or(0, |v| v.len());
3951        let wb = edge_reads
3952            .get(&(entry_node, arm_starts[b]))
3953            .map_or(0, |v| v.len());
3954        wb.cmp(&wa)
3955    });
3956    let n_arms = arm_order.len().min(2);
3957
3958    let mut groups: Vec<Vec<usize>> = vec![Vec::new(); n_arms];
3959    let mut assigned = vec![false; n_reads];
3960
3961    for (slot, &arm_idx) in arm_order[..n_arms].iter().enumerate() {
3962        let arm_start = arm_starts[arm_idx];
3963        if let Some(reads) = edge_reads.get(&(entry_node, arm_start)) {
3964            for &r in reads {
3965                let r = r as usize;
3966                if r < n_reads && !assigned[r] {
3967                    groups[slot].push(r);
3968                    assigned[r] = true;
3969                }
3970            }
3971        }
3972    }
3973
3974    let largest = groups
3975        .iter()
3976        .enumerate()
3977        .max_by_key(|(_, g)| g.len())
3978        .map(|(i, _)| i)
3979        .unwrap_or(0);
3980    for (r, &done) in assigned.iter().enumerate() {
3981        if !done {
3982            groups[largest].push(r);
3983        }
3984    }
3985
3986    groups.retain(|g| !g.is_empty());
3987    groups
3988}
3989
3990fn choose_seed(group: &[usize], reads: &[Vec<u8>]) -> usize {
3991    if group.len() == 1 {
3992        return 0;
3993    }
3994    let mut lens: Vec<usize> = group.iter().map(|&i| reads[i].len()).collect();
3995    lens.sort_unstable();
3996    let median = lens[lens.len() / 2];
3997    group
3998        .iter()
3999        .enumerate()
4000        .min_by_key(|&(_, &i)| reads[i].len().abs_diff(median))
4001        .map(|(slot, _)| slot)
4002        .unwrap_or(0)
4003}
4004
4005// ─── PoaGraph public API ─────────────────────────────────────────────────────
4006
4007impl PoaGraph {
4008    pub fn new(seed: &[u8], config: PoaConfig) -> Result<Self, PoaError> {
4009        if seed.is_empty() {
4010            return Err(PoaError::EmptyInput);
4011        }
4012
4013        let mut nodes: Vec<Node> = Vec::with_capacity(seed.len());
4014        for &base in seed {
4015            let idx = push_node(&mut nodes, base);
4016            nodes[idx].coverage = 1;
4017        }
4018        let n = nodes.len();
4019        let mut edge_reads: HashMap<(usize, usize), Vec<u32>> = HashMap::new();
4020        for i in 0..n.saturating_sub(1) {
4021            add_edge(&mut nodes, i, i + 1);
4022            // Seed's own bases are genuine confirmation, not a skip-through.
4023            edge_reads.insert((i, i + 1), vec![0u32]);
4024        }
4025
4026        Ok(PoaGraph {
4027            nodes,
4028            config,
4029            n_reads: 1,
4030            reads: vec![seed.to_vec()],
4031            edge_reads,
4032            edge_delete_reads: HashMap::new(),
4033            bypass_edges: HashMap::new(),
4034            warnings: 0,
4035            cached_spine: Vec::new(),
4036            spine_updated_at: 0,
4037            spine_interval: 1,
4038            align_scratch: AlignScratch::new(),
4039            spine_mers: HashMap::new(),
4040            fork_arm_index: HashMap::new(),
4041            used_band_retry: false,
4042        })
4043    }
4044
4045    pub fn add_read(&mut self, read: &[u8]) -> Result<(), PoaError> {
4046        if read.is_empty() {
4047            return Err(PoaError::EmptyInput);
4048        }
4049
4050        let (topo, rank_of) = topological_order(&self.nodes);
4051        // Permanent internal invariant, zero-cost in release builds: the
4052        // graph must always be a DAG entering `add_read`. If it isn't,
4053        // `topological_order`'s Kahn's-algorithm queue silently excludes the
4054        // cyclic nodes from `topo` rather than erroring, which would hang
4055        // `heaviest_path`'s predecessor-pointer traceback in an unbounded
4056        // loop the next time the spine is refreshed (see
4057        // `reuse_would_create_back_edge`'s doc comment for how this was
4058        // introduced and fixed). Catching the violation here, right at the
4059        // point a caller could next observe it, is far cheaper to diagnose
4060        // than the OOM it would otherwise cause.
4061        debug_assert_eq!(
4062            topo.len(),
4063            self.nodes.len(),
4064            "graph has a cycle before adding read {}: topo includes {} of {} nodes",
4065            self.n_reads,
4066            topo.len(),
4067            self.nodes.len()
4068        );
4069
4070        // Refresh the spine when the cache is empty or the interval has elapsed.
4071        // The interval doubles each time the spine is stable (≤ SPINE_STABLE_THRESHOLD
4072        // base changes), and resets to 1 when it changes significantly.  The final
4073        // consensus always recomputes the heaviest path from scratch.  A stale
4074        // spine affects both alignment speed and anchor quality: shifted k-mers
4075        // built from an outdated spine can produce wrong anchor chains.
4076        let reads_since_update = self.n_reads.saturating_sub(self.spine_updated_at);
4077        if self.cached_spine.is_empty() || reads_since_update >= self.spine_interval {
4078            let new_spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4079            let diff = spine_diff(&self.cached_spine, &new_spine);
4080            self.cached_spine = new_spine;
4081            self.spine_updated_at = self.n_reads;
4082            // Rebuild spine minimizer index whenever the spine is refreshed.
4083            self.spine_mers = build_spine_mers(&self.cached_spine, MINI_K, MINI_W);
4084            if diff <= SPINE_STABLE_THRESHOLD {
4085                self.spine_interval = (self.spine_interval * 2).min(SPINE_MAX_INTERVAL);
4086            } else {
4087                self.spine_interval = 1;
4088            }
4089        }
4090
4091        // Build minimizer anchor chain: match read k-mers against unique spine
4092        // k-mers, then retain the longest colinear hit chain via LIS.  The chain
4093        // constrains the per-row j-window inside align(), narrowing the DP band
4094        // on spine nodes at exact anchor positions.
4095        //
4096        // Density gate: in repetitive sequence (CAG, GAA, AAGGG repeats), error-
4097        // induced unique k-mers in the spine can match error k-mers in a read at
4098        // a different repeat unit but the same cycle phase, creating wrong anchors
4099        // with plausible (read_pos, topo_rank) pairs that survive LIS colinearity
4100        // filtering.  These wrong anchors narrow the j-window and may exclude the
4101        // correct j position, causing repeat unit loss.
4102        //
4103        // A fixed minimum threshold (MINI_MIN_CHAIN) separates the two regimes:
4104        // in repetitive sequence, expected wrong anchor count ≈ n_err² / (n_units
4105        // × n_sub_types) ≈ 1-3 for typical ONT depths — well below the threshold.
4106        // In non-repetitive sequence, ~46% of k-mers survive unmodified at 5%
4107        // error rate, giving ≈read_len × 0.46 / MINI_W correct anchors — well
4108        // above the threshold for reads ≥ 200 bp.
4109        let read_mers = compute_minimizers(read, MINI_K, MINI_W);
4110        let raw_anchors =
4111            build_anchor_chain(&read_mers, &self.spine_mers, &self.cached_spine, &rank_of);
4112        let anchors: Vec<(usize, usize)> = if raw_anchors.len() >= MINI_MIN_CHAIN {
4113            raw_anchors
4114        } else {
4115            vec![]
4116        };
4117
4118        let (ops, retried) = align_with_retry(
4119            &self.nodes,
4120            &topo,
4121            &rank_of,
4122            &self.cached_spine,
4123            read,
4124            &self.config,
4125            &mut self.align_scratch,
4126            &anchors,
4127        )?;
4128        if retried {
4129            self.used_band_retry = true;
4130        }
4131        let read_idx = self.n_reads as u32;
4132        add_to_graph(
4133            &mut self.nodes,
4134            &mut self.edge_reads,
4135            &mut self.bypass_edges,
4136            &mut self.fork_arm_index,
4137            &rank_of,
4138            read,
4139            &ops,
4140            read_idx,
4141        );
4142        self.reads.push(read.to_vec());
4143        self.n_reads += 1;
4144        Ok(())
4145    }
4146
4147    pub fn consensus(&self) -> Result<Consensus, PoaError> {
4148        if self.n_reads < self.config.min_reads {
4149            return Err(PoaError::InsufficientDepth {
4150                got: self.n_reads,
4151                min: self.config.min_reads,
4152            });
4153        }
4154
4155        if self.n_reads == 1 {
4156            let (topo, _) = topological_order(&self.nodes);
4157            let sequence: Vec<u8> = topo.iter().map(|&idx| self.nodes[idx].base).collect();
4158            let coverage: Vec<u32> = topo.iter().map(|_| 1).collect();
4159            let path_weights: Vec<i32> = topo.iter().map(|_| 1).collect();
4160            let mut graph_stats =
4161                compute_stats(&self.nodes, self.config.min_allele_freq, self.n_reads);
4162            graph_stats.median_input_read_len = median_read_len(&self.reads);
4163            return Ok(Consensus {
4164                sequence,
4165                coverage,
4166                path_weights,
4167                n_reads: 1,
4168                graph_stats,
4169                gaps: vec![],
4170                bubble_sites: vec![],
4171                read_indices: vec![],
4172            });
4173        }
4174
4175        let (topo, rank_of) = topological_order(&self.nodes);
4176
4177        let filtered: Vec<(usize, u8, i32)> = match self.config.consensus_mode {
4178            ConsensusMode::HeaviestPath => {
4179                // Consensus path selection uses greedy heaviest bundling
4180                // (abPOA-style), NOT the length-biased `(weight-1)` longest path
4181                // (`heaviest_path`, kept for the alignment-centering spine
4182                // cache): the length bias over-calls periodic repeats. See
4183                // design/vntr_overcall_delete_edge_visibility.md.
4184                let path = greedy_heaviest_walk(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4185
4186                // Boundary trim: find the first/last node whose Match
4187                // coverage clears an absolute population floor. This has
4188                // to stay absolute, not evidence-relative -- a trailing (or
4189                // leading) minority extension has delete_count == 0 at its
4190                // first node (nobody deletes through it; the rest of the
4191                // population simply never reaches that far under
4192                // semi-global alignment), so a Match-vs-Delete comparison
4193                // alone can't distinguish "real interior disagreement"
4194                // from "the majority already ended here." Only an absolute
4195                // floor can.
4196                //
4197                // The floor's *population* is a per-position local estimate
4198                // (see `local_population_profile`), not the flat
4199                // `self.n_reads` used before: for a genuinely partial-read
4200                // population (no single read spans the whole target
4201                // region), `self.n_reads` counts reads that can never
4202                // reach a given position at all, making
4203                // `(n_reads/2+1).max(2)` unreachable almost everywhere and
4204                // silently collapsing the consensus toward half its true
4205                // length (confirmed on `gen_short_reads_long_region_ont_r10`
4206                // in `bench/compare_callers.py --general`).
4207                //
4208                // Radius choice: see `LOCAL_POP_RADIUS`'s own doc comment
4209                // for the full empirical trade-off (a read-length-scaled
4210                // radius under-fixes this bug; a small, jitter-scale radius
4211                // over-fixes it by resurrecting three majority-vs-minority
4212                // regressions on real repeat data). For a normal
4213                // fully-spanning population this is a no-op (nearby columns
4214                // already share the same near-total coverage, same as
4215                // before); only a structurally partial population sees the
4216                // floor actually shrink.
4217                let radius = LOCAL_POP_RADIUS;
4218                let path_coverage: Vec<u32> = path
4219                    .iter()
4220                    .map(|&(node_idx, _, _)| self.nodes[node_idx].coverage)
4221                    .collect();
4222                let local_pop = local_population_profile(&path_coverage, radius);
4223                let local_min_cov_by_pos: Vec<u32> = local_pop
4224                    .iter()
4225                    .map(|&pop| coverage_threshold(pop as usize, self.config.min_coverage_fraction))
4226                    .collect();
4227
4228                let meets_floor: Vec<bool> = path
4229                    .iter()
4230                    .enumerate()
4231                    .map(|(i, &(node_idx, _, _))| {
4232                        self.nodes[node_idx].coverage >= local_min_cov_by_pos[i]
4233                    })
4234                    .collect();
4235
4236                let start = meets_floor.iter().position(|&s| s).unwrap_or(0);
4237                let end = meets_floor
4238                    .iter()
4239                    .rposition(|&s| s)
4240                    .map(|i| i + 1)
4241                    .unwrap_or(path.len());
4242
4243                let range = if start < end {
4244                    start..end
4245                } else {
4246                    0..path.len()
4247                };
4248
4249                // Interior inclusion: within the trimmed boundary, two
4250                // distinct evidence axes decide whether a node survives,
4251                // and they need different tests because they answer
4252                // different questions:
4253                //
4254                // 1. Match vs Delete -- "is this base here at all, or did
4255                //    the reads that reached this exact point skip it?"
4256                //    Only meaningful when NO fork (2+ out-edges) exists
4257                //    anywhere back along this node's unbranched run, all
4258                //    the way to the trimmed boundary: with no competing
4259                //    arm anywhere in reach, a node's own coverage and
4260                //    delete_count are the *exact*, complete count of reads
4261                //    that reached this precise point, so a plain
4262                //    `coverage > delete_count` is sufficient and needs no
4263                //    population floor -- majority-delete fabrication and a
4264                //    false rescue of it (Known Bugs #6, #9; DAB1 SCA37
4265                //    cov=3 vs del=23) both come down to exactly this axis.
4266                // 2. This base vs a *different* base -- a real fork exists
4267                //    somewhere back along this node's unbranched run. Here
4268                //    "coverage beats delete_count" is the wrong test: a
4269                //    base can have zero deletes of its own and still be
4270                //    nothing more than one arm of a genuine near-tie with
4271                //    a sibling arm (confirmed regressions: RFC1 AAAAG
4272                //    5-vs-5, SV tandem-duplication 10-vs-4 -- the latter
4273                //    only visible several nodes downstream of the fork
4274                //    itself, which is why the search below walks the whole
4275                //    unbranched run rather than checking one hop back).
4276                //    This axis needs the fork's own coverage to clear a
4277                //    majority of the fork's local total (not merely beat
4278                //    zero), gated on that local total itself clearing the
4279                //    position-local absolute floor (see
4280                //    `local_population_profile`) so a heavily fragmented,
4281                //    near-empty fork can't manufacture a "majority" out of
4282                //    noise (Known Bug #7). This gate used a single global
4283                //    `min_cov` before the partial-read-population fix
4284                //    below; using the same per-position local floor here
4285                //    keeps both checks on one consistent notion of
4286                //    "population," and is a no-op for the fully-spanning
4287                //    populations Bug #7 was originally about (the nearby
4288                //    window still sees the same near-total coverage).
4289                //
4290                // A node several hops downstream of a fork, on an
4291                // otherwise-unbranched continuation of one of its arms,
4292                // still belongs to axis 2 -- the fork's accept/reject
4293                // verdict has to hold for its entire arm, not just the
4294                // arm's first node. Bug #8 originally found this via a
4295                // bounded (64-hop) backward walk over `path[]` re-derived on
4296                // every call; Phase 4 (design/graph_data_model_rework.md)
4297                // replaces that re-derivation with `Node.nearest_fork`, a
4298                // cache populated incrementally as the graph is built (see
4299                // `propagate_fork_if_new`) instead of walked fresh here.
4300                // Judgment is unchanged -- same two axes, same tests below --
4301                // only how the fork-context lookup happens.
4302                //
4303                // One boundary-compatibility check is still needed: the old
4304                // walk refused to look further back than `range_start` (the
4305                // start of *this call's* boundary-trimmed region), so a
4306                // fork sitting entirely within the trimmed-away leading
4307                // section was treated as "none found," falling back to
4308                // axis 1. `nearest_fork` is a graph-level cache with no
4309                // notion of any particular call's boundary trim, so that
4310                // check is reproduced explicitly via `rank_of` to keep the
4311                // exact same behaviour at that edge.
4312                let range_start = range.start;
4313                let range_start_rank = rank_of[path[range_start].0];
4314                range
4315                    .filter(|&i| {
4316                        let (node_idx, _, _) = path[i];
4317                        if meets_floor[i] {
4318                            return true;
4319                        }
4320                        let fork_info = self.nodes[node_idx]
4321                            .nearest_fork
4322                            .filter(|&(pred_idx, _)| rank_of[pred_idx] >= range_start_rank);
4323                        let Some((pred_idx, arm_idx)) = fork_info else {
4324                            return self.nodes[node_idx].coverage
4325                                > self.nodes[node_idx].delete_count;
4326                        };
4327                        // Phase 4 territory (design/graph_data_model_rework.md):
4328                        // this is the interior filter's fork-population/
4329                        // plurality logic (Known Bugs #6-#10). Kept on total
4330                        // (matched + deleted) weight, byte-identical to
4331                        // pre-split behaviour -- not part of this pass.
4332                        let local_total: i32 = self.nodes[pred_idx]
4333                            .out_edges
4334                            .iter()
4335                            .map(|&(_, ew)| ew.total())
4336                            .sum();
4337                        if (local_total.max(0) as u32) < local_min_cov_by_pos[i] {
4338                            return false;
4339                        }
4340                        let local_min_cov = coverage_threshold(
4341                            local_total.max(0) as usize,
4342                            self.config.min_coverage_fraction,
4343                        );
4344                        if self.nodes[node_idx].coverage as i32 >= local_min_cov as i32 {
4345                            return true;
4346                        }
4347                        // Plurality relaxation: trust the arm's own entry
4348                        // weight against its siblings' -- but *only* when
4349                        // both the fork itself and this candidate are
4350                        // "clean" (zero delete_count), i.e. genuinely
4351                        // undisputed populations on both ends, not entangled
4352                        // with an unresolved Match-vs-Delete decision. A
4353                        // fork with its own delete_count > 0 means the
4354                        // arrival at the fork is itself still contested
4355                        // (confirmed case: RFC1 AAAAG's fork had cov=4,
4356                        // del=6 of its own -- a majority-delete decision one
4357                        // level up from the 5-vs-5 split below it -- so
4358                        // trusting that split's plurality independently of
4359                        // the unresolved arrival fabricated a base no read
4360                        // actually has). Confirmed target case: a genuine
4361                        // 3-way split of the *full* population at a fork
4362                        // with cov=7, del=0 of its own (cag20_d05_r10) --
4363                        // no attrition, no unresolved arrival, just three
4364                        // different bases competing at the same position.
4365                        if self.nodes[pred_idx].delete_count != 0
4366                            || self.nodes[node_idx].delete_count != 0
4367                        {
4368                            return false;
4369                        }
4370                        let arm_weight = self.nodes[pred_idx]
4371                            .out_edges
4372                            .iter()
4373                            .find(|&&(to, _)| to == arm_idx)
4374                            .map(|&(_, ew)| ew.total())
4375                            .unwrap_or(0);
4376                        self.nodes[pred_idx]
4377                            .out_edges
4378                            .iter()
4379                            .all(|&(_, ew)| ew.total() <= arm_weight)
4380                    })
4381                    .map(|i| path[i])
4382                    .collect()
4383            }
4384            // `MajorityFrequency` keeps the flat, whole-population floor:
4385            // its documented use case is HiFi data with near-identical read
4386            // lengths within an allele group (CLAUDE.md's "Consensus
4387            // Modes"), not the genuinely-partial-read populations the local
4388            // profile above targets, and `topo` order (unlike `path`) does
4389            // not correspond to sequence position closely enough for a
4390            // position-windowed estimate to be meaningful.
4391            ConsensusMode::MajorityFrequency => {
4392                majority_frequency(&self.nodes, &topo, self.min_coverage())
4393            }
4394        };
4395
4396        let sequence: Vec<u8> = filtered.iter().map(|&(_, base, _)| base).collect();
4397        let coverage: Vec<u32> = filtered
4398            .iter()
4399            .map(|&(node_idx, _, _)| self.nodes[node_idx].coverage)
4400            .collect();
4401        let path_weights: Vec<i32> = filtered.iter().map(|&(_, _, w)| w).collect();
4402
4403        let mut graph_stats = compute_stats(&self.nodes, self.config.min_allele_freq, self.n_reads);
4404        graph_stats.median_input_read_len = median_read_len(&self.reads);
4405        let gaps = detect_coverage_gaps(&coverage);
4406        let bubble_sites = collect_bubble_sites(
4407            &self.nodes,
4408            &topo,
4409            &filtered,
4410            &self.edge_reads,
4411            self.config.min_allele_freq,
4412            self.n_reads,
4413            self.config.phasing_bubble_min_span,
4414        );
4415
4416        Ok(Consensus {
4417            sequence,
4418            coverage,
4419            path_weights,
4420            n_reads: self.n_reads,
4421            graph_stats,
4422            gaps,
4423            bubble_sites,
4424            read_indices: vec![],
4425        })
4426    }
4427
4428    pub fn stats(&self) -> GraphStats {
4429        let mut s = compute_stats(&self.nodes, self.config.min_allele_freq, self.n_reads);
4430        s.median_input_read_len = median_read_len(&self.reads);
4431        s
4432    }
4433
4434    /// Number of long-unbanded warnings emitted during `add_read` calls.
4435    /// With the new bubble-aware aligner this always returns 0 (warnings removed).
4436    pub fn warnings_emitted(&self) -> usize {
4437        self.warnings
4438    }
4439
4440    /// Return all edge weights in the graph (one entry per directed edge).
4441    ///
4442    /// Total (matched + deleted) traversal count, unchanged in meaning from
4443    /// before the Match/Delete edge-weight split -- this is a general
4444    /// diagnostic/visualization API (see `src/plot.rs`), not a consensus
4445    /// decision point, so its contract is preserved as-is.
4446    pub fn edge_weights(&self) -> Vec<i32> {
4447        self.nodes
4448            .iter()
4449            .flat_map(|n| n.out_edges.iter().map(|&(_, ew)| ew.total()))
4450            .collect()
4451    }
4452
4453    /// Return per-node coverage in topological order.
4454    pub fn node_coverages(&self) -> Vec<u32> {
4455        let (topo, _) = topological_order(&self.nodes);
4456        topo.iter().map(|&i| self.nodes[i].coverage).collect()
4457    }
4458
4459    /// Align `read` into the current graph and return the alignment operations
4460    /// without modifying the graph.
4461    pub fn align_read_ops(
4462        &self,
4463        read: &[u8],
4464    ) -> Result<(Vec<AlignOp>, usize, Vec<usize>), PoaError> {
4465        let (topo, rank_of) = topological_order(&self.nodes);
4466        let spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4467        let (ops, _retried) = align_with_retry(
4468            &self.nodes,
4469            &topo,
4470            &rank_of,
4471            &spine,
4472            read,
4473            &self.config,
4474            &mut AlignScratch::new(),
4475            &[],
4476        )?;
4477        Ok((ops, 0, rank_of))
4478    }
4479
4480    /// Like [`align_read_ops`] but kept for compatibility.
4481    pub fn align_read_ops_unbanded(
4482        &self,
4483        read: &[u8],
4484    ) -> Result<(Vec<AlignOp>, Vec<usize>), PoaError> {
4485        let (topo, rank_of) = topological_order(&self.nodes);
4486        let spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4487        let (ops, _retried) = align_with_retry(
4488            &self.nodes,
4489            &topo,
4490            &rank_of,
4491            &spine,
4492            read,
4493            &self.config,
4494            &mut AlignScratch::new(),
4495            &[],
4496        )?;
4497        Ok((ops, rank_of))
4498    }
4499
4500    /// Number of nodes currently in the graph.
4501    pub fn node_count(&self) -> usize {
4502        self.nodes.len()
4503    }
4504
4505    /// `true` if any `add_read` call so far needed `align_with_retry`'s
4506    /// pass 2 or 3 (the configured band was too narrow for at least one
4507    /// read). Crate-internal: consumed by `build_graph` (`src/lib.rs`) to
4508    /// decide whether the whole graph needs a consistent unbanded rebuild
4509    /// -- see `used_band_retry`'s own field doc comment for why a graph
4510    /// built from a mix of band widths across different reads is not
4511    /// trustworthy as-is in a periodic/repetitive locus.
4512    pub(crate) fn used_band_retry(&self) -> bool {
4513        self.used_band_retry
4514    }
4515
4516    /// Return a snapshot of the graph topology for visualization and inspection.
4517    ///
4518    /// Nodes are in topological order; `edges` use topological ranks as source
4519    /// and target identifiers.  `spine_ranks` lists the ranks of nodes on the
4520    /// heaviest-path (consensus) spine.
4521    pub fn graph_topology(&self) -> crate::types::GraphTopology {
4522        use crate::types::{GraphEdgeInfo, GraphNodeInfo, GraphTopology};
4523
4524        let (topo, rank_of) = topological_order(&self.nodes);
4525        let spine = heaviest_path(&self.nodes, &topo, &rank_of, &self.bypass_edges);
4526
4527        let nodes: Vec<GraphNodeInfo> = topo
4528            .iter()
4529            .enumerate()
4530            .map(|(rank, &node_idx)| {
4531                let n = &self.nodes[node_idx];
4532                GraphNodeInfo {
4533                    node_idx,
4534                    base: n.base,
4535                    coverage: n.coverage,
4536                    delete_count: n.delete_count,
4537                    topo_rank: rank,
4538                }
4539            })
4540            .collect();
4541
4542        // GraphEdgeInfo.weight is documented as "number of reads that
4543        // traversed this edge" -- total (matched + deleted), unchanged from
4544        // before the split.
4545        let edges: Vec<GraphEdgeInfo> = topo
4546            .iter()
4547            .enumerate()
4548            .flat_map(|(from_rank, &node_idx)| {
4549                self.nodes[node_idx]
4550                    .out_edges
4551                    .iter()
4552                    .map(move |&(succ_idx, ew)| (from_rank, succ_idx, ew.total()))
4553                    .collect::<Vec<_>>()
4554            })
4555            .map(|(from_rank, succ_idx, weight)| GraphEdgeInfo {
4556                from_rank,
4557                to_rank: rank_of[succ_idx],
4558                weight,
4559            })
4560            .collect();
4561
4562        let spine_ranks: Vec<usize> = spine
4563            .iter()
4564            .map(|(node_idx, _, _)| rank_of[*node_idx])
4565            .collect();
4566
4567        GraphTopology {
4568            nodes,
4569            edges,
4570            spine_ranks,
4571        }
4572    }
4573
4574    /// For each node, return `(out_edges_count, max_out_edge_weight, min_out_edge_weight)`.
4575    ///
4576    /// Total (matched + deleted) weight, same reasoning as `edge_weights()`.
4577    pub fn node_out_edge_info(&self) -> Vec<(usize, i32, i32)> {
4578        self.nodes
4579            .iter()
4580            .map(|n| {
4581                let count = n.out_edges.len();
4582                let max_w = n
4583                    .out_edges
4584                    .iter()
4585                    .map(|&(_, ew)| ew.total())
4586                    .max()
4587                    .unwrap_or(0);
4588                let min_w = n
4589                    .out_edges
4590                    .iter()
4591                    .map(|&(_, ew)| ew.total())
4592                    .min()
4593                    .unwrap_or(0);
4594                (count, max_w, min_w)
4595            })
4596            .collect()
4597    }
4598
4599    /// Return arm lengths for each bubble node with 2+ qualifying arms (weight >= threshold).
4600    ///
4601    /// Arm length is measured by walking the single-successor chain from each arm start,
4602    /// stopping at reconvergence points (nodes with multiple in-edges after the first step).
4603    ///
4604    /// `weight_threshold` is compared against total (matched + deleted) weight,
4605    /// unchanged from before the Match/Delete edge-weight split -- a general
4606    /// diagnostic API (see `tests/rfc1_real_data.rs`), not a consensus decision point.
4607    pub fn bubble_arm_lengths(
4608        &self,
4609        weight_threshold: i32,
4610        arm_len_threshold: usize,
4611    ) -> Vec<(usize, Vec<usize>)> {
4612        let (topo, _) = topological_order(&self.nodes);
4613        let mut result = Vec::new();
4614        for (t, &node_idx) in topo.iter().enumerate() {
4615            let qualifying: Vec<usize> = self.nodes[node_idx]
4616                .out_edges
4617                .iter()
4618                .filter(|&&(_, ew)| ew.total() >= weight_threshold)
4619                .map(|&(succ, _)| succ)
4620                .collect();
4621            if qualifying.len() >= 2 {
4622                // Measure each arm by walking single-successor chains.
4623                let arm_lens: Vec<usize> = qualifying
4624                    .iter()
4625                    .map(|&arm_start| materialize_arm_len(&self.nodes, arm_start, 500))
4626                    .collect();
4627                let min_len = arm_lens.iter().copied().min().unwrap_or(0);
4628                if min_len >= arm_len_threshold {
4629                    result.push((t, arm_lens));
4630                }
4631            }
4632        }
4633        result
4634    }
4635
4636    /// Build one consensus per detected allele.
4637    ///
4638    /// Structural bubbles (arm span ≥ phasing_bubble_min_span) are phased first using
4639    /// cross-bubble compatibility grouping. If no structural bubbles are found and the
4640    /// alignment was banded, the graph is rebuilt with unbanded alignment so that large
4641    /// allele-length differences create a visible Insert-arm bubble; structural phasing
4642    /// then retries on the unbanded graph. If still no structural bubble, falls back to
4643    /// single-best SNP bubble partitioning for substitution haplotypes.
4644    ///
4645    /// Each returned [`Consensus`]'s `read_indices` are in this graph's own
4646    /// read ordering: the [`PoaGraph::new`] seed is index 0 and each
4647    /// [`PoaGraph::add_read`] call follows in order. The free-function
4648    /// [`crate::consensus_multi`] wrapper translates these back to the input
4649    /// slice's indexing; when calling this method directly the add-order
4650    /// indexing is left as-is.
4651    pub fn consensus_multi(&self) -> Result<Vec<Consensus>, PoaError> {
4652        if self.n_reads < self.config.min_reads {
4653            return Err(PoaError::InsufficientDepth {
4654                got: self.n_reads,
4655                min: self.config.min_reads,
4656            });
4657        }
4658
4659        let (topo, _) = topological_order(&self.nodes);
4660
4661        // Try structural bubble phasing first (length variants, SVs, large indels).
4662        let structural = find_structural_bubbles(&self.nodes, &topo, self.n_reads, &self.config);
4663
4664        // If no structural bubble was found and alignment was banded, the band may be too
4665        // narrow to create a clean Insert-arm bubble for large allele-length differences.
4666        // Rebuild the graph with unbanded alignment (reads include flanking → rotation is
4667        // anchored) and recurse once. The config's band settings are zeroed so the recursive
4668        // call does not loop.
4669        if structural.is_empty() && (self.config.band_width > 0 || self.config.adaptive_band) {
4670            let mut cfg2 = self.config.clone();
4671            cfg2.band_width = 0;
4672            cfg2.adaptive_band = false;
4673            let seed = &self.reads[0];
4674            let mut g2 = PoaGraph::new(seed, cfg2)?;
4675            for read in self.reads.iter().skip(1) {
4676                g2.add_read(read)?;
4677            }
4678            return g2.consensus_multi();
4679        }
4680
4681        let groups = if !structural.is_empty() {
4682            let g = phasing_groups(
4683                &self.edge_reads,
4684                &structural,
4685                self.n_reads,
4686                self.config.min_reads,
4687                &self.reads,
4688            );
4689            // Validate against read-length bimodality + vote weight + a clean
4690            // distinguishing bubble before trusting phasing_groups' split --
4691            // see validate_and_merge_groups' doc comment for why this is scoped
4692            // to the structural-bubble path specifically, not the same-length
4693            // SNP-bubble fallback below.
4694            validate_and_merge_groups(
4695                g,
4696                &self.reads,
4697                self.config.min_reads,
4698                &structural,
4699                &self.edge_reads,
4700            )
4701        } else {
4702            // Unbanded alignment and still no structural bubble. Try SNP bubble
4703            // partitioning for substitution haplotypes (same-length alleles).
4704            let snp_bubbles = find_bubbles(
4705                &self.nodes,
4706                &topo,
4707                self.n_reads,
4708                self.config.min_allele_freq,
4709            );
4710            if !snp_bubbles.is_empty() {
4711                let (entry, arm_starts) = snp_bubbles
4712                    .iter()
4713                    .max_by_key(|(entry, arms)| {
4714                        arms.iter()
4715                            .filter_map(|&arm| self.edge_reads.get(&(*entry, arm)))
4716                            .map(|v| v.len())
4717                            .min()
4718                            .unwrap_or(0)
4719                    })
4720                    .unwrap();
4721                partition_reads_by_bubble(&self.edge_reads, *entry, arm_starts, self.n_reads)
4722            } else {
4723                return Ok(vec![self.consensus()?]);
4724            }
4725        };
4726
4727        if groups.len() < 2 {
4728            return Ok(vec![self.consensus()?]);
4729        }
4730
4731        let mut results = Vec::with_capacity(groups.len());
4732        for group in &groups {
4733            if group.len() < self.config.min_reads {
4734                return Err(PoaError::InsufficientDepth {
4735                    got: group.len(),
4736                    min: self.config.min_reads,
4737                });
4738            }
4739            let seed_slot = choose_seed(group, &self.reads);
4740            let seed = &self.reads[group[seed_slot]];
4741            // Per-allele consensus is a SINGLE-allele problem: the reads have
4742            // already been partitioned to one allele, so build the sub-graph in
4743            // single-allele mode (diagonal-skip disabled). Multi-allele mode's
4744            // diagonal-skip is needed only for the *separation* on the shared
4745            // graph (keeping reads on their allele track); within a partitioned
4746            // group its greedy forward-match reintroduces the periodic over/
4747            // under-call. See PoaConfig::multi_allele.
4748            let mut sub_cfg = self.config.clone();
4749            sub_cfg.multi_allele = false;
4750            let mut sub = PoaGraph::new(seed, sub_cfg)?;
4751            for (slot, &read_idx) in group.iter().enumerate() {
4752                if slot == seed_slot {
4753                    continue;
4754                }
4755                sub.add_read(&self.reads[read_idx])?;
4756            }
4757            let mut c = sub.consensus()?;
4758            c.read_indices = group.to_vec();
4759            results.push(c);
4760        }
4761
4762        Ok(results)
4763    }
4764
4765    fn min_coverage(&self) -> u32 {
4766        coverage_threshold(self.n_reads, self.config.min_coverage_fraction)
4767    }
4768}
4769
4770/// Minimum coverage required to keep a node, given a population size (either
4771/// the full read count, or the local vote total at one bubble) and the
4772/// configured `min_coverage_fraction`.
4773fn coverage_threshold(population: usize, min_coverage_fraction: f64) -> u32 {
4774    if min_coverage_fraction > 0.0 {
4775        ((population as f64 * min_coverage_fraction).ceil() as u32).max(1)
4776    } else if population <= 1 {
4777        1
4778    } else {
4779        ((population / 2 + 1).max(2)) as u32
4780    }
4781}
4782
4783/// For each position `i` along `coverages`, the maximum value observed within
4784/// `radius` positions on either side (inclusive) -- an O(n) sliding-window
4785/// maximum (monotonic deque), safe for path lengths in the thousands.
4786///
4787/// This is the "local population" proxy consumed by `coverage_threshold`'s
4788/// absolute-floor fallback for `ConsensusMode::HeaviestPath` (boundary trim
4789/// and the interior filter's fork-population gate). It replaces `self.n_reads`
4790/// -- the total read count ever added to the graph -- with an estimate of
4791/// how many reads could plausibly reach *near* this exact position.
4792///
4793/// The distinction matters for a genuinely partial-read population (no
4794/// single read spans the whole target region, by construction -- see
4795/// `Scenario.partial` / `gen_short_reads_long_region_ont_r10` in
4796/// `bench/compare_callers.py`): `self.n_reads` counts reads that structurally
4797/// can never reach a given interior position at all, so the absolute floor
4798/// `(n_reads/2+1).max(2)` is unreachable almost everywhere and the consensus
4799/// silently collapses toward half its true length. A *local* window instead
4800/// asks "how much agreement has this graph achieved *anywhere nearby*" --
4801/// for a fully-spanning population that is still essentially `n_reads`
4802/// (nearby columns share the same near-total coverage), so the fallback is
4803/// unchanged in the common case; for a partial population it correctly
4804/// shrinks to the achievable local depth.
4805///
4806/// A *global* (whole-path) maximum was tried first and rejected: a single
4807/// anomalously dense stretch elsewhere in the graph (e.g. where many
4808/// independently-placed partial reads' random start/end positions happen to
4809/// overlap) would inflate the floor everywhere else in the same graph,
4810/// which is exactly the miscalibration being fixed, just moved to a
4811/// different spot. Bounding the window to `radius` (see `LOCAL_POP_RADIUS`'s
4812/// own doc comment for how that constant's value was chosen -- it is an
4813/// empirically-tuned trade-off, not derived from read length or any other
4814/// single principle) keeps the estimate local to what nearby evidence can
4815/// actually support.
4816///
4817/// Deliberately does *not* use a forward-recovery scan (does coverage climb
4818/// back up further along?) -- Known Bug #9's writeup found that approach
4819/// regressed genuine trailing-minority-extension tests
4820/// (`long_repeat_length_majority_wins`, `edge_extreme_length_variation_majority_wins`)
4821/// by letting a stray noisy read overlapping the tail look like "recovery."
4822/// A symmetric, bounded window has no such directional bias: at a true
4823/// boundary, the high-coverage core sits on one side only, so the window's
4824/// max there still reflects the real population, correctly rejecting a
4825/// trailing extension a few positions further out.
4826fn local_population_profile(coverages: &[u32], radius: usize) -> Vec<u32> {
4827    let n = coverages.len();
4828    let mut out = vec![0u32; n];
4829    if n == 0 {
4830        return out;
4831    }
4832    let mut deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
4833    let mut right = 0usize;
4834    for (i, out_i) in out.iter_mut().enumerate() {
4835        let hi = (i + radius).min(n - 1);
4836        while right <= hi {
4837            while let Some(&back) = deque.back() {
4838                if coverages[back] <= coverages[right] {
4839                    deque.pop_back();
4840                } else {
4841                    break;
4842                }
4843            }
4844            deque.push_back(right);
4845            right += 1;
4846        }
4847        let lo = i.saturating_sub(radius);
4848        while let Some(&front) = deque.front() {
4849            if front < lo {
4850                deque.pop_front();
4851            } else {
4852                break;
4853            }
4854        }
4855        *out_i = deque.front().map(|&idx| coverages[idx]).unwrap_or(0);
4856    }
4857    out
4858}
4859
4860// ─── White-box tests: nearest_fork cache (Phase 4) ──────────────────────────
4861//
4862// `Node.nearest_fork` is a private field, so exercising it directly (not just
4863// observing its downstream effect on `consensus()`'s output) requires a
4864// same-module test, unlike the rest of this crate's tests (in `src/tests.rs`,
4865// a sibling module that only sees public API). This module is specifically
4866// for verifying the cache's own internal correctness -- the staleness
4867// question the design doc calls out as unproven.
4868#[cfg(test)]
4869mod fork_cache_tests {
4870    use super::*;
4871
4872    fn b(s: &str) -> Vec<u8> {
4873        s.as_bytes().to_vec()
4874    }
4875
4876    /// Directly exercises the staleness scenario: several reads establish a
4877    /// clean run of single-out-edge nodes, *then* a later read introduces a
4878    /// divergence at a node that was already a stable single-successor
4879    /// predecessor for existing descendants, turning it into a fork after
4880    /// the fact. Confirms the *pre-existing* descendants' cached
4881    /// `nearest_fork` gets updated by forward propagation, by comparing
4882    /// against an independent, exhaustive (unbounded) backward walk computed
4883    /// fresh from the raw graph -- not just checking the cache agrees with
4884    /// itself.
4885    #[test]
4886    fn nearest_fork_updates_for_preexisting_descendants_after_late_fork() {
4887        // Seed: 4 A's (unique-enough prefix) + a 12-base non-repetitive tail.
4888        // Node at position 4 (the tail's first base) is the one we'll fork
4889        // later; positions 5..15 are its pre-existing descendants.
4890        let seed = b("AAAACGTACGTACGTA");
4891        let cfg = PoaConfig {
4892            min_reads: 3,
4893            band_width: 0,
4894            adaptive_band: false,
4895            warn_on_long_unbanded: false,
4896            ..Default::default()
4897        };
4898        let mut g = PoaGraph::new(&seed, cfg).unwrap();
4899
4900        // Several reads that match the seed exactly: establishes positions
4901        // 5..15 as a clean, unforked single-successor chain (their
4902        // nearest_fork should all be None at this point -- no fork anywhere
4903        // in this graph yet).
4904        for _ in 0..4 {
4905            g.add_read(&seed).unwrap();
4906        }
4907        for idx in 5..seed.len() {
4908            assert_eq!(
4909                g.nodes[idx].nearest_fork, None,
4910                "node {idx} should have no fork ancestor yet (pre-divergence)"
4911            );
4912            assert_eq!(
4913                g.nodes[idx].in_edges.len(),
4914                1,
4915                "node {idx} should be a plain single-predecessor chain node"
4916            );
4917        }
4918        let fork_node = 4; // 'C', the first tail base
4919        assert_eq!(g.nodes[fork_node].out_edges.len(), 1, "not yet a fork");
4920        let orig_child = g.nodes[fork_node].out_edges[0].0;
4921        assert_eq!(orig_child, 5);
4922
4923        // Now a later read diverges immediately after position 4 (matches
4924        // "AAAAC" then substitutes the next base), giving node 4 a second
4925        // out-edge -- turning it into a fork *after* positions 5..15 already
4926        // existed as node 4's only descendants. Deliberately ends right at
4927        // the mismatch (no shared suffix appended): the read's remaining
4928        // bases would otherwise happen to equal seed[6..] exactly (both are
4929        // the same repeat unit), so the aligner would legitimately re-Match
4930        // them onto nodes 6..15 and reconverge there for real -- a correct
4931        // but different scenario (fork-then-genuine-reconvergence) that this
4932        // test isn't targeting. Ending here under SemiGlobal (free trailing
4933        // gap, the default) isolates the forward-propagation question: does
4934        // the still-untouched, never-reconverged 5..15 chain get its cached
4935        // nearest_fork updated once node 4 retroactively becomes a fork.
4936        let mut divergent = b("AAAAC");
4937        divergent.push(b'T'); // seed has 'G' here; this read has 'T'
4938        for _ in 0..3 {
4939            g.add_read(&divergent).unwrap();
4940        }
4941        assert_eq!(
4942            g.nodes[fork_node].out_edges.len(),
4943            2,
4944            "node {fork_node} should now be a genuine fork"
4945        );
4946
4947        // Independent ground truth: walk backward from each descendant
4948        // through raw in_edges (exhaustive, no hop bound -- safe here since
4949        // the test graph is tiny), looking for the nearest node with 2+
4950        // out-edges. This is deliberately NOT the cache and NOT the
4951        // production interior-filter walk, so agreement is real evidence,
4952        // not the cache checking itself.
4953        fn ground_truth_nearest_fork(nodes: &[Node], start: usize) -> Option<(usize, usize)> {
4954            let mut cur = start;
4955            loop {
4956                if nodes[cur].in_edges.len() != 1 {
4957                    return None; // reached a reconvergence/source with no single predecessor
4958                }
4959                let pred = nodes[cur].in_edges[0];
4960                if nodes[pred].out_edges.len() >= 2 {
4961                    // `cur` is pred's direct child -- the arm entry point --
4962                    // not the node we started the walk from.
4963                    return Some((pred, cur));
4964                }
4965                cur = pred;
4966            }
4967        }
4968
4969        for idx in 5..seed.len() {
4970            let expected = ground_truth_nearest_fork(&g.nodes, idx);
4971            assert_eq!(
4972                g.nodes[idx].nearest_fork, expected,
4973                "node {idx}: cached nearest_fork should match the independently-computed \
4974                 ground truth after node {fork_node} became a fork post-hoc"
4975            );
4976            assert_eq!(
4977                g.nodes[idx].nearest_fork,
4978                Some((fork_node, orig_child)),
4979                "node {idx}: expected the cache to have been updated by forward \
4980                 propagation to point at the newly-created fork"
4981            );
4982        }
4983    }
4984
4985    /// The adversarial case that the first version of the test above
4986    /// actually hit (by accident, before its own construction was fixed):
4987    /// a single read both creates a brand-new fork AND, later in that exact
4988    /// same read's own traceback, reconverges back onto the pre-existing
4989    /// chain. This is exactly the timing hazard eager, inline propagation
4990    /// got wrong (a downstream node looked like an ordinary single-
4991    /// predecessor node at the moment the fork upstream was created, then
4992    /// gained a second in-edge moments later in the same read). Confirms
4993    /// the reconvergence node itself, and everything downstream of it, is
4994    /// correctly left untouched (still `None`, matching an independent
4995    /// ground-truth walk) rather than being stale-updated to point at the
4996    /// new fork.
4997    #[test]
4998    fn nearest_fork_same_read_reconvergence_is_not_stale_updated() {
4999        let seed = b("AAAACGTACGTACGTA");
5000        let cfg = PoaConfig {
5001            min_reads: 3,
5002            band_width: 0,
5003            adaptive_band: false,
5004            warn_on_long_unbanded: false,
5005            ..Default::default()
5006        };
5007        let mut g = PoaGraph::new(&seed, cfg).unwrap();
5008        for _ in 0..4 {
5009            g.add_read(&seed).unwrap();
5010        }
5011        let fork_node = 4;
5012        let orig_child = g.nodes[fork_node].out_edges[0].0;
5013        assert_eq!(orig_child, 5);
5014
5015        // This divergent read substitutes one base at position 5, then
5016        // continues with the *same* suffix as the seed (seed[6..]) -- so
5017        // after the mismatch, its remaining bases genuinely re-Match nodes
5018        // 6..15 one-for-one, giving node 6 a real second in-edge from the
5019        // new mismatch node. Node 4 becomes a fork; node 6 becomes a true
5020        // reconvergence point; both effects come from this one read.
5021        let mut divergent = b("AAAAC");
5022        divergent.push(b'T'); // seed has 'G' here
5023        divergent.extend_from_slice(&seed[6..]);
5024        for _ in 0..3 {
5025            g.add_read(&divergent).unwrap();
5026        }
5027        assert_eq!(
5028            g.nodes[fork_node].out_edges.len(),
5029            2,
5030            "node {fork_node} should now be a genuine fork"
5031        );
5032        let reconv = 6usize;
5033        assert_eq!(
5034            g.nodes[reconv].in_edges.len(),
5035            2,
5036            "node {reconv} should have genuinely reconverged (2 predecessors) \
5037             within the same read that created the fork"
5038        );
5039
5040        fn ground_truth_nearest_fork(nodes: &[Node], start: usize) -> Option<(usize, usize)> {
5041            let mut cur = start;
5042            loop {
5043                if nodes[cur].in_edges.len() != 1 {
5044                    return None;
5045                }
5046                let pred = nodes[cur].in_edges[0];
5047                if nodes[pred].out_edges.len() >= 2 {
5048                    return Some((pred, cur));
5049                }
5050                cur = pred;
5051            }
5052        }
5053
5054        // Node 5 sits strictly between the fork and the reconvergence: it
5055        // should still get the new fork.
5056        assert_eq!(
5057            g.nodes[5].nearest_fork,
5058            Some((fork_node, orig_child)),
5059            "node 5 (between the fork and the reconvergence) should still be updated"
5060        );
5061        assert_eq!(
5062            g.nodes[5].nearest_fork,
5063            ground_truth_nearest_fork(&g.nodes, 5)
5064        );
5065
5066        // Node 6 (the reconvergence point itself) and everything downstream
5067        // of it must NOT be stale-updated to the new fork -- they should
5068        // match ground truth, which is None past a real reconvergence.
5069        for idx in 6..seed.len() {
5070            let expected = ground_truth_nearest_fork(&g.nodes, idx);
5071            assert_eq!(
5072                expected, None,
5073                "sanity check on the ground-truth helper itself: node {idx} \
5074                 is past a real reconvergence, so ground truth must be None"
5075            );
5076            assert_eq!(
5077                g.nodes[idx].nearest_fork, expected,
5078                "node {idx}: must not be stale-updated across the reconvergence \
5079                 at node {reconv} -- this is the exact hazard deferred (end-of-read) \
5080                 propagation is meant to avoid"
5081            );
5082        }
5083    }
5084}
5085
5086// ─── White-box tests: bypass edges (Phase 1 of bypass_edge_delete_rework) ────
5087//
5088// `PoaGraph.bypass_edges` is a private field written by `add_to_graph` but not
5089// yet read by any consumer (Phase 1 is purely additive; wiring it into
5090// `heaviest_path` is Phase 2). These same-module tests assert the field's own
5091// contents directly against hand-constructed read sets with known deletion
5092// patterns, since nothing observable in `consensus()` output depends on it
5093// yet. All use unbanded Global alignment over a deliberately non-repetitive
5094// seed so every read's Match/Delete op sequence -- and therefore the exact
5095// (from, to) node indices of the bypass edges -- is unambiguous and exactly
5096// predictable (a linear seed builds nodes 0..len in sequence order, so for a
5097// pure-deletion read every op references node index == seed position).
5098#[cfg(test)]
5099mod bypass_edge_tests {
5100    use super::*;
5101
5102    fn b(s: &str) -> Vec<u8> {
5103        s.as_bytes().to_vec()
5104    }
5105
5106    // 16 bp, non-repetitive: no adjacent duplicate bases near the deletion
5107    // sites used below, so each deletion has a single optimal placement.
5108    const SEED: &str = "ACGTGCATCGTAGCTA";
5109
5110    fn cfg_global_unbanded() -> PoaConfig {
5111        PoaConfig {
5112            band_width: 0,
5113            adaptive_band: false,
5114            alignment_mode: AlignmentMode::Global,
5115            warn_on_long_unbanded: false,
5116            min_reads: 2,
5117            ..Default::default()
5118        }
5119    }
5120
5121    /// Flatten `bypass_edges` into a sorted `(from, to, weight)` list for
5122    /// order-independent assertions.
5123    fn flatten(g: &PoaGraph) -> Vec<(usize, usize, i32)> {
5124        let mut out: Vec<(usize, usize, i32)> = g
5125            .bypass_edges
5126            .iter()
5127            .flat_map(|(&from, tos)| tos.iter().map(move |&(to, w)| (from, to, w)))
5128            .collect();
5129        out.sort_unstable();
5130        out
5131    }
5132
5133    /// The `EdgeWeight` on the `from -> to` out-edge, or `None` if no such edge.
5134    fn out_edge(g: &PoaGraph, from: usize, to: usize) -> Option<EdgeWeight> {
5135        g.nodes[from]
5136            .out_edges
5137            .iter()
5138            .find(|&&(t, _)| t == to)
5139            .map(|&(_, ew)| ew)
5140    }
5141
5142    /// Asserts the pure-bypass invariant across the whole graph: no `out_edge`
5143    /// anywhere carries any `deleted`-bucket weight (under pure bypass a
5144    /// deleting read never traverses an edge into a skipped node), and
5145    /// `edge_delete_reads` was never populated.
5146    fn assert_no_delete_bucket_or_delete_reads(g: &PoaGraph) {
5147        for (idx, nd) in g.nodes.iter().enumerate() {
5148            for &(to, ew) in &nd.out_edges {
5149                assert_eq!(
5150                    ew.deleted, 0,
5151                    "pure bypass: out-edge {idx}->{to} must carry no deleted-bucket weight, \
5152                     got {}",
5153                    ew.deleted
5154                );
5155            }
5156        }
5157        assert!(
5158            g.edge_delete_reads.is_empty(),
5159            "pure bypass: edge_delete_reads must never be populated"
5160        );
5161    }
5162
5163    #[test]
5164    fn single_base_deletion_records_one_bypass_edge() {
5165        // Delete seed position 6 ('A', between 'C'@5 and 'T'@7, both distinct
5166        // from 'A' and from each other -> unambiguous single-base deletion).
5167        let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5168        let read = b("ACGTGCTCGTAGCTA"); // SEED without index 6
5169        g.add_read(&read).unwrap();
5170
5171        // Expected op sequence: Match(0..=5), Delete(6), Match(7..=15).
5172        // Entry predecessor of the run = node 5; clean-match resume onto the
5173        // existing node 7 -> one bypass edge 5->7, weight 1.
5174        assert_eq!(
5175            flatten(&g),
5176            vec![(5, 7, 1)],
5177            "one single-base deletion should record exactly one bypass edge 5->7 weight 1"
5178        );
5179        // (a) delete_count on the skipped node is still incremented -- this is
5180        // all mean_column_entropy/majority_frequency need.
5181        assert_eq!(
5182            g.nodes[6].delete_count, 1,
5183            "delete_count on the skipped node must still be incremented"
5184        );
5185        // Pure-bypass representation: the laundered through-edge is ABSENT.
5186        // Under the superseded dual-bookkeeping the deleting read advanced
5187        // through node 6 and its resume Match(7) inflated edge 6->7's matched
5188        // weight to 2 (seed + this read); under pure bypass 6->7 stays at the
5189        // seed's matched=1, and node 5's seed edge 5->6 gains no deleted-bucket
5190        // weight either.
5191        assert_eq!(
5192            out_edge(&g, 6, 7).map(|ew| ew.matched),
5193            Some(1),
5194            "edge 6->7 must NOT have gained matched weight from the deleting read \
5195             (that would be the laundering this rework removes)"
5196        );
5197        assert_no_delete_bucket_or_delete_reads(&g);
5198    }
5199
5200    #[test]
5201    fn multi_base_deletion_run_records_one_spanning_bypass_edge() {
5202        // Delete seed positions 6,7 ('A','T'): "C A T C"@5..=8 -> "C C",
5203        // a clean 2-base deletion between the two 'C's.
5204        let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5205        let read = b("ACGTGCCGTAGCTA"); // SEED without indices 6,7
5206        g.add_read(&read).unwrap();
5207
5208        // Match(0..=5), Delete(6), Delete(7), Match(8..=15): a single bypass
5209        // edge spanning the whole run, 5 -> 8, not one edge per deleted node.
5210        assert_eq!(
5211            flatten(&g),
5212            vec![(5, 8, 1)],
5213            "a 2-base deletion run should record exactly one bypass edge 5->8 weight 1"
5214        );
5215        assert_eq!(g.nodes[6].delete_count, 1);
5216        assert_eq!(g.nodes[7].delete_count, 1);
5217        // Pure-bypass: resume edge 7->8 not inflated by the deleting read.
5218        assert_eq!(out_edge(&g, 7, 8).map(|ew| ew.matched), Some(1));
5219        assert_no_delete_bucket_or_delete_reads(&g);
5220    }
5221
5222    #[test]
5223    fn deletion_run_spanning_a_preexisting_fork() {
5224        // Build in multi-allele mode so the diagonal-skip fast path is active
5225        // (it is disabled for single-allele; see PoaConfig::multi_allele). The
5226        // exact equal-cost placement of a fork-spanning deletion run depends on
5227        // it: with diag-skip the run attaches at 5->9 (this test's scenario);
5228        // in single-allele mode the full DP picks the equal-cost 4->8. Either
5229        // way it is ONE consolidated bypass of weight 2 -- the property under
5230        // test. Pinned to the diag-skip alignment for a stable fork-spanning case.
5231        let mut cfg = cfg_global_unbanded();
5232        cfg.multi_allele = true;
5233        let mut g = PoaGraph::new(&b(SEED), cfg).unwrap();
5234
5235        // First, an insertion read that gives node 7 ('T') a second out-edge
5236        // (to the first inserted node), making it a genuine fork. This read
5237        // has no Delete ops, so it records no bypass edge itself.
5238        let ins = b("ACGTGCATAACGTAGCTA"); // SEED with "AA" inserted after node 7
5239        g.add_read(&ins).unwrap();
5240        assert_eq!(
5241            g.nodes[7].out_edges.len(),
5242            2,
5243            "node 7 should now be a fork (matched arm to C@8, inserted arm to the new 'A')"
5244        );
5245        assert!(
5246            flatten(&g).is_empty(),
5247            "an insertion-only read must not record any bypass edge"
5248        );
5249
5250        // Now a deletion read that deletes nodes 6,7,8 -- a run that spans the
5251        // fork at node 7. Added twice to also exercise weight incrementing.
5252        let del = b("ACGTGCGTAGCTA"); // SEED without indices 6,7,8
5253        g.add_read(&del).unwrap();
5254        g.add_read(&del).unwrap();
5255
5256        // Match(0..=5), Delete(6,7,8), Match(9..=15): one bypass edge 5 -> 9,
5257        // weight 2 (two identical deletion reads). The fork at node 7 in the
5258        // middle of the deleted run does not fragment or misplace it.
5259        assert_eq!(
5260            flatten(&g),
5261            vec![(5, 9, 2)],
5262            "a deletion run spanning a fork should still record one bypass edge 5->9, \
5263             incremented to weight 2 across the two identical deletion reads"
5264        );
5265        assert_eq!(g.nodes[6].delete_count, 2);
5266        assert_eq!(g.nodes[7].delete_count, 2);
5267        assert_eq!(g.nodes[8].delete_count, 2);
5268        // Pure-bypass: resume edge 8->9 is matched=2 (the seed plus the
5269        // insertion read, both genuine matchers that traverse 8->9), NOT 4 --
5270        // the two DELETING reads did not launder their weight onto it. Under
5271        // the superseded dual-bookkeeping it would have been 4.
5272        assert_eq!(out_edge(&g, 8, 9).map(|ew| ew.matched), Some(2));
5273        assert_no_delete_bucket_or_delete_reads(&g);
5274    }
5275
5276    #[test]
5277    fn leading_delete_run_records_no_bypass_edge() {
5278        // Leading delete run: read is a strict suffix of the seed. Under
5279        // Global alignment the seed's leading bases must be deleted to connect
5280        // to the source, so the read's ops begin with a Delete run -- one that
5281        // has no entry predecessor (`bypass_pending == Some(None)`). It
5282        // resolves (closes) at the first Match but records no bypass edge,
5283        // because there is no predecessor node to bypass *from*.
5284        let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5285        let suffix = b("ATCGTAGCTA"); // SEED[6..16]
5286        g.add_read(&suffix).unwrap();
5287        assert!(
5288            flatten(&g).is_empty(),
5289            "a leading delete run (no predecessor to bypass from) must record no bypass edge"
5290        );
5291        // Confirm the leading Delete run genuinely occurred (so this really
5292        // exercises the Some(None) path, not a vacuous empty-ops case) while
5293        // the existing per-node delete bookkeeping still happens.
5294        assert_eq!(
5295            g.nodes[0].delete_count, 1,
5296            "leading deleted nodes are still counted by the existing bookkeeping"
5297        );
5298    }
5299
5300    #[test]
5301    fn trailing_terminal_delete_run_records_no_bypass_edge() {
5302        // A trailing terminal Delete run (a Delete run with no following
5303        // Match/Insert to resume at) does not arise from `align()` over a
5304        // linear seed -- its terminal-cell search gives a free trailing gap,
5305        // so the optimal traceback simply ends at the last matched node rather
5306        // than deleting onward. To exercise the end-of-ops drop path directly
5307        // and deterministically, call `add_to_graph` with a hand-built op
5308        // sequence that ends in a Delete run (a shape a real traceback can
5309        // produce in a branching graph): Match(0), Match(1), Delete(2),
5310        // Delete(3). The pending bypass run must be dropped, never recorded as
5311        // an edge to a nonexistent resume node.
5312        let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5313        let (_topo, rank_of) = topological_order(&g.nodes);
5314        let ops = vec![
5315            AlignOp::Match(0),
5316            AlignOp::Match(1),
5317            AlignOp::Delete(2),
5318            AlignOp::Delete(3),
5319        ];
5320        let query = b("AC"); // matches nodes 0 ('A') and 1 ('C')
5321        add_to_graph(
5322            &mut g.nodes,
5323            &mut g.edge_reads,
5324            &mut g.bypass_edges,
5325            &mut g.fork_arm_index,
5326            &rank_of,
5327            &query,
5328            &ops,
5329            1,
5330        );
5331        assert!(
5332            flatten(&g).is_empty(),
5333            "a trailing terminal delete run must not create a bypass edge to nowhere"
5334        );
5335        // The per-node delete bookkeeping still runs for the terminal deletes
5336        // -- only the (correctly absent) bypass edge differs.
5337        assert_eq!(g.nodes[2].delete_count, 1);
5338        assert_eq!(g.nodes[3].delete_count, 1);
5339        // Pure-bypass: the terminal Delete run created no edges into/out of the
5340        // skipped nodes.
5341        assert_no_delete_bucket_or_delete_reads(&g);
5342    }
5343
5344    #[test]
5345    fn no_deletions_records_no_bypass_edges() {
5346        // A pure-match read set never touches bypass_edges at all.
5347        let mut g = PoaGraph::new(&b(SEED), cfg_global_unbanded()).unwrap();
5348        g.add_read(&b(SEED)).unwrap();
5349        g.add_read(&b(SEED)).unwrap();
5350        assert!(
5351            flatten(&g).is_empty(),
5352            "reads with no Delete ops must leave bypass_edges empty"
5353        );
5354    }
5355}
5356
5357// ─── White-box tests: validate_and_merge_groups structure-corroborated split ─
5358//
5359// Phase 3 of design/bypass_edge_delete_rework.md added a "clean distinguishing
5360// bubble" requirement to `validate_and_merge_groups` (a private fn), so a
5361// single allele whose deletion-heavy reads `phasing_groups` split into
5362// narrowly length-separated sub-groups is merged back, while genuine alleles
5363// with a real arm-choice difference stay split. These tests exercise the
5364// decision function directly with hand-built `groups`/`bubbles`/`edge_reads`
5365// -- deterministic, and guarding BOTH directions of the discriminator (the
5366// full pbsim-driven integration case is the `multi_skew_cag20_40` bench
5367// scenario, which black-box reproduction of the exact over-split is too
5368// error-model-dependent to capture reliably in a unit test).
5369#[cfg(test)]
5370mod validate_and_merge_tests {
5371    use super::*;
5372
5373    fn read_of_len(n: usize) -> Vec<u8> {
5374        vec![b'A'; n]
5375    }
5376
5377    /// Two length-separated sub-groups that take the SAME majority arm at every
5378    /// bubble (structurally indistinguishable -- the multi_skew_cag20_40 shape:
5379    /// one allele split by deletion noise) must merge back into one allele.
5380    /// Without the fix, the length-separated sub-group is confirmed as its own
5381    /// allele and this returns 3 groups.
5382    #[test]
5383    fn merges_structurally_indistinct_length_split() {
5384        // reads 0..10 = short allele (~100bp); 10..15 = long sub-group A
5385        // (~316bp); 15..19 = long sub-group B (~300bp, narrowly length-
5386        // separated from A -- a 16bp median gap, like the real over-split).
5387        let mut reads = Vec::new();
5388        for _ in 0..10 {
5389            reads.push(read_of_len(100));
5390        }
5391        for k in 0..5 {
5392            reads.push(read_of_len(314 + k)); // 314..=318, median 316
5393        }
5394        for k in 0..4 {
5395            reads.push(read_of_len(298 + k)); // 298..=301, median 300
5396        }
5397        let g0: Vec<usize> = (0..10).collect();
5398        let sub_a: Vec<usize> = (10..15).collect();
5399        let sub_b: Vec<usize> = (15..19).collect();
5400
5401        // Two structural bubbles (require_length_separation gate). Synthetic
5402        // node ids; arm 0 = "short" arm, arm 1 = "long" arm.
5403        let bubbles = vec![
5404            (0usize, vec![1usize, 2usize]),
5405            (3usize, vec![4usize, 5usize]),
5406        ];
5407        let mut edge_reads: HashMap<(usize, usize), Vec<u32>> = HashMap::new();
5408        edge_reads.insert((0, 1), g0.iter().map(|&r| r as u32).collect());
5409        edge_reads.insert((3, 4), g0.iter().map(|&r| r as u32).collect());
5410        // Both long sub-groups take arm 1 at both bubbles -> no distinguishing
5411        // bubble between them.
5412        let long: Vec<u32> = sub_a.iter().chain(&sub_b).map(|&r| r as u32).collect();
5413        edge_reads.insert((0, 2), long.clone());
5414        edge_reads.insert((3, 5), long);
5415
5416        let out = validate_and_merge_groups(
5417            vec![g0.clone(), sub_a.clone(), sub_b.clone()],
5418            &reads,
5419            3,
5420            &bubbles,
5421            &edge_reads,
5422        );
5423        assert_eq!(
5424            out.len(),
5425            2,
5426            "structurally-indistinct length-split sub-groups must merge to 2 alleles, got {}",
5427            out.len()
5428        );
5429        // Every long-allele read (both sub-groups) ends up together.
5430        let long_group = out
5431            .iter()
5432            .find(|g| g.contains(&10))
5433            .expect("a group containing the long allele");
5434        for r in sub_a.iter().chain(&sub_b) {
5435            assert!(
5436                long_group.contains(r),
5437                "long-allele read {r} must be in the single merged long group"
5438            );
5439        }
5440    }
5441
5442    /// Companion (the over-merge guard): two genuine alleles that DO have a
5443    /// clean distinguishing bubble (different majority arms) must stay split --
5444    /// the fix narrows splitting, so it must not collapse real alleles.
5445    #[test]
5446    fn keeps_structurally_distinct_length_split() {
5447        let mut reads = Vec::new();
5448        for _ in 0..10 {
5449            reads.push(read_of_len(100)); // allele X, arm 0
5450        }
5451        for _ in 0..8 {
5452            reads.push(read_of_len(200)); // allele Y, arm 1
5453        }
5454        let gx: Vec<usize> = (0..10).collect();
5455        let gy: Vec<usize> = (10..18).collect();
5456        let bubbles = vec![
5457            (0usize, vec![1usize, 2usize]),
5458            (3usize, vec![4usize, 5usize]),
5459        ];
5460        let mut edge_reads: HashMap<(usize, usize), Vec<u32>> = HashMap::new();
5461        edge_reads.insert((0, 1), gx.iter().map(|&r| r as u32).collect());
5462        edge_reads.insert((3, 4), gx.iter().map(|&r| r as u32).collect());
5463        edge_reads.insert((0, 2), gy.iter().map(|&r| r as u32).collect());
5464        edge_reads.insert((3, 5), gy.iter().map(|&r| r as u32).collect());
5465
5466        let out = validate_and_merge_groups(vec![gx, gy], &reads, 3, &bubbles, &edge_reads);
5467        assert_eq!(
5468            out.len(),
5469            2,
5470            "two genuine alleles with a clean distinguishing bubble must stay split, got {}",
5471            out.len()
5472        );
5473    }
5474}
5475
5476// ─── White-box tests: BandTooNarrow + align_with_retry ─────────────────────
5477//
5478// `align` and `align_with_retry` are private, so directly confirming the
5479// exact threshold at which a too-narrow band now returns `BandTooNarrow`
5480// (rather than the old silent empty-ops collapse), and that the retry
5481// wrapper recovers a correct alignment, requires a same-module test.
5482//
5483// Fixture: a 100bp seed and a 220bp query built by appending 120bp of new
5484// content to the seed's own sequence -- deliberately non-repetitive (unlike
5485// `tests/adaptive_band_collapse.rs`'s real GAA-repeat fixture) so the
5486// expected alignment shape (100 Match + 120 Insert, 0 Delete) is
5487// unambiguous and exactly predictable, isolating the band/retry mechanism
5488// itself from any repeat-driven alignment ambiguity.
5489#[cfg(test)]
5490mod band_too_narrow_tests {
5491    use super::*;
5492
5493    fn fixture() -> (Vec<u8>, Vec<u8>) {
5494        let seed: Vec<u8> = "ACGTACGTCG".repeat(10).into_bytes(); // 100bp
5495        let mut query = seed.clone();
5496        query.extend_from_slice(&"TGCA".repeat(30).into_bytes()); // +120bp
5497        (seed, query)
5498    }
5499
5500    fn align_direct(
5501        g: &PoaGraph,
5502        topo: &[usize],
5503        rank_of: &[usize],
5504        spine: &[(usize, u8, i32)],
5505        query: &[u8],
5506        band_width: usize,
5507        scratch: &mut AlignScratch,
5508    ) -> Result<Vec<AlignOp>, PoaError> {
5509        let cfg = PoaConfig {
5510            band_width,
5511            adaptive_band: false,
5512            alignment_mode: AlignmentMode::SemiGlobal,
5513            ..PoaConfig::default()
5514        };
5515        align(&g.nodes, topo, rank_of, spine, query, &cfg, scratch, &[])
5516    }
5517
5518    /// Directly confirms the exact band-width threshold: below it, `align`
5519    /// now returns `Err(BandTooNarrow)` (never an empty, silently-wrong
5520    /// `Ok(vec![])` -- the bug `tests/adaptive_band_collapse.rs` guards
5521    /// against); at or above it, `align` succeeds directly with no retry
5522    /// needed. Values confirmed empirically when this test was written, not
5523    /// assumed: 100 (exactly the seed's own length, plausible-looking but
5524    /// still 120bp short of the true diagonal shift) still errors; 119
5525    /// (one short of the full 120bp gap) already succeeds, because the
5526    /// windowing here centres with a small amount of slack, not because
5527    /// the boundary is off by one in the traceback itself.
5528    #[test]
5529    fn band_too_narrow_returned_below_threshold_ok_at_and_above() {
5530        let (seed, query) = fixture();
5531        let g = PoaGraph::new(&seed, PoaConfig::default()).unwrap();
5532        let (topo, rank_of) = topological_order(&g.nodes);
5533        let spine = heaviest_path(&g.nodes, &topo, &rank_of, &g.bypass_edges);
5534        let mut scratch = AlignScratch::new();
5535
5536        for &band_width in &[10usize, 50, 100] {
5537            let result = align_direct(
5538                &g,
5539                &topo,
5540                &rank_of,
5541                &spine,
5542                &query,
5543                band_width,
5544                &mut scratch,
5545            );
5546            match result {
5547                Err(PoaError::BandTooNarrow {
5548                    configured,
5549                    required,
5550                }) => {
5551                    assert_eq!(configured, band_width);
5552                    assert!(
5553                        required > band_width,
5554                        "required ({required}) should exceed the too-narrow configured \
5555                         width ({band_width})"
5556                    );
5557                }
5558                other => panic!(
5559                    "band_width={band_width} expected BandTooNarrow, got {:?}",
5560                    other.map(|ops| ops.len())
5561                ),
5562            }
5563        }
5564
5565        for &band_width in &[119usize, 120, 121, 150] {
5566            let result = align_direct(
5567                &g,
5568                &topo,
5569                &rank_of,
5570                &spine,
5571                &query,
5572                band_width,
5573                &mut scratch,
5574            );
5575            match result {
5576                Ok(ops) => assert_eq!(
5577                    ops.len(),
5578                    query.len(),
5579                    "band_width={band_width}: expected one op per query base \
5580                     (100 Match + 120 Insert), got {} ops",
5581                    ops.len()
5582                ),
5583                Err(e) => panic!("band_width={band_width} expected Ok, got {e:?}"),
5584            }
5585        }
5586    }
5587
5588    /// Confirms `align_with_retry` recovers a fully correct alignment from
5589    /// every narrow starting width tested above, transparently -- the
5590    /// actual fix for callers, not just the diagnostic in the test above.
5591    #[test]
5592    fn align_with_retry_recovers_correct_alignment_from_any_narrow_start() {
5593        let (seed, query) = fixture();
5594        let g = PoaGraph::new(&seed, PoaConfig::default()).unwrap();
5595        let (topo, rank_of) = topological_order(&g.nodes);
5596        let spine = heaviest_path(&g.nodes, &topo, &rank_of, &g.bypass_edges);
5597        let mut scratch = AlignScratch::new();
5598
5599        for &band_width in &[10usize, 50, 100] {
5600            let cfg = PoaConfig {
5601                band_width,
5602                adaptive_band: false,
5603                alignment_mode: AlignmentMode::SemiGlobal,
5604                ..PoaConfig::default()
5605            };
5606            let (ops, retried) = align_with_retry(
5607                &g.nodes,
5608                &topo,
5609                &rank_of,
5610                &spine,
5611                &query,
5612                &cfg,
5613                &mut scratch,
5614                &[],
5615            )
5616            .unwrap_or_else(|e| panic!("band_width={band_width}: retry should recover, got {e:?}"));
5617            assert!(
5618                retried,
5619                "band_width={band_width}: expected align_with_retry to report that pass 1 \
5620                 needed widening"
5621            );
5622
5623            let n_match = ops
5624                .iter()
5625                .filter(|o| matches!(o, AlignOp::Match(_)))
5626                .count();
5627            let n_insert = ops
5628                .iter()
5629                .filter(|o| matches!(o, AlignOp::Insert(_)))
5630                .count();
5631            let n_delete = ops
5632                .iter()
5633                .filter(|o| matches!(o, AlignOp::Delete(_)))
5634                .count();
5635            assert_eq!(
5636                (n_match, n_insert, n_delete),
5637                (100, 120, 0),
5638                "band_width={band_width}: expected the retry to recover the exact \
5639                 alignment shape (100 Match + 120 Insert, 0 Delete), got \
5640                 Match={n_match} Insert={n_insert} Delete={n_delete}"
5641            );
5642        }
5643    }
5644}