Skip to main content

uno_core/
input.rs

1//! Read single-run biosaur2/KOTH feature TSVs or aligned KOTH intensity matrices.
2//!
3//! Single-run tables retain feature-finding quality descriptors. Aligned
4//! matrices retain cross-run contributions and intensities, but omit several
5//! individual-feature descriptors. Optional matrix evidence columns supply
6//! validated summaries of KOTH's LFQ extraction diagnostics. Raw-file feature
7//! finding is handled separately by `uno::koth_ingest`.
8//!
9//! Columns are located BY NAME (order-independent). Required: `massCalib`, `mz`,
10//! `charge`, `rtApex`. Everything else is optional and read if present, with
11//! biosaur2 name aliases (`nIsotopes`↔`n_isotopes`, `nScans`↔`n_scans`).
12
13use anyhow::{anyhow, Context, Result};
14use std::path::Path;
15
16/// Optional, label-independent summaries derived from KOTH's LFQ details.
17/// All columns must precede `n_contributing_runs`; partial schemas are rejected.
18/// These describe extraction confidence, not peptide identification confidence.
19pub const MATRIX_EVIDENCE_COLUMNS: [&str; 9] = [
20    "matrix_confident_fraction",
21    "matrix_confident_direct_fraction",
22    "matrix_confident_mbr_fraction",
23    "matrix_median_log_confidence",
24    "matrix_median_isotope_pattern",
25    "matrix_median_coelution",
26    "matrix_median_isotopes",
27    "matrix_median_abs_ppm",
28    "matrix_median_abs_rt",
29];
30
31/// One LC-MS feature from a single run: a deisotoped, charge-resolved precursor
32/// mass at an apex RT, that we try to assign a peptide to.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Feature {
35    /// Monoisotopic neutral mass (Da).
36    pub mass: f64,
37    /// Monoisotopic m/z.
38    pub mz: f64,
39    pub charge: u8,
40    /// Apex retention time (minutes).
41    pub rt: f64,
42    /// Ion mobility (1/K0); `None` when the data has no IM dimension.
43    pub im: Option<f64>,
44    /// Feature-quality score (isotope x coelution), in [0, 1]. Defaults to 1.0
45    /// when the input has no `combined_score` column.
46    pub combined_score: f64,
47    /// Number of original runs contributing to a consensus feature; always 1
48    /// for single-run input. Does not count MBR-filled intensity cells.
49    pub n_contributing_runs: u32,
50    /// Per-feature shape descriptors: number of isotopic peaks and number of
51    /// scans the feature spans (biosaur2 `nIsotopes`/`nScans`). `None` if absent.
52    pub n_isotopes: Option<f64>,
53    pub n_scans: Option<f64>,
54    /// biosaur2 isotope-pattern fit error (deviation of the observed isotope
55    /// envelope from theory) and RT peak width (minutes). Real peptides fit
56    /// theory tightly; random mass coincidences (and shuffled decoys) do not.
57    pub isoerror: Option<f64>,
58    pub isoerror2: Option<f64>,
59    pub rt_width: Option<f64>,
60    /// Per-feature PFM-quality columns. These are the target/decoy discriminators
61    /// the PFM ML rescorer relies on (the same signal ms1searchpy's LightGBM PFM
62    /// filter uses to drop the random-match rate). `None` if the column is absent.
63    pub cosine_score: Option<f64>,
64    pub isotope_score: Option<f64>,
65    pub ppm_error: Option<f64>,
66    pub neutron_offset: Option<f64>,
67    /// One apex intensity for single-run input, or per-run integrated LFQ
68    /// intensities for matrix input. The estimators are not interchangeable.
69    pub intensities: Vec<f64>,
70}
71
72/// A single-run or aligned multi-run feature set.
73#[derive(Debug, Clone)]
74pub struct FeatureMatrix {
75    /// Run intensity column names in vector order.
76    pub run_names: Vec<String>,
77    pub features: Vec<Feature>,
78    /// Optional LFQ evidence, one vector per retained consensus feature.
79    /// Absent for ordinary matrices and single-run inputs: their classifier
80    /// vectors keep the exact original dimensions and column order.
81    pub matrix_evidence: Option<Vec<[f32; 9]>>,
82    /// Original, aligned observations per consensus row for one joint search.
83    /// Quantitative cells remain on `features`; observations never duplicate them.
84    pub joint_evidence: Option<Vec<Vec<crate::joint::Observation>>>,
85    /// Extraction summaries per joint row, independent of candidate identity.
86    pub joint_cell_evidence: Option<Vec<[f32; 12]>>,
87}
88
89impl FeatureMatrix {
90    pub fn len(&self) -> usize {
91        self.features.len()
92    }
93    pub fn is_empty(&self) -> bool {
94        self.features.is_empty()
95    }
96    /// Number of runs; 1 for single-run input.
97    pub fn n_samples(&self) -> usize {
98        self.run_names.len()
99    }
100
101    /// Load a single-run biosaur2 feature file (`*.features.tsv`).
102    pub fn load(features_path: impl AsRef<Path>) -> Result<Self> {
103        let (intensity_name, rows) =
104            read_features(features_path.as_ref()).with_context(|| "reading feature file")?;
105        let features = rows
106            .into_iter()
107            .map(MatrixRow::into_feature)
108            .collect::<Vec<_>>();
109        Ok(FeatureMatrix {
110            run_names: vec![intensity_name],
111            features,
112            matrix_evidence: None,
113            joint_evidence: None,
114            joint_cell_evidence: None,
115        })
116    }
117
118    /// Load a koth-aligned consensus matrix (`intensity_matrix.tsv`): one row per
119    /// consensus feature, with the leading columns `massCalib, mz, charge, rtApex,
120    /// im, combined_score, seed_run, n_contributing_runs` followed by one
121    /// intensity column per run. Unlike single-run input, each feature carries the
122    /// FULL per-run intensity vector (`intensities`) — that's what the multi-sample
123    /// quant rollup consumes, and the ID featurizer already reads `n_detected`
124    /// (runs with signal) and `sum_int` from it. The per-feature single-run quality
125    /// columns (cosine/isotope/ppm/nScans) are absent here and read as `None`
126    /// (inert in the featurizer); `combined_score` and `n_contributing_runs`
127    /// survive alignment and are used.
128    ///
129    /// `min_runs` drops consensus features detected in fewer than that many runs.
130    /// This filter is REQUIRED for aligned-matrix search, not cosmetic: alignment
131    /// discards the per-feature `nIsotopes` column that uno's single-run path uses
132    /// to reject junk, so without a reproducibility floor the random-match
133    /// background `p` climbs to ~0.83 and the binomial finds nothing. A real
134    /// peptide is detected across replicates; single-run features are dominated by
135    /// noise. `min_runs = 2` is a safe floor (won't drop a condition-specific
136    /// peptide that has ≥2 replicates).
137    pub fn load_matrix(path: impl AsRef<Path>, min_runs: u32) -> Result<Self> {
138        let path = path.as_ref();
139        let mut rdr = csv::ReaderBuilder::new()
140            .delimiter(b'\t')
141            .flexible(false)
142            .from_path(path)
143            .with_context(|| format!("opening {}", path.display()))?;
144        let header = rdr.headers()?.clone();
145        let col = |name: &str| -> Result<usize> {
146            header
147                .iter()
148                .position(|h| h == name)
149                .ok_or_else(|| anyhow!("aligned matrix missing column `{name}`"))
150        };
151        let (c_mass, c_mz, c_charge, c_rt) = (
152            col("massCalib")?,
153            col("mz")?,
154            col("charge")?,
155            col("rtApex")?,
156        );
157        let c_im = header.iter().position(|h| h == "im");
158        let c_cs = header.iter().position(|h| h == "combined_score");
159        let c_ncr = col("n_contributing_runs")?;
160        let evidence_cols: Vec<Option<usize>> = MATRIX_EVIDENCE_COLUMNS
161            .iter()
162            .map(|name| header.iter().position(|h| h == *name))
163            .collect();
164        let has_evidence = evidence_cols.iter().any(Option::is_some);
165        if has_evidence && evidence_cols.iter().any(|c| c.is_none_or(|c| c >= c_ncr)) {
166            return Err(anyhow!(
167                "matrix evidence requires all nine named columns before n_contributing_runs"
168            ));
169        }
170        let mut matrix_evidence = has_evidence.then(Vec::new);
171        // Run intensity columns are everything after `n_contributing_runs`.
172        let first_run = c_ncr + 1;
173        if first_run >= header.len() {
174            return Err(anyhow!("aligned matrix has no run intensity columns"));
175        }
176        let run_names: Vec<String> = header
177            .iter()
178            .skip(first_run)
179            .map(|s| s.to_string())
180            .collect();
181
182        let parse = |rec: &csv::StringRecord, c: usize, what: &str, line: usize| -> Result<f64> {
183            rec.get(c)
184                .unwrap_or("")
185                .parse::<f64>()
186                .map_err(|_| anyhow!("line {line}: cannot parse {what}"))
187        };
188        let mut features = Vec::new();
189        for (n, rec) in rdr.records().enumerate() {
190            let line = n + 2;
191            let rec = rec.with_context(|| format!("reading record at line {line}"))?;
192            let mut evidence = [0.0f32; 9];
193            if has_evidence {
194                for (i, c) in evidence_cols.iter().enumerate() {
195                    let value = parse(&rec, c.unwrap(), MATRIX_EVIDENCE_COLUMNS[i], line)?;
196                    if !value.is_finite()
197                        || value < 0.0
198                        || value > f32::MAX as f64
199                        || (matches!(i, 0 | 1 | 2 | 4 | 5) && value > 1.0)
200                    {
201                        return Err(anyhow!(
202                            "line {line}: invalid matrix evidence {}",
203                            MATRIX_EVIDENCE_COLUMNS[i]
204                        ));
205                    }
206                    evidence[i] = value as f32;
207                }
208            }
209            let intensities: Vec<f64> = (first_run..header.len())
210                .map(|c| {
211                    rec.get(c)
212                        .unwrap_or("0")
213                        .trim()
214                        .parse::<f64>()
215                        .unwrap_or(0.0)
216                })
217                .collect();
218            // Reproducibility floor uses koth's `n_contributing_runs` — the number
219            // of runs that DETECTED the feature, NOT the count of non-zero cells.
220            // With MBR, nearly every cell is filled, so counting non-zero cells
221            // would pass ~all junk (defeating the floor); the detected count still
222            // discriminates. Falls back to the non-zero count if the column is
223            // absent (e.g. an older matrix without it).
224            let n_detected = rec
225                .get(c_ncr)
226                .and_then(|s| s.trim().parse::<u32>().ok())
227                .unwrap_or_else(|| intensities.iter().filter(|&&x| x > 0.0).count() as u32);
228            if n_detected < min_runs {
229                continue;
230            }
231            if let Some(values) = &mut matrix_evidence {
232                values.push(evidence);
233            }
234            let im = c_im
235                .and_then(|c| rec.get(c))
236                .map(str::trim)
237                .filter(|s| !s.is_empty())
238                .and_then(|s| s.parse::<f64>().ok());
239            let combined_score = c_cs
240                .and_then(|c| rec.get(c))
241                .and_then(|s| s.trim().parse::<f64>().ok())
242                .unwrap_or(1.0);
243            features.push(Feature {
244                mass: parse(&rec, c_mass, "massCalib", line)?,
245                mz: parse(&rec, c_mz, "mz", line)?,
246                charge: parse(&rec, c_charge, "charge", line)? as u8,
247                rt: parse(&rec, c_rt, "rtApex", line)?,
248                im,
249                combined_score,
250                n_contributing_runs: n_detected.max(1),
251                n_isotopes: None,
252                n_scans: None,
253                isoerror: None,
254                isoerror2: None,
255                rt_width: None,
256                cosine_score: None,
257                isotope_score: None,
258                ppm_error: None,
259                neutron_offset: None,
260                intensities,
261            });
262        }
263        Ok(FeatureMatrix {
264            run_names,
265            features,
266            matrix_evidence,
267            joint_evidence: None,
268            joint_cell_evidence: None,
269        })
270    }
271}
272
273/// A parsed feature row.
274struct MatrixRow {
275    mass: f64,
276    mz: f64,
277    charge: u8,
278    rt: f64,
279    im: Option<f64>,
280    combined_score: f64,
281    n_isotopes: Option<f64>,
282    n_scans: Option<f64>,
283    isoerror: Option<f64>,
284    isoerror2: Option<f64>,
285    rt_width: Option<f64>,
286    cosine_score: Option<f64>,
287    isotope_score: Option<f64>,
288    ppm_error: Option<f64>,
289    neutron_offset: Option<f64>,
290    intensity: f64,
291}
292
293impl MatrixRow {
294    fn into_feature(self) -> Feature {
295        Feature {
296            mass: self.mass,
297            mz: self.mz,
298            charge: self.charge,
299            rt: self.rt,
300            im: self.im,
301            combined_score: self.combined_score,
302            n_contributing_runs: 1,
303            n_isotopes: self.n_isotopes,
304            n_scans: self.n_scans,
305            isoerror: self.isoerror,
306            isoerror2: self.isoerror2,
307            rt_width: self.rt_width,
308            cosine_score: self.cosine_score,
309            isotope_score: self.isotope_score,
310            ppm_error: self.ppm_error,
311            neutron_offset: self.neutron_offset,
312            intensities: vec![self.intensity],
313        }
314    }
315}
316
317/// Column indices within a single-run feature-file header, located by name.
318struct Layout {
319    mass: usize,
320    mz: usize,
321    charge: usize,
322    rt: usize,
323    im: Option<usize>,
324    combined_score: Option<usize>,
325    n_isotopes: Option<usize>,
326    n_scans: Option<usize>,
327    isoerror: Option<usize>,
328    isoerror2: Option<usize>,
329    rt_width: Option<usize>,
330    cosine_score: Option<usize>,
331    isotope_score: Option<usize>,
332    ppm_error: Option<usize>,
333    neutron_offset: Option<usize>,
334    intensity: Option<usize>,
335    intensity_name: String,
336}
337
338impl Layout {
339    fn from_header(header: &csv::StringRecord) -> Result<Self> {
340        let find = |name: &str| -> Result<usize> {
341            header
342                .iter()
343                .position(|h| h == name)
344                .ok_or_else(|| anyhow!("missing required column `{name}` in feature-file header"))
345        };
346        // Find the first present of a set of alias names.
347        let find_any = |names: &[&str]| -> Option<usize> {
348            names
349                .iter()
350                .find_map(|&n| header.iter().position(|h| h == n))
351        };
352
353        // Intensity: prefer apex, fall back to sum.
354        let (intensity, intensity_name) =
355            match find_any(&["intensityApex", "intensitySum", "intensity"]) {
356                Some(i) => (Some(i), header.get(i).unwrap_or("intensity").to_string()),
357                None => (None, "intensity".to_string()),
358            };
359
360        Ok(Layout {
361            mass: find("massCalib")?,
362            mz: find("mz")?,
363            charge: find("charge")?,
364            rt: find("rtApex")?,
365            im: find_any(&["im", "ion_mobility", "FAIMS"]),
366            combined_score: find_any(&["combined_score"]),
367            n_isotopes: find_any(&["n_isotopes", "nIsotopes"]),
368            n_scans: find_any(&["n_scans", "nScans"]),
369            isoerror: find_any(&["isoerror"]),
370            isoerror2: find_any(&["isoerror2"]),
371            rt_width: find_any(&["rt_width"]),
372            cosine_score: find_any(&["cosine_score"]),
373            isotope_score: find_any(&["isotope_score"]),
374            ppm_error: find_any(&["ppm_error"]),
375            neutron_offset: find_any(&["neutron_offset"]),
376            intensity,
377            intensity_name,
378        })
379    }
380}
381
382/// Read a single-run feature TSV. Returns (intensity_column_name, rows).
383fn read_features(path: &Path) -> Result<(String, Vec<MatrixRow>)> {
384    let mut rdr = csv::ReaderBuilder::new()
385        .delimiter(b'\t')
386        .flexible(false)
387        .from_path(path)
388        .with_context(|| format!("opening {}", path.display()))?;
389
390    let header = rdr.headers()?.clone();
391    let layout = Layout::from_header(&header)?;
392
393    let parse_f64 = |rec: &csv::StringRecord, col: usize, what: &str, line: usize| -> Result<f64> {
394        let s = rec.get(col).unwrap_or("");
395        s.parse::<f64>()
396            .map_err(|_| anyhow!("line {line}: cannot parse {what} `{s}` as number"))
397    };
398
399    let mut rows = Vec::new();
400    for (n, rec) in rdr.records().enumerate() {
401        let line = n + 2; // 1-based, +1 for header
402        let rec = rec.with_context(|| format!("reading record at line {line}"))?;
403
404        let opt_f64 = |col: Option<usize>| -> Option<f64> {
405            col.and_then(|c| rec.get(c))
406                .map(str::trim)
407                .filter(|s| !s.is_empty())
408                .and_then(|s| s.parse::<f64>().ok())
409        };
410
411        let charge = rec
412            .get(layout.charge)
413            .unwrap_or("")
414            .parse::<u8>()
415            .map_err(|_| anyhow!("line {line}: cannot parse charge"))?;
416
417        rows.push(MatrixRow {
418            mass: parse_f64(&rec, layout.mass, "massCalib", line)?,
419            mz: parse_f64(&rec, layout.mz, "mz", line)?,
420            charge,
421            rt: parse_f64(&rec, layout.rt, "rtApex", line)?,
422            im: opt_f64(layout.im),
423            // No combined_score column → treat as 1.0 (neutral quality).
424            combined_score: opt_f64(layout.combined_score).unwrap_or(1.0),
425            n_isotopes: opt_f64(layout.n_isotopes),
426            n_scans: opt_f64(layout.n_scans),
427            isoerror: opt_f64(layout.isoerror),
428            isoerror2: opt_f64(layout.isoerror2),
429            rt_width: opt_f64(layout.rt_width),
430            cosine_score: opt_f64(layout.cosine_score),
431            isotope_score: opt_f64(layout.isotope_score),
432            ppm_error: opt_f64(layout.ppm_error),
433            neutron_offset: opt_f64(layout.neutron_offset),
434            intensity: opt_f64(layout.intensity).unwrap_or(0.0),
435        });
436    }
437
438    Ok((layout.intensity_name, rows))
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    fn matrix_fixture(evidence: bool, invalid: bool) -> std::path::PathBuf {
446        let stamp = std::time::SystemTime::now()
447            .duration_since(std::time::UNIX_EPOCH)
448            .unwrap()
449            .as_nanos();
450        let path =
451            std::env::temp_dir().join(format!("uno-matrix-{}-{stamp}.tsv", std::process::id()));
452        let extra = if evidence {
453            format!("{}\t", MATRIX_EVIDENCE_COLUMNS.join("\t"))
454        } else {
455            String::new()
456        };
457        let values = if evidence {
458            if invalid {
459                "NaN\t0\t0\t0\t0\t0\t0\t0\t0\t"
460            } else {
461                "0.5\t0.25\t0.25\t2\t0.8\t0.9\t3\t1.2\t0.1\t"
462            }
463        } else {
464            ""
465        };
466        std::fs::write(
467            &path,
468            format!(
469                "massCalib\tmz\tcharge\trtApex\t{extra}n_contributing_runs\tA\tB\n\
470             1000\t501\t2\t10\t{values}1\t100\t0\n\
471             1100\t551\t2\t12\t{values}2\t200\t300\n"
472            ),
473        )
474        .unwrap();
475        path
476    }
477
478    #[test]
479    fn ordinary_matrix_keeps_original_classifier_schema() {
480        let path = matrix_fixture(false, false);
481        let m = FeatureMatrix::load_matrix(&path, 2).unwrap();
482        std::fs::remove_file(path).unwrap();
483        assert_eq!(m.run_names, ["A", "B"]);
484        assert_eq!(m.len(), 1);
485        assert_eq!(m.features[0].intensities, [200.0, 300.0]);
486        assert!(m.matrix_evidence.is_none());
487    }
488
489    #[test]
490    fn matrix_evidence_tracks_filtered_feature_rows() {
491        let path = matrix_fixture(true, false);
492        let m = FeatureMatrix::load_matrix(&path, 2).unwrap();
493        std::fs::remove_file(path).unwrap();
494        assert_eq!(m.run_names, ["A", "B"]);
495        assert_eq!(m.features[0].mass, 1100.0);
496        let values = m.matrix_evidence.unwrap();
497        assert_eq!(values.len(), 1);
498        assert_eq!(values[0], [0.5, 0.25, 0.25, 2.0, 0.8, 0.9, 3.0, 1.2, 0.1]);
499    }
500
501    #[test]
502    fn matrix_evidence_rejects_nonfinite_values_even_in_filtered_rows() {
503        let path = matrix_fixture(true, true);
504        let error = FeatureMatrix::load_matrix(&path, 2).unwrap_err();
505        std::fs::remove_file(path).unwrap();
506        assert!(error.to_string().contains("invalid matrix evidence"));
507    }
508
509    #[test]
510    fn matrix_evidence_rejects_partial_schema() {
511        let path = matrix_fixture(true, false);
512        let text = std::fs::read_to_string(&path)
513            .unwrap()
514            .replace("matrix_median_abs_rt", "unexpected");
515        std::fs::write(&path, text).unwrap();
516        let error = FeatureMatrix::load_matrix(&path, 2).unwrap_err();
517        std::fs::remove_file(path).unwrap();
518        assert!(error.to_string().contains("all nine named columns"));
519    }
520
521    #[test]
522    fn loads_single_run_features() {
523        let Some(path) = crate::test_input("tests/data/09744.features.tsv") else {
524            return;
525        };
526        let m = FeatureMatrix::load(&path).expect("load features");
527        assert_eq!(m.n_samples(), 1, "single-run input");
528        assert!(m.len() > 1000, "expected many features, got {}", m.len());
529
530        let f = &m.features[0];
531        assert!(f.charge >= 1);
532        assert!(f.mass > 0.0);
533        assert_eq!(f.intensities.len(), 1);
534        // The quality columns that separate target from decoy must be present on
535        // single-run input (the reason we do not use aligned matrices).
536        assert!(
537            f.cosine_score.is_some() || f.isotope_score.is_some(),
538            "single-run features must carry PFM-quality columns"
539        );
540    }
541}