Skip to main content

koth_ms/config/
mod.rs

1use serde::{Deserialize, Serialize};
2
3use crate::alignment::AlignmentConfig;
4use crate::lfq::LfqConfig;
5
6mod align;
7mod features;
8mod file;
9mod hills;
10mod scoring;
11
12// Re-export every config type at the `crate::config::*` path so that existing
13// import sites (`crate::config::FileConfig`, `crate::config::CosineAnchor`, …)
14// keep resolving unchanged after the split into submodules.
15pub use align::AlignOutputConfig;
16pub use features::{CosineAnchor, FeaturesConfig};
17pub use file::{FileConfig, ImToleranceType, ToleranceType};
18pub use hills::{HillsConfig, HillsMs2Overrides};
19pub use scoring::{OutputConfig, ScoringConfig};
20
21/// Output format for hills / features / matrix files: "tsv" or "parquet".
22/// Shared by [`OutputConfig`] (koth_ff) and [`AlignOutputConfig`] (koth_align).
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum OutputFormat {
26    #[default]
27    Tsv,
28    Parquet,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32#[serde(default, deny_unknown_fields)]
33pub struct KothConfig {
34    pub file: FileConfig,
35    pub hills: HillsConfig,
36    /// Optional per-field overrides for MS2 (DIA fragment) hill detection.
37    /// Absent (`None`) ⇒ MS2 hills use `[hills]` verbatim (byte-identical to the
38    /// pre-existing behavior). Present ⇒ MS2 hill detection inherits `[hills]`
39    /// and applies only the fields set here. See [`KothConfig::ms2_hills`].
40    #[serde(default)]
41    pub hills_ms2: Option<HillsMs2Overrides>,
42    pub features: FeaturesConfig,
43    pub scoring: ScoringConfig,
44    pub output: OutputConfig,
45}
46
47/// Top-level configuration for the `koth_align` binary.
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49#[serde(default, deny_unknown_fields)]
50pub struct AlignConfig {
51    pub alignment: AlignmentConfig,
52    pub lfq: LfqConfig,
53    pub output: AlignOutputConfig,
54}
55
56impl AlignConfig {
57    pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::KothError> {
58        let content = std::fs::read_to_string(path)?;
59        let cfg: Self = toml::from_str(&content)
60            .map_err(|e| crate::error::KothError::ConfigError(e.to_string()))?;
61        cfg.lfq.consensus.validate()?;
62        Ok(cfg)
63    }
64
65    pub fn to_toml_string(&self) -> Result<String, crate::error::KothError> {
66        toml::to_string(self).map_err(|e| crate::error::KothError::ConfigError(e.to_string()))
67    }
68}
69
70impl KothConfig {
71    pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::KothError> {
72        let content = std::fs::read_to_string(path)?;
73        let cfg: KothConfig = toml::from_str(&content)
74            .map_err(|e| crate::error::KothError::ConfigError(e.to_string()))?;
75        cfg.features.validate()?;
76        Ok(cfg)
77    }
78
79    pub fn to_toml_string(&self) -> Result<String, crate::error::KothError> {
80        toml::to_string(self).map_err(|e| crate::error::KothError::ConfigError(e.to_string()))
81    }
82
83    /// Resolve the [`HillsConfig`] to use for MS2 (DIA fragment) hill detection.
84    ///
85    /// When `[hills_ms2]` is absent this returns `self.hills` unchanged — the MS2
86    /// path is byte-for-byte identical to using `[hills]` directly. When present,
87    /// MS2 hill detection inherits every `[hills]` value and overwrites only the
88    /// fields set under `[hills_ms2]` (see [`HillsMs2Overrides`]).
89    ///
90    /// Pipeline entry points (`run_pipeline_with_ms2`, `run_pipeline_streaming`,
91    /// …) and the binary route MS2 detection through this method, so a single
92    /// `KothConfig` drives MS1 and MS2 hill settings independently.
93    pub fn ms2_hills(&self) -> HillsConfig {
94        match &self.hills_ms2 {
95            Some(ov) => ov.apply_to(&self.hills),
96            None => self.hills.clone(),
97        }
98    }
99}
100
101#[cfg(test)]
102mod config_parse_tests {
103    use super::{AlignConfig, KothConfig};
104
105    /// Every shipped config must parse under `deny_unknown_fields`. If a field
106    /// is renamed or removed in the code, the stale key in one of these files
107    /// makes this test fail — which is the point: config drift is now caught at
108    /// build time rather than silently ignored at runtime.
109    fn parse_koth(name: &str, toml_src: &str) {
110        if let Err(e) = toml::from_str::<KothConfig>(toml_src) {
111            panic!("shipped koth_ff config `{name}` failed to parse: {e}");
112        }
113    }
114
115    fn parse_align(name: &str, toml_src: &str) {
116        if let Err(e) = toml::from_str::<AlignConfig>(toml_src) {
117            panic!("shipped koth_align config `{name}` failed to parse: {e}");
118        }
119    }
120
121    // These used to also parse eight tuned configs from `benchmark/config/`. The
122    // benchmark now lives in its own repository (pgarrett-scripps/koth-paper), and
123    // `include_str!` resolves at COMPILE time, so leaving them would not merely
124    // skip a test -- it would stop this crate building at all.
125    //
126    // The guard still covers everything this repository ships. The tuned configs
127    // are guarded on the other side by `#[serde(deny_unknown_fields)]`: a renamed
128    // or removed field makes koth_ff reject them the moment the benchmark runs,
129    // which is the only thing that runs them.
130
131    #[test]
132    fn shipped_koth_configs_parse() {
133        parse_koth(
134            "example_config.toml",
135            include_str!("../../../example_config.toml"),
136        );
137    }
138
139    #[test]
140    fn shipped_align_configs_parse() {
141        parse_align(
142            "example_config_align.toml",
143            include_str!("../../../example_config_align.toml"),
144        );
145    }
146
147    #[test]
148    fn removed_consensus_gates_are_rejected_instead_of_ignored() {
149        for (key, value) in [
150            ("min_member_combined_score", "0.5"),
151            ("min_seed_combined_score", "0.75"),
152            ("min_group_size", "2"),
153            ("allow_replicated_weak_seeds", "true"),
154        ] {
155            let source = format!("[lfq.consensus]\n{key} = {value}\n");
156            let err = toml::from_str::<AlignConfig>(&source).unwrap_err();
157            assert!(err.to_string().contains(key));
158        }
159    }
160
161    #[test]
162    fn consensus_gate_rejects_invalid_values() {
163        let mut cfg = AlignConfig::default();
164        for value in [f64::NAN, f64::INFINITY, -0.01, 1.01] {
165            cfg.lfq.consensus.max_group_qvalue = value;
166            assert!(cfg.lfq.consensus.validate().is_err());
167        }
168        cfg.lfq.consensus.max_group_qvalue = 0.05;
169        cfg.lfq.consensus.rt_window_pct = -0.1;
170        assert!(cfg.lfq.consensus.validate().is_err());
171    }
172
173    #[test]
174    fn omitted_lfq_windows_use_release_defaults_without_changing_grouping() {
175        for text in [
176            "",
177            "[lfq]\n",
178            include_str!("../../../example_config_align.toml"),
179        ] {
180            let cfg: AlignConfig = toml::from_str(text).expect("valid alignment config");
181            assert_eq!(cfg.lfq.rt_window_pct, 0.005);
182            assert_eq!(cfg.lfq.im_tolerance, 0.015);
183            assert_eq!(cfg.lfq.consensus.rt_window_pct, 0.02);
184            assert_eq!(cfg.lfq.consensus.im_tolerance, 0.05);
185            assert_eq!(cfg.alignment.im_tolerance, 0.05);
186        }
187    }
188
189    #[test]
190    fn explicit_lfq_windows_override_defaults_without_changing_grouping() {
191        let cfg: AlignConfig =
192            toml::from_str("[lfq]\nrt_window_pct = 0.01\nim_tolerance = 0.025\n")
193                .expect("valid custom extraction windows");
194        assert_eq!(cfg.lfq.rt_window_pct, 0.01);
195        assert_eq!(cfg.lfq.im_tolerance, 0.025);
196        assert_eq!(cfg.lfq.consensus.rt_window_pct, 0.02);
197        assert_eq!(cfg.lfq.consensus.im_tolerance, 0.05);
198    }
199}
200
201#[cfg(test)]
202mod hills_ms2_tests {
203    use super::KothConfig;
204
205    /// A valid `KothConfig` whose `[hills]` carries a few *non-default* values,
206    /// so tests can tell "inherited from `[hills]`" apart from "fell back to
207    /// `HillsConfig::default()`" (defaults are min_scans=3, max_gap=0,
208    /// split_hills=true, lfc_weight=0.5, smoothing_enabled=false,
209    /// smoothing_window=1). Built from `default()` + a valid `[file]` so we
210    /// don't have to hardcode every required `[file]` field.
211    fn base_config_toml() -> String {
212        let mut cfg = KothConfig::default();
213        cfg.hills.min_scans = 5;
214        cfg.hills.max_gap = 2;
215        cfg.hills.split_hills = false;
216        cfg.hills.lfc_weight = 0.9;
217        cfg.hills.smoothing_enabled = true;
218        cfg.hills.smoothing_window = 3;
219        cfg.file.mz_tolerance = 8.0;
220        cfg.to_toml_string().expect("serialize base config")
221    }
222
223    /// (a) Absent `[hills_ms2]` ⇒ `ms2_hills()` is byte-identical to `hills`.
224    /// This is the regression guard for the default path.
225    #[test]
226    fn absent_hills_ms2_equals_hills() {
227        let cfg: KothConfig = toml::from_str(&base_config_toml()).expect("parse");
228        assert!(cfg.hills_ms2.is_none());
229        let ms2 = cfg.ms2_hills();
230        // Compare via TOML serialization for a full field-by-field equality that
231        // does not depend on HillsConfig deriving PartialEq.
232        let a = toml::to_string(&cfg.hills).unwrap();
233        let b = toml::to_string(&ms2).unwrap();
234        assert_eq!(
235            a, b,
236            "ms2_hills() must equal [hills] when [hills_ms2] absent"
237        );
238    }
239
240    /// (b) A partial `[hills_ms2]` overrides only the named fields and inherits
241    /// every other field from `[hills]` (NOT from HillsConfig::default()).
242    #[test]
243    fn partial_hills_ms2_overrides_named_inherits_rest() {
244        let toml_src = format!(
245            "{}\n[hills_ms2]\nmin_scans = 2\nsplit_hills = true\n",
246            base_config_toml()
247        );
248        let cfg: KothConfig = toml::from_str(&toml_src).expect("parse");
249        assert!(cfg.hills_ms2.is_some());
250        let ms2 = cfg.ms2_hills();
251
252        // Overridden fields take the [hills_ms2] value.
253        assert_eq!(ms2.min_scans, 2);
254        assert!(ms2.split_hills);
255
256        // Un-overridden fields inherit from [hills] (max_gap=2, lfc_weight=0.9,
257        // smoothing on / window=3), which differ from the HillsConfig defaults
258        // (0 / 0.5 / false / 1) — proving inheritance, not default fallback.
259        assert_eq!(ms2.max_gap, 2, "inherited from [hills], not default");
260        assert_eq!(ms2.lfc_weight, 0.9, "inherited from [hills], not default");
261        assert!(ms2.smoothing_enabled, "inherited from [hills]");
262        assert_eq!(ms2.smoothing_window, 3, "inherited from [hills]");
263
264        // [hills] itself is untouched by the MS2 override.
265        assert_eq!(cfg.hills.min_scans, 5);
266        assert!(!cfg.hills.split_hills);
267    }
268
269    /// (d) `deny_unknown_fields` still rejects a typo inside `[hills_ms2]`.
270    #[test]
271    fn hills_ms2_rejects_unknown_field() {
272        let toml_src = format!("{}\n[hills_ms2]\nmin_scanz = 2\n", base_config_toml());
273        let err = toml::from_str::<KothConfig>(&toml_src);
274        assert!(err.is_err(), "typo inside [hills_ms2] must be rejected");
275    }
276}