Skip to content

perf: stop the banded search running out of memory MAPPINGS, and make pooling a byte copy - #109

Merged
RobbinBouwmeester merged 47 commits into
mainfrom
perf/integrate
Sep 22, 2026
Merged

RobbinBouwmeester merged 47 commits into
mainfrom
perf/integrate

Conversation

@RobbinBouwmeester

@RobbinBouwmeester RobbinBouwmeester commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Six subsystems, one owner each, every change adversarially reviewed and the review acted on.
The engine now searches a 203M-precursor library in isolation-window bands without dying, and
the stage that was about to cost 40 hours on the running experiment costs 3 minutes.

Why

A grouped search of the immuno library dies at ~290 GB resident with 1.7 TB free, reporting
memory allocation of 3072 bytes failed. It is not out of memory: it is out of per-process
memory MAPPINGS. Sampled on the live process at 180 GB:

mapping size count
64K-256K 92,030
256K-1M 36,368
everything else 995
total 129,393 of a 1,048,576 limit

Those sizes are one-heap-block-per-item structures under mimalloc, which maps large blocks
individually. So the work here is about the NUMBER of live medium blocks, not about bytes.

What each area did

  • index + search-seed: the label column read as one bit per row instead of one String
    (203M blocks, 6.5 GB), candidate_id verified streaming and never held, prec_mz moved
    rather than copied, the fragment payload released once the seed's index exists (7 GB), and
    a bug: the window-width survey counted the run's windows rather than the band's, so on 63
    bands the median was always 1 and a warning about a malformed isolation window fired 63
    times per run for a run that has none.
  • compete: the HashMap<key, Vec<usize>> grouping replaced by one sorted Vec<(key, row)>
    (one small block per PSM removed, 100 MB in one allocation for a 3.1M-PSM pool), a
    pass-through instead of take on all 398 columns when a batch loses nothing (the normal
    case under the shipped grouping), the peptidoform and label columns streamed, and the
    competed table's row groups capped at 131,072 rows, which is what rescore decodes.
  • features: one flat buffer instead of a Vec<f64> per PSM, dense peptidoform ids and a
    charge bitmask instead of a HashSet per peptidoform, the column permutation precomputed
    once instead of ~390 string-hash lookups per row, the encode moved to a writer thread, and
    the trace alignment built once per PSM with a fast path when the fragments share an axis.
  • quant: the chromatogram table read into flat buffers with an axis store instead of three
    heap blocks per row, one union map instead of two, the fixed window computed once, and the
    fragment areas memoised per candidate rather than per PSM row.
  • rescore: the competed features streamed straight into the sidecar handoff instead of
    materialising the engine's own FeatureMatrix first, which under rescore.strict with a
    sidecar classifier is ~250 GB at experiment scale that need not exist; the per-fold training
    matrix flattened from one block per training row (320M live blocks at scale).
  • io: the parquet footer parsed once per handle and reused (a 13,000-row-group fragment
    table was parsed ten times to read nine columns), and write_table chunked so a table is
    not built as one Arrow copy before a byte is written.

The accumulator, which is the change the mapping census asked for

extract gave every candidate with evidence its own Vec<Hit>, grown by doubling. A
candidate with no run_windows row gets infinite retention-time bounds and collects hits
across the whole gradient, which is 10^4 to 10^5 hits, 240 KB to 2.4 MB in one block: those
are the 64 KB-1 MB mappings the census counted. The accumulator is now CSR -- ascending
candidate ids, offsets, one flat hit buffer. A sub-range task collects flat pairs and groups
them with a stable counting sort applied in place by cycle following, so the hit order is
what it was: ascending scan order within a task, tasks concatenated in window order. The
window batch is no longer the flush unit either; a sub-range flushes once every window that
intersects it has passed, which is what a band of a 63-band plan needs, since it overlaps so
few windows that the whole band used to be one batch.

Live medium blocks go from one per candidate with evidence to one per task, which is twice
the thread count. On the author's fixture the instrument reads 728,814 candidates with
evidence before and largest_open_payload 0.367 GiB before against 0.000 after with the
whole run in one batch, the shape a band has.

Measured on a real 2.87M-candidate band, same accepted rows and same chromatogram rows in
both arms: 25 GB resident against 29, and 2:48 against 3:04. The mapping count on that band
is unchanged at ~265, because its candidates all have retention-time windows, so no
candidate ever reaches the hit count that produces a large block; the census that found
129,393 and later 434,243 mappings was taken on the whole grouped run, with eight bands in
one process. That measurement is the one still outstanding, and it needs a grouped run
rather than a band.

The pool, which was the emergency

Pooling a grouped run's bands decoded every row group and re-encoded it. Measured on the
running experiment: 3.5 MB/s at 110% CPU, 42.7 GB in 2 h 50 min, on a disk that reads
221 MB/s. Seven runs of that is about 40 hours of pure re-encoding.

A band's rows are already in the pooled table's order and already encoded, so pool now
splices the parquet column chunks into the output as bytes (SpliceWriter), decoding only the
row groups whose candidate_id statistics say they hold a candidate the overlap dedup drops.
Same 63 bands, same 136 GB:

wall peak RSS
decode and re-encode 42.7 GB in 2 h 50 min, killed 4.3 GB
splice 3 min 12 s, all of it 2.6 GB

mumdia pool --groups-dir <run>/groups exposes the stage, so a grouped search that finished
while the run did not costs a pool rather than a re-search.

A deadlock nobody had hit

groups.parallel >= --threads deadlocks: each band in flight parks a rayon worker on its
extraction's accumulation channel, so with as many bands as threads there is no worker left to
feed them. Reproduced on the fixture at parallel = 2, --threads 2, where the process sat at
0.1 s of CPU indefinitely with no error and no output. The value is now clamped to
threads - 1 with a warning. Production runs 8 bands on 128 threads, which is why this had
never appeared.

Evidence

  • Fixture, whole pipeline, grouped and ungrouped: 56 of 59 artifacts byte-identical to the
    previous build. The three that differ are the two pooled tables, which are spliced rather
    than re-encoded (same rows, same values, same schema, verified with pyarrow), and the pooled
    psms_extracted, which is no longer written because its only reader is the candidate audit.
  • Production scale, one 2.87M-candidate band of the real library: same 590,426 accepted
    PSMs, chromatograms.parquet byte-identical, psms_extracted.parquet identical in every
    value and 2,797 bytes different in page framing, which is the documented consequence of the
    chunked write on a column that is entirely NULL (apex_im).
  • Production scale, 40 band seeds of the running experiment: byte-identical to the
    previous binary's, 40 of 40.
  • Production scale, the band chain of a live run: the seven-file experiment was
    relaunched on this branch and its band artifacts compared with the previous binary's, band
    for band. chromatograms.parquet and features.parquet are byte-identical; the two that
    differ hold identical values (814,000 rows, 398 columns, verified with pyarrow) and differ
    only where this branch intends: psms_competed moves from 1 row group to 7 under the
    131,072-row cap, and psms_extracted keeps its single row group with 2,797 bytes of
    different page framing.
  • Unit tests: 299 in the engine and 23 in the io layer, up from 231 and 21.
  • A portability finding, not a defect of this branch: the Extended feature values are
    not bit-identical between Windows and Linux. The same fixture digests to 0x4e43..a1cc on
    Windows and 0x6137..c42a on Linux, stably on both, because the battery reaches ln, exp
    and powf and those are the platform's libm. The PIN is unaffected, being formatted to
    fixed decimals. Any comparison of feature values, or of an artifact hash downstream of
    them, has to be made on one operating system.

The reviews

Every change was reviewed by an agent told to refute it, and every review is acted on in a
follow-up commit that says what was fixed and what was rejected and why. What that caught,
among others: a null gate silently dropped in compete's pass-through; a byte-equality claim
for the chunked write that does not hold for an entirely-null column (measured: 16 bytes of
page framing, rows identical, now pinned by a test that says so); the rescore handoff being
written in full before the non-finite check that used to precede it; a span read that
re-parsed the whole parquet footer twice per span; and a column permutation whose test proved
totality rather than correctness, now pinned against a golden captured from the pre-change
code, with a deliberate transposition shown to fail it.

Not every finding survived. Three were rejected in writing with the arithmetic: the per-column
value matrix is 774 mappings against a million-mapping limit, not a regression; the seed
scratch sizing needed a conditional rather than a revert, because map_init runs per rayon
leaf before the leaf knows whether it drew a served group; and a u32::MAX assertion is
unreachable at any allocatable scale.

🤖 Generated with Claude Code

RobbinBouwmeester and others added 30 commits September 21, 2026 19:11
…cross runs

`run-experiment` refused `groups.window_groups > 1`, so a grouped search of several files
meant one `mumdia run` per file and assembling the pooled rescore, the match between runs
and the cross-run quantities by hand. It now routes each run's middle through
`run_groups`: the run's competed table and chromatograms come back under the names the
pooled stages already read, so the experiment-wide rescore, the per-source `run_psm_q`,
MBR, per-run quant and the cross-run report are unchanged.

`experiment.rt_library_scope` carries over. A grouped run adapts its bands rather than one
library table, so the first run's `groups/` directory is what the rest reuse: each band
takes that run's table for its own band, keeping the ids and row order that
`fragment_offset` and the per-band seed views key on, and still fits its own per-run LOESS
on top. Under `per_run` every run adapts its own bands as before. A run whose shared bands
do not match its plan fails with the missing path named rather than searching a mismatched
band.

`run_groups` takes its manifest as an `Option`, because `run-experiment` keeps one
experiment-level manifest rather than one per run.

Measured on the fixture: a two-file grouped experiment plans three bands per run, pools
each run to 288 competed rows and rescores 576 PSMs experiment-wide to 151 peptides at 1%,
matching the ungrouped fixture experiment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lel)

A grouped run searched its bands one after another, so a large machine sat idle: measured
on a seven-file experiment with four runs in flight, basilisk was 97.6% idle with one
process using 12 of 256 cores, because four bands (one per run) were all that was ever in
flight, each using about three.

Both per-band phases now run `groups.parallel` bands at a time: the slice-and-seed phase
and the windows-extract-features-compete phase. Chunked rather than a free-running pool,
because the chunk is exactly how many extraction working sets are resident, which is what
banding exists to bound. Each band's manifest records are collected and merged in band
order, so the manifest is what the sequential run wrote, and the artifacts are pooled in
band order regardless of which band finishes first.

The default stays 1, which is the previous behaviour exactly. Raise it after checking one
band's peak RSS: on the 203M-precursor library at 63 bands the largest band took 39 GB and
the median far less, so the low-m/z bands, which are both the largest and the slowest, set
the ceiling.

Byte-identical output: on the fixture, three bands searched three at a time reproduce the
sequential grouped run's `psms_extracted`, `chromatograms`, `features`, `psms_competed`,
`psms_scored` and `peptides.tsv` exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ccumulator

`plan` closed a band once its running total reached the target share and then reset the
accumulator. Every band therefore overshoots a little, the overshoot compounds along the
m/z axis, and the windows left at the end fall into the last band.

Measured on the 8-12-mer immunopeptidomics library, whose first 14 isolation windows
select nothing because the library starts at m/z 326: 63 bands asked for came back as 36,
ranging from 3.23M to 37.89M precursors against a 3.2M target, one of them holding 28 of
the 114 windows. That band alone drove a 233 GB extract peak with only six bands in
flight, which is what made the experiment unschedulable.

Cuts are now taken where the running total crosses each k/n share of the whole, so they
cannot compound: each is measured against the same running total rather than a reset one.
A regression test covers the shape that broke it, a leading run of empty windows followed
by equal ones, and asserts every band asked for comes back, within 3x of each other, with
every window placed exactly once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A grouped run of the 8-12-mer library writes about 380 GB per file and the disk does
188 MB/s, so on a seven-file experiment the wall clock is partly the disk. Three changes,
each measurable on its own:

1. A band's stages emit library-wide candidate ids. `extract` loads one band as a library
   whose rows are numbered 0..n and used to write those local numbers, so every downstream
   table only meant something next to the band's offset and pooling had to rewrite every
   id. Outputs now carry `local + Library::global_offset`, so pooling is a concatenation
   and a band's table can be read on its own. This is also what makes 2 and 3 simple.
2. The pooled `features.parquet` is not written. Nothing reads it: the competed table
   carries the feature columns, and the run-level copy was 55 GB per run. The per-band
   views of the pooled seed go with it, because features can now read the pooled seed
   directly, its ids matching the band's tables.
3. `MUMDIA_PARQUET_COMPRESSION=zstd` writes every artifact with zstd instead of snappy
   (`uncompressed` is also accepted). Both are read transparently whatever wrote them.
   Snappy stays the default, because it is what released artifacts use and what the
   sidecars' pyarrow reads without configuration, and because the setting changes every
   artifact's bytes: two runs compared by content hash must agree on it. On the CI fixture
   zstd is 3-11% smaller, which is too small a table to predict the float-heavy
   chromatograms of a real run.

Unchanged output: the CI smoke passes with its TSV hashes intact, and a grouped fixture
run reproduces the previous grouped run's `peptides.tsv` exactly, with and without zstd.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	rust/mumdia/crates/mumdia/src/stages/run_groups.rs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s that are sorted

Two costs that only appear when several bands of a run are searched at once.

The candidate-range fan-out took `rayon::current_num_threads()`, which is the whole pool,
and every band in flight computed it independently: 24 bands each fanning out to twice the
pool gave thousands of live narrowed-bin caches of 1-2 MB, allocated in lockstep, for pure
scratch. `ExtractParams::sibling_bands` now says how many bands share the pool, and the
fan-out is this band's share of it. The grouped orchestrator passes `groups.parallel`;
every other caller passes 1, which is the previous behaviour exactly.

Probing walks a window's scans in ascending index, so a candidate's hits are already
rt-ascending in the ordinary case, and the sort then allocated scratch as large as the
vector itself. Equal-rt hits collapse to a per-(rt, fragment) maximum afterwards whichever
order they arrive in, so skipping a sort that would not move anything is exact. Those
vectors are in the 64 KB to 1 MB class that dominates the process's memory mappings
(measured on a live run: 92,030 mappings of 64-256 KB and 36,368 of 256 KB-1 MB out of
129,393 at 180 GB resident), so halving their churn matters beyond the copy itself.

Output unchanged: the CI smoke passes with its hashes intact, and a grouped fixture run
reproduces the previous run's `psms_extracted`, `chromatograms`, `psms_competed` and
`peptides.tsv` byte for byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…columns

The engine dies at ~290 GB resident with ~1.7 TB free because it is out of
per-process memory MAPPINGS (1,048,576), not out of memory. Five changes in the
library loader and the seed, all of which reduce live blocks or live bytes, plus
one bug that has been printing a warning about a problem that does not exist.

1. `label` is read as one boolean per row instead of one `String` per row. The
   column is two-valued and its only consumer is `Candidate::is_decoy`, so a
   `Vec<String>` was 203M heap blocks and about 6.5 GB on the full 203M-precursor
   library to produce one bit each. `read_is_decoy` streams the column in 64k-row
   batches; the validity rule and the message stay in `fdr::validate_labels`,
   which now sees only the offending value, the one string ever allocated.
2. `candidate_id` is verified the same way and never held. The ids are a
   precondition, never data -- every use is the row position -- and the column
   was 4 B per precursor (812 MB) live across the whole fragment load and index
   build.
3. `Library::prec_mz` is the MOVED `precursor_mz` column rather than a second
   copy of it: same values, same order, row c is candidate c. 8 B per candidate,
   1.6 GB and one large block off the load's peak. (The other two copies of this
   value, `Candidate::precursor_mz` and `FragIndex::prec_mz`, are read from
   extract.rs and cloned in fragindex.rs, so they are outside this change's file
   set and stay.)
4. `Library::release_fragment_payload` frees `frag_int`, `frag_name_id` and the
   name dictionary, and search-seed calls it once its fragindex exists. The seed
   reads neither predicted intensity nor fragment name: the hyperscore is count
   plus observed intensity, and the mass recalibration needs only `frag_mz`,
   which is kept and is now reached through `cand_frag_mz`. 6 B per library
   fragment, held for the whole search, roughly 7 GB and two large mappings on a
   1.2G-fragment library. `cand_frags` panics after a release rather than
   handing back a short slice; the bucketed path keeps the payload.
5. The seed's output columns are reserved once at the known row count, the
   `label` column is built FROM the `is_decoy` boolean at write time instead of
   the boolean being rebuilt by re-scanning the column, and the per-scan
   `touched()` copy (up to one candidate window wide, per scan) is a borrow.

BUG, unrelated to memory: the seed's window-width survey mapped every window
group of the run, including the ones outside the loaded band. An out-of-band
group resolves to an empty candidate range and contributed a width of 1, so with
63 bands the median was 1 on every band -- the scratch was sized to a single slot
and grown on first use -- and the `widest > 8 * median` guard fired on all 63,
telling the user to check for a zero-width or missing isolation window in a run
that has none. `window_width_stats` now excludes groups with `hi <= lo`, the same
test the worker already uses to skip a group, so both the sizing and the warning
describe this search.

Output equality: the boolean is exactly `label[c] == "decoy"` and an unknown
label is still refused; the id check is the same comparison against the same file
row; `prec_mz` is the same array; the released columns are unread on that path;
the seed's `label` column is the same text and `is_dec` the same booleans; the
scratch size never affects a result (SeedScratch grows on demand and new slots
are unstamped), only the allocation. Tests added for each claim, including
before/after equality of `cand_frag_mz` across a release and the old/new identity
of the label round trip.

Not done, blocked by the file set: a seed-only `FragIndex` build omitting
`post_int`/`post_frag` (fragindex.rs), and moving `Candidate`'s two owned Strings
to a flat buffer plus a dictionary id (extract.rs, fragindex.rs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mpeted row group

Four terms, all of them live heap BLOCKS rather than bytes, which is what the engine
actually dies on: a grouped search holds several bands in one process and hits the
kernel's per-process mapping limit (1,048,576) at ~290 GB resident with 1.7 TB free
(measured on a live run: 129,393 mappings at 180 GB, 92,030 of them 64-256 KB and
36,368 of them 256 KB-1 MB).

The grouping was a `HashMap<key, Vec<usize>>` with one `Vec` per competition group.
Under the shipped `group_by = peptidoform_charge` nearly every group is a singleton, so
that is about one small heap block per PSM, plus a 48-byte-per-entry table at up to 87.5%
load. It is now a `Vec<(key, row)>`, sorted once: 32 bytes per PSM in ONE allocation, so
100 MB and one mapping for the six-run Astral pool's 3.13 M PSMs. The resolver walks
contiguous runs of equal keys instead of a sorted key list. It visits the same groups in
the same order with members in the same ascending row order, and the winner rule was
already order-independent with an index tiebreak, so the kept set and the removal pairs
are unchanged; a test pins that against the previous HashMap resolver over all five modes
on a population with ties, a NaN prelim and a signed-zero pair.

`copy_kept_rows` built a take index and called `take` on all ~398 columns of every batch
even when the batch had lost nothing, which under the default grouping is the normal case:
the widest artifact of the run was copied through `take` to reproduce itself. An f64
column of a 16,384-row batch is exactly 128 KB, i.e. the 64-256 KB band above, 398 of them
per batch and 191 batches for that pool. When as many rows survive as the batch holds they
ARE the batch, in order, so the column is now passed through as an Arc clone. The
synthesised all-zero `peak_rank` has no source array and is still built.

The peptidoform column was read as a `Vec<String>` for the whole table and, unless the
competition audit is on, only used to number dense ids; the label column was read as
`Vec<String>` to be compared against two literals. Both are now streamed: the label as one
byte per row, the peptidoform into first-appearance ids whose map holds one String per
DISTINCT peptidoform rather than one per row. `base_peptide_id` is read only for the
groupings that key on it and `candidate_id` only for the audit sidecar, which is the only
consumer of either as a value; both columns are still required and type-checked by the
pass-through copy. At 3.13 M rows that is ~6.2 M fewer String blocks and ~175 MB less.

The competed table is written in 131,072-row groups, the cap the rescore handoff already
uses and the one every other wide writer in the engine sets. This file took parquet's
default 1,048,576, which at ~387 f64 feature columns is ~3.2 GB decoded per group: that is
what the writer buffers before each flush, and rescore is the reader that decodes a whole
group before slicing batches out of it. 131,072 rows is ~405 MB.

Output equality: the competed parquet, its `.schema.json`, its report's row/kept/removed
stats and the audit sidecar are byte-identical to the previous implementation over seven
configurations (all three groupings, all four modes, audit on and off) on a 500-row crafted
features table, and value- and order-identical on a 300,000-row one, where the cap binds
and splits one row group into three. Only row-group boundaries move, so the competed
table's `content_hash` changes for any run above 131,072 kept rows; nothing downstream
reads that hash as an equality assertion. The one behavioural drift is error text, not
values: a features table missing or mistyping `candidate_id` or `base_peptide_id` now
fails in the pass-through copy's column check rather than in the typed getter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…le once

Three changes to the grouped middle, none of which changes a value.

1. `psms_extracted.parquet` is pooled only when something reads it. Its one
   consumer is the candidate audit (`extract.emit_candidate_audit`, off by
   default and, per CLAUDE.md, reading a per-candidate audit table extract does
   not yet write). Pooling it read every band's extracted rows and wrote them
   again into the run's second-widest artifact, for a file nothing opened. The
   band tables are written either way, so the audit path is unchanged and the
   run says in the log when it skips the pool.

2. Each pooled artifact is hashed once. `record_artifact` hashed the file for
   the manifest record and `ArtifactReport` hashed it again for the report
   beside it; on a real run those are tens of GB each, and blake3_file reads all
   of it. `record_artifact_with_hash` takes the hash the caller already has.

3. Under `experiment.rt_library_scope = first_run_only` the runs after the first
   reuse the first run's band precursor slices. A slice is a deterministic
   function of (library, row span) and a shared-band run searches the same
   library under the same plan, so rewriting it produced the same bytes; only
   the seed reads it, and the adapted table replaces it everywhere else. On a
   203M-precursor library that is the whole precursor table not written, per run
   after the first. The reuse is guarded on the file existing with the row count
   the plan expects, and falls back to writing the slice.

Two more, outside the grouped path:

- `split_by_source` caps its row groups at 131,072 rows, as the rescore handoff
  does. The scored table is ~390 float columns wide, so parquet's default
  1,048,576-row group is about 3 GB decoded per group for every reader of the
  per-run tables. Row-group boundaries move; rows, order and values do not.
- `load_ms1` copies each peak list once instead of twice. Both lists were copied
  whole and then copied again truncated to the shorter of the two, which on MS1
  scans of tens of thousands of peaks is two allocations per scan per column
  that are freed immediately. New unit test covers the truncation and the
  RT ordering, neither of which had one.

Measured on the running seven-file experiment (63 bands, 8 in flight): extract
218 CPU-min per run, features 156, search-seed 99, rt-im-train 1.2. The RT
calibration is not worth hoisting; the writes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… columns that already exist

Five allocation-count reductions in the rescore stage. Output is unchanged: the
crafted features -> compete -> rescore pipeline writes a scored parquet with the
same blake3 (e535d04b2821a47d4fc8f9bc0a7ba57461b701b607496ffef0700a0352cd135a)
before and after, and the two kernels that were rewritten are checked against
transcriptions of their previous selves at bit level.

1. `percolator_lite` built its standardised training slice as a `Vec<Vec<f32>>`,
   one heap block per training row, inside the parallel fold map, so every fold's
   copy is live at once. That is the exact layout `FeatureMatrix`'s own comment
   records was removed from the matrix for this reason, surviving one level down:
   at 11.6M PSMs x 3 folds it is ~23M live blocks, now 3 flat buffers sliced by
   `chunks_exact`. The held-out fold is scored through the standardizer without
   materialising a row at all. The f32 narrowing is kept at both sites, because
   the score consumed the standardised value AFTER it was rounded to f32.

2. The competed-input merge cloned every metadata string (label, peptidoform,
   protein) into a second vector while the reader's stayed alive: twice the
   string bytes and 3 extra live allocations per PSM at peak. `merge_col` moves
   the first input's column and reserves the footer row count, so a single-input
   rescore copies nothing and a multi-input one reallocates once.

3. `target_decoy_q` sorted an index permutation with a comparator that
   dereferenced a separate key column (two random loads per comparison, two more
   per row in the tie walk) and allocated four n-length vectors. It now sorts one
   16-byte `(key, row, is_decoy)` record in parallel and merges the monotonization
   into the block walk, so `fdr_at` is gone: 32 -> 24 bytes per row, 4 -> 2
   allocations, and no indirection. The tie-block handling is unchanged, and the
   permutation is the same one the stable sort produced (`key` desc, row asc).

4. The non-finite validation was a serial `n_rows x n_features` scan over
   hundreds of GB. Both scans are parallel `find_first` now, and their answers are
   combined so the row reported is still the first offending row, features before
   scalars within a row, exactly as the interleaved serial loop reported it.

5. `psms_scored` has three duplicate columns by construction (`protein_group` is
   `protein`; `global_q_value` and `experiment_psm_q` are the pooled `q_value`).
   Through `write_table` each had to arrive as its own `Vec`, so the stage carried
   a second full copy of the protein strings and two more of the q column. They
   share one refcounted Arrow array now. A test asserts the parquet is byte-for-byte
   what `write_table` wrote from separate equal vectors.

Risk to output equality: (1) and (3) are arithmetic-identical by construction and
covered by reference-implementation tests; (5) is byte-tested; (2) and (4) move or
parallelise without touching values. The one behaviour that is not identical is
which error a malformed input reports when it has both a non-finite feature and a
non-finite prelim_score in the SAME row -- it is still the feature, and still the
first offending row, so this is preserved too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n chunks

Four changes to the Arrow/Parquet table layer, all of them about how many live
heap blocks and how much repeated work one band of a grouped search costs. No
artifact changes: the scalar write path is asserted byte-identical to the one it
replaces, and every new reader is asserted equal to the reader it stands beside.

1. `TableFile` holds its parsed footer (`ArrowReaderMetadata`) and every reader
   it builds reuses it. `ParquetRecordBatchReaderBuilder::try_new` re-reads and
   re-parses the footer on every call, and it was being called once per `open`,
   once per `row_group_stats` and once per typed getter, so reading nine columns
   of one band cost ten parses. Measured on a 13,000-row-group, six-column,
   1.3-million-row file (the shape of a library fragment table): 26.6 ms per
   parse against 118 ms to read a whole f64 column, so each getter paid about a
   fifth of its own cost again for a footer it already had. `row_group_stats`
   now touches no file at all.

   `TableFile::span(first_row, n_rows)` is the span read against already-parsed
   metadata: `open_rows` is now `open` + `span`, and a caller that cuts one
   library into many bands can open once and span per band instead of re-parsing
   per band per stage. Spans do not nest, so a span's rows are always file rows.

   `span_of_an_open_handle_matches_open_rows` asserts a span equals `open_rows`
   column for column and equals the whole table's slice, over an empty span, the
   whole file, inside one row group, across several and the last row.

2. `str_eq(name, value)` on both read paths: a string column as a boolean
   equality test. `str` returns one `String` per row, which for `label` on a
   203-million-row precursor table is 203 million live allocations and 4.9 GB of
   `String` spine to carry one bit per row. No caller changed; the test asserts
   `str_eq` is exactly `str(...).iter().map(|s| s == value)`, error for error,
   including the NULL refusal.

   `str_flat` is the same idea for the high-cardinality columns where an equality
   test will not do (`peptidoform`, `protein`): one buffer plus offsets, the
   counterpart of the `list_f32_flat` that is already here.

3. `write_table` encodes in 65,536-row chunks instead of building one
   `RecordBatch` for the whole table. The full batch was a second complete Arrow
   copy of every column beside the source vectors, one very large heap block per
   column; mimalloc maps those individually, which is what the per-process
   mapping limit counts. The chunk size divides the writer's default
   1,048,576-row row group and is a multiple of its 1,024-value write batch, so
   the row groups and the pages fall where they fell:
   `write_table_matches_one_batch_byte_for_byte_on_scalars` compares the new
   output with the old code path (`cols_to_batch` + `write_batches`) byte for
   byte over 196,615 rows, and again at 0, 1 and 1,000 rows. List columns hold a
   variable number of leaf values per row, so their pages are the encoder's
   business; there the test asserts the rows and the row-group count (measured
   byte-identical too, at 0-6 and 0-136 values per row, but not asserted).

   Validation moved into `validate_cols` so the whole column set is still checked
   once, before anything is written, with the messages it always had.

Risk to output equality: the scalar write path is byte-asserted; the list write
path is row-asserted and was measured byte-identical. The read changes cannot
alter values -- the metadata is the same metadata, and `str_eq` / `str_flat` /
`span` are new entry points with equality tests against the existing ones. One
behavioural difference worth naming: a `TableFile` is now a snapshot of the
footer as of `open`, where before each getter re-read it. Readers still open the
file by path per call, so a file replaced under an open handle was already
inconsistent between `row_group_stats` and a getter; a run does not rewrite an
artifact it is reading.

cargo fmt, cargo clippy -p mumdia-io --all-targets -D warnings, cargo test
--workspace all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tation

quant was the last consumer of the chromatogram table still holding it as
`Vec<Vec<f32>>` plus a `String` per row: three heap blocks per chromatogram row,
on top of four to six more per candidate. That is the shape that kills a grouped
search at ~290 GB resident with 1.7 TB free, because the process runs out of
per-process memory MAPPINGS (1,048,576) rather than out of memory, and
`--out-peak-bounds` bypasses the accepted-candidate filter so the whole table is
held that way.

What changed, in the order of the findings:

1. `ChromStore` gives the table the flat treatment `features` already gives it:
   one buffer for intensities, one for RT axes with the axis shared between the
   rows of a candidate that sample the same grid (every row under extract's
   window grid), interned fragment names, and flat per-row index arrays. The MS1
   isotope pseudo-traces are dropped at load rather than stored and skipped
   later, after the rt/intensity length check, which stays a guard on the table.
   Live heap blocks per row go 3 -> 0. Derived from the layout at a 60-sample
   trace and 8 fragments per candidate: ~624 B/row -> ~314 B/row, before
   allocator per-block overhead, which the figure does not count.

2. `CandIndex` replaces `BTreeMap<u32, Vec<usize>>` (a node and a `Vec` per
   candidate) with one grouped row array, and `win` / `applied_win` / the areas
   become flat arrays indexed by candidate position. The per-candidate `Vec<f64>`
   of areas, the `(area, predicted)` pairs and the `(name, area)` pairs are gone:
   rayon writes the areas into disjoint sub-slices of one buffer, so the
   positional pairing the serial fold used to assert is now structural. Blocks
   per candidate go 4-6 -> 0; what remains transient is bounded by the thread
   count, not the candidate count.

3. `peak_window` built the union of the RT axes by inserting every sample into
   TWO BTreeMaps keyed identically, one tree walk per sample each. It now either
   accumulates directly into the shared axis (the common case) or sorts the
   samples once by RT bit pattern. Both reduce each RT's f64 sum in the same
   order the tree did -- row order, then sample order -- and the sort is stable
   for that reason.

4. The fixed-window indices were computed twice per fragment row, once for the
   area and once to report the bounds; they are computed once. The nearest-sample
   search inside them linear-scanned the whole trace, which is O(trace) where the
   integration is O(window); it binary-searches when the store says every axis is
   non-decreasing and NaN-free, and falls back to the scan otherwise.

5. `select_fragment_areas` collected and SORTED a candidate's areas once per
   scored row mapping to it; memoised per candidate.

6. The cross-run combine cloned every feature vector into the protein matrix and
   again into each of the two sibling levels. `lfq_profile` now takes anything
   that borrows as `[Option<f64>]`, so all three borrow from one owner. The
   protein-group key is no longer allocated per row in either the rollup or the
   combine.

Risk to output equality: the arithmetic and every reduction order are unchanged,
and the equality claims are tested rather than argued. `peak_window_both` runs
every existing `peak_window` test through BOTH union constructions and asserts
they are bit-identical; `fixed_window_indices_both` runs the window tests through
both nearest-sample searches; `nearest_index_binary_search_matches_the_scan`
covers the tie-breaks the scan's strict `<` produced (duplicate RTs below, at and
above the target, exact midpoints, targets off both ends);
`a_repeated_rt_sample_merges_into_one_profile_point` pins the merge the BTreeMap
did; `the_candidate_index_groups_exactly_as_the_map_it_replaces` compares the
grouping against the map for a grouped, an interleaved and a single-row table;
`borrowed_rows_give_the_same_profile_as_owned_rows` pins the LFQ core. The
residual risk is the shared-axis shortcut in `peak_window`, which is taken only
for an axis checked strictly increasing and non-negative when it is stored, and
the binary search, which is taken only when every stored axis is non-decreasing
and non-negative; either property failing sends the code back to the merge and
the scan. Not run end to end on a real chromatogram artifact.

Not done: streaming quant per candidate rather than materialising the run's
chromatograms (finding 7). It needs each candidate's rows to be contiguous in the
table, which this stage does not currently require, and it doubles the parquet
read on the normal path, where the accepted-candidate filter already keeps only a
few percent of the table. Reported rather than attempted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…instead of materialising them

Under `rescore.strict` with a PIN sidecar the engine's own `FeatureMatrix` is
never read. The handoff file is written FROM it, it is released immediately
after, and strict turns a sidecar failure into an error rather than a fall back
to `native_tda`, so the native path that would read it is unreachable. It was
still materialised in full first -- one allocation of rows x features x 4 bytes,
about 250 GB at experiment scale (203M-precursor library, ~11.6M PSMs x 387
features and up) -- purely so that it could be copied to disk column by column.

The competed feature columns now go straight into the handoff. What stays
resident is one staging block of HANDOFF_BATCH_ROWS rows, about 390 MB at 387
features, plus its transposed columns while a block is encoded. That is the
largest single allocation in the stage removed outright, and with it the largest
contributor to the mapping count the OOM at ~290 GB resident was traced to.

How it is arranged:

- the load is two passes. Pass 1 reads the per-PSM metadata columns of every
  input; pass 2 reads the feature values, either into the matrix
  (`load_feature_matrix`) or through `for_each_feature_row` into the handoff.
  Splitting them is what makes the feature pass skippable, and it keeps the
  order of the population checks: labels and the target/decoy population are
  still validated before anything reads a feature value. It costs one extra
  parquet footer read per input.
- `HandoffWriter` writes either encoding a row at a time, so the same writer
  serves the matrix path and the streamed path. The parquet blocks are the same
  250k-row blocks, the row groups the same 131,072 rows, the values narrowed
  through the same `as f32` (including `f64::NAN as f32` for a null cell), so the
  file the worker reads is unchanged. A test writes both ways over two inputs
  with a batch small enough to force several blocks and compares the bytes.
- the non-finite feature validation moves into the streaming pass, inline with
  the narrowing while the row is still in cache. `bail_non_finite` then picks
  between it and the scalar scan on the row index, so the error is still the
  first offending row, feature before scalar, with the same message.
- `sidecar_paths` names the per-invocation files once; `run_pin_sidecar` takes
  the already-written handoff when there is one. A guard refuses a handoff whose
  row count disagrees with the metadata: the two passes cannot disagree, but if
  they ever did the handoff would be row-misaligned with every label.

Equality: the streamed and matrix-built handoffs are byte-identical (tested, both
encodings); the scored artifact of the crafted features -> compete -> rescore
pipeline keeps the same blake3
(e535d04b2821a47d4fc8f9bc0a7ba57461b701b607496ffef0700a0352cd135a) as before this
branch. `rescore.max_feature_matrix_gib` is deliberately left applying to the
streamed path too: the worker still builds a matrix of exactly that size, so the
ceiling still describes a real allocation.

Residual risk: the streamed path is covered at unit level (row values equal to
the matrix rows, handoff bytes equal, and `run` driven end to end with an
unspawnable interpreter so the handoff is written and then strict fails), but no
test in this repository runs a real Python sidecar, so the worker's side of the
contract is unexercised here. It reads the same bytes it read before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… thread, and a pruned confident-bounds pass

Six changes to the features stage, all output-equal. The stage's problem is the
NUMBER of live medium heap blocks, not bytes: mimalloc maps blocks of that size
individually and a grouped search dies at ~290 GB resident against the kernel's
1,048,576 per-process mapping limit, with 92,030 mappings of 64-256 KB and 36,368
of 256 KB-1 MB measured on a live process at 180 GB.

- The per-PSM extended values were one `Vec<f64>` each inside a `Vec<PerPsm>`:
  337 f64 (2,696 B) per block, 70k-175k live from the collect() to the drop at
  the end of every chunk. They are one flat rows x n_ext buffer now, filled by
  par_chunks_mut. `FragFeatures` (no heap) stays a Vec.
- The cross-charge reduction was a `HashMap<&str, HashSet<i32>>` plus a
  `HashMap<&str, f64>`: one HashSet block per distinct peptidoform, about 1M live
  for the whole run. Peptidoforms get a dense id (compete.rs's `pform_id`
  pattern) and the reductions become a `Vec<u32>` charge bitmask and a `Vec<f64>`
  of sums. The bitmask is exact for charges in 0..32 and falls back to the
  set-per-peptidoform build otherwise; the sums accumulate in the same row order,
  so the f64 addition order is unchanged.
- The serial tail hashed a feature NAME per value per row, ~390 lookups per row
  in the Extended set, to resolve a permutation fixed before the loop. `ColIx`
  resolves it once. Its field names ARE the feature names (macro-generated), and
  a test asserts the fields of each set account for every active column exactly
  once, so a typo cannot silently drop a column.
- The value matrix's columns are owned, so the writer takes them by move instead
  of `column(c).to_vec()`. That copy is why the matrix existed twice at the write
  and why `memlog` understated the peak by exactly 2x; the report now names the
  in-flight peak as well.
- The parquet encode moved to a writer thread mirroring the loader thread, order
  preserved. Both channels are rendezvous (depth 0): `sync_channel(1)` held THREE
  chunks, not the two its comment claimed.
- `confident_global_bounds` streamed the entire chromatogram table (up to 88 GB)
  to learn two scalars from a few thousand candidates, on by default. It now
  reads only the row groups whose `candidate_id` statistics can contain a
  confident candidate; a candidate's rows are contiguous, so one that spans two
  groups is inside both ranges and neither is skipped. Chunk boundaries inside a
  span stay on the same ABSOLUTE row grid, so a candidate straddling one is split
  where it was split before and the percentiles are identical. On the test
  fixture (100 row groups, every fifth candidate an anchor) it reads 40% of the
  rows.
- Hoisted allocations, all bit-identical: the per-scan `obs_k` in the pCos loop
  and the per-fragment `others` in the contrast loop (features.rs); the
  summed-positive gather in nonzero.rs, which was two Vecs per fragment PAIR for
  a per-fragment quantity, plus scratch reuse for the third; the k x k `corr`
  (coelution.rs) and m x m Gram matrix (interference.rs) are one flat buffer each
  instead of a Vec per row, and power_top's per-iteration `nv` is reused.

The trace alignment was built twice per PSM, once in `fragment_features` and once
in `build_evidence`, each fragment through its own `HashMap<u32, f32>` keyed on
the RT bit pattern. It is built once per PSM and handed to both (Evidence takes
ownership), and it has a fast path: the chunk store shares ONE axis across a
candidate's rows, so when every row with a trace samples that same strictly
ascending slice the union axis IS that slice and every map is the identity. The
two paths' bounding behaviour (`fragment_features` honours `bound_features`,
`build_evidence` does not) is untouched.

Two hazards the threading uncovered, both fixed rather than inherited:

- the loader's receiver lived in the enclosing frame, so an error return from the
  scope closure left the loader blocked in `send` on a channel nobody would
  receive from again while the scope waited to join it: a hang instead of an
  error. Depth 0 makes that more reachable, so both channels are created inside
  the scope and their receivers drop with it.
- with the write on its own thread, an error would drop the sender, end the
  writer's loop normally and PUBLISH a truncated features table over the previous
  good one. A `None` commit marker is sent only after the last chunk; without it
  the writer drops unpublished and `AtomicPath` removes its temp file.

Output equality: eight configurations (Extended/Rich/Minimal x
bound_from_confident on/off x chunk sizes 64, 4096 and 2^20, with and without a
seed table) over a 97-candidate fixture produce byte-identical feature values and
PIN bytes before and after, compared as raw f64 bit patterns per column. New
tests assert the new code against the old: the shared-axis alignment against the
union-and-map build (and that it refuses an unsorted or repeated axis), the
charge bitmask against the set build, the pruned confident-bounds pass against
the whole-table scan at four chunk sizes, ColIx against the name lookup, and the
whole chunked Extended pass for chunk invariance, which the existing
`features_chunking_is_value_preserving` never reached because it runs the default
Minimal set. Residual risk: the confident-bounds pass already depended on the
chunk size before this change (a candidate straddling a chunk boundary is bounded
from each part separately), which the alignment preserves but does not fix.

cargo fmt, cargo clippy -p mumdia --all-targets -D warnings and cargo test
--workspace all pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Measured on the running seven-file experiment, at the moment it reached the pool:
the stage was writing 3.3 MB/s and reading 3.9 MB/s at 110% CPU, on a disk that
reads 221 MB/s (dd, direct). One run's chromatograms are 63 bands x 1.09 GB, so
at that rate pooling one run would have taken about five hours, and the
experiment pools seven of them. The rows were being decoded from parquet,
filtered, and encoded again for no reason: the bands already write library-wide
ids, so the pooled table is the bands' own rows in the bands' own order.

`SpliceWriter` (mumdia-io) appends a source file's encoded column chunks into
the output with the parquet writer's `append_column`, which is what that API
exists for. The output takes the template's parquet schema AND its Arrow
metadata, so the `LargeList` chromatogram columns read back as `LargeList` and
not `List` -- that distinction lives only in the key-value metadata, and losing
it would have changed the type of the run's largest artifact. A test asserts it.

Only the row groups that can hold a candidate the overlap dedup drops are
decoded: the statistics on `candidate_id` say which, the rest are copied. A
dropped candidate comes from a window that two bands share, so those row groups
are at the two ends of a band. The rewritten group is encoded to a temporary
file beside the band and spliced in at its own position, so the pooled row order
is unchanged whether a group was copied or rewritten.

Also here: `groups.parallel` is clamped to one less than the thread count. A
band in flight parks a rayon worker on its extraction's accumulation channel
while the probing tasks run on the others, so as many bands as threads leaves
nothing to feed them and the run deadlocks. Reproduced on the fixture with
`parallel = 2, --threads 2`: the process sat at 0.1 s of CPU with no error and
no output. The production runs are far from the bound (8 bands, 128 threads),
which is why this was never hit.

Tests: the three pool tests cover a clean splice, a band whose losers sit in one
of ten row groups (mixed copy and rewrite, asserting the pooled ids are the
bands' rows in order), and the skipped `psms_extracted`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…non-nullable

The pass-through copy reads fewer columns through the typed getters than the
typed path did -- `candidate_id` only for the audit, `base_peptide_id` only for
the groupings that key on it -- and those getters are where nulls were refused.
Without a check the copy would carry a null into the competed table and the
failure would surface somewhere downstream, or nowhere. Raised by the adversarial
review of the compete change.

`Array::null_count` is a counter on the array, not a scan, so this is one
comparison per column per batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adversarial review of the chunked `write_table` argued that its byte-equality
claim does not hold for an entirely NULL optional column, because the definition
levels are then RLE-run encoded and the writer takes its internal mini-batches in
a different size, so a row-chunk boundary no longer lands on a mini-batch
boundary. That is correct, and it is measured here rather than argued: 2,287,903
bytes against 2,287,919 at 196,615 rows, a 16-byte difference in page framing,
with identical rows.

It matters because every real run writes such a column: `apex_im` on
`psms_extracted` and the three ion-mobility columns on `run_windows` have exactly
one push site each and it pushes `None`. Those two artifacts therefore have
different content hashes from the ones a pre-chunking build wrote. The rows,
their order and their values are unchanged, which is what this layer promises;
the byte change is of the same kind as the row-group caps elsewhere in this
series.

Two tests, neither of which existed: an all-null table compared row for row and
row-group for row-group, and a table past the writer's 1,048,576-row row-group
maximum, where the chunked and single-batch files come out byte-identical with
the groups in the same places. The second is the property the 65,536-row chunk
size was chosen for and nothing covered it: every other chunking test is inside
one row group.

Also: the all-null test gets its own temporary directory. The shared one is
wiped with `remove_dir_all` by a test in another module while these run in
parallel threads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…andidate

The grouped search dies at ~290 GB resident with "memory allocation of N bytes
failed" while 1.7 TB of RAM is free. Measured on the live process, the limit it
reaches is the per-process memory MAPPING table (vm.max_map_count = 1,048,576):
at 180 GB it held 129,393 mappings, 92,030 of them 64-256 KB and 36,368 of them
256 KB-1 MB. Those sizes are one-heap-block-per-item structures, and the item
here is a candidate: every candidate with evidence owned a `Vec<Hit>` (24 B per
hit) created on first collision and grown by doubling, in the task-local map
inside `probe_range`, in the merge into the accumulator, and in the drain. A
candidate with no `run_windows` row gets infinite RT bounds and so collects hits
across the whole gradient, 10^4-10^5 of them, i.e. 240 KB-2.4 MB in one block,
which mimalloc maps individually.

Those hits now live in a CSR layout (`HitStore`: ascending `cids`, per-candidate
`offs`, one flat `hits` buffer), so a band of 1.9-5.8M precursors holds three
allocations per probing task instead of one per candidate with evidence. Live
medium/large blocks drop from O(candidates with evidence) to O(tasks), which is
2x threads.

Mechanics, all chosen so the bytes never double:

- `probe_range` collects flat `(cid, hit)` pairs in probe order and groups them
  at the end of the task with a STABLE counting sort (`group_hits_by_candidate`).
  The permutation is applied in place by cycle following rather than scattered
  into a second buffer, and the `cid` array is reused as the slot array, so the
  extra is 4 B per hit plus one u32 per candidate of the sub-range, not another
  24 B per hit.
- A window's sub-range tasks cover disjoint ascending candidate spans, so their
  stores concatenate into one ascending run (`HitRun`) with no per-candidate
  merge at all. The consumer appends runs in window order; `gather_chunk`
  resolves the concatenation lazily at flush, one `CAND_CHUNK` of candidates at
  a time through a reused buffer.
- `per_candidate` takes `&mut [Hit]` into that buffer instead of an owned
  `Vec<Hit>`; it sorted a slice either way.
- The eager (non-fragindex, two-pass, allowlist-free-of-fragindex) paths still
  build the whole-run `HashMap<u32, Vec<Hit>>` and are now moved into the CSR
  buffer a chunk at a time, freeing the map's vectors as they are consumed, so
  the flat copy never coexists with the whole map.

Also hoists the per-peak query m/z out of the sub-range tasks: `peak.mz /
mass_off.factor_at(peak.mz)` is a division, and a binary search into the masscal
grid when one is present, and it was recomputed once per task per peak, up to
2x threads redundantly. It is now computed once per window into a flat buffer
the window's tasks share, built only when the window is actually split. The bin
computation (a `ln()` inside `probe_peak_win`) is the other half of that hoist
and is NOT done here: it needs a `FragIndex` entry point taking a precomputed
bin, which is outside this file.

Risk to output equality: none intended, and the ordering is the whole argument.
A candidate's hits must arrive in ascending scan order within a window and with
windows concatenated in window order, because every float reduction downstream
(rt sort, apex sum, wsum, chromatogram grids) is order-sensitive. The counting
sort is stable, so within a task the order is arrival order; the sub-range spans
are disjoint, so a candidate is produced by exactly one task of a window; and
the gather walks runs in window order, appending each candidate's segments. PSM
row order, chromatogram row order and the CAND_CHUNK partition (hence parquet
row-group boundaries) are unchanged: the flush is the same candidate partition
in the same sequence.

Tests: `candidate_range_split_reproduces_the_unsplit_accumulation` now
materialises the accumulator through the production gather and `slices_mut` and
still asserts 2/8/16 threads agree hit for hit;
`grouping_reproduces_per_candidate_push_order` asserts the counting sort
reproduces the naive "push onto this candidate's Vec" accumulation it replaced,
including untouched candidates inside the span;
`gather_concatenates_a_candidate_across_runs_in_window_order`,
`gather_stops_at_the_bound_and_keeps_the_rest` and
`gather_chunking_partitions_the_accumulator` cover the flush.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… boundary

From the adversarial review of the library change. Reading the label column in
65,536-row batches means the loop must push exactly one value per row; the
`other` arm called `fdr::validate_labels`, which refuses anything but
target/decoy, and pushed nothing. That is correct only because that rule is
absolute today: if it ever widened, the column would come out shorter than the
table and every later row would read its neighbour's label. The arm now ends in
an explicit error naming the row and the file.

The batch boundary itself had no test: every other fixture in index.rs is six
rows. The new one is 65,541, and covers the label values either side of the
boundary and in the short last batch, the `ncand` cap, a wrong candidate_id in
the second batch (which must be named by its absolute row, not its row within
the batch), and a NULL label there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`run` pools at the end of a grouped search. Standalone the same stage is for the
case where the search finished and the run did not: the band directories hold
every artifact, and pooling them is now a byte copy of their parquet row groups,
so a killed run costs a pool rather than a re-search. It also makes the pool
testable at real scale without a search, which is how the splice was measured.

`--psms` opts into pooling psms_extracted, which only the candidate audit reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`window_groups` drops the windows that select no candidate of the band, so a
band of a 63-band grouped plan overlaps only 1-3 windows: the whole band is one
batch, the flush bound is `u32::MAX`, and nothing was ever flushed early. The
accumulator held the entire band, which is 1.9-5.8M precursors' worth of hits,
for the whole stage. `windows_in_flight` cannot help, because there is only one
batch.

The sub-ranges that already split each window's candidate range are now a
GLOBAL grid shared by every window of the batch, and a sub-range is flushed as
soon as every window that intersects it has reported. Per-window sub-ranges
parallelise just as well but cannot be flushed: a candidate is final once every
window of the batch has passed it, and that is a statement about a boundary the
windows share. Tasks are one per (sub-range, window) pair that actually
intersects, k-major, so the pool starts on what will be flushed first.

The width is taken from the MEAN window span divided by `tasks_per_window`
rather than from the global span divided by a task count, so each window still
splits into about `tasks_per_window` pieces and the batch still runs about
2x threads of them. No extra probing, no extra `WindowNarrow` caches: these are
the tasks that existed before, on a shared grid. On a one-window band at 32
threads the flush unit becomes 1/64 of the band; on a 16-window batch of a
whole-library search it becomes roughly one window's span instead of sixteen.

The end-of-batch drain stays, because a batch's windows can reach past the last
window's range when the windows differ in width, and the sub-range grid stops at
the widest window's end.

Risk to output equality: PSM rows and values unchanged; chromatogram parquet
row-group boundaries may move. Sub-ranges are flushed in ascending index and
candidates ascending within, and the sub-range grid partitions the candidate
axis, so the flushed sequence is the same ascending candidate sequence as
before. A candidate's hits are still gathered run by run in window order, which
is what every float reduction downstream depends on. What does change is where
a flush ends: the ChromChunk partition now follows sub-range boundaries as well
as `CAND_CHUNK`, so the chromatogram writer's row groups can fall differently.
That is the same class of change `windows_in_flight` already documents as
acceptable, and no row and no value moves with it.

Also hoists the per-window query-m/z buffer out of the window probe and into the
batch plan, since the per-window task count is now known there.

Test: `candidate_range_split_reproduces_the_unsplit_accumulation` now runs the
fixture twice, once with `bound = 0` (nothing flushes, the accumulation is
compared as before across 2/8/16 threads) and once with `bound = u32::MAX`, and
asserts the per-sub-range flush delivers exactly the same candidates, strictly
ascending, with the same hits in the same order, and leaves nothing open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of the feature-streaming change found five issues in
`stages/rescore.rs`. Four are real and fixed here; one is a documentation
claim, corrected in place rather than by changing code.

Fixed:

- The non-finite validation ran AFTER the whole handoff was written and
  published. It used to run before `run_pin_sidecar` was entered, so a
  malformed competed table cost nothing; deferring it made the same failure
  pay a full handoff write, hundreds of GB at experiment scale, for a run
  that was always going to abort. The scan now aborts the stream on the
  first offending row. The message is unchanged: `bad_scalar` is known
  before the stream starts, so `bail_non_finite` still makes the same
  choice between the two answers on the row index, and the stream stops at
  the scalar row once no earlier feature row can win the tie-break. A
  parquet handoff never reaches its final name (`AtomicPath`), but the PIN
  encoding writes it directly, so an aborted stream now removes it.
- The row-count guard existed on the streamed path only. The matrix path
  gets the same guard, and both now go through one `refuse_row_disagreement`
  helper, as does a third check the review flagged as a silently dropped
  invariant: `merge_col` appends whatever the reader returned, while
  `source` is still sized from the footer, so every concatenated metadata
  column is now measured against the footer row count at the point `n` is
  taken from it.
- An empty feature set is refused by name. The old code neither errored nor
  worked: `iter_rows` is `chunks_exact(n_features.max(1))` over an empty
  buffer, so the validation loop ran zero times, which also skipped the
  prelim_score/precursor_mz check for every row, and the run trained on no
  features. An explicitly empty `rescore.features` list was already an
  error, so this makes the two consistent.
- `a_shared_column_array_writes_the_same_parquet_as_two_copies` ran at 2,000
  rows, below `write_table`'s 65,536-row chunk, where the two writers cannot
  diverge. It now runs at 3 chunks plus a short last one. The row-group seam
  is pinned in mumdia-io at 1,048,583 rows.

Rejected:

- "`write_scored_table`'s memory claim inverts on the merge target": the
  byte output is unaffected and the mechanism is right, but the review is
  correct that on `perf/integrate` chunked `write_table` holds one chunk of
  Arrow copies while one batch re-packs each string column in full. The doc
  comment now says that instead of claiming an unqualified saving; the code
  is unchanged.
- `assert!(n <= u32::MAX)` in `fdr::target_decoy_q` stays an assert. It is
  unreachable at any allocatable scale, the alternative it guards is silent
  truncation scattering q onto the wrong rows, and an `anyhow` error would
  change a signature used by search-seed and others, outside this file set.
- `rescore.max_feature_matrix_gib` stays checked on the streamed path. The
  ceiling is `rows x features x 4`, which is exactly the matrix the Python
  worker builds from the handoff, so it still describes a real allocation;
  making it path-dependent would let a strict `nn_torch` pool that will OOM
  the worker start anyway. The default is 0.0 (off).
- The commit-message residency figure for the staging block: corrected in
  the code comment (the staged f32 block plus what `flush_block` transposes
  out of it, roughly twice the stated 390 MB).

Tests: the two abort tests fail against the pre-fix ordering (the handoff
was written and published before the error), the tie-break tests pass
before and after, which is the point.

Not fixed, outside the file set: `cargo fmt --check` already fails on
`stages/pool.rs` and `mumdia-io/src/table.rs` at this branch tip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RobbinBouwmeester and others added 17 commits September 22, 2026 18:17
…her than enforced

Two adversarial reviews of the quant chromatogram store and the seed's
window-width survey. Both found real defects; each fix is pinned by a test
that fails when the fix is reverted, verified one fix at a time.

quant.rs

- `nearest_index`'s comment claimed any non-finite target "makes every
  predicate false and lands on 0". True for NaN and -inf, false for +inf:
  `r < inf` holds at every sample, so `partition_point` returned `rt.len()`
  and the LAST index came back where the scan returns 0. The precondition
  that actually holds is "finite", so it is now a guard returning the scan's
  answer, not a comment. Unreachable today (both call sites filter the apex
  hint on `is_finite`, and convert drops a spectrum whose scan start time is
  not finite), so no artifact moves.

- `ChromStore::axis_for` deduplicated axes with slice `==`, and `-0.0 == 0.0`
  under f32's `PartialEq`, while `peak_window`'s merge keys the union on
  `to_bits`. A row whose axis differed from a stored one only in the sign of a
  zero was given the stored axis, folding two union points into one: different
  profile, apex, window and quantity. The comparison is now bitwise, so
  interning can only substitute an axis identical to the last bit.

- `peak_window`'s `axis_sorted` guard asked whether the merged axis's last
  sample was `>= 0.0`, which `-0.0` satisfies although its bits sort after
  every positive f32. An axis ending in `-0.0` was declared ascending while
  descending at its last step, and `nearest_index` then binary-searched it,
  moving `ai` (the apex index that anchors `peak_bounds`). It now checks the
  order itself, one pass over an axis the sort already paid O(n log n) for.

  Neither -0.0 path is reachable from extract, whose grid is positive mzML
  scan times; both are reachable through `mumdia quant --chromatograms`,
  which is why the neighbouring rt/intensity length check exists.

- `axis_for` could in principle mint the `NO_AXIS` (`u32::MAX`) sentinel,
  which every consumer reads as "no RT axis" and silently integrates to 0.
  Checked where the id is minted (one comparison per distinct axis);
  `ChromStore::push` and the loader now return `Result`.

- `peak_window_both`'s claim to run every case through BOTH union
  constructions did not hold: `peak_window` takes the shared-axis fast path
  whenever the rows resolve to one strict axis id, which a single-row call
  always does whatever candidate id it was pushed under, so three of eight
  call sites compared the shared path against itself. The merged fixture now
  clears `axis_strict`, which withdraws the fast path without touching a
  stored value, and the helper asserts that routing. Reverting the fixture
  fails all six `peak_window_both` callers.

search_seed.rs

- ACCEPTED, the guard was weakened. Restricting the width survey to the
  in-band groups removed a 63x false positive and introduced a false
  negative: under `groups.window_groups` a full-range window resolves to the
  whole loaded band, and so does the band's own isolation window, so
  `widest > 8 * median` can never fire on a band. The malformed scan is now
  detected from the WINDOW instead: convert substitutes (0.0, 1e6) for a
  zero-width or missing isolation window, which reads the same on every band.
  A mixture of full-range and narrow windows warns; all full-range is a
  legitimate all-ion acquisition and stays silent. The width ratio is kept as
  the second, weaker diagnostic, with a comment saying what it can see under
  a grouped search.

- ACCEPTED, the eager scratch sizing was a memory regression. "It grows on
  demand to the widest window it processes anyway" holds only for a leaf that
  processes a served group. `map_init` runs once per rayon LEAF, before the
  leaf knows which groups it drew, so in a banded search where 62 of 63
  groups select nothing, nearly every leaf would allocate 16 B x band width
  and return empty: about 14 MB per worker on the profiled 54.8M-candidate
  library over 63 bands, roughly 445 MB across 32 workers, almost all never
  touched. `initial_scratch_width` now takes the served median only when a
  majority of groups is served; the leaf that does draw work still reaches
  the same size through `SeedScratch::ensure`, in one amortised realloc.

Rejected

- The reviews' remaining findings are all in `index.rs`, which is out of this
  change's scope and already addressed on the integration branch.

- Not acted on, recorded instead: `peak_window`'s shared fast path indexes
  `prof` (sized from the AXIS) by the INTENSITY position, so its
  non-panicking behaviour rests on the loader's per-row length check. Making
  the loop `zip` would replace a panic with silent truncation, which is
  worse, so the invariant is asserted at `ChromStore::push` (debug only; the
  loader keeps the named, candidate-identifying error).

- Neither review's output-equality reasoning is backed by an end-to-end
  artifact diff, and neither is this change. Every claim here is
  crafted-fixture evidence. The byte-identical contract still needs a
  before/after `blake3_file` of the quant tables and `seed_psms.parquet` on a
  real run, including one banded `groups.window_groups` run.

cargo clippy -p mumdia --all-targets -D warnings clean; 283 lib + 5 bin +
2 + 3 integration tests pass. `cargo fmt --check` still reports pre-existing
diffs in `pool.rs` and `mumdia-io/table.rs`, untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of 1ac1b44 raised seven findings against the features
stage. Five are real and fixed here, one is rejected on arithmetic, and one is
answered with the evidence it said was missing. The stage's outputs are
unchanged: a golden captured from 1ac1b44^ now proves that in the suite.

FIXED

- The confident-bounds pass re-opened the chromatogram file and re-parsed its
  whole footer once per span (`TableFile::open_rows`), and `ChromStream::open`
  opened it a second time for `column_names(path)`. Both are gone:
  `confident_global_bounds` takes the caller's already-open `&TableFile` and
  uses `TableFile::span` (an Arc clone of the parsed footer, and the call that
  method exists for and had no callers), and the stream answers "does this table
  carry frag_obs_mz?" from the handle's schema, which a span carries in full.
  The cost used to grow with the saving: the pruning's whole purpose is to
  produce many spans, and each one cost two footer parses.
  `the_chrom_stream_reads_its_optional_column_from_the_handle_it_is_given`
  pins the old answer across has-column x whole-file/span.

- The PIN and the features table were committed in the opposite order. The
  in-line write ran `pin.finish()` then `writer.close()`, so a PIN that failed
  at the flush left the previous features table untouched; moving `close` onto
  the writer thread silently reversed it, publishing the table and then erroring.
  The PIN is now flushed inside the scope, before the commit marker, so a failure
  returns without the marker and the writer drops unpublished.
  `a_failed_pin_finish_does_not_publish_the_features_table` pins the old
  ordering through a `PinFinish` parameter (cfg(test) variant, not a global, so
  it cannot interfere with another test in the binary).

- The pruning fixture wrote a fixed 6 rows per candidate into 12-row row groups,
  so no candidate ever straddled a boundary and the safety argument the pruning
  rests on was never exercised. The fixture now varies the fragment count (4..9);
  the test asserts that candidates do straddle boundaries, that a skipped group
  sits between two kept ones, and that more than one span comes back, and it adds
  a chunk size larger than a row group.

- The claimed benefit of the pruning is a property of that fixture. Measured:
  extract writes 1<<16-row groups, so one group spans thousands of candidates
  while the confident set (~21,856 on AIF) is spread over the id range, and
  essentially every group holds an anchor. `row_group_pruning_saves_nothing_on_a
  _production_shaped_table` builds that distribution and asserts the pruning
  returns ONE span covering the whole table. The doc comment now says so instead
  of quoting the fixture's 40%, and says what it does buy (a clustered confident
  set reads 3 of 200 groups). Kept, not dropped: it is exact, it now costs no
  extra file open, and the sparse case is real.

- `in_flight_peak` asserted `2 * max_matrix_bytes`, which was the in-line write's
  peak (one matrix plus its copy). With the writer thread there is no copy but
  the previous chunk's columns are held while the next chunk's matrix AND its
  extended buffer are live, so the overlap is nearer 2.9x at Extended -- above
  what it replaced. The report measures the three buffers now.
  `the_in_flight_peak_is_not_two_matrices` pins the arithmetic.

ALSO, from the review's output-equality section

- `align_shared_axis` decided "same axis" by `ptr::eq` plus a length check, so
  the fast path's correctness rested on a `ChromChunk::axis_for` invariant
  nothing in the function could see. It compares bit patterns now (pointer
  equality kept as the cheap accept), which keeps -0.0 and +0.0 distinct and so
  accepts exactly the set the union build reproduces. One existing assertion
  that pinned the old rejection was updated; the equality assertion it carried
  is unchanged.
- `confident_row_spans` refuses statistics that cannot describe a u32 column
  (negative or inverted bounds) rather than skipping a group on them, which would
  drop an anchor and shift the half-widths that bound every candidate.
- `charge_states_per_row` asserts the charge column covers every PSM row. Both
  builds zip, and a zip truncates where the `charge[i]` it replaced panicked.

REJECTED

- "The per-column `ValueMatrix` multiplies the live block count." The arithmetic
  does not support it. 387 columns of one chunk are 387 blocks; at most two
  matrices are in flight, so 774 -- 0.07% of the 1,048,576 mappings a process
  gets, against the 92,030 + 36,368 medium mappings measured on the process that
  hit the limit. What the layout removes is a whole-matrix copy at every write,
  hundreds of MB. The trade is lopsided in favour of the columns. The arithmetic
  is now in the type's doc comment and `value_matrix_columns_are_one_block_each`
  pins the count so a future widening cannot creep past it. The sub-point that
  `payload_bytes` became a formula rather than a measurement is granted: it sums
  the columns' capacities now, so a moved-out column counts as the zero it is.

- The finding that `extended_features_are_chunk_invariant` compares new against
  new, and that `colix_covers_every_active_column` cannot see a transposed pair,
  is correct and is answered rather than argued with. Route taken: the golden,
  not the cheaper ColIx-name settlement (both are here).
  `extended_features_match_the_pre_permutation_build` pins a digest of every
  column's bit patterns, and the PIN hash, captured by running this same fixture
  against 1ac1b44^ -- the last commit whose assembly keyed each value by a
  feature-name string literal. Verified non-vacuous: swapping `ff.frag_corr` and
  `ff.frag_cosine` between their two columns fails it, while chunk invariance
  still passes, which is exactly the gap the review named. It also covers the
  uniform-shift case and the `align_shared_axis` widening above.
  `colix_resolves_each_field_to_the_column_its_own_name_denotes` is the second
  layer: `stringify!` from the same macro that builds the struct, so each field
  is pinned to the column its own name denotes without listing 387 names.

Not touched, reported instead: `cargo fmt` reformats
`mumdia-io/src/table.rs` and `stages/pool.rs`, which are committed unformatted
on this branch and are outside this change's file set.

cargo clippy -p mumdia --all-targets -D warnings and cargo test --workspace pass;
rustfmt --check is clean on the files touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 GB of one run's band artifacts: 3 min 12 s spliced at 2.6 GB resident,
against 3.5 MB/s decoding and re-encoding on one core, which had reached 42.7 GB
in 2 h 50 min when it was stopped. The disk does 221 MB/s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wo byte changes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e window scan

Four scratch structures in the per-candidate pass, plus a linear scan over every
isolation window.

- The distinct-fragment set was `hits.iter().map(|h| h.frag).collect::<Vec<u16>>()`
  followed by a sort and a dedup: one `u16` PER HIT, and a candidate with no
  `run_windows` row collects 10^4-10^5 hits, so that was a 20-200 KB allocation
  and an n log n sort to recover at most a few dozen distinct ordinals. It is now
  a bitmask over the candidate's local fragment ordinals (`FragSet`, 512 inline,
  spilling to a sorted vector above that, which no real library reaches). The
  sorted vector is materialised only where the co-elution score asks for it,
  which the default gate mode does not.
- The grid alignment built a `HashMap<u64, usize>` of the scan grid per
  candidate. Both sides are ascending, so it is a merge; the match is still on
  the exact bit pattern, so a group whose RT is not a grid RT is dropped exactly
  as before.
- The chromatogram emission built a `HashMap<u16, Vec<(f64, f32)>>` over the
  whole candidate and then a `HashMap<u64, f32>` per fragment to sample it onto
  the grid. In grid mode `groups` IS the grid -- it was rebuilt on it above, one
  entry per grid RT in the same order -- so a fragment's trace is its value in
  each group, read directly. The RT axis is the grid itself, the same for every
  fragment and for the MS1 XICs, so it is converted once instead of once per
  fragment. In sparse mode the trace is the fragment's entries in group order,
  which is what the per-fragment vector held before its already-sorted stable
  sort.
- `wsum` (per-fragment intensity-weighted observed m/z) was a `HashMap<u16, ...>`
  over ordinals that are `0..n_frag` by construction; it is now indexed. An
  untouched entry keeps `sum_w == 0`, which takes the same theoretical-m/z
  fallback the absent map entry took.
- The scan grid scanned all ~150 windows per candidate to find the covering
  ones. `windows` is sorted by lower m/z, and a covering window has
  `lower_mz <= pm` and `lower_mz >= upper_mz - max_width >= pm - max_width`, so
  the span is two binary searches; the membership test inside it is unchanged.
  With one covering window the slice is already ascending, so the sort is
  skipped (the dedup still runs).

No speed claim from the fixture available here. On the AIF benchmark
(1,691,048 candidates, 465,806 scans, 152 windows, 728,814 candidates with
evidence, 41,677 accepted) the extract compute phase is 2.12-2.16 s with and
without these changes over five warm runs each, i.e. flat within noise. That
fixture cannot resolve them: it is I/O-dominated (4.0 s total, ~1.8 s of it
loading a 309 MB spectra table), its `run_windows` covers every candidate so the
10^4-10^5-hit population these target never occurs, and it averages about two
hits per candidate. The first numbers measured for this change were cold-cache
and are not real.

Risk to output equality: none intended. Verified end to end against the base
binary on that fixture, both `--release` from the same tree:
`psms_extracted.parquet` is BYTE-IDENTICAL, and `chromatograms.parquet` is equal
as a table (620,169 rows, every column, same order, `pyarrow` equality) -- its
bytes differ only because the preceding commit moves the row-group boundaries,
which the flush-unit change documents.

Tests: `FragSet` is asserted against sort+dedup over several ordinal patterns,
including the spill past the inline mask; the covering-window span is asserted
to select exactly the windows a full scan selects, over uneven overlapping
windows, a gap, and 2,000 precursor m/z probes plus every window edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… trusting a stale in-tree binary

The committed CLI reference was missing the whole `pool` subcommand: the
generator prefers `rust/mumdia/target/release/mumdia` over the target directory
cargo actually reports, and on a machine whose `build.target-dir` is redirected
off the synced tree that path holds whatever was built before the redirect. Here
it was a binary from 2026-09-09. CI has no such directory, which is the only
reason it noticed. The generator now asks cargo first, from inside the
workspace, the way ci/smoke.sh already does.

Also: the third-party licence inventory picks up the zstd crates, and a public
doc comment in mumdia-io no longer links to a private item, which cargo doc
refuses under -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ory and buys nothing

Item (3) of the brief was to hoist the per-peak setup recomputed once per
sub-range task: `peak.mz / mass_off.factor_at(peak.mz)` and the bin computation
(a `ln()`). Only the first half is reachable from this file -- the bin is
computed inside `FragIndex::probe_peak_win` and using a precomputed one needs a
new entry point on `FragIndex`, which is outside `extract.rs`. This reverts the
first half as well, because it measured as a loss.

The buffer is 8 bytes per peak of every window in flight. Measured peak RSS on
the AIF fixture (39.6M MS2 peaks, 152 windows), two runs per arm on the same
host: with the whole run in one batch (`windows_in_flight: 400`, which is the
shape a grouped band search has, since `window_groups` leaves a band 1-3 windows
and one batch) the buffer covers every peak of the run, 317 MB, and the process
went to 2,913 and 2,921 MB against 2,693 and 2,719 MB for the baseline -- the
flat accumulator's saving swallowed and then some. Under the default
`windows_in_flight` it is a sixteenth of that, but still not free.

It buys no measurable time. The extract compute phase is 2.12-2.16 s with the
hoist and 2.12-2.16 s without, five warm runs each; the redundant work it
removes is one f64 division per peak per task (the masscal grid, where the
binary search would also be saved, is absent on this run: `mz_cal_grid=0`).

Memory is the whole point of this branch, so a structure that is hundreds of MB
in the shape that is failing in production and flat in time does not belong in
it. Both halves are recorded in a comment at the call site so the next reader
does not re-derive them.

Also records, at the counting sort, that giving back the hit buffer's doubling
slack with `shrink_to_fit` was tried and reverted too: 2,520-2,560 MB against
2,456-2,505 MB, i.e. the copy costs more than the slack it returns, and the
slack is the same slack the per-candidate vectors carried before.

Output equality unchanged: re-verified on the same fixture with the final
binary, `psms_extracted.parquet` byte-identical to the baseline's and
`chromatograms.parquet` equal as a table (620,169 rows, every column, same
order).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e values are

The digest the review asked for was captured on Windows and fails on Linux. It
is not nondeterminism: the same fixture gives 0x4e43..a1cc on Windows and
0x6137..c42a on Linux, stably, two runs each on the same binary. The extended
battery reaches `ln`, `exp` and `powf`, which are the platform's libm and agree
only to within the last bit, so the engine's f64 feature values are not
bit-identical across operating systems. Worth knowing on its own: any comparison
of feature values, or of any artifact hash downstream of them, has to be made on
one operating system.

The PIN is unaffected and its hash is the same on both, because it formats to
fixed decimals.

The test now carries a digest per platform and asserts the one for the platform
it runs on; a platform with no entry checks the shape and the PIN and says so,
rather than failing on a constant captured somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the allowlist

Two items from the brief, both scheduling/lookup only, neither touching a value.

(7) The consumer runs on the thread that called `accumulate_groups`, and under
`groups.parallel` that thread is itself a rayon worker, so a plain blocking
`recv` parks one worker per band in flight: 24 bands parked 24 of 32 threads and
the pool ran on what was left. It now calls `rayon::yield_now` while waiting,
which executes one queued task instead of parking -- usually one of this batch's
own probes -- and falls back to a 1 ms blocking wait when the pool reports
`Idle`, so an empty pool does not become a spin. The results are still consumed
in arrival order and reordered by sub-range, so nothing about the output depends
on it. The per-candidate merge this thread used to do is gone with the flat
accumulator; what is left is the gather and the flush.

(6) The `--restrict-candidates` allowlist was a `HashSet<u32>` probed once per
VERIFIED POSTING, inside the probe callback: a hash and a bucket load for every
fragment match of every peak of every scan, on all six probe sites. Candidate
ids are dense `0..ncand`, so it is now a bitset (`CandMask`): a shift and a load,
in one allocation of `ncand / 8` bytes instead of a hash table -- smaller as soon
as the allowlist holds more than about a sixty-fourth of the library, which is
the case the allowlist exists for (a gate-on pass over a 35M-83M candidate
immunopeptidomics library). Ids outside `0..ncand` are absent from the mask,
which is not a behaviour change: the probe only ever asks about ids it produced.

The other half of (6), slicing and zipping the posting arrays in the verify loop
to drop bounds checks, is NOT done: that loop is `FragIndex::emit_range` in
`matchers/fragindex.rs`, outside the one file this work may touch. The ppm
predicate is untouched either way, as the brief requires.

Verified on the AIF fixture (1,691,048 candidates, 465,806 scans, 152 windows)
against the base binary, both `--release`, on the plain path AND on the
allowlist path (`--restrict-candidates` over the plain run's 41,677 accepted
candidates, which the mask counts identically): `psms_extracted.parquet` is
BYTE-IDENTICAL in both, and `chromatograms.parquet` is equal as a table in both
(620,169 rows, every column, same order), its bytes differing only through the
row-group boundaries the sub-range flush moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flat CSR hit accumulator, branched from main, meets the fan-out divisor and
the library-wide ids this branch already carried. The accumulator's own two
hunks are taken from the rewrite; `sibling_bands` is reapplied on top, because
under `groups.parallel` every band in flight sizes its probing fan-out from the
whole pool, and 24 bands each asking for twice the pool is thousands of live
narrowed-bin caches. The in-file test's call site passes 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference records the environment reads whose name is not a literal by
source line; the CSR accumulator moved three of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit 0e02cff into main Sep 22, 2026
9 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant