koth_core/config/features.rs
1use crate::scoring::model::IsotopeModelSpec;
2use serde::{Deserialize, Serialize};
3
4/// Resolved form of `FeaturesConfig::cosine_anchor` — which hill the
5/// chromatographic-cosine gate is measured against during isotope-chain
6/// extension. Parsed (case-insensitively) from the config string.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CosineAnchor {
9 /// Anchor to the immediate predecessor in the chain (legacy koth default).
10 Adjacent,
11 /// Anchor every isotope to the monoisotope seed hill.
12 Seed,
13}
14
15impl CosineAnchor {
16 /// Parse a `cosine_anchor` string strictly: an unrecognised value is a
17 /// config error rather than a silent fallback.
18 pub fn parse(s: &str) -> Result<Self, crate::error::Error> {
19 match s.to_ascii_lowercase().as_str() {
20 "adjacent" => Ok(CosineAnchor::Adjacent),
21 "seed" => Ok(CosineAnchor::Seed),
22 other => Err(crate::error::Error::Config(format!(
23 "invalid features.cosine_anchor `{other}`: expected \"adjacent\" or \"seed\""
24 ))),
25 }
26 }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(default, deny_unknown_fields)]
31pub struct FeaturesConfig {
32 pub min_charge: u8,
33 pub max_charge: u8,
34 /// Per-extension chromatographic-cosine threshold used while building an
35 /// isotope chain. The candidate hill's cosine vs the seed hill must clear
36 /// this to be added; otherwise chain extension stops in that direction.
37 pub min_chain_cosine: f64,
38 /// Minimum intensity a candidate isotope hill must retain relative to its
39 /// chain predecessor, as a fraction: a heavier isotope must be at least
40 /// this fraction of its predecessor's intensity or the chain stops. Chain
41 /// extension runs upward only (the seed IS the monoisotope hypothesis), so
42 /// there is no downward counterpart to this knob. Default 0.01. The
43 /// pre-0.3.0 name `right_max_decrease` is still accepted.
44 #[serde(alias = "right_max_decrease")]
45 pub min_isotope_step_ratio: f64,
46 pub max_isotopes: usize,
47 /// Per-extension intensity-ratio gate. After a candidate hill clears
48 /// `min_chain_cosine`, also check that its apex intensity vs the
49 /// predecessor's matches the averagine ratio within ± this many log2
50 /// units. Catches column-bleed / contaminant hills that co-elute (so
51 /// pass cosine) but have wrong intensity for an isotope. Set to a
52 /// very large value to effectively disable. Typical: 1.5 (within
53 /// ~2.83× of expected).
54 #[serde(default = "default_max_isotope_log2_ratio")]
55 pub max_isotope_log2_ratio: f64,
56 /// When true (legacy behaviour), isotope-chain extension stops as
57 /// soon as the averagine-*predicted* intensity of the next isotope falls
58 /// below the per-run noise floor (5th-pct hill intensity × 0.8), computed
59 /// from the seed intensity *before the hill is searched for*. This cheaply
60 /// trims decayed chain tails, but it also blocks legitimate low-abundance
61 /// monoisotopic seeds from ever pairing their M+1: a seed already near the
62 /// floor has a predicted M+1 below the floor, so the chain never forms and
63 /// the feature collapses to charge 0. When false (the default), this
64 /// predicted-intensity break is skipped and chain extension is terminated
65 /// purely by *evidence*:
66 /// a missing hill (`find_neighbors` empty), `min_chain_cosine`, the
67 /// `max_isotope_log2_ratio` intensity-ratio gate, `min_isotope_step_ratio`
68 /// (found hill must be ≥ this fraction of the predecessor), and the
69 /// averagine-template-length / `max_isotopes` caps. Setting this false
70 /// recovers dim 2+/3+ features that have both isotope hills present but
71 /// were never paired — recommended when feeding an MS1 search that needs
72 /// feature depth. Re-benchmark FDR/quant when flipping.
73 #[serde(default = "default_chain_predicted_intensity_gate")]
74 pub chain_predicted_intensity_gate: bool,
75 /// Minimum number of mutually-overlapping scans two hills must share
76 /// before a chromatographic cosine is computed between them; below it the
77 /// cosine is 0 and the isotope chain will not extend across that pair.
78 /// Default 3 (the long-standing hard gate — keep for PXD003881).
79 ///
80 /// On fast gradients hills are only 3–5 scans wide, so a dim isotope hill
81 /// frequently overlaps its monoisotope by just 1–2 scans and is rejected
82 /// outright regardless of how well the shapes agree — the dominant cause of
83 /// koth emitting a low-abundance monoisotope as an unpaired charge-0 feature
84 /// on such data (biosaur2 requires only ≥1 shared scan). Lowering this to 2
85 /// recovers those pairs; pair it with a shorter `hills.min_scans` (a 2-scan
86 /// hill can never reach a 3-scan overlap) and re-benchmark quant, since
87 /// short-overlap pairs are noisier.
88 #[serde(default = "default_min_scan_overlap")]
89 pub min_scan_overlap: usize,
90 /// Minimum isotope-pattern (Bhattacharyya) score a candidate — original or
91 /// truncated — must reach before it may *claim* its hills; below-bar
92 /// candidates are dropped so their hills stay free for a better-fitting
93 /// feature. Default 0.0 = no gate.
94 #[serde(default = "default_exhaustive_min_isotope_score")]
95 pub exhaustive_min_isotope_score: f64,
96 /// Signal standard deviation of seed-relative log2 isotope-ratio errors.
97 /// The broad noise null has fixed sigma 2; valid range is 0 < sigma < 2.
98 pub isotope_evidence_ratio_sigma: f64,
99 /// Shape of the Beta(shape, 1) co-elution model against a uniform null.
100 /// Must be finite and > 1; larger values favor tighter co-elution.
101 pub isotope_evidence_cosine_shape: f64,
102 /// Legacy compatibility field; no longer affects claim ordering.
103 /// The exhaustive resolver always ranks by additive isotope log evidence.
104 /// Accepted so existing configuration files continue to deserialize.
105 #[serde(default)]
106 pub exhaustive_isotope_priority: bool,
107 /// Chromatographic-cosine **anchor** for isotope-chain extension: which hill
108 /// each candidate isotope's cosine gate is measured against.
109 ///
110 /// `"seed"` (the default): anchor *every* isotope's cosine to the
111 /// monoisotope seed hill (the convention biosaur2 / AlphaPept / Dinosaur all
112 /// use). The m/z step target and the intensity-ratio predecessor still step
113 /// from the immediate predecessor — only the cosine reference changes.
114 /// Rejects a far isotope that co-elutes with its neighbour but not with the
115 /// mono. Beat `"adjacent"` on the 20-run PXD003881 cohort (+0.31 pp recall);
116 /// see `default_cosine_anchor`.
117 ///
118 /// `"adjacent"` (legacy): anchor to the immediate predecessor in the chain —
119 /// for M+1 that is the seed, for M+k≥2 the previously-claimed isotope hill.
120 /// koth's former default; byte-identical to the pre-2026-07 paper output.
121 ///
122 /// The per-extension cosine that feeds the composite `mean_cosine` is
123 /// computed against whichever reference this selects (so the composite is
124 /// consistent with the gate). The reported `cosine_score` field and the
125 /// exhaustive-resolver rescoring remain adjacent-style regardless.
126 #[serde(default = "default_cosine_anchor")]
127 pub cosine_anchor: String,
128 /// Sulfur-count offsets to score each isotope chain against, relative to
129 /// the **ceiling** of the averagine-expected sulfur count. The chain is
130 /// scored against one averagine template per offset and the best
131 /// Bhattacharyya kept — this corrects the systematic penalty on Cys/Met-rich
132 /// peptides whose M+2 is elevated by ³⁴S (4.25 %, +2 Da).
133 ///
134 /// Default `[-1, 0, 1]`, i.e. `{0, 1, 2}` sulfurs up to 2665 Da, `{1, 2, 3}`
135 /// to 5330 Da, `{2, 3, 4}` to 7995 Da. Negative results saturate at 0 and
136 /// duplicates collapse, so `[-2, -1, 0, 1]` on a small peptide is `{0, 1, 2}`.
137 /// Widen it (e.g. `[-2, -1, 0, 1]`) to keep the no-sulfur template on large
138 /// peptides — ~32 % of 3 kDa tryptic peptides have no sulfur at all.
139 ///
140 /// An **empty list disables sulfur awareness**: one plain averagine template
141 /// at the half-up rounded count.
142 ///
143 /// Scoring takes a **max over templates**, so a longer list can only raise
144 /// scores — including for decoys and mis-assembled chains. Judge a change on
145 /// discrimination (PSM recall / target–decoy separation), never on the
146 /// isotope-score distribution alone.
147 #[serde(default = "default_sulfur_offsets")]
148 pub sulfur_offsets: Vec<i8>,
149 /// Which analyte class's average composition the theoretical isotope
150 /// pattern is built from.
151 ///
152 /// `"peptide"` (the default) is Senko's averagine and the only model the
153 /// published benchmark exercises. `"rna"` and `"dna"` are the means of the
154 /// four ribo- / deoxyribonucleotide chain residues. An explicit table
155 /// overrides both:
156 ///
157 /// ```toml
158 /// isotope_model = { residue_mass = 321.2916, c = 9.5, h = 11.75, n = 3.75, o = 7.0 }
159 /// ```
160 ///
161 /// `s` defaults to 0 and a model without sulfur ignores `sulfur_offsets`
162 /// entirely — varying a structurally absent atom would hand every candidate,
163 /// decoys included, a free maximum over templates. An unknown class name is
164 /// a config parse error, never a silent fallback to peptide.
165 #[serde(default)]
166 pub isotope_model: IsotopeModelSpec,
167 /// Neutron (C13) mass in Da
168 pub neutron_mass: f64,
169 /// Drop features whose **isotope_score** (Bhattacharyya vs averagine) is
170 /// below this. 0.0 = keep all. Default 0.5.
171 pub min_isotope_score: f64,
172 /// Drop features whose **cosine_score** (mean chromatographic cosine of
173 /// adjacent isotope hills) is below this. 0.0 = keep all.
174 pub min_cosine_score: f64,
175 /// Drop features whose **combined_score** (= isotope × cosine) is below
176 /// this. 0.0 = keep all. All three filters are AND-ed.
177 pub min_combined_score: f64,
178}
179
180fn default_max_isotope_log2_ratio() -> f64 {
181 1.5
182}
183
184fn default_chain_predicted_intensity_gate() -> bool {
185 // `false` since the downward (M-1, M-2, …) chain extension was removed.
186 //
187 // The gate exists only in the upward loop, so while the downward walk
188 // existed it could route around it: a dim monoisotope whose own upward walk
189 // was killed by the predicted-intensity break was still recovered by seeding
190 // on its M+1 and stepping down (the assembler's `uncontested_envelope_assembled`
191 // fixture is exactly this — every upward walk there breaks at step 1, and
192 // the whole 3-hill envelope used to be built by seeding the M+2 and walking
193 // down twice). With one direction only there is no second route, so leaving
194 // the gate on silently drops those features. Termination is now purely by
195 // evidence — see the field docs.
196 false
197}
198
199fn default_min_scan_overlap() -> usize {
200 3
201}
202
203fn default_exhaustive_min_isotope_score() -> f64 {
204 0.0
205}
206
207fn default_cosine_anchor() -> String {
208 // `seed` is the default: anchoring every isotope's chromatographic cosine
209 // to the monoisotope seed (as biosaur2 / AlphaPept / Dinosaur all do) beat
210 // the former `adjacent` (predecessor) anchor on the full 20-run PXD003881
211 // cohort, at +0.31 pp recall (+1565 covered PSMs) with no quant regression
212 // (median CV, MV rate, and HUMAN FPR all flat-to-better).
213 //
214 // The absolute recall figures that used to be quoted here (0.7933 ->
215 // 0.7964) predate `b83e1e1` and no longer describe any current run. The
216 // current cohort recall at this default is 0.7943; the matching `adjacent`
217 // arm has not been re-measured since, so the +0.31 pp delta above is
218 // historical and should be re-derived before being cited anywhere.
219 // Set to `adjacent` to reproduce the pre-2026-07 paper feature output.
220 "seed".to_string()
221}
222
223pub(crate) fn default_sulfur_offsets() -> Vec<i8> {
224 vec![-1, 0, 1]
225}
226
227impl Default for FeaturesConfig {
228 fn default() -> Self {
229 Self {
230 min_charge: 2,
231 max_charge: 6,
232 min_chain_cosine: 0.4,
233 min_isotope_step_ratio: 0.01,
234 max_isotopes: 6,
235 max_isotope_log2_ratio: 1.5,
236 chain_predicted_intensity_gate: false,
237 min_scan_overlap: 3,
238 exhaustive_min_isotope_score: 0.0,
239 exhaustive_isotope_priority: false,
240 isotope_evidence_ratio_sigma: 0.75,
241 isotope_evidence_cosine_shape: 2.0,
242 cosine_anchor: default_cosine_anchor(),
243 sulfur_offsets: default_sulfur_offsets(),
244 isotope_model: IsotopeModelSpec::default(),
245 neutron_mass: 1.003_354_835,
246 min_isotope_score: 0.5,
247 min_cosine_score: 0.0,
248 min_combined_score: 0.0,
249 }
250 }
251}
252
253impl FeaturesConfig {
254 /// Validate distribution parameters and strings that serde alone cannot
255 /// check. Called after TOML deserialization so invalid values fail at load time
256 /// rather than silently falling back at runtime.
257 pub fn validate(&self) -> Result<(), crate::error::Error> {
258 CosineAnchor::parse(&self.cosine_anchor)?;
259 if !self.isotope_evidence_ratio_sigma.is_finite()
260 || self.isotope_evidence_ratio_sigma <= 0.0
261 || self.isotope_evidence_ratio_sigma >= 2.0
262 {
263 return Err(crate::error::Error::Config(
264 "features.isotope_evidence_ratio_sigma must be finite and between 0 and 2 (exclusive)".into(),
265 ));
266 }
267 if !self.isotope_evidence_cosine_shape.is_finite()
268 || self.isotope_evidence_cosine_shape <= 1.0
269 {
270 return Err(crate::error::Error::Config(
271 "features.isotope_evidence_cosine_shape must be finite and greater than 1".into(),
272 ));
273 }
274 Ok(())
275 }
276
277 /// Resolve the chromatographic-cosine anchor. Infallible at runtime because
278 /// `validate()` has already rejected bad values at config load; the
279 /// defensive fallback preserves legacy behaviour if reached.
280 pub fn cosine_anchor_mode(&self) -> CosineAnchor {
281 CosineAnchor::parse(&self.cosine_anchor).unwrap_or(CosineAnchor::Adjacent)
282 }
283}