Part of the MuMDIA developer documentation (see docs/README.md).
extract is the central stage of the pipeline. It takes the run-independent
spectral library (precursor and b/y fragment m/z, predicted intensities, iRT),
the per-candidate RT windows learned in rt-im-train, and the converted MS2
(optionally MS1) scans, and produces one apex-level PSM per surviving candidate
plus per-fragment chromatograms. Exact intensity-based scores are not computed
here; they are computed downstream in features from the chromatograms this
stage emits. extract is therefore an evidence-gathering and coarse-acceptance
stage, not a scoring stage.
The design is data-driven and peak-major (see the module doc comment,
extract.rs:1). Observed peaks probe an inverted fragment index, and a candidate
hypothesis is materialized only where fragment evidence actually collides with an
observed peak. Work scales with peak-candidate collisions, not with library size.
In wide-window DIA roughly 98% of fragment m/z collide within tolerance
(extract.rs:1479), so one observed peak typically matches many co-isolated
candidates; the peak-claim strategies decide how that shared intensity is
apportioned.
RT is applied as a per-candidate window post-filter (the documented Stage D part
2 fallback), and the MVP is 3D so the ion-mobility (IM) dimension is absent
(extract.rs:8, apex_im is always written None at extract.rs:2484).
| Path | Role |
|---|---|
rust/mumdia/crates/mumdia/src/stages/extract.rs |
The stage: accumulation, cascade, apex, chromatogram/MS1 emission, schema writing |
rust/mumdia/crates/mumdia/src/peaks.rs |
Pure top-K chromatographic peak enumerator (enumerate_peaks) for the retain_top_peaks path |
rust/mumdia/crates/mumdia/src/index.rs |
Library (SoA library + bucketed page_search, candidate_range, cand_frags) |
rust/mumdia/crates/mumdia/src/matchers/fragindex.rs |
FragIndex backend (build, probe_peak, candidate_range), the default matcher |
rust/mumdia/crates/mumdia/src/spectra.rs |
load_ms2 / load_ms1 / Ms1Scan scan loaders |
rust/mumdia/crates/mumdia-core/src/config.rs |
ExtractConfig, GateMode, PeakClaim, ClaimCues, MatcherKind |
rust/mumdia/crates/mumdia-core/src/constants.rs |
ISOTOPE_SPACING, ppm_bounds |
rust/mumdia/crates/mumdia-core/src/schema.rs |
artifact::PSMS_EXTRACTED, artifact::CHROMATOGRAMS schema ids |
Entry point: stages::extract::run(ExtractParams) (extract.rs:1300), wired from
the CLI in main.rs:535 (Cmd::Extract) and from the orchestrator in
stages/run.rs:326.
ms2(Parquet): converted MS2 scans, loaded viaload_ms2. On the fragindex matcher the MS2 and MS1 decodes run concurrently with the library load and the index build; the mass calibration JSON is read first because its learned tolerance is the one the index is built at, and its log lines and errors keep their old place after the spectra. A caller may lend the decodes (ExtractParams::scans): a grouped search lends both to every band, and the ungroupedrunlends the seed's MS2 alone (SharedScans::ms1 = None, so extract still decodes the MS1 concurrently with its library load, and its errors keep their place after the library's).runlends only when no DeepLC step ran in between andextract.matcherisfragindex: the bucketed matcher's library load builds a sorted copy of every fragment, and scans held from the seed would sit through that transient. EachMs2Scancarriesrt_seconds, an isolationwindow(lower_mz,upper_mz), and centroidedpeaks(mz,intensity). This stage applies no peak cap of its own: the MS2 peak budget is fixed at conversion time by--top-peaks-ms2(convert.rs:76-79) and everything below operates on whatever survived it.search_seed.top_n_peaks(config.rs:410-415) is a separate, non-destructive seed-probing limit and does not change the evidence available here.library_precursors+library_fragments(Parquet): loaded once intoLibrary::load_with(extract.rs:1306) atcfg.bucket_size.load_withskips building the bucketedpage_searchindex when the fragindex backend is selected (index.rs:73).run_windows(Parquet): per-candidate RT windows, columnscandidate_id,rt_pred_cal,rt_lo,rt_hi, read byread_run_windowsand scattered into the densert_lo/rt_hi/rt_calarrays indexed bycandidate_id. The decoded columns (28 bytes per row) are dropped as soon as the scatter is done, so only the 24-byte-per-candidate dense copy lives through the extraction. Candidates with no window row keep[-inf, +inf]andrt_cal = NaN, the same "calibration unavailable" sentinelrt-im-trainwrites; the NaN disables the Gaussian RT prior for those candidates (the prior requiresrt_cal > 0). A NaNrt_loorrt_hiis rejected with the row named, because a NaN bound would match every scan. An orchestrator hands the same three arrays over in memory (ExtractParams::rt_windows, fromrt_im_train::run_in_memory); extract uses them only when they were fitted on its ownlibrary_precursorspath, written to its ownrun_windowspath, and cover exactly its library's candidates (RtWindows::mismatch), and reads the file otherwise, so the arrays and every output are the same either way (docs/08, "The in-memory handoff to extract").ms1(optional Parquet): MS1 scans viaload_ms1(extract.rs:1350). When absent, all MS1 columns are null.mass_cal(optional JSON, the seed's<seed>.masscal.json): readsfrag_ppm_offsetandfrag_tol_ppm(extract.rs:1394), and optionally an m/z-dependent offset grid. Missing file = offset 0 andcfg.frag_tol_ppm.restrict_candidates(optional priorpsms.parquet): acandidate_idallowlist (extract.rs:1316) for the "gate first, then compete" workflow.
Column order and types from extract.rs:2480:
| Column | Type | Meaning |
|---|---|---|
candidate_id |
U32 | Library candidate id (sorted ascending in the file) |
peak_rank |
I32 | Chromatographic peak rank; 0 = selected apex. Ranks >= 1 exist only when promote_top_peaks > 1 (extract.rs:2205) |
apex_rt |
F64 | Selected apex RT (seconds) |
apex_im |
OptF64 | Always null (3D MVP) |
apex_intensity |
F32 | Full summed intensity of the apex scan group |
n_matched_fragments |
I32 | Distinct matched predicted fragments |
n_predicted_fragments |
I32 | Predicted fragment count for the candidate |
coelution_run |
I32 | Longest consecutive-scan co-elution run |
rt_pred_cal |
F64 | Calibrated predicted RT for this candidate |
precursor_mz |
F64 | Candidate precursor m/z |
charge |
I32 | Precursor charge |
label |
Str | "target" or "decoy" |
base_peptide_id |
U32 | Stripped-peptide id (for competition grouping) |
peptidoform |
Str | ProForma-lite string |
protein |
Str | Protein accession |
predicted_irt |
F32 | Library iRT |
contested_frac |
F64 | Intensity fraction lost to a better co-eluter (0 when two-pass off) |
ms1_isom1 |
OptF64 | MS1 intensity at (mono - spacing/z) |
ms1_mono |
OptF64 | MS1 monoisotopic intensity |
ms1_iso1 |
OptF64 | MS1 +1 isotope intensity |
ms1_iso2 |
OptF64 | MS1 +2 isotope intensity |
The rows are written as they are produced (PsmStream). Each time 65,536 rows have
accumulated (WRITE_TABLE_CHUNK_ROWS) they go to the table's TableWriter, which is
exactly the chunk sequence write_table cut the whole table into, so the file is
byte-identical to the one written at the end of the stage before 2026-09-25 while only
one chunk of rows is resident (at most about 6 GB less on an unbanded
immunopeptidomics run). the_streamed_psms_table_is_the_write_table_file checks the
bytes at 0, 1, 65,536, 65,537 and 131,075 rows, with every optional column group.
The one exception is emit_demix_features: its five columns are patched in after the
candidate loop, so the rows are kept whole and written at the end through
write_table_hashed, which produces the same chunks. Either way the table, like the
chromatograms, is hashed as it is written, so the report's content hash needs no
read-back (docs/03_io_layer.md, "Hash on write"). The stage logs extract: psms_extracted writer with the row count, whether the table was streamed and the
writer's busy time.
A chunk write that fails inside the candidate loop stops the loop, and the stage
then publishes neither table: the chromatogram writer is dropped unclosed
(close_chromatograms), so its temporary file is removed and a chromatograms table
already at the path stays as it was, as the psms_extracted one does. Publishing
the chromatograms there would put a truncated table beside an older
psms_extracted. Before the rows were streamed, the psms table was written after
the chromatograms were complete, so this case could not arise.
Conditional columns (default-off, added only when the knob is set so the production schema stays byte-identical):
emit_contested_features->contested_count_fracF64,apportioned_fracF64 (extract.rs:2505).emit_gate_diagnostics->gate_apex,gate_peak_spectral,gate_coelution,gate_spectral_entropy(all F32,extract.rs:2511).emit_demix_features->deconv_explained_frac,deconv_active,deconv_share,deconv_max_collinearity,shadow_kept_frac(all F32,extract.rs:2517), from the spectrum-centric NNLS demix at the apex scan.
The three soft-competition columns are all derived from the per-candidate
Contested accumulator (extract.rs:130), whose fields are won/lost
(summed observed intensity of shared peaks this candidate won or lost as
most-eluting claimant), n_won/n_lost (the corresponding peak-instance
counts), and apportioned (the co-elution-weighted proportional share the
candidate would keep under CoelutionProportional). The columns are (all 0 when
the two-pass path did not run):
contested_frac=lost / (won + lost)(extract.rs:2048): fraction of contested intensity lost to a better co-eluter.contested_count_frac=n_lost / (n_won + n_lost)(extract.rs:2056): fraction of contested fragment-peaks lost.apportioned_frac=apportioned / (won + lost)(extract.rs:2064): fraction of contested intensity the candidate retains under proportional apportionment (1 = keeps all; ~0 = a peak-borrower stripped by its co-eluting competitors).
Column order from extract.rs:2528:
| Column | Type | Meaning |
|---|---|---|
candidate_id |
U32 | Candidate id |
frag_name |
Str | Fragment name (b3, y7, or ms1_mono/ms1_iso1/ms1_iso2) |
frag_mz |
F64 | Theoretical fragment m/z (or precursor isotope m/z for MS1 rows) |
frag_obs_mz |
F64 | Intensity-weighted observed m/z (falls back to theoretical) |
predicted_intensity |
F32 | Library predicted intensity (0.0 for MS1 rows) |
rt |
LargeListF32 | Per-scan RT axis of the trace |
intensity |
LargeListF32 | Per-scan intensity, 0-filled on the window grid |
One row is emitted for every predicted transition (extract.rs:2095), so
features sees the full predicted set and can penalize a missing strong ion. A
never-observed fragment carries an empty trace (not a grid-length zero
vector) to avoid bloating the list column (extract.rs:2098). rt/intensity
are LargeList (64-bit offsets) because the total list-value count can exceed
the ~2.1B 32-bit ListArray offset ceiling when gates are opened wide
(extract.rs:2534). frag_name is written as a string but the library stores
fragment names as interned u16 dictionary ids, resolved back through
Library::frag_name_str (extract.rs:2137).
The table is written in 65,536-row groups (CHROM_ROW_GROUP_ROWS) with the
rt column PLAIN (TableWriter::with_plain_column("rt")): every fragment row
of a candidate carries the same axis, and snappy shortens those repeated PLAIN
runs far better than a dictionary's bit-packed indices. On the AIF
chromatograms that is 12.0% smaller than the planned dictionary (160.9 against
182.8 MB) with identical values. The other float columns keep the writer's
planned encodings (docs/03_io_layer.md, "Float encodings planned from the first
rows").
extract.chromatogram_schema = 2 (default 1) writes the same rows in the v2
layout (docs/15_data_dictionary.md, "Layout v2"): each candidate's axis once per
row group (rt_axis), each intensity trace from its first to its last nonzero
value (intensity_trimmed), and trace_offset / trace_len to rebuild it. Every
row passes through one chromatograms::Encoder in table order. The encoder counts
rows to know where each row group starts, so it and the writer take the row-group
size from one place (chromatograms::row_group_rows). The report and the run
manifest record schema version 2. Every table downstream of extract (features,
competed, scored, quant) is byte-identical to the v1 run's; only the chromatogram
table and its content hash change. It is opt-in because a reader outside the
engine, or an engine binary from before v2, reads rt and intensity, which a v2
table does not have. Such a reader stops at the missing column rather than
misreading the table, and mumdia::chromatograms::rewrite converts the table back
to v1 for it.
To validate v2 on a new dataset, run the same configuration twice, once with
extract.chromatogram_schema = 2, and compare features.parquet,
psms_competed.parquet, psms_scored.parquet and the three quant tables with cmp.
ci/smoke.sh (arm 4e) does this on the fixture, ungrouped and grouped, and once more
with MUMDIA_CHROM_ROW_GROUP_ROWS=1. That environment variable is a test knob: it
sets the rows per chromatogram row group (default 65,536), which moves the seams of the
table and nothing downstream, so at 1 every row is its own group.
Written only when retain_top_peaks > 1 produced rows (extract.rs:2544), one
row per (candidate, peak). Columns: candidate_id U32, peak_rank I32,
apex_rt F64, start_rt F64, end_rt F64, evidence_count F64, area F64.
These peaks are not scored and do not affect FDR; they are candidate peaks
for an offline peak-selection model (see below). This sidecar is distinct from
promote_top_peaks, which instead emits extra psms_extracted rows carrying
peak_rank >= 1.
Each artifact (psms_extracted and chromatograms, but not the top-K peaks
sidecar) also gets an <artifact>.report.json (extract.rs:2569) recording the
row count, blake3 content hash, stage name, schema name+version, and
elapsed_ms. The params object (extract.rs:2577) carries frag_tol_ppm
(nominal), effective_frag_tol_ppm (post mass-cal), frag_ppm_offset,
presence_min_fragments, presence_min_coelution, gate_min_score, gate_mode,
gate_coelution_min, and scan_window. The stats map (extract.rs:2562)
carries accepted (the accepted-candidate count) and scan_window.
model_identity is None (no model in this stage).
The control flow of run (extract.rs:1300) is: load library and windows, load
scans, build the matcher, accumulate peak-candidate hits (peak-major), then per
candidate run the acceptance cascade and apex selection, emit chromatograms and
MS1 XICs, and write the tables.
The fragment tolerance and a systematic ppm offset come from mass_cal
(extract.rs:1394). The offset is applied as a divisor, not a subtraction:
MassOffset::factor_at(mz) returns 1 + ppm(mz)*1e-6 (extract.rs:640) and
every observed peak m/z is corrected by q_mz = peak.mz / factor_at(peak.mz)
before probing (extract.rs:705). The offset may be a single scalar ppm or, when
the seed fitted one, an m/z-dependent grid that is linearly interpolated and
clamped at the ends (extract.rs:632), so the correction is not necessarily
constant across the m/z range.
q_mz is then binned, which is a ln(). accumulate_groups fills a task-local
Vec<(f64, u32)> with one scan's (q_mz, bin) and then probes from it, rather than
computing both inside the probe per peak. The scratch is a few KB, refilled per scan
and reused, and it makes exactly the same number of ln() calls the probe made. It
still pays, because a separate pass takes the ln() off the dependency chain that
the bin-cache load and then the posting loads hang from. Measured on
rust/mumdia/crates/mumdia/tests/bench_fragindex.rs, which is #[ignore]d and has
to be asked for:
bench_wall, extract's own task shape over rayon, 32 threads, min of 9, two passes: -11.7 / -6.4% on the default shape (16 windows in flight, 4 tasks each), -15.1 / -17.9% with smaller windows (1-2 tasks each), -17.4 / -18.6% with one wide all-ion window in the batch.bench_probe, one thread, m/z-sorted peaks: -9.1 / -12.7 / -6.7% for a narrow / medium / wide candidate window.
The scratch carries q_mz and not just the bin, so factor_at still runs once per
peak. That matters under search_seed.mass_cal_loess, where the offset is a ~74
point grid and factor_at is a binary search plus an interpolation: 8.3 ns/peak
against 0.725 for the scalar default. A bin-only scratch, which recomputes q_mz in
the peak loop, turned that arm from -1.7% into +5.6%.
A shared per-window buffer was tried twice and reverted twice. The m/z half went
first (8 B/peak, +200 MB of peak RSS on the AIF fixture, 317 MB with the whole run in
one batch, no measurable time). The ln() bin half was then hoisted into a flat
u32 buffer per window, shared by that window's sub-range tasks, and reverted in
turn: the buffer has to be filled before rayon::scope starts, so it turns per-task
work that was SPREAD ACROSS THE POOL into serial work on the calling thread, and the
default shape has nowhere near enough tasks per window to pay that back.
tasks_per_window is (2 * threads).div_ceil(groups.len()), which is 4 at 32
threads and the default 16 windows in flight, so the fill costs one pass per peak to
save three thirty-seconds of one; break-even needs tasks_per_window >= threads,
i.e. a batch of one or two windows. Measured, same harness:
The two numbers in each cell are two independent passes of min-of-9, on a machine that was quieter for the first; read the direction and the sign, which are the same in both, rather than the third digit.
bench_wall, min of 9, 32 threads |
in probe | per-scan scratch | shared buffer, serial fill | shared buffer, filled on the pool |
|---|---|---|---|---|
| default: 16 windows, 4 tasks each | 51.3 / 62.8 ms | -11.7 / -6.4% | +8.5 / +22.1% | -16.6 / -8.5% |
| 16 windows, 1-2 tasks each | 19.9 / 43.9 ms | -15.1 / -17.9% | +38.9 / +30.0% | -10.3 / -15.3% |
| one wide window, 49 tasks | 167.9 / 189.3 ms | -17.4 / -18.6% | -23.7 / -19.0% | -26.2 / -22.1% |
Filling it on the pool instead removes the regression, and it is still not worth it:
against the plain scratch it is -5.6 / -2.3% on the default shape and -10.7 / -4.3%
on the all-ion shape, and it LOSES 5.7 / 3.2% on the second row, for 4 bytes per peak
of every window in flight. The in-flight fraction is windows_in_flight / n_windows
-- 16 of 152 on the AIF benchmark, about 17 MB there, but the whole run's peaks on an
acquisition with 16 windows or fewer, and one copy per band under groups.parallel,
in extract, the tallest stage in the pipeline.
End to end, mumdia extract on the real AIF run (152 windows, 1.69M candidates,
465,806 MS2 scans, 32 threads, i9-13900KS, three binaries interleaved over 24 / 21 /
24 reps), timing the accumulation phase between the loaded; probing peaks and
candidates with evidence log lines so the 310 MB spectra read is not in the number:
| min | mean of 3 fastest | mean of 5 fastest | median | |
|---|---|---|---|---|
| in probe (before either change) | 3,274 ms | 3,360 ms | 3,419 ms | 4,233 ms |
| shared buffer, serial fill | +14.0% | +13.2% | +14.0% | +9.3% |
| per-scan scratch (shipped) | -0.3% | -1.9% | -2.3% | -4.5% |
The machine is noisy (reps spread 2.5x, and it is a hybrid CPU), so read the sign and
the ordering, not the third digit. The buffer's regression is the same size the model
predicts: one serial pass over the in-flight peaks, 39.6M x 5.4 ns = 430 ms, against a
3.3 s accumulation phase. The scratch's gain is smaller end to end than the -6 to -18%
bench_wall measures on the probe loop, because the accumulation phase also runs the
RT filter, the claim arbitration, the hit store and the counting sort, none of which
this touches.
All 69 runs wrote a byte-identical psms_extracted.parquet and
chromatograms.parquet (41,677 accepted in every one), and peak RSS did not separate
the arms (2,266-2,499 MB across all three).
candidate_range_split_reproduces_the_unsplit_accumulation runs the fixture at two,
eight and sixteen threads and compares the accumulation hit for hit, and ci/smoke.sh
wrote 127 byte-identical artifacts against a binary built from the parent commit (the
five that differ are logs and a work-directory path inside planted.json).
Two matcher backends dispatch through Prober::probe (extract.rs:57):
MatcherKind::Fragindex(default): probes binsbin-1 ..= bin+1of the whole-library log-space geometry at the learned tolerance, verifies each posting with the exact f64 ppm predicate, and carries the true generating fragment ordinal inpost_frag. On the default streamed path each probing task builds aLocalIndexover its own candidate sub-range and the globalFragIndexis not built; the two-pass arbitration andemit_demix_features, which probe arbitrary candidate windows, build the global index as before (docs/06, "Task-local indexes"). The postings a task sees, and their order, are the narrowed global index's, so the outputs are the same either way (candidate_range_split_reproduces_the_unsplit_accumulationcompares the two probes callback for callback on every task shape of its fixture and the two accumulations hit for hit).- Bucketed fallback (
MatcherKind::Bucketed):Library::page_search(index.rs:350) resolves the fragment ordinal by nearest stored m/z viaLibrary::local_frag_index(index.rs:325). This is a semantic difference for fragments at sub-f32-identical m/z (extract.rs:48). The bucketed index is only built when this backend is selected, so choosing it is the only reason to pay for it (extract.rs:1302).
Library::candidate_range / FragIndex::candidate_range (index.rs:341,
fragindex.rs:154) give the half-open candidate-id range [lo, hi) whose
precursor m/z falls in an isolation window, exploiting that the library is sorted
by precursor m/z. This is what makes a per-window probe cheap.
The accumulator acc: HashMap<u32, Vec<Hit>> (extract.rs:1435) maps each
candidate to the observed hits it collected. A Hit is {scan, frag, inten, obs_mz}, 16 bytes: scan is the index of the scan in the stage's scans slice,
whose rt_seconds is the hit's RT (hit_rt), and obs_mz is the peak's own f32
m/z. Until 2026-09-25 it was 24 bytes, carrying a copy of the scan RT as an f64 and
the f32 m/z widened to an f64; both are recovered exactly, so no value moved. Code
that depends on the order of hits sorts and groups them on the looked-up RT, never on
the scan index, and the two-pass elution profile still keys on the RT's bits, so two
scans that share an RT behave as they always did. Entries are created lazily on the
first collision.
There are three accumulation paths:
- Streamed, single-pass (
accumulate_groups): the default, used with the fragindex matcher whenever the path is not two-pass, with or without arestrictallowlist. Scans are grouped by isolation window, a batch ofextract.windows_in_flightwindows is probed at a time, and each window is cut on a shared grid of candidate sub-ranges; one task probes one (sub-range, window) pair through its ownLocalIndex. A sub-range is flushed to the per-candidate pass as soon as every window of the batch has reported, so the accumulator holds the windows in flight rather than the run. A flush hands the pass at mostCAND_CHUNKcandidates. When every candidate of a flush sits in one window's store as its only segment, which is the common case (a sub-range reached by one window, no leftovers from the batch before), the flush lends slices of that store directly (single_run_span,flush_below); otherwise the candidates are gathered and concatenated window by window into a reusable buffer first. The zero-copy flush hands over exactly the batches the gather would build, because each flush becomes one chromatogram chunk and the parquet page framing follows the chunk sizes (the_zero_copy_flush_hands_over_the_batches_the_gather_builds). Its effect on the AIF run (1.8M candidates, standalone extract, commit against its parent, interleaved on an i9-13900KS) is inside run-to-run noise: +0.4% wall at 32 threads (11 pairs), +1.9% and -2.7% at one thread (12 and 5 pairs), peak working set within 10 MiB. The copy it removes is at most about 1% of extract by the survey's estimate, so AIF cannot resolve it; it was not measured on a larger run. - Serial single-pass: the fallback of the bucketed matcher. It honors every
non-co-elution
peak_claimstrategy. - Two-pass co-elution (
extract_twopass_windows,extract.rs:787): used whenpeak_claimis one of theCoelution*variants oremit_contested_featuresis set (extract.rs:1443). Like the single-pass parallel path it partitions scans by isolation window and fans out over the ~150 windows with rayon (extract.rs:782), so it stays parallel even with arestrictallowlist. Pass 1 builds each candidate's per-scan elution profile (summed matched intensity,extract.rs:872). Pass 2 arbitrates each shared peak to the claimant with the highest competition weight at that scan (extract.rs:1200), tracks won/lost/apportioned contested intensity inContested(extract.rs:130), and reassigns intensity per the co-elution claim variant (extract.rs:1236). Thereassignflag (extract.rs:1557) is true forCoelutionWinner,CoelutionProportional,CoelutionWinnerMargin,CoelutionDemix, andCoelutionShadow, and forCoelutionMultiCueonly whenclaim_cues.reassignis also set:CoelutionMultiCueships non-destructive, feeding the contested/apportioned features only. When the two-pass path is triggered byemit_contested_featuresalone (a non-co-elutionpeak_claim), pass 2 still computes the contested statistics but the returned accumulation is the base full-intensityacc1, not the reassignedacc2(extract.rs:1274). Soemit_contested_featuresadds the soft-competition features without altering any extracted intensity.restrictis honored inside both passes via the push closure (extract.rs:853,extract.rs:907).
Per-peak claim strategies (PeakClaim, config.rs:132; applied in the serial
single-pass loop at extract.rs:1500 and mirrored in the parallel path at
extract.rs:718):
None: every matching candidate gets the full peak intensity (legacy default).WinnerPredictedIntensity: only the claimant with the highest predicted intensity keeps the peak; ties break by lowestcandidate_idfor determinism (extract.rs:1501).Proportional: split the peak by predicted-intensity share (extract.rs:1518).CoelutionWinner/CoelutionProportional/CoelutionWinnerMargin: two-pass variants keyed on elution-profile height, arbitrated inextract.rs:1236.CoelutionWinnerMarginonly strips a peak from the runner-up when the top eluter dominates bypeak_claim_margin(extract.rs:1218); otherwise the peak stays shared, avoiding stripping real peptides at ambiguous peaks.CoelutionMultiCue: the per-claimant weight is the elution-profile height multiplied by the composable cues enabled inClaimCues(config.rs:192): sub-tolerance m/z proximity, RT prior, MS1 precursor support, and so on (claim_cue_multiplier,extract.rs:163). Every cue defaults to 1.0, so with no cue enabled the arbitration reduces bit-identically toCoelutionWinner(extract.rs:1184).CoelutionDemix: at each scan (subject todemix_scan_stride) the co-isolated candidate-by-fragment design matrix is solved by ridge NNLS (demix_solve_scan,extract.rs:279) and each shared peak is split in proportion to the joint deconvolution rather than stripped winner-take-all. Always destructive.CoelutionShadow: solver-free shadow subtraction; each claimant's peak intensity is reduced by the portion other co-eluters explain.
These last three, and the emit_demix_features columns they share machinery
with, are recent and unvalidated. Treat them as research paths, not tuning knobs.
acc is drained into (cid, hits) in sorted candidate_id order (cand_hits
at extract.rs:1651) for determinism, then each candidate is processed in
parallel via into_par_iter().map(...) returning Vec<CandOut>
(extract.rs:1721). Each candidate's work depends only on its own hits plus
read-only library/window/MS1 data, so collect() in the sorted order reproduces
the serial push order byte-for-byte (extract.rs:1645). The map returns a Vec
rather than an Option because promote_top_peaks > 1 can emit more than one row
per candidate.
The cheap-to-expensive acceptance cascade, in order:
- Distinct-fragment presence (tier b): distinct matched fragments must be at
least
presence_min_matched(extract.rs:1727), else the candidate is dropped before any grouping work.presence_min_matched,presence_min_fragments, andpresence_min_coelutionare each floored at 1 via.max(1)(extract.rs:1727,extract.rs:1926,extract.rs:1915), so a configured 0 still requires at least one fragment. - Scan grouping: hits are rt-sorted (on the looked-up scan RT) and grouped
into scan groups, deduping the same fragment within one scan by max. The groups
are one dense
ScanGroupsper candidate: the group RTs, agroups x widthf32 value array and a presence bitmask of the same shape,widthbeing one past the largest fragment ordinal among the candidate's hits. Until 2026-09-25 each group was aBTreeMap<frag, intensity>; the dense form answers the same questions (count, fragments ascending, lookup, the values summed in ordinal order through the sameIterator::sum) with the same values, so the f32 apex sum is deterministic and unchanged, and it allocates three buffers per candidate instead of a tree node per occupied group (dense_groups_answer_what_the_trees_answered). Becausewidthis one past the largest observed ordinal rather than the number of observed fragments, the buffers holdgroups x (max observed ordinal + 1)f32 values plus the presence words, at mostgroups x n_predicted_fragmentsvalues since ordinals are candidate-local. That can exceed the traces the candidate emits: a candidate that observed only ordinal 11 holds 12 values per group against one trace, and in sparse (non-grid) mode a trace covers only the groups where its fragment occurs. Measured on the AIF run against the tree form (standalone extract, interleaved, i9-13900KS), the change is inside run-to-run noise: -1.5% wall at 32 threads (11 pairs, CPU time -0.3%), -1.5% and -3.7% at one thread (12 and 5 pairs), peak working set within 10 MiB. The survey estimated at most 1-2% of extract; the saving grows with the scan groups per candidate, which AIF exercises little, and it was not measured on a larger run. Presence is a bit rather than a sentinel value, and a fragment first seen by a later hit of the same group starts from 0.0 before the max, exactly as the tree'sentry(..).or_insert(0.0)did. - Acquisition-scan grid projection (
extract.rs:1782): whenemit_window_gridis on, the sparse groups are projected onto the full set of covering-window scans inside the RT window, so missing acquisition scans count as 0 and break a co-elution run rather than being invisible. - Apex selection (see below).
- Co-elution run: the longest run of consecutive scan groups with at least
presence_min_coelutionfragments present (extract.rs:1911). - Acceptance (tier c) (
extract.rs:1926): reject unless distinct fragments=
presence_min_fragments,best_run >= scan_window(thefixed_scan_windowfloor),best_run >= min_coelution_run, andmatched_fraction >= min_matched_fraction.matched_fractionisdistinct / n_predicted(extract.rs:1925); it is the primary symmetric discriminator (real peptides match a large fraction, chimeric and decoy matches a small fraction alike, keeping the target-decoy null valid). - MS1 isotope evidence (
extract.rs:1948) computed before the Pearson gate so it can rescue a candidate.ms1_supportrequires a present mono and a +1/mono ratio in[0.1, 1.5](extract.rs:1960). - Pearson gate (tier d, optional) (
extract.rs:2004): only whengate_min_score > 0.0. It thresholds the score of the activeGateModeand only the active score is computed (the closures atextract.rs:1989are lazy). Ifms1_rescueis set, a gate failure is overridden when the candidate has MS1 support and enough matched fragments (extract.rs:2020).
The two presence thresholds (presence_min_matched at tier b,
presence_min_fragments at tier c) are the main coupling between this stage and
convert. Because the --top-peaks-ms2 truncation is baked into the spectra
artifact and extract adds no cap of its own, a cap that removes most MS2 peaks
removes the fragment evidence these thresholds count, and the candidate is
rejected before any gate or score is reached. Measured on a 50-window Orbitrap
DIA run, --top-peaks-ms2 300 discarded 78.6% of MS2 peaks; a mumdia audit
ladder restricted to peptides that an external library-free search confirms are
present in the run showed 49,105 of 78,782 (62.3%) stopping at
candidate_generated with rejection code DID_NOT_SURVIVE_EXTRACTION (rejection.rs:62).
Only 5,380 were lost to FDR and 355 to competition. Replaying those candidates
against the uncapped artifact recovered 41,948 of the 49,105 (85.4%), which is
what identifies the cap as the cause.
DID_NOT_SURVIVE_EXTRACTION (named NO_PEAK_GROUP until docs/29 #16) does not mean
"never assembled presence_min_fragments distinct fragments", although this document
previously said so. audit.rs reads a
per-candidate audit table that extract does not write -- emit_candidate_audit
is unwired -- so the reason map is always empty and the _ => DidNotSurviveExtraction
catch-all absorbs presence failures, matched-fraction failures and every
extraction-gate rejection alike. Read it as "did not survive extract". The
attribution to the peak cap above stands on the replay experiment, not on the
label.
When DID_NOT_SURVIVE_EXTRACTION dominates an audit ladder, check the conversion cap before
tuning the presence thresholds or gates here; see docs/04_convert.md for the
peak census and the cap dose-response.
The apex is chosen among scan groups whose smoothed distinct-fragment count qualifies, then by a per-scan score:
- Rolling-window count (
apex_count_window): a centered rolling sum of the per-scan distinct-fragment count (extract.rs:1809). It is deliberately a sum, not a mean: edge truncation makes interior positions accumulate more, center-weighting the apex toward the RT-window centre (a mild RT prior). Window 1 reproduces exact per-scan counts. Only scans with smoothed count>= maxc - apex_count_tolqualify (extract.rs:1848). An opt-in Gaussian smoother over the same profile is available viaapex_gaussian_sigma_scans(default 0.0 = off,extract.rs:1813). - Signature ions (
apex_top_fragments, default 3 viak_sig,extract.rs:1860): the top-K predicted fragments, so a bright interferent on a non-signature ion cannot define the apex. - RT prior (
apex_rt_prior_s): when > 0 andrt_cal > 0(extract.rs:1855), each qualifying scan's score is multiplied byexp(-0.5*((rt - rt_cal)/sigma)^2)(extract.rs:1886). - Scoring mode (
apex_evidence_rank,extract.rs:1890): when true, the score isn_frag + sig_sum/(sig_sum+1)times the prior, so the number of distinct co-eluting predicted fragments dominates and observed signature intensity only breaks sub-integer ties. This is interference-resistant in wide-window DIA because intensity is chimeric. When false (default), the legacysig_sum * prioris used (extract.rs:1902), bit-identical to the pre-feature behaviour.
apex_intensity reported is the full summed intensity of the winning scan group
(extract.rs:1907), not the signature-only score.
Spectral-agreement scores share a helper peak_window (extract.rs:523) that
finds the contiguous elution-peak scan range [lo, hi] around the signature-ion
apex (scans above 10% of the reference apex height), returning None when there
are fewer than 3 scans or no reference signal. This restriction matters: over the
full wide extraction window the traces are mostly zeros and any correlation is
noise, so the co-elution and spectral gates are only meaningful across the
elution peak itself (extract.rs:520). GateMode scores:
ApexPearson: Pearson of observed-vs-predicted intensities at the single apex scan (extract.rs:1989); one chimeric scan can dominate it.PeakSpectral:peak_spectral_score(extract.rs:564) correlates the peak-summed observed spectrum (each fragment integrated over the peak scans) with predicted intensities, averaging out a single interfered scan.SpectralEntropy: Li spectral-entropy similarity of the sqrt-transformed apex spectrum via the sharedfeatures::entropykernel (extract.rs:1995).Coelution:coelution_gate_score(extract.rs:590), the predicted-intensity-weighted mean Pearson of each matched fragment's XIC to the signature-ion reference profile, restricted to the elution peak. Orthogonal to intensity agreement (temporal, not shape).Combined: requirespeak_spectral >= gate_min_scoreandcoelution >= gate_coelution_min(extract.rs:2016).
All four diagnostic scores are computed for every accepted candidate only when
emit_gate_diagnostics is set (extract.rs:2035); otherwise they are zeroed and
not written, so the default chain pays no extra cost.
Per-fragment observed m/z is an intensity-weighted mean per fragment
(extract.rs:2073) for mass-accuracy features. Every predicted transition emits a
chromatogram row (extract.rs:2095); observed fragments carry the grid-sampled
(or rt-sorted) trace, absent ones an empty trace. When MS1 is present and grid
mode is on, three MS1 isotope XICs (ms1_mono, ms1_iso1, ms1_iso2) are
sampled on the same scan grid via nearest-MS1-scan lookup (extract.rs:2151),
using ISOTOPE_SPACING / charge (constants.rs:22, value 1.003354835) for the
isotope offsets and sum_near (extract.rs:495) to integrate within
prec_tol_ppm. The apex MS1 isotope intensities (ms1_isom1/mono/iso1/iso2)
are the per-PSM columns, taken from the nearest MS1 scan to the apex RT
(extract.rs:1957).
The chromatogram rows of each candidate chunk are handed to one writer thread
through a two-slot channel, so encoding overlaps extraction. The stage logs one
extract: chromatogram writer line with chunks, send_blocked_ms (time the
extraction side waited for a free channel slot, the column build excluded) and
writer_busy_ms (time the writer thread spent in write_cols, plus the final
close, which runs on the extraction side once the loop has ended). A
send_blocked_ms near zero says the serial encoder does not bound the stage; one
that approaches the stage's accumulation time says it does, and that a parallel
column encoder (survey item R2) would pay off here. The line is log output only.
Two independent knobs consume the enumerator, and they must not be confused:
retain_top_peaks writes the unscored .peaks.parquet sidecar, whereas
promote_top_peaks emits additional psms_extracted rows with peak_rank >= 1
that do flow downstream. Both default to 1 (off).
When retain_top_peaks > 1 and the candidate has groups (extract.rs:2173), the
per-scan distinct-fragment count profile (count_prof, extract.rs:2176) is
passed to crate::peaks::enumerate_peaks (peaks.rs:52) with k = retain_top_peaks, bound_fraction = 1/3 and min_prominence_frac = 0.1
(extract.rs:2177). enumerate_peaks is a pure, side-effect-free function
(peaks.rs:1):
- An empty profile,
k == 0, or an all-non-positive profile returns an empty vector (peaks.rs:58,peaks.rs:63). - Local maxima (
peaks.rs:71): indexiqualifies when its value is> 0and>= prom_floor(the prominence floormin_prominence_frac * global_max,peaks.rs:66, tested atpeaks.rs:74), is strictly greater than its left neighbour (or is the left edge), and is>=its right neighbour (or is the right edge). The strict-left / non-strict-right rule makes a flat plateau register once, at its left edge; edges count as maxima against their single neighbour. - Boundary walk (
peaks.rs:87): from each apex, walk left and right, stopping when the profile drops belowbound_fraction * apexor turns back upward (a valley);areais the integrated profile over[start_idx, end_idx]. - Dedup + rank (
peaks.rs:123): peaks are sorted strongest-first byarea, then byapex_intensity, then by earliestapex_idx(peaks.rs:125); a maximum whose apex falls inside an already-kept peak's[start_idx, end_idx]envelope is dropped (peaks.rs:140); the firstksurvivors are kept and assignedrank0..k-1 (peaks.rs:148).
enumerate_peaks(.., k=1, ..) returns the single global-argmax peak, so callers
can adopt it incrementally. The retained peaks rank by co-eluting fragment
breadth (count), not intensity, per finding A6 in
docs/18_findings_and_decisions.md (intensity is chimeric in DIA). Back in
extract, each returned PeakGroup is mapped to a sidecar row
(extract.rs:2179): apex_rt/start_rt/end_rt are the RTs of the
apex/start/end scan groups, evidence_count is the distinct-fragment count at
the apex group (groups[pk.apex_idx].1.len()), and area is pk.area. At the
default promote_top_peaks = 1 the PSM table still reports only the single
selected apex, so FDR is unaffected and the sidecar peaks are unscored candidate
peaks for an offline peak-selection model (extract.rs:1700).
promote_top_peaks > 1 (extract.rs:2238) is the separate, opt-in path that does
change FDR: it re-enumerates the same count profile (extract.rs:2252), filters
alternates by alt_peak_min_area_frac and alt_peak_min_separation_s plus the
presence_min_matched floor (extract.rs:2292), and pushes up to
promote_top_peaks - 1 extra CandOut rows with peak_rank >= 1
(extract.rs:2297). Because these become real scored rows, enabling it changes
the training/FDR population and needs entrapment validation, not a count check.
| Name | file:line | What it does |
|---|---|---|
run |
extract.rs:1300 |
Stage entry point; orchestrates load, accumulate, cascade, write |
ExtractParams |
extract.rs:83 |
Input path bundle + config + config hash |
Hit / hit_rt |
extract.rs |
One observed hit (16 bytes): scan, frag, inten, obs_mz; hit_rt looks its RT up in scans |
Contested |
extract.rs:130 |
Per-candidate two-pass contested-peak stats: won/lost intensity, n_won/n_lost peak counts, apportioned share |
CandOut |
extract.rs:1656 |
Per-candidate parallel-map result (PSM row + chrom rows + peaks); one candidate may yield several |
Prober / Prober::probe |
extract.rs:38, extract.rs:57 |
Dispatch a peak probe to fragindex or bucketed backend |
MassOffset::factor_at |
extract.rs:640 |
Scalar or m/z-interpolated ppm divisor for observed m/z |
claim_cue_multiplier |
extract.rs:163 |
Composite per-claimant cue weight for CoelutionMultiCue |
demix_apex_scan / demix_solve_scan |
extract.rs:259, extract.rs:279 |
Spectrum-centric ridge-NNLS demix at one scan |
demix_features_for |
extract.rs:414 |
Per-candidate demix features from a solved scan |
extract_accumulate_windows |
extract.rs:662 |
Parallel single-pass per-window accumulation |
extract_twopass_windows |
extract.rs:787 |
Parallel two-pass co-elution arbitration + contested stats |
peak_window |
extract.rs:523 |
Elution-peak scan range around the signature apex (10% height) |
peak_spectral_score |
extract.rs:564 |
Peak-integrated observed-vs-predicted Pearson |
coelution_gate_score |
extract.rs:590 |
Weighted mean XIC-to-reference co-elution Pearson |
nearest_index |
extract.rs:139 |
Binary search for nearest RT in a sorted array |
sum_near |
extract.rs:495 |
Sum intensities within a ppm window (m/z-sorted) |
enumerate_peaks |
peaks.rs:52 |
Pure top-K peak-group enumerator |
PeakGroup |
peaks.rs:22 |
One enumerated peak (apex/start/end idx, apex intensity, area, rank) |
Library::load_with |
index.rs:73 |
Load the SoA library, optionally skipping the bucketed index |
Library::candidate_range |
index.rs:341 |
Candidate-id range for an isolation window |
Library::cand_frags |
index.rs:312 |
Fragment m/z, predicted intensity, and interned name-id slices |
Library::page_search |
index.rs:350 |
Bucketed-backend fragment probe |
FragIndex::probe_peak |
fragindex.rs:169 |
Verified postings for one observed peak in a candidate range |
FragIndex::probe_peak_win_binned / bin_of |
fragindex.rs:225 / :197 |
The same probe with the bin handed in, which is how the per-scan setup scratch probes |
All fields live in ExtractConfig (config.rs:516); defaults in
config.rs:683. The config was pruned of dead fields in the "clean-slate
declutter" commit: extract.scan_window_mode (and the ScanWindowMode enum),
extract.scan_scale, extract.k_select, extract.max_fragment_charge, and
extract.tolerance_regime were removed. Do not reintroduce them.
| Field | Default | Effect |
|---|---|---|
fixed_scan_window |
3 | Minimum co-elution run length (scan_window floor); .max(1) at extract.rs:1591 |
frag_tol_ppm |
20.0 | Fragment match tolerance (overridden by mass_cal) |
prec_tol_ppm |
20.0 | MS1 isotope integration tolerance |
presence_min_matched |
3 | Tier-b: minimum distinct matched fragments (extract.rs:1727) |
presence_min_fragments |
3 | Acceptance: minimum distinct fragments (extract.rs:1926) |
presence_min_coelution |
2 | Min simultaneously-present fragments to extend a run (extract.rs:1915) |
gate_min_score |
0.2 | Pearson/gate threshold; 0 disables the gate. Measured better than 0.6 for both rescorers under the current defaults; see docs/18. Renamed from min_frag_corr, which is not a correlation under any gate_mode except by coincidence |
min_matched_fraction |
0.0 | Acceptance: min matched/predicted fraction (default off) |
apex_top_fragments |
0 | Signature-ion count for apex; 0 -> default 3 (extract.rs:1860). Config marks it superseded by apex_count_tol, kept for compat (config.rs:545) |
apex_rt_prior_s |
0.0 | Gaussian RT-prior sigma on apex tiebreak; 0 = off |
apex_count_tol |
1 | Count slack for qualifying apex scans |
apex_count_window |
1 | Rolling-sum width for the count profile; 1 = no smoothing. Window 5 cut AIF apex misassignment (median |dRT| 131s -> 9s) |
apex_gaussian_sigma_scans |
0.0 | Opt-in Gaussian apex smoother; 0 = off (config.rs:572) |
apex_evidence_rank |
true | Breadth-of-evidence apex. The legacy signature-intensity apex silently falls back to the lowest-RT qualifying scan when none of the top-K predicted fragments is observed |
emit_window_grid |
true | Zero-filled full-window-grid chromatograms |
bucket_size |
8192 | m/z bucket size (power of two) |
peak_claim |
None |
Shared-peak apportionment strategy (PeakClaim, config.rs:132) |
claim_cues |
all off | Composable cue weights for CoelutionMultiCue (ClaimCues, config.rs:192); claim_cues.reassign is what makes MultiCue destructive |
peak_claim_margin |
2.0 | Dominance factor for CoelutionWinnerMargin |
emit_demix_features |
false | Adds the five deconv_*/shadow_* columns; forces the apex demix solve (config.rs:592) |
demix_lambda |
1.0 | Ridge term of the NNLS demix (config.rs:595) |
demix_max_candidates |
64 | Column cap of the per-scan demix design matrix (config.rs:598) |
demix_scan_stride |
1 | Solve the demix every Nth scan (config.rs:606) |
emit_contested_features |
false | Adds contested_count_frac/apportioned_frac; forces the two-pass path |
matcher |
Fragindex |
Fragment-matcher backend |
min_coelution_run |
0 | Extra co-elution-run floor (0 = off; scan_window still applies) |
ms1_rescue |
false | Rescue Pearson-gate failures with MS1 isotope support |
retain_top_peaks |
1 | K>1 writes the .peaks.parquet sidecar (unscored) |
promote_top_peaks |
1 | K>1 emits extra psms_extracted rows with peak_rank >= 1; does change FDR (config.rs:645) |
alt_peak_min_area_frac |
0.10 | Alternate peak must reach 10% of the rank-0 area (config.rs:649) |
alt_peak_min_separation_s |
5.0 | Alternate apex must sit >= 5 s from the rank-0 apex (config.rs:653) |
emit_candidate_audit |
false | Candidate-audit sidecar (diagnostic) |
emit_gate_diagnostics |
false | Adds the four gate_* diagnostic columns |
gate_mode |
ApexPearson |
Which score gate_min_score thresholds (GateMode, config.rs:735) |
gate_coelution_min |
0.5 | Second threshold for GateMode::Combined |
Note: emit_candidate_audit is a declared knob but the candidate-audit write is
not present in this extract.rs; the audit ladder is produced by the separate
mumdia audit command. Treat the in-stage audit as unwired here.
Every knob below is default-off, so the production PSM schema and per-candidate
compute stay byte-identical unless the knob is set. None has an end-to-end
identification-gain measurement yet; each must pass the entrapment-holdout gate
on >=2 datasets before being enabled by default (see the benchmark-gated list in
CLAUDE.md and docs/20_sensitivity_and_quantification_playbook.md). Two of the
knobs live in other stages' configs (search_seed, rt_im_train) but are listed
here for one index.
| knob | extra columns / artifact | validation status |
|---|---|---|
extract.retain_top_peaks (config.rs:635, default 1) |
<out_psms>.peaks.parquet sidecar, unscored, written only when K>1 (extract.rs:2544); no PSM columns |
ID loop not closed (sidecar peaks are unscored); gate pending |
extract.promote_top_peaks (config.rs:645, default 1) |
extra psms_extracted rows with peak_rank >= 1 (extract.rs:2297); no new columns |
not schema-neutral in rows: changes the scored population and therefore FDR; gate pending |
extract.emit_candidate_audit (config.rs:658) |
none in extract.rs (unused there); in run it gates the audit stage -> candidate_audit.parquet (run.rs:428) |
diagnostic; no ID effect |
extract.emit_gate_diagnostics (config.rs:673) |
4 F32 cols gate_apex, gate_peak_spectral, gate_coelution, gate_spectral_entropy (extract.rs:2511) |
diagnostic; no ID effect |
extract.apex_evidence_rank (config.rs:667) |
none; changes the apex-selection score (extract.rs:1890) |
now the default, promoted on correctness grounds rather than a count (docs/25 section 2): the legacy score cannot distinguish "no signature fragment observed anywhere" from "the earliest qualifying scan is the apex" |
extract.apex_gaussian_sigma_scans (config.rs:572) |
none; smooths the apex count profile | not measured; gate pending |
search_seed.two_pass_mass_cal (config.rs:423) |
none; refits the <seed>.masscal.json offset + tolerance |
not measured; gate pending |
rt_im_train.adaptive_rt_window (config.rs:487) |
none; per-region RT half-window widths in run_windows |
not measured; gate pending |
extract.emit_contested_features (config.rs:611) |
2 F64 cols contested_count_frac, apportioned_frac (extract.rs:2505); forces the two-pass path (extract.rs:1443) |
not measured; gate pending |
extract.emit_demix_features (config.rs:592) |
5 F32 cols deconv_explained_frac, deconv_active, deconv_share, deconv_max_collinearity, shadow_kept_frac (extract.rs:2517) |
not measured; gate pending |
extract.peak_claim = CoelutionMultiCue / CoelutionDemix / CoelutionShadow (config.rs:132) |
none; MultiCue is non-destructive unless claim_cues.reassign, the other two always rewrite extracted intensity |
not measured; gate pending |
compete.mode (CompetitionMode, config.rs:840, default winner_take_all) |
retains more rows in competed; optional <out>.compete_audit.parquet via emit_competition_audit (config.rs:850) |
not measured; gate pending |
- Determinism: output is emitted in ascending
candidate_idorder (extract.rs:1651); the parallel per-candidate map preserves that order viacollect(). Per-scan fragment values are read in ascending ordinal order (ScanGroups, which replaced aBTreeMapper scan group) so f32 apex sums have a fixed addition order. The parallel window accumulation is documented as bit-identical to the serial loop (extract.rs:1456). A HashMap f32 sum shifting the apex once broke reproducibility; keep ordered maps and sorted iteration wherever floats are summed. - Default-off contract:
retain_top_peaks=1,promote_top_peaks=1,apex_evidence_rank=false,emit_contested_features=false,emit_demix_features=false,emit_gate_diagnostics=false,peak_claim=Nonemake the schema and per-candidate compute byte-identical to the production chain. Every sensitivity knob added here must keep that property. Note thatpromote_top_peaksis schema-neutral but not row-neutral, so it is the one knob in this list that alters the FDR population when enabled. - The gate optimum depends on the rescorer, not the gate in isolation. The
full-feature search found
spectral_entropy_similarity_sqrtthe single best target/decoy discriminator (AUC 0.826), yet gating on it regressed end-to-end identifications versus the apex gate, because gating on the rescorer's own best feature enriches hard decoys. "Best discriminator" is the wrong criterion for a gate; the lever is the rescorer. This is why every gate change must pass an entrapment-holdout gate before being enabled by default. - The MS2 peak budget is set in
convert, not here.convert.rs:76-79is the only MS2 peak truncation in the chain; extract reads whateverspectra_ms2.parquetcontains. The two peak limits in the pipeline are independent: on a 50-window Orbitrap DIA run, moving--top-peaks-ms2from 300 to uncapped left seed output identical (80,474 seed PSMs) while moving extract's accepted-candidate count from 188,027 to 2,286,840 (12.2x) and extract wall clock from 57.9 s to 91.9 s. Uncapping is cheap at this stage relative to the identification difference it produces, but the accepted-row count it feeds intofeaturesandcompetegrows by an order of magnitude, so size the downstream stages accordingly. - Mass recal is a divisor, not a subtraction: observed m/z is corrected by
q_mz = peak.mz / factor_at(peak.mz)wherefactor_atreturns1 + ppm(mz)*1e-6(extract.rs:640, applied atextract.rs:705); a missing/absentmasscal.jsonyields offset 0 and the config tolerance. The ppm term may be m/z-dependent, so do not assume one constant offset per run. - Empty vs zero traces: a never-observed predicted fragment carries an empty
trace, not a grid-length zero vector (
extract.rs:2098). Downstream code must treat an empty trace asobs_apex = 0. apex_imis always null (3D MVP); do not assume an IM value.restrict_candidatesonly forces the serial path in the non-two-pass case. In the non-two-pass branch the parallel window accumulation is used only when a fragindex is present and there is norestrictlist (extract.rs:1455); arestrictlist therefore routes to the slower serial single-pass loop, which honors the allowlist and every non-co-elutionpeak_claimstrategy. In the two-pass branch (Coelution*oremit_contested_features) extraction stays on the parallelextract_twopass_windowspath regardless ofrestrict, which applies the allowlist inside each pass's push closure (extract.rs:853,extract.rs:907, wired atextract.rs:1578).peaks.parquetis not scored and never enters FDR; it is a research sidecar. Thepeak_rankcolumn ofpsms_extractedis a different thing: rank-0 rows are the ordinary selected apex, and ranks >= 1 appear only underpromote_top_peaks > 1, where they are scored and do enter FDR.- Chromatogram list columns are
LargeListF32on purpose (extract.rs:2534); do not downgrade to 32-bitListF32or wide-open gates overflow the offset buffer.
Only the pure gate-scoring helpers, the mass-offset interpolator, and the peak
enumerator are unit-tested; there is no stage-level test of run itself. The
tests encode the behavioral invariants that gate tuning must preserve.
mass_offset_tests(extract.rs:2604): scalar and m/z-grid ppm interpolation agree at the anchors and clamp outside them.coelution_tests(extract.rs:2630): co-eluting fragments score> 0.95(extract.rs:2651); a strong non-co-eluting interferent drops the co-elution score (extract.rs:2665); fewer than 3 scan groups returns1.0(do-not-reject) rather than a low score (extract.rs:2674);peak_spectralscores> 0.99when the peak-integrated pattern matches predicted (extract.rs:2688) and still recovers a fragment that is momentarily unsampled at the apex scan by integrating over the peak (extract.rs:2705, the DIA scan-gap case the single-scan apex Pearson fails).peaks::tests(peaks.rs:154): empty/all-zero profiles yield no peaks; a clean triangular peak resolves apex and 1/3-height boundaries;k=1keeps only the strongest-area peak; a dominant interference peak withk=1discards a weaker true peak butk>=2retains it (the core sensitivity behavior,peaks.rs:190); two maxima rank by area; a left-edge-truncated peak apexes at index 0; the prominence filter suppresses a noise bump; a shoulder inside a stronger envelope collapses into it; and repeated calls are bit-identical with ties broken by earliest apex (peaks.rs:260).
- A new gate metric: add a variant to
GateMode(config.rs:735), add a lazy score closure next toapex_pearson/peak_spec/coel(extract.rs:1989), and a match arm in the acceptance gate (extract.rs:2010). If it is worth diagnosing, also wire it into theemit_gate_diagnosticstuple (extract.rs:2035) and the conditional column block (extract.rs:2511). Default the gate off and validate against entrapment before enabling. - A new peak-claim strategy: add a
PeakClaimvariant (config.rs:132); if it needs elution profiles, extend the two-pass trigger (extract.rs:1443), thereassignpredicate (extract.rs:1557), and the reassignment match inextract_twopass_windows(extract.rs:1236); otherwise add it to the serial single-pass match (extract.rs:1500) and the parallel accumulation (extract.rs:718). Keep tie-breaks deterministic (lowestcandidate_id). Prefer a cue inClaimCues(config.rs:192) over a new variant when the change is really a new per-claimant weight:claim_cue_multiplier(extract.rs:163) already composes them and defaults each to 1.0. - Scoring the top-K peaks: the
retain_top_peakssidecar peaks are unscored.promote_top_peaksis the partial closure of that loop: it emits alternate peaks as realpsms_extractedrows carryingpeak_rank, so they pick up the full feature vector downstream. What is still missing is per-candidate q-collapse over(candidate_id, peak_rank)and entrapment validation, so it stays default-off. - New per-PSM columns: append to the
CandOutstruct (extract.rs:2682), set it in therank0construction (extract.rs:2203) and in the promoted-alternate construction (extract.rs:2297), add a vector toPsmRows, push it inPsmRows::pushand emit itsColinPsmRows::take_cols, which writes the columns in the table's order for every streamed chunk. Gate any non-production column behind anemit_*flag to preserve the byte-identical default schema, and bump the schema version inschema.rsif the default schema changes. - IM / 4D:
apex_imand the IM data-model hooks exist but are unfilled; a diaPASEF extension adds an IM window post-filter alongside the RT window and IM apex/feature families. It cannot be validated without diaPASEF data.