Skip to main content

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}