Skip to main content

openmassspec_core/
conformance.rs

1//! Conformance checks for any [`crate::SpectrumSource`].
2//!
3//! This module hosts a small invariant suite that every vendor crate runs
4//! in its integration tests against a real fixture. It is *not* a
5//! validator for arbitrary mzML files; it operates on the in-memory
6//! `SpectrumRecord` stream so failures pinpoint the parser, not the
7//! writer.
8//!
9//! Invariants checked (per spectrum unless noted):
10//!
11//! 1. `mz.len() == intensity.len()`. Empty spectra are allowed because
12//!    sparse PASEF / SRM frames can legitimately yield zero peaks within
13//!    a scan range.
14//! 2. `inv_mobility_per_peak`, when present, has the same length as `mz`.
15//! 3. `total_ion_current`, when provided by the parser, equals
16//!    `sum(intensity)` within a relative tolerance.
17//! 4. `base_peak_intensity`, when provided, equals the maximum of
18//!    `intensity` within tolerance.
19//! 5. MS2+ spectra carry a [`crate::PrecursorInfo`] with at least one of
20//!    `target_mz`, `selected_mz`, or `precursor_native_id` populated.
21//! 6. Retention time is non-decreasing within the spectrum stream of one
22//!    function / acquisition. The check is per native-ID prefix so
23//!    interleaved Waters functions or Bruker MS1/MS2 frames are not
24//!    flagged.
25//! 7. The first spectrum's `index` is 0 and indices are strictly
26//!    increasing.
27//!
28//! On failure each check emits a [`ConformanceError`] with the offending
29//! `native_id` so the operator can jump straight to the problem.
30
31use std::collections::HashMap;
32
33use crate::source::SpectrumSource;
34use crate::types::SpectrumRecord;
35
36/// Relative tolerance applied to TIC / base-peak floating-point checks.
37const FLOAT_REL_TOL: f64 = 1e-4;
38
39/// Failure modes detected by [`assert_source_invariants`].
40///
41/// The enum and each variant are `#[non_exhaustive]`; match with `{ .. }`
42/// patterns and a wildcard arm. Field names say what they hold: the
43/// offending spectrum's `native_id`, the value the parser declared, and the
44/// value computed from the arrays.
45#[derive(Debug)]
46#[non_exhaustive]
47#[allow(missing_docs)]
48pub enum ConformanceError {
49    /// The source yielded `Err` instead of a spectrum. `position` is the
50    /// zero-based position in the stream.
51    #[non_exhaustive]
52    Source {
53        position: usize,
54        error: crate::Error,
55    },
56    /// `mz` / `intensity` arrays have mismatched lengths.
57    #[non_exhaustive]
58    PeakArrayLengthMismatch {
59        native_id: String,
60        mz_len: usize,
61        intensity_len: usize,
62    },
63    /// `inv_mobility_per_peak.len()` did not match `mz.len()`.
64    #[non_exhaustive]
65    MobilityArrayLengthMismatch {
66        native_id: String,
67        mz_len: usize,
68        mobility_len: usize,
69    },
70    /// `total_ion_current` did not match `sum(intensity)` within tolerance.
71    #[non_exhaustive]
72    TicMismatch {
73        native_id: String,
74        declared: f64,
75        computed: f64,
76    },
77    /// `base_peak_intensity` did not match `max(intensity)` within tolerance.
78    #[non_exhaustive]
79    BasePeakIntensityMismatch {
80        native_id: String,
81        declared: f64,
82        computed: f64,
83    },
84    /// MS2+ spectrum was missing precursor info.
85    #[non_exhaustive]
86    MissingPrecursor { native_id: String, ms_level: u32 },
87    /// Retention time went backwards within one acquisition stream.
88    #[non_exhaustive]
89    RetentionTimeNonMonotonic {
90        prefix: String,
91        previous: f64,
92        current: f64,
93        native_id: String,
94    },
95    /// Spectrum index sequence was not strictly increasing or did not start at 0.
96    #[non_exhaustive]
97    IndexSequence {
98        native_id: String,
99        previous: Option<usize>,
100        current: usize,
101    },
102    /// Spectrum had no peaks at all.
103    #[non_exhaustive]
104    EmptySpectrum { native_id: String },
105}
106
107impl std::fmt::Display for ConformanceError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::Source { position, error } => {
111                write!(f, "source error at stream position {position}: {error}")
112            }
113            Self::PeakArrayLengthMismatch {
114                native_id,
115                mz_len,
116                intensity_len,
117            } => write!(
118                f,
119                "{native_id}: mz.len()={mz_len} != intensity.len()={intensity_len}"
120            ),
121            Self::MobilityArrayLengthMismatch {
122                native_id,
123                mz_len,
124                mobility_len,
125            } => write!(
126                f,
127                "{native_id}: inv_mobility_per_peak.len()={mobility_len} != mz.len()={mz_len}"
128            ),
129            Self::TicMismatch {
130                native_id,
131                declared,
132                computed,
133            } => {
134                write!(
135                    f,
136                    "{native_id}: declared TIC {declared} != computed {computed}"
137                )
138            }
139            Self::BasePeakIntensityMismatch {
140                native_id,
141                declared,
142                computed,
143            } => write!(
144                f,
145                "{native_id}: declared base-peak intensity {declared} != max(intensity) {computed}"
146            ),
147            Self::MissingPrecursor {
148                native_id,
149                ms_level,
150            } => write!(f, "{native_id}: ms_level={ms_level} but no precursor info"),
151            Self::RetentionTimeNonMonotonic {
152                prefix,
153                previous,
154                current,
155                native_id,
156            } => write!(
157                f,
158                "{native_id}: RT regressed in stream '{prefix}' ({previous} -> {current})"
159            ),
160            Self::IndexSequence {
161                native_id,
162                previous,
163                current,
164            } => write!(
165                f,
166                "{native_id}: index sequence broken (prev={previous:?}, current={current})"
167            ),
168            Self::EmptySpectrum { native_id } => write!(f, "{native_id}: empty spectrum"),
169        }
170    }
171}
172
173impl std::error::Error for ConformanceError {
174    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
175        match self {
176            Self::Source { error, .. } => Some(error),
177            _ => None,
178        }
179    }
180}
181
182fn rel_close(a: f64, b: f64, tol: f64) -> bool {
183    let scale = a.abs().max(b.abs()).max(1.0);
184    (a - b).abs() <= tol * scale
185}
186
187/// Per-native-ID-prefix key used to scope retention-time monotonicity.
188///
189/// We split on the first whitespace and strip any trailing `scan=...`
190/// token so that mzML native IDs like `function=2 process=0 scan=17`,
191/// `frame=12 scan=8`, and `controllerType=0 controllerNumber=1 scan=42`
192/// each group correctly.
193fn rt_stream_key(native_id: &str) -> String {
194    native_id
195        .split_whitespace()
196        .filter(|tok| !tok.starts_with("scan="))
197        .collect::<Vec<_>>()
198        .join(" ")
199}
200
201/// Run the invariant suite against a borrowed source. Returns the first
202/// failure encountered, or `Ok(spectrum_count)` if every spectrum passed.
203///
204/// The source is iterated to completion on success; on failure iteration
205/// stops at the offending spectrum. An `Err` item from the source fails the
206/// check as [`ConformanceError::Source`].
207pub fn assert_source_invariants<S: SpectrumSource + ?Sized>(
208    src: &mut S,
209) -> Result<usize, ConformanceError> {
210    check_stream(
211        src.iter_spectra().enumerate().map(|(position, item)| {
212            item.map_err(|error| ConformanceError::Source { position, error })
213        }),
214    )
215}
216
217/// Run the invariant suite against any iterator yielding already-decoded
218/// spectra, e.g. records read back from an mzML file.
219pub fn assert_iter_invariants<I: IntoIterator<Item = SpectrumRecord>>(
220    iter: I,
221) -> Result<usize, ConformanceError> {
222    check_stream(iter.into_iter().map(Ok))
223}
224
225fn check_stream(
226    iter: impl Iterator<Item = Result<SpectrumRecord, ConformanceError>>,
227) -> Result<usize, ConformanceError> {
228    let mut last_index: Option<usize> = None;
229    let mut last_rt: HashMap<String, f64> = HashMap::new();
230    let mut count = 0usize;
231    for spectrum in iter {
232        let spectrum = spectrum?;
233        check_one(&spectrum, last_index, &mut last_rt)?;
234        last_index = Some(spectrum.index);
235        count += 1;
236    }
237    Ok(count)
238}
239
240fn check_one(
241    s: &SpectrumRecord,
242    last_index: Option<usize>,
243    last_rt: &mut HashMap<String, f64>,
244) -> Result<(), ConformanceError> {
245    if s.mz.len() != s.intensity.len() {
246        return Err(ConformanceError::PeakArrayLengthMismatch {
247            native_id: s.native_id.clone(),
248            mz_len: s.mz.len(),
249            intensity_len: s.intensity.len(),
250        });
251    }
252    if let Some(mob) = &s.inv_mobility_per_peak {
253        if mob.len() != s.mz.len() {
254            return Err(ConformanceError::MobilityArrayLengthMismatch {
255                native_id: s.native_id.clone(),
256                mz_len: s.mz.len(),
257                mobility_len: mob.len(),
258            });
259        }
260    }
261    if let Some(tic) = s.total_ion_current {
262        let computed: f64 = s.intensity.iter().map(|&v| v as f64).sum();
263        if !rel_close(tic, computed, FLOAT_REL_TOL) {
264            return Err(ConformanceError::TicMismatch {
265                native_id: s.native_id.clone(),
266                declared: tic,
267                computed,
268            });
269        }
270    }
271    if let Some(bp) = s.base_peak_intensity {
272        if s.intensity.is_empty() {
273            // Nothing to compare against; trust the parser.
274            let _ = bp;
275        } else {
276            let computed = s
277                .intensity
278                .iter()
279                .copied()
280                .fold(f32::NEG_INFINITY, f32::max) as f64;
281            if !rel_close(bp, computed, FLOAT_REL_TOL) {
282                return Err(ConformanceError::BasePeakIntensityMismatch {
283                    native_id: s.native_id.clone(),
284                    declared: bp,
285                    computed,
286                });
287            }
288        }
289    }
290    if s.ms_level >= 2 {
291        let has_precursor = match &s.precursor {
292            Some(p) => {
293                p.target_mz.is_some() || p.selected_mz.is_some() || p.precursor_native_id.is_some()
294            }
295            None => false,
296        };
297        if !has_precursor {
298            return Err(ConformanceError::MissingPrecursor {
299                native_id: s.native_id.clone(),
300                ms_level: s.ms_level,
301            });
302        }
303    }
304    match last_index {
305        None => {
306            if s.index != 0 {
307                return Err(ConformanceError::IndexSequence {
308                    native_id: s.native_id.clone(),
309                    previous: None,
310                    current: s.index,
311                });
312            }
313        }
314        Some(prev) => {
315            if s.index <= prev {
316                return Err(ConformanceError::IndexSequence {
317                    native_id: s.native_id.clone(),
318                    previous: Some(prev),
319                    current: s.index,
320                });
321            }
322        }
323    }
324    let key = rt_stream_key(&s.native_id);
325    if let Some(prev) = last_rt.get(&key).copied() {
326        // Allow tiny floating-point regressions (<1 us).
327        if s.retention_time_sec + 1e-6 < prev {
328            return Err(ConformanceError::RetentionTimeNonMonotonic {
329                prefix: key,
330                previous: prev,
331                current: s.retention_time_sec,
332                native_id: s.native_id.clone(),
333            });
334        }
335    }
336    last_rt.insert(key, s.retention_time_sec);
337    Ok(())
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::types::RunMetadata;
344    use crate::{Polarity, PrecursorInfo, SpectrumRecord};
345
346    /// Yields `Err` instead of the spectrum at position `.1`, when set.
347    struct ToySource(Vec<SpectrumRecord>, Option<usize>);
348
349    impl SpectrumSource for ToySource {
350        fn run_metadata(&self) -> RunMetadata {
351            RunMetadata::new(
352                "toy",
353                crate::CvTerm::new("MS:1000563", "Thermo RAW format"),
354                crate::CvTerm::new("MS:1000768", "Thermo nativeID format"),
355                crate::CvTerm::new("MS:1000483", "Thermo Fisher Scientific instrument model"),
356                "test",
357                "0.0",
358            )
359        }
360        fn iter_spectra<'a>(&'a mut self) -> crate::SpectrumIter<'a> {
361            let fail_at = self.1;
362            Box::new(self.0.clone().into_iter().enumerate().map(move |(i, s)| {
363                if Some(i) == fail_at {
364                    Err(crate::Error::Format("bad block".into()))
365                } else {
366                    Ok(s)
367                }
368            }))
369        }
370    }
371
372    #[test]
373    fn source_error_fails_conformance() {
374        let mut src = ToySource(vec![ok_spec(0, 1, 1.0), ok_spec(1, 1, 2.0)], Some(1));
375        let err = assert_source_invariants(&mut src).unwrap_err();
376        assert!(
377            matches!(err, ConformanceError::Source { position: 1, .. }),
378            "{err:?}"
379        );
380        assert!(std::error::Error::source(&err).is_some());
381    }
382
383    #[test]
384    fn iter_invariants_accept_plain_records() {
385        assert_eq!(
386            assert_iter_invariants(vec![ok_spec(0, 1, 1.0), ok_spec(1, 2, 2.0)]).unwrap(),
387            2
388        );
389    }
390
391    fn ok_spec(index: usize, ms_level: u32, rt: f64) -> SpectrumRecord {
392        let mz = vec![100.0, 200.0];
393        let intensity = vec![10.0f32, 20.0];
394        SpectrumRecord {
395            index,
396            scan_number: (index + 1) as u32,
397            native_id: format!("controllerType=0 controllerNumber=1 scan={}", index + 1),
398            ms_level,
399            polarity: Some(Polarity::Positive),
400            scan_mode: None,
401            analyzer: None,
402            acquisition_event_id: None,
403            filter: None,
404            retention_time_sec: rt,
405            total_ion_current: Some(30.0),
406            base_peak_mz: Some(200.0),
407            base_peak_intensity: Some(20.0),
408            low_mz: Some(100.0),
409            high_mz: Some(200.0),
410            ion_injection_time_ms: None,
411            inv_mobility: None,
412            faims_cv: None,
413            precursor: if ms_level >= 2 {
414                Some(PrecursorInfo {
415                    selected_mz: Some(500.0),
416                    ..Default::default()
417                })
418            } else {
419                None
420            },
421            mz,
422            intensity,
423            inv_mobility_per_peak: None,
424            extra: Default::default(),
425        }
426    }
427
428    #[test]
429    fn happy_path_passes() {
430        let mut src = ToySource(
431            vec![ok_spec(0, 1, 1.0), ok_spec(1, 2, 2.0), ok_spec(2, 1, 3.0)],
432            None,
433        );
434        assert_eq!(assert_source_invariants(&mut src).unwrap(), 3);
435    }
436
437    #[test]
438    fn detects_tic_mismatch() {
439        let mut s = ok_spec(0, 1, 1.0);
440        s.total_ion_current = Some(999.0);
441        let mut src = ToySource(vec![s], None);
442        let err = assert_source_invariants(&mut src).unwrap_err();
443        assert!(matches!(err, ConformanceError::TicMismatch { .. }));
444    }
445
446    #[test]
447    fn detects_missing_precursor() {
448        let mut s = ok_spec(0, 2, 1.0);
449        s.precursor = None;
450        let mut src = ToySource(vec![s], None);
451        let err = assert_source_invariants(&mut src).unwrap_err();
452        assert!(matches!(err, ConformanceError::MissingPrecursor { .. }));
453    }
454
455    #[test]
456    fn detects_rt_regression_within_same_stream() {
457        let s0 = ok_spec(0, 1, 5.0);
458        let s1 = ok_spec(1, 1, 2.0);
459        let mut src = ToySource(vec![s0, s1], None);
460        let err = assert_source_invariants(&mut src).unwrap_err();
461        assert!(matches!(
462            err,
463            ConformanceError::RetentionTimeNonMonotonic { .. }
464        ));
465    }
466
467    #[test]
468    fn detects_mobility_length_mismatch() {
469        let mut s = ok_spec(0, 1, 1.0);
470        s.inv_mobility_per_peak = Some(vec![0.5]);
471        let mut src = ToySource(vec![s], None);
472        let err = assert_source_invariants(&mut src).unwrap_err();
473        assert!(matches!(
474            err,
475            ConformanceError::MobilityArrayLengthMismatch { .. }
476        ));
477    }
478}