Part of the MuMDIA developer documentation (see docs/README.md).
Stage S is a native broad, DIA-aware search whose product is calibration anchors, not final identifications. It scores library candidates against every MS2 scan with a Sage-lite hyperscore, keeps the single best-scoring spectrum per candidate, assigns target-decoy q-values, and from the confident subset derives a per-run fragment mass recalibration (systematic ppm offset plus a learned tolerance). Downstream stages consume its two outputs:
rt-im-trainreadsseed_psms.parquet(the confident target PSMs give the observed-RT-vs-predicted-iRT anchors for LOESS/linear calibration and the RT window widths).extractreads<seed>.masscal.json(the offset is applied to the observed peak m/z to align it with the predicted-fragment frame, the learned tolerance replacesextract.frag_tol_ppm).
The stage sits behind a file contract (search_seed.rs:2-4), so a real Sage /
sage-core adapter can replace the native scorer later without touching the
consumers, as long as it writes the same seed_psms schema and masscal.json.
Library-level decoys are the single source of truth: there is no engine-side decoy
generation here, so target-decoy counting is never mixed-method
(search_seed.rs:6-7). Because the seed is broad and best-per-candidate rather
than best-per-spectrum, it is deliberately high-recall and low-precision at this
point; precision is recovered downstream by RT/mass calibration, feature
rescoring, and FDR control.
The stage is iRT-independent: predicted_irt is only copied through to the
output, never used in scoring. That is why the run orchestrator computes the
seed once on the base library and reuses it both before and after the optional
DeepLC fine-tune (run.rs:237-280).
| path | role |
|---|---|
rust/mumdia/crates/mumdia/src/stages/search_seed.rs |
the stage: scoring loop, best-per-candidate merge, mass recalibration, output writers |
rust/mumdia/crates/mumdia/src/fdr.rs |
target_decoy_q, count_targets_at_q, ln_factorial (shared with rescore) |
rust/mumdia/crates/mumdia/src/matchers/fragindex.rs |
FragIndex (CSR inverted fragment index) + SeedScratch epoch accumulator |
rust/mumdia/crates/mumdia/src/index.rs |
Library::load, candidate_range, page_search (bucketed backend), cand_frags |
rust/mumdia/crates/mumdia/src/calibrate.rs |
percentile used by the tolerance fit |
rust/mumdia/crates/mumdia/src/spectra.rs |
load_ms2 (reads the converted MS2 Parquet into Ms2Scan, with the per-peak 1/K0 of spectra v2), nearest_peak (the one nearest-peak rule of the calibrants and the 1/K0 points) |
rust/mumdia/crates/mumdia/src/stats.rs |
weighted_median (the observed 1/K0) |
rust/mumdia/crates/mumdia-core/src/constants.rs |
ppm_bounds, ppm_diff, within_ppm (the shared ppm math) |
rust/mumdia/crates/mumdia-core/src/config.rs |
SearchSeedConfig (:286), MatcherKind (:38) |
rust/mumdia/crates/mumdia-core/src/schema.rs |
artifact::SEED_PSMS = ("seed_psms", 1), SEED_PSMS_V2 and the selector artifact::seed_psms(has_im) |
rust/mumdia/crates/mumdia/src/main.rs |
CLI Cmd::SearchSeed (:75, dispatch :458) |
rust/mumdia/crates/mumdia/src/stages/run.rs |
orchestrator wiring (:219), masscal handoff to extract (:309) |
- MS2 spectra (
--ms2, aconvertoutput Parquet), loaded byload_ms2intoVec<Ms2Scan>. EachMs2Scancarriesscan_index,rt_seconds, anIsolationWindow { target_mz, lower_mz, upper_mz, im_lower, im_upper }, andpeaks: Vec<Peak { mz: f32, intensity: f32 }>. The peak m/z is the artifact's own f32 width; this stage widens it withas f64at each comparison, which is exact, exactly as it already did for the library's f32 fragment m/z. - Library (
--lib-precursors+--lib-fragments), loaded byLibrary::load_with(index.rs). Provides the per-candidate columns (peptidoform(cid),charge,prec_mz,base_peptide_id,protein(cid),is_decoy,predicted_irt: f32, and the CSRfrag_offsets;cand(cid)reads them back as one borrowed record), the flat fragment arrays (frag_mz,frag_int,frag_name_id), and the bucketed inverted index forpage_search. - Config:
cfg.search_seedpluscfg.extract.bucket_size(the bucketed Library index bucket width;run.rs:225,main.rs:473).
seed_psms.parquet (schema id seed_psms v1, schema.rs:15), one row per
candidate that, in at least one scan, both cleared min_matched_peaks and ranked
within that scan's top report_psms (the per-scan hyperscore sort is truncated to
report_psms before the best-per-candidate fold, search_seed.rs:89/:369, so
a candidate that always ranks below report_psms gets no row even if it cleared
min_matched_peaks). Rows are sorted by candidate_id. Written at
search_seed.rs:233-250. The ArtifactReport records logical_name =
schema_name = "seed_psms", schema_version = 1, and stage = "search-seed"
(search_seed.rs:257-260):
| column | type | meaning |
|---|---|---|
candidate_id |
u32 | dense library candidate id (== library row index) |
peptidoform |
str | ProForma-lite peptidoform string |
charge |
i32 | precursor charge |
precursor_mz |
f64 | library precursor m/z |
base_peptide_id |
u32 | stripped-peptide id (for peptide-level rollup) |
protein |
str | protein accession |
label |
str | "target" or "decoy" (from Candidate::is_decoy) |
score |
f64 | best hyperscore over all scans |
spectrum_q |
f64 | best-per-candidate target-decoy q-value |
observed_rt |
f64 | RT (seconds) of the best-scoring scan |
predicted_irt |
f32 | library iRT, copied through (unused in scoring) |
matched_peaks |
i32 | matched-fragment count at the best scan |
scan_index |
u32 | scan_index of the best-scoring scan |
observed_im |
f64, nullable | v2 only: observed 1/K0 of a confident target and, under rt_im_train.im_window_sizing = seed_decoy_corrected (the default), of every other PSM up to im_window_sizing_q (step 6) |
The seed is version 2 (artifact::seed_psms(true)), with the observed_im column and the
report stat with_observed_im, only when the searched scans carry per-peak 1/K0 (spectra v2,
the native diaPASEF reader). Otherwise it is version 1 with exactly the columns above it, byte
for byte. The orchestrators record the version read from the table's footer
(search_seed::seed_schema), not a constant.
<out>.masscal.json (fitted and serialised by masscal::MassCal, masscal.rs):
| key | type | meaning |
|---|---|---|
frag_ppm_offset |
f64 | median signed ppm of observed_peak relative to predicted_fragment (ppm_diff(peak_mz, fmz)), i.e. the systematic offset |
frag_tol_ppm |
f64 | learned tolerance: `max(frag_tol_floor_ppm, 1.5 * P95( |
frag_ppm_sigma |
f64 | duplicate of frag_tol_ppm (the learned tolerance is the local mass-uncertainty estimate) |
n_dev |
usize | number of fragment-to-nearest-peak deviations collected (devs.len()) |
cal_passes |
int | 0 (fallback, too few devs), 1 (single pass), or 2 (robust second pass) |
Only frag_ppm_offset and frag_tol_ppm are consumed downstream (by extract,
extract.rs:678-698). frag_ppm_sigma, n_dev, and cal_passes are written but
read by no consumer (grep-confirmed); they are diagnostic / audit fields. The
deviations are not the postings matched during scoring: for every confident target
PSM a fresh nearest-peak search over the candidate's full library fragment list is
run at its best scan (see step 6), so n_dev counts fragment-to-peak pairs, not
matched hyperscore postings.
<out>.report.json (ArtifactReport, search_seed.rs:256-274): rows,
content_hash (blake3 of the output Parquet, search_seed.rs:262), params
(fragment_tol_ppm, report_psms, min_matched_peaks, top_n_peaks,
fdr_seed, and count_fragments_once: true only when that key is on; note matcher
and two_pass_mass_cal are not recorded in params),
a stats map (psms, and targets_at_q<fdr_seed> whose key is the float-
formatted threshold, e.g. targets_at_q0.01), model_identity = "native-seed-hyperscore-v1", and elapsed_ms.
Entry point: search_seed::run(SearchSeedParams) (search_seed.rs:175), a wrapper
over run_hashed (search_seed.rs:181), itself a wrapper over run_returning_scans
(search_seed.rs:189).
1. Load the library and MS2 scans (load_ms2), then log candidate and scan
counts. On the fragindex matcher the library is the m/z-only one
(Library::load_mz_only) and its index the m/z-only index
(FragIndex::build_mz_only): the seed reads neither predicted intensities nor
fragment names (the hyperscore is a match count plus observed intensity, and the mass
recalibration walks frag_mz), so neither the library nor the index holds those two
columns, 12 bytes per fragment at the build peak (AIF, 20.6M fragments: seed peak
1,269-1,297 to 1,023-1,052 MB). They are still decoded, batch by batch, and checked
exactly as extract's full load checks them (presence and type, NULLs, non-finite
intensities, more distinct fragment names than the u16 id holds), then discarded. A
library that extract would refuse is therefore refused here, with the same message,
before any DeepLC step runs on the seed's output. The seed's accumulator probes through FragIndex::probe_peak_cand, which visits the same
postings in the same order as probe_peak; the payload entry points refuse an
m/z-only index. The bucketed matcher loads the full payload, as page_search needs it. On the fragindex matcher the MS2 decode runs
concurrently with the library load and the index build (rayon::join); they are
independent, and the scans are resident during the index build either way. The
bucketed matcher keeps them in sequence, because its library load builds a sorted
copy of every fragment and holding the scans through that transient would raise the
peak. run_returning_scans hands the decoded scans back to the caller: the
ungrouped run lends them to extract when no DeepLC step runs between seed and
extract and extract.matcher is fragindex, so a run decodes its MS2 once. With a
DeepLC step it does not, because the scans would then sit in the parent across the
sidecar's peak; with the bucketed extract matcher it does not either, because they
would sit through that matcher's sorted library copy. Only the MS2 is lent: extract
decodes the MS1 itself, concurrently with its library load. load_ms2 sorts the
returned Vec<Ms2Scan> by
rt_seconds ascending (spectra.rs:95); this RT ordering is what makes the
within-group strictly-greater update deterministic (earliest-RT wins a tie). It
does not re-sort each scan's peaks: peak m/z order is inherited from convert
(see the mass-cal invariant below).
2. Build the matcher. When cfg.matcher == Fragindex (the default), a
FragIndex is built once over the whole library at cfg.fragment_tol_ppm
(search_seed.rs:56-57; FragIndex::build, fragindex.rs:46). This is a
log-space-binned CSR inverted fragment index: postings are scattered in
candidate-id order so post_cand is ascending within every bin, which lets
probe_peak narrow to the precursor-window candidate sub-range by binary search
(fragindex.rs:152-182). The bucketed backend uses the Library's own inverted
index via page_search and needs no separate build.
3. Best-per-candidate accumulation. Two paths produce the same
HashMap<u32, Best> where Best { score, rt, matched, scan_index }
(search_seed.rs:37-43):
- Fragindex path (
seed_fragindex_windows,search_seed.rs:313-410): scans are grouped by isolation window, keyed on(lower_mz.to_bits(), upper_mz.to_bits())in aBTreeMapfor deterministic group order (:321-330). Each scan belongs to exactly one window, so groups are independent parallel units;rayonpar_iter().map_init(SeedScratch::new)processes them (:334-390). Per group,candidate_rangeis computed once (:343). Per scan,select_peakspicks the probe set,SeedScratch::accumulateprobes each peak and fuses(count, obs_sum)per touched candidate (fragindex.rs:214-237), the candidates withcount >= min_matched_peaks(the scratch's qualified list, collected as they reach the count) are scored byhyperscore, sorted by score desc then candidate-id asc, truncated toreport_psms, and folded into a group-local best with a strictly-greater update (:357-385). Since 2026-09-25 the parallel unit is a contiguous RT chunk of a served window group rather than the whole group (seed_chunk_plan): groups whose candidate range is empty get no task, and the served groups are split when their scans would give fewer than about four tasks per thread, never below 32 scans per chunk. A banded search serves only the one to three windows over its band, so its whole probe phase ran on that many threads before. A group's chunks merge back in chunk order with the same strictly-greater update, so an earlier chunk keeps an exact tie and the group's best is the one the scan-by-scan walk gives (chunk_tests::a_chunked_seed_is_the_one_task_per_window_seed_exactly). An ungrouped run with many windows mostly keeps one task per group. - Count each fragment once (
count_fragments_once, default false, fragindex only). The default accumulation counts every matched posting, so two peaks inside the tolerance of one predicted fragment count as two matched fragments inln(matched!). On a diaPASEF slot spectrum the mobility pieces of one fragment ion sit at one m/z and do exactly that. With the key on, the seed index also carries each posting's fragment ordinal (FragIndex::build_mz_only_with_ordinals, 2 bytes per posting more; the lentSeedLibraryrecords it in its key), andSeedScratch::accumulate_count_oncecollects the scan's(candidate, fragment, intensity)hits, sorts them by(candidate, fragment)and counts each pair once at its most intense peak, adding the maxima in that order. The defaultaccumulateandprobe_peak_candare untouched. With no fragment matched twice the counts and the qualified set are the default's (count_once_is_the_plain_accumulation_when_no_fragment_repeats). Config validation refuses the key withmatcher = bucketed. Benchmark-gated (docs/35_diapasef.md, arm T13). On the IM branch the mobility valley split alone cut the seed's confident anchors from 5,200 to 3,331, and counting once brought them to 5,534, +3.8% peptides with the split (three seeds). The diaPASEF recipe (configs/examples/diapasef.json) sets it with both valley fractions. - Bucketed path (serial,
search_seed.rs:66-107): per scan,candidate_rangeon the Library,select_peaks, thenpage_searchfor each probed peak accumulates(count, obs_sum)into aHashMap. Samemin_matched_peaksfilter, hyperscore, sort,report_psmstruncation, and best-per-candidate update.
Both paths skip a scan whose candidate range is empty (hi <= lo,
search_seed.rs:69-71 / :344-346), so out-of-library-range windows cost nothing.
Both accumulate obs_sum as the summed observed peak intensity of matched
postings; the predicted fragment intensity is deliberately discarded in the seed
(fragindex.rs:185-190, and the _pi/_mz discards at search_seed.rs:77).
The two backends do not use the same tolerance-edge predicate, so their matched
sets (and therefore ID counts) can differ slightly on the same data. The fragindex
probe_peak verifies with within_ppm (min-relative, symmetric about the smaller
mass, f64; constants.rs:92), while the bucketed page_search matches inside
ppm_bounds(peak, tol) (query/observed-relative, f32-truncated bounds;
index.rs:258-282). The mass-cal deviation collection uses a third convention,
ppm_diff (theoretical/predicted-relative; constants.rs:66). Do not assume the
three are interchangeable at the edge; config.rs:30-35 records that the
predicate difference shifts IDs more on the AIF full-range-window case.
Hyperscore (search_seed.rs:413-415):
hyperscore = ln(matched!) + ln(1 + sum_obs)
ln(matched!) is ln_factorial(matched) = the summed logs of 2..=matched
(fdr.rs:137-143), rewarding fragment breadth; ln(1 + sum_obs) adds a bounded
intensity term. This is the Sage-style form and is intentionally simple because
the seed is a calibration pass, not the scored identification.
4. Cross-group merge (fragindex path, search_seed.rs:392-409): a total-order
merge over the per-group partials keeps, per candidate, max score, ties broken by
earliest rt, then min scan_index. This is order-independent, so it is
bit-identical to the serial global best regardless of thread or group scheduling.
5. q-values. Rows are collected and sorted by candidate_id
(search_seed.rs:111-112), a (score, is_decoy) vector is built, and
target_decoy_q (fdr.rs:7-52) computes per-candidate q. That routine sorts by
score descending, walks tied-score blocks together so every PSM in a block gets
the same q, uses the conservative numerator q = (n_decoys + 1) / max(1, n_targets),
and monotonizes from worst to best score so q is non-increasing. The output column
is spectrum_q. count_targets_at_q(q, is_decoy, fdr_seed) (fdr.rs:115) reports
the confident target count into stats (search_seed.rs:148).
6. Fragment mass recalibration (search_seed.rs:376-468). A
scan_index -> &Ms2Scan map is built (:381-384). For every confident target
PSM (!is_decoy && q <= fdr_seed, :400-410), each library fragment m/z of that
candidate (lib.cand_frag_mz(cid), :417; index.rs:1566) is searched against the
best scan's peaks. cand_frag_mz is the only fragment accessor the m/z-only seed
library serves: cand_frags hands out intensities and names too, and panics on a
library that does not hold them. The search window is max(50 ppm, fragment_tol_ppm) (ppm_bounds(fmz, collect_ppm), :425-429; partition_point
finds the low edge, then a linear scan to the high edge).
This partition_point + linear scan assumes scan.peaks is m/z-ascending;
load_ms2 does not re-sort peaks by m/z (spectra.rs:20-97), so the assumption
rests on convert writing peaks in m/z order. The nearest peak by absolute m/z
distance (spectra::nearest_peak; a tie keeps the first, lower-m/z peak, because only a
strictly smaller distance replaces the best) contributes one signed ppm deviation
(ppm_diff(peak_mz, fmz), constants.rs:66) to devs (:426-449). Every library fragment of the candidate
is probed, not only those that matched during scoring, so a fragment that found no
scoring posting can still supply a calibrant. The 50 ppm floor keeps the net wider
than a narrow fragment_tol_ppm, so a systematic offset larger than the tolerance can
still be measured; on a wide-tolerance search the net is the tolerance itself, so the
percentile that sets the learned tolerance is not truncated by the net.
Observed 1/K0 (observed_im, spectra with per-peak 1/K0 only). The same walk gives each
confident target its observed 1/K0: every nearest peak whose deviation lies within
fragment_tol_ppm (not the wider collection window, so random matches stay out) contributes
its (1/K0, intensity), and observed_im is the intensity-weighted median of those points
(stats::weighted_median: the smallest value whose cumulative weight reaches half the
total). Fragments inherit the precursor's mobility in diaPASEF, because the TIMS separation
precedes fragmentation, so the median over the matched fragments is the precursor's 1/K0
at its best scan, and an intense fragment outweighs a faint interferer at another 1/K0. The
1/K0 point of a fragment is the peak whose deviation calibrates the mass, so a 3D run's
calibrants are unchanged by the shared helper. Under rt_im_train.im_window_sizing = seed_decoy_corrected, the default (SearchSeedParams::observed_im_q), a separate pass after
the calibrant walk gives every other PSM with spectrum_q at most im_window_sizing_q
(default 0.1), targets and decoys, an observed_im by the same rule; rt-im-train uses those
rows only to size the 1/K0 window, and takes its anchors from the confident targets alone.
Under anchors, decoys and unconfident targets get null. A row without a matched peak in
tolerance gets null either way. rt-im-train calibrates the library's predicted_im against
these values (docs/08_rt_im_train.md, "Ion-mobility calibration").
The grouped seed pool does not carry the column yet: a grouped run is refused on diaPASEF
input (docs/35_diapasef.md section 3).
The one estimator is masscal::fit_floored(devs, floor, mad_k). It takes the median
of the deviations as the offset (sorted[len/2], the upper-middle element for
even-length inputs, not the two-element average) and sets the tolerance to
max(floor, 1.5 * P95(|dev - offset|)) using calibrate::percentile(.., 0.95)
(nearest-rank on round(p*(n-1))). floor is search_seed.frag_tol_floor_ppm (default
5) in every pass. Control flow (MassCal::fit_from):
devs.len() < 20: fallback,(0.0, fragment_tol_ppm, cal_passes = 0)(no offset, tolerance unchanged).- otherwise single pass:
(o1, t1, 1). - if
two_pass_mass_cal: keep only deviations inside|dev - o1| <= t1and, when>= 20survive, re-fit to(o2, t2, 2); otherwise keep the single-pass result. The second pass rejects random-match outliers so they cannot bias the median.
search_seed.frag_tol_mad_k (default 0, off) replaces the P95 width with the robust
sigma of the deviations: mad_k * 1.4826 * MAD, MAD being the median of
|dev - offset|, still floored at frag_tol_floor_ppm. A P95 is set by the tail of the
deviations, so a shoulder of random matches (10% of the deviations in
the_mad_estimator_ignores_a_shoulder_that_sets_the_p95) can widen it several-fold,
where the MAD follows the core. Under two_pass_mass_cal its second pass refits the
offset only, on the deviations inside the first-pass window, and keeps the first-pass
width: a MAD re-estimated on inliers truncated at that width is narrower than the
spread they were cut from, so every pass would tighten it for no reason in the data
(the_mad_second_pass_refits_the_offset_and_keeps_the_width). At 0 the result is the
P95 estimator's, bit for bit. On the IM branch MAD with k = 4 gave +5.7% over the P95
arm on diaPASEF E. coli (three seeds, single pass); HYE was not isolated, and every IM
number is single-pass. On main with two-pass mass calibration (diaPASEF E. coli, one seed)
k = 4 learned 10.0 ppm against 11.7 ppm for P95 and gave 11,037 peptides against 11,072, no
gain. The key is benchmark-gated (docs/35_diapasef.md, arm T3), and
whether one estimator can serve every acquisition is open; if one ties or wins
everywhere, the other is retired.
A negative result, recorded so it is not tried again: do not size the fragment tolerance from accepted identifications. The IM branch's variant C re-estimated the tolerance from the deviations of the identified precursors and set it to 7.99 ppm, which cost 2.7% of the identifications; a fixed 8 ppm cost 3.7%. The accepted set is already filtered by the tolerance it would size, so its spread understates the run's.
The estimator is masscal::MassCal::fit_from and the JSON body its to_json, both in
masscal.rs, because a grouped search fits the very same estimator a second time: each
band's seed would otherwise learn a tolerance from its own ~2,000 deviations and
seed-pool would average the bands' scalars, which measured 35% wider and cost 3.4% of the
peptides (docs/33_window_groups.md section 4a). Under groups.window_groups > 1 the stage
is additionally asked (SearchSeedParams::emit_calibrants) to write
<out>.masscal.parquet: candidate_id (library-wide), scan_index, frag_mz, ppm, one
row per deviation, for every target PSM of the band and not only its own confident ones,
so the pooled q rather than the band's own q chooses the calibrants (a fixed prefix of the
band's 2,000 best targets was short at 2 and 4 bands; docs/33_window_groups.md section
4a). The report's stats then carry calibrant_deviations, calibrant_bytes (16 B each) and
calibrant_target_rows; the count is bounded by the band's scans times report_psms times
the fragments per candidate, not by the library. An ungrouped run writes no sidecar, adds
no stats and is byte-identical.
7. Write seed_psms.parquet (write_table, :233) and the ArtifactReport
(:256-274), then log psms, confident, elapsed_ms.
run writes the seed to seed_psms.parquet (run.rs:218-235) and passes
mass_cal: Some("<seed>.masscal.json") to extract (run.rs:309). In
extract (extract.rs:678-698), the JSON is read: frag_ppm_offset becomes
offset_factor = 1.0 + frag_ppm_offset * 1e-6 (extract.rs:698), which divides
each observed peak m/z before matching (q_mz = peak.mz / offset_factor, e.g.
extract.rs:303/:740) to bring observed peaks into the predicted-fragment
frame, and frag_tol_ppm replaces extract.frag_tol_ppm as the matching
tolerance (falling back to the config value if the file is absent,
extract.rs:696).
| name | file:line | what it does |
|---|---|---|
run |
search_seed.rs:175 |
stage entry point; orchestrates load, score, q, masscal, write |
run_hashed |
search_seed.rs:181 |
run, returning the row count and the report content hash (mumdia_io::report::Written) |
run_returning_scans |
search_seed.rs:189 |
run_hashed, also handing back the MS2 scans it decoded itself (None when they were lent), for a caller that lends them on to extract |
SearchSeedParams |
search_seed.rs:27 |
input struct (ms2, library_precursors, library_fragments, out output-path prefix, cfg, bucket_size, config_hash, fragment_offset, ms2_scans, emit_calibrants, library) |
SeedLibrary / SeedLibrary::load |
search_seed.rs:92 / :120 |
a seed's library and index, loaded once and lent to every seed of run-experiment; records how it was built and is refused by a seed with other settings |
Best |
search_seed.rs:168 |
per-candidate best { score, rt, matched, scan_index } |
seed_fragindex_windows |
search_seed.rs:695 |
parallel per-window fragindex scoring + deterministic merge |
select_peaks |
search_seed.rs:563 |
top-N-by-intensity peak selection, re-sorted to index order |
hyperscore |
search_seed.rs:850 |
ln(matched!) + ln(1 + sum_obs) |
Library::load_mz_only |
index.rs:1133 |
the seed's library: precursor columns and fragment m/z; the payload is decoded and checked as the full load checks it, then discarded |
FragIndex::build / build_mz_only |
fragindex.rs:78 / :99 |
build the CSR inverted index at a fixed tolerance; build_mz_only without the intensity and fragment-ordinal payload (has_payload, :105) |
FragIndex::probe_peak / probe_peak_cand |
fragindex.rs:396 / :425 |
matched postings for one peak in a candidate range; probe_peak_cand reports the candidate only and is what the seed probes through (it also serves an m/z-only index) |
SeedScratch::with_min_count / accumulate / qualified |
fragindex.rs:667 / :691 / :741 |
epoch-stamped (count, obs_sum) accumulator, one 16-byte slot per candidate; qualified lists the candidates that reached min_matched_peaks, in first-reached order |
SeedScratch::accumulate_count_once / FragIndex::probe_peak_cand_frag / build_mz_only_with_ordinals |
fragindex.rs |
count_fragments_once: each (candidate, fragment) counted once per scan at its most intense peak, through an m/z-only index that also holds the fragment ordinals |
spectra::nearest_peak |
spectra.rs |
nearest peak within a ppm window, ties to the first; serves the calibrant deviations and the 1/K0 points |
stats::weighted_median |
stats.rs |
the observed 1/K0 of a confident target |
seed_schema |
search_seed.rs |
the seed_psms version a table on disk records (v2 when it has observed_im) |
Library::candidate_range |
index.rs:1612 |
half-open candidate id range for an isolation window |
Library::page_search |
index.rs:1621 |
bucketed inverted-index probe (bucketed backend) |
Library::cand_frag_mz |
index.rs:1566 |
fragment m/z slice for a candidate; the one fragment accessor an m/z-only library serves |
Library::cand_frags |
index.rs:1549 |
(m/z, intensity, name id) slices for a candidate; panics on a library without the payload |
target_decoy_q |
fdr.rs:51 |
tied-block, monotonized (n_decoys+1)/max(1,n_targets) q |
count_targets_at_q |
fdr.rs:238 |
target count at or below a q threshold |
ln_factorial |
fdr.rs:260 |
ln(n!) via summed logs |
ppm_diff / ppm_bounds |
constants.rs:97 / :109 |
signed ppm (theoretical-relative) and ppm window bounds (query-relative) |
within_ppm |
constants.rs:130 |
min-relative tolerance predicate used by the fragindex match (differs at the edge from the two above) |
load_ms2 |
spectra.rs:86 |
reads spectra_ms2.parquet into Vec<Ms2Scan>, RT-sorted, decoding row-contiguous parts in parallel |
percentile |
calibrate.rs:250 |
nearest-rank percentile (used for the tolerance) |
SearchSeedConfig (config.rs:286-320, #[serde(default, deny_unknown_fields)]).
The struct was pruned to the fields actually read here; unknown keys are rejected
on load.
| field | default | effect |
|---|---|---|
fdr_seed |
0.01 |
q threshold defining "confident" for the calibration subset and the reported targets_at_q stat |
fragment_tol_ppm |
20.0 |
matching tolerance for scoring; also the masscal fallback tolerance when too few deviations |
report_psms |
5 |
max candidates kept per spectrum before the best-per-candidate fold (wide-window DIA) |
min_matched_peaks |
4 |
minimum matched fragments for a candidate to score in a scan |
top_n_peaks |
300 |
probe only the N most intense peaks per scan (0 = all); seed-only, does not shrink the extract artifact |
matcher |
Fragindex |
backend; MatcherKind::Bucketed (config.rs:38) uses the serial page_search path |
two_pass_mass_cal |
true |
robust second-pass mass fit on the in-window inliers (sensitivity_plan P3.1); default true since 2026-10-11 |
count_fragments_once |
false |
count each predicted fragment once per spectrum at its most intense matching peak (step 3); fragindex only; omitted from the canonical configuration at the default |
frag_tol_floor_ppm |
5.0 |
floor of the learned tolerance, in both passes |
frag_tol_mad_k |
0.0 |
> 0: the learned tolerance is frag_tol_mad_k * 1.4826 * MAD instead of 1.5 * P95, and the second pass refits the offset only (step 6); omitted from the canonical configuration at the default; benchmark-gated |
bucket_size for the bucketed Library index is taken from cfg.extract.bucket_size,
not from SearchSeedConfig (run.rs:225). The 50 ppm mass-calibration search
window, the 1.5 * P95 scale, the 1.4826 MAD-to-sigma factor and the >= 20
deviation threshold are constants (search_seed.rs, masscal.rs), not config fields;
the floor is frag_tol_floor_ppm.
The standalone subcommand Cmd::SearchSeed (main.rs:75-86, dispatch :458-476)
takes --ms2, --lib-precursors, --lib-fragments, --out, and
--config (all String; --config optional). There is no --bucket-size flag;
bucket_size is read from the resolved config's extract.bucket_size
(main.rs:473).
- Determinism (
docs/14_build_test_deploy_gotchas.md): the fragindex parallel path is bit-identical to the serial fragindex best-per-candidate (this is a parallel-vs-serial claim about the same backend, not a fragindex-vs-bucketed claim; the two backends use different edge predicates, see the accumulation section). Groups run inBTreeMapkey order, within a group scans run in RT-ascending order (guaranteed byload_ms2'ssort_by rt_seconds,spectra.rs:95) with a strictly-greater update, and the cross-group merge is a total order (max score, tie earliest RT, tie min scan_index;search_seed.rs:392-409).select_peaksre-sorts the top-N back to index-ascending (:299) so theobs_sumfloat reduction is summed in a fixed order. TheHashMapis only ever reduced through this total order, never iterated for a float sum. - Best-per-candidate, not best-per-spectrum. One output row per candidate that,
in at least one scan, cleared
min_matched_peaksand ranked within that scan's topreport_psms(the per-scan sort is truncated before the fold). A candidate belowreport_psmsin every scan it appears in is dropped. Ties in the update use strictly-greater (score > entry.score,:97and:377), so the earliest-RT scan wins a tie. - Library decoys only.
labelcomes straight fromCandidate::is_decoy(:138); the stage never mints decoys. The target-decoy null therefore depends on the library carrying paired decoys (see the DIA-NN library recipe /digest). - obs_sum is observed intensity. The seed drops predicted fragment intensity on
purpose (
fragindex.rs:185-190); do not "fix" the discarded_pi/_mz. - Mass-cal fallback is an identity. With
< 20deviations the offset is0.0and the tolerance is left atfragment_tol_ppm(cal_passes = 0); extract then applies no shift.frag_ppm_sigmais an exact copy offrag_tol_ppm. - The 50 ppm mass-cal window is fixed and independent of
fragment_tol_ppm; it is intentionally wide so a systematic error larger than the scoring tolerance is still observable. Do not tie it tofragment_tol_ppm. - Mass-cal assumes m/z-sorted peaks. The deviation search uses
scan.peaks.partition_point(|pk| pk.mz < lo)+ a linear scan to the high edge (search_seed.rs:168-178), which is only correct ifscan.peaksis m/z-ascending.load_ms2sorts scans by RT but never re-sorts peaks (spectra.rs:20-97), so the invariant is inherited fromconvert. The fragindex scoring path does not need it (it bins each peak independently); only mass-cal relies on it. config_hashis carried but unused insiderun(part ofSearchSeedParamsfor call-site uniformity,search_seed.rs:34). In the standalone CLI it isblake3_str(cfg.canonical_json())(main.rs:466); in therunorchestrator it is the shared chain hashch(run.rs:226). Either way the report'scontent_hashis the blake3 of the output Parquet, not this value.top_n_peaksis seed-only. It caps the probe set to reduce index probing on the dominant cost, but abundant peptides supply the calibration anchors anyway; the downstreamextractstage still sees all converted peaks.MatcherKind::Bucketedstays serial (search_seed.rs:66-107); only the fragindex path is parallelized.- iRT is inert here.
predicted_irtis only copied to the output, so the seed can be computed once and reused across the DeepLC fine-tune boundary (run.rs:237-280). - Test coverage. Only
select_peakshas a unit test here (zero_selects_all_and_seed_cap_keeps_only_top_intensity_peaks,search_seed.rs:417-450), assertingtop_n = 0returns all indices andtop_n = 300over 305 peaks keeps only the top-intensity5..305re-sorted to index order.target_decoy_q(tied-block,+1conservatism) andentrapment_qare tested infdr.rs(:145-207);ln_factorialhas no direct test. The fragindex match, epoch reset, precursor gate, and the naive-equivalence gate are tested infragindex.rs(:280-451). There is no stage-level test forsearch_seed::run, the masscal output, or bucketed-vs-fragindex equivalence at the stage level (see docs/14_build_test_deploy_gotchas.md).
- Swap in a real Sage / sage-core scorer. Replace the accumulation in
runbehind the existing file contract. Keep theseed_psmsschema andmasscal.jsonkeys unchanged sort-im-trainandextractneed no edits. Bumpmodel_identity(search_seed.rs:271) so provenance inreport.jsonis honest. - Add a
seed_psmscolumn. Bump the schema version atschema.rs:15(("seed_psms", 1)->2), push the newColin thewrite_tablecall (search_seed.rs:233-250), and update any consumer that reads by column name. - Make the mass-cal window / thresholds configurable. The 50 ppm search
window, the
>= 20minimum, the1.5xscale, and the5.0 ppmfloor are literals inrun; promote them toSearchSeedConfigfields (with conservative defaults) if you need to tune them, per the "every algorithmic choice is a typed config field" convention. - Add a matcher backend. Add a
MatcherKindvariant (config.rs:38) and a branch in theif let Some(idx) = fidxdispatch (search_seed.rs:63-108); mirror the deterministic merge if the new path is parallel. - Charge-2 / m/z-binned tolerance. The current fit produces one global offset
and tolerance. To make them charge- or m/z-dependent, partition
devsbeforemasscal::fitand emit per-bin entries inmasscal.json, then teachextractto pick the matching bin. - Change the score.
hyperscore(search_seed.rs:413) is a free function; keep it monotone in matched-fragment count and observed intensity so the target-decoy q ordering stays meaningful, and keep the summation order fixed for determinism.