1use crate::input::{Feature, FeatureMatrix};
4use anyhow::{ensure, Context, Result};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use std::{
8 collections::{HashMap, HashSet},
9 fs::File,
10 io::Read,
11 path::{Path, PathBuf},
12};
13
14#[derive(Deserialize)]
15struct Table {
16 path: String,
17 sha256: String,
18}
19#[derive(Deserialize)]
20pub struct Run {
21 pub run_id: usize,
22 pub name: String,
23 pub n_observations: usize,
24 #[serde(default)]
25 sha256: Option<String>,
26}
27#[derive(Deserialize)]
28pub struct Bundle {
29 #[serde(default)]
30 config: Option<serde_json::Value>,
31 schema_version: u32,
32 n_consensus: usize,
33 reference_run_id: usize,
34 pub runs: Vec<Run>,
35 observations: Table,
36 cells: Table,
37 #[serde(skip)]
38 root: PathBuf,
39}
40
41pub fn file_sha(path: &Path) -> Result<String> {
42 let mut f = File::open(path)?;
43 let mut h = Sha256::new();
44 let mut buf = [0; 65536];
45 loop {
46 let n = f.read(&mut buf)?;
47 if n == 0 {
48 break;
49 }
50 h.update(&buf[..n]);
51 }
52 Ok(format!("{:x}", h.finalize()))
53}
54impl Bundle {
55 pub fn open(path: &Path) -> Result<Self> {
56 let path = if path.is_dir() {
57 path.join("matrix_manifest.json")
58 } else {
59 path.to_owned()
60 };
61 let mut b: Self = serde_json::from_reader(File::open(&path)?)?;
62 b.root = path
63 .parent()
64 .context("manifest requires parent directory")?
65 .to_owned();
66 ensure!(
67 (1..=3).contains(&b.schema_version),
68 "unsupported long-matrix schema version {} (supported: 1, 2, 3)",
69 b.schema_version
70 );
71 ensure!(
72 !b.runs.is_empty() && b.reference_run_id < b.runs.len(),
73 "invalid run/reference list"
74 );
75 let mut names = HashSet::new();
76 for (i, r) in b.runs.iter().enumerate() {
77 ensure!(
78 r.run_id == i && names.insert(&r.name),
79 "duplicate or invalid run ID/name"
80 );
81 }
82 for t in [&b.observations, &b.cells] {
83 ensure!(
84 matches!(
85 Path::new(&t.path).components().next(),
86 Some(std::path::Component::Normal(_))
87 ) && Path::new(&t.path).components().count() == 1,
88 "table path must be a filename"
89 );
90 ensure!(
91 file_sha(&b.root.join(&t.path))? == t.sha256,
92 "bundle table checksum mismatch: {}",
93 t.path
94 );
95 }
96 Ok(b)
97 }
98 fn observations(&self, mut visit: impl FnMut(Observation) -> Result<()>) -> Result<()> {
99 let mut rdr = csv::ReaderBuilder::new()
100 .delimiter(b'\t')
101 .from_path(self.root.join(&self.observations.path))?;
102 let mut counts = vec![0; self.runs.len()];
103 let mut previous = None;
104 for row in rdr.deserialize::<Observation>() {
105 let o = row?;
106 ensure!(o.run_id < counts.len(), "unknown observation run");
107 ensure!(
108 o.observation_id == counts[o.run_id],
109 "duplicate, missing, or unordered observation ID"
110 );
111 ensure!(
112 previous.is_none_or(|r| o.run_id >= r),
113 "observations must be ordered by run"
114 );
115 previous = Some(o.run_id);
116 counts[o.run_id] += 1;
117 o.validate()?;
118 if let Some(c) = o.consensus_id {
119 ensure!(c < self.n_consensus, "invalid observation consensus");
120 }
121 visit(o)?;
122 }
123 ensure!(
124 counts
125 .iter()
126 .zip(&self.runs)
127 .all(|(n, r)| *n == r.n_observations),
128 "incomplete observations table"
129 );
130 Ok(())
131 }
132 pub fn load_run(&self, run: usize) -> Result<FeatureMatrix> {
134 ensure!(run < self.runs.len(), "unknown run");
135 let mut features = Vec::new();
136 self.observations(|o| {
137 if o.run_id == run {
138 features.push(o.feature());
139 }
140 Ok(())
141 })?;
142 Ok(FeatureMatrix {
143 run_names: vec![self.runs[run].name.clone()],
144 features,
145 matrix_evidence: None,
146 joint_evidence: None,
147 joint_cell_evidence: None,
148 })
149 }
150 pub fn load_joint(&self, min_runs: u32) -> Result<FeatureMatrix> {
154 let mut matrix = self.load_consensus(0, true, false)?;
156 let mut groups = vec![Vec::new(); self.n_consensus];
157 let seed_floor = if self.schema_version == 1 {
161 self.config
162 .as_ref()
163 .and_then(|c| c.pointer("/lfq/consensus/min_seed_combined_score"))
164 .and_then(serde_json::Value::as_f64)
165 .unwrap_or(0.75)
166 } else {
167 0.0
168 };
169 ensure!(
170 seed_floor.is_finite() && (0.0..=1.0).contains(&seed_floor),
171 "invalid joint bootstrap quality floor"
172 );
173 let mut source_ids = HashMap::new();
174 let run_ids: Vec<usize> = self
175 .runs
176 .iter()
177 .map(|r| {
178 r.sha256
179 .as_ref()
180 .map_or(r.run_id, |hash| *source_ids.entry(hash).or_insert(r.run_id))
181 })
182 .collect();
183 self.observations(|o| {
184 if let Some(c) = o.consensus_id {
185 let mut f = o.feature();
186 f.mass = o
187 .aligned_mass
188 .context("joint input requires aligned mass for every member")?;
189 f.rt = o
190 .aligned_rt
191 .context("joint input requires aligned RT for every member")?;
192 f.im = o.aligned_im;
193 ensure!(
194 f.charge == matrix.features[c].charge,
195 "member/consensus charge mismatch"
196 );
197 f.mz = f.mass / f.charge as f64 + 1.007_276_466_621;
198 groups[c].push(crate::joint::Observation {
199 bootstrap_eligible: matrix.features[c].combined_score >= seed_floor,
200 run_id: run_ids[o.run_id],
201 observation_id: o.observation_id,
202 feature: f,
203 });
204 }
205 Ok(())
206 })?;
207 let mut cell_summaries = vec![crate::cell_evidence::Summary::default(); self.n_consensus];
210 let mut rdr = csv::ReaderBuilder::new()
211 .delimiter(b'\t')
212 .from_path(self.root.join(&self.cells.path))?;
213 for row in rdr.deserialize::<Cell>() {
214 let e = row?;
215 if e.extraction_kind == "target" && run_ids[e.run_id] == e.run_id {
216 cell_summaries[e.consensus_id].add(e.lfq_is_mbr, e.lfq_intensity, e.lfq_q_value);
217 }
218 }
219 let n_sources = run_ids.iter().copied().collect::<HashSet<_>>().len();
220 let mut retained_cells = Vec::new();
221 let mut retained = Vec::new();
222 let mut retained_groups = Vec::new();
223 for ((cell, mut group), summary) in
224 matrix.features.into_iter().zip(groups).zip(cell_summaries)
225 {
226 group.sort_by_key(|o| (o.run_id, o.observation_id));
227 group.dedup_by_key(|o| (o.run_id, o.observation_id));
230 let n_runs = group.iter().map(|o| o.run_id).collect::<HashSet<_>>().len();
231 if n_runs < min_runs.max(1) as usize {
232 continue;
233 }
234 let representative = group
237 .iter()
238 .max_by(|a, b| {
239 let a_iso = a.feature.n_isotopes.is_some_and(|n| n >= 3.0);
240 let b_iso = b.feature.n_isotopes.is_some_and(|n| n >= 3.0);
241 a_iso
242 .cmp(&b_iso)
243 .then(
244 a.feature
245 .combined_score
246 .total_cmp(&b.feature.combined_score),
247 )
248 .then_with(|| {
249 (b.run_id, b.observation_id).cmp(&(a.run_id, a.observation_id))
250 })
251 })
252 .context("joint group has no original members")?;
253 let mut f = representative.feature.clone();
254 f.intensities = cell.intensities; f.n_contributing_runs = n_runs as u32;
256 retained.push(f);
257 retained_groups.push(group);
258 retained_cells.push(summary.values(n_sources));
259 }
260 log::info!(
261 "joint matrix: {} groups, {} original observations, {} runs, min_runs={}",
262 retained.len(),
263 retained_groups.iter().map(Vec::len).sum::<usize>(),
264 self.runs.len(),
265 min_runs.max(1)
266 );
267 matrix.features = retained;
268 matrix.joint_evidence = Some(retained_groups);
269 matrix.joint_cell_evidence = Some(retained_cells);
270 Ok(matrix)
271 }
272 pub fn load_consensus(
273 &self,
274 min_runs: u32,
275 quality: bool,
276 legacy_values: bool,
277 ) -> Result<FeatureMatrix> {
278 self.load_consensus_with_representative(min_runs, quality, legacy_values, false)
279 }
280 pub fn load_consensus_with_representative(
283 &self,
284 min_runs: u32,
285 quality: bool,
286 legacy_values: bool,
287 prefer_isotopes: bool,
288 ) -> Result<FeatureMatrix> {
289 ensure!(
290 !prefer_isotopes || quality,
291 "isotope representative requires original quality"
292 );
293 let nr = self.runs.len();
294 let mut features: Vec<Option<Feature>> = vec![None; self.n_consensus];
295 let mut seeds = vec![None; self.n_consensus];
296 let mut cells_seen = HashSet::new();
297 let mut primary: HashMap<(usize, usize), usize> = HashMap::new();
298 let mut direct = vec![0usize; self.n_consensus];
299 let mut group_evidence = vec![None; self.n_consensus];
300 let mut rdr = csv::ReaderBuilder::new()
301 .delimiter(b'\t')
302 .from_path(self.root.join(&self.cells.path))?;
303 let release_columns = [
304 "group_score",
305 "group_qvalue",
306 "lfq_ownership_status",
307 "lfq_owned_samples",
308 "lfq_excluded_samples",
309 "lfq_competing_consensus_id",
310 "lfq_preceding_signal_fraction",
311 ];
312 let required = match self.schema_version {
313 1 => 0,
314 2 => 2,
315 _ => release_columns.len(),
316 };
317 for field in &release_columns[..required] {
318 ensure!(
319 rdr.headers()?.iter().any(|h| h == *field),
320 "schema {} requires column {field}",
321 self.schema_version
322 );
323 }
324 for row in rdr.deserialize::<Cell>() {
325 let e = row?;
326 let c = e.consensus_id;
327 let r = e.run_id;
328 ensure!(
329 c < features.len() && r < nr && e.seed_run_id < nr,
330 "invalid cell/seed identity"
331 );
332 self.validate_release_evidence(&e)?;
333 if self.schema_version >= 2 {
334 let evidence = (e.group_score.unwrap(), e.group_qvalue.unwrap());
335 if let Some(previous) = group_evidence[c] {
336 ensure!(
337 previous == evidence,
338 "inconsistent group confidence across cells"
339 );
340 } else {
341 group_evidence[c] = Some(evidence);
342 }
343 }
344 ensure!(
345 matches!(e.extraction_kind.as_str(), "target" | "decoy"),
346 "invalid extraction kind"
347 );
348 ensure!(
349 cells_seen.insert((c, r, e.extraction_kind == "decoy")),
350 "duplicate LFQ cell"
351 );
352 ensure!(
353 [e.mass, e.mz, e.rt, e.combined_score, e.lfq_intensity]
354 .iter()
355 .all(|x| x.is_finite())
356 && e.mass > 0.0
357 && e.mz > 0.0
358 && e.charge > 0
359 && e.lfq_intensity >= 0.0,
360 "invalid cell coordinates/intensity"
361 );
362 ensure!(
363 e.im.is_none_or(|x| x.is_finite()) && (0.0..=1.0).contains(&e.combined_score),
364 "invalid cell quality/IM"
365 );
366 for value in [
367 e.lfq_expected_rt,
368 e.lfq_apex_rt,
369 e.lfq_expected_mz,
370 e.lfq_observed_mz,
371 e.lfq_hybrid_score,
372 e.lfq_isotope_pattern,
373 e.lfq_coelution,
374 ]
375 .into_iter()
376 .flatten()
377 {
378 ensure!(value.is_finite(), "nonfinite extraction measurement");
379 }
380 ensure!(
381 e.lfq_q_value
382 .is_none_or(|q| q.is_finite() && (0.0..=1.0).contains(&q)),
383 "invalid extraction q-value"
384 );
385 ensure!(
386 e.n_contributing_runs > 0 && e.n_contributing_runs as usize <= nr,
387 "invalid contributing-run count"
388 );
389 let seed = (e.seed_run_id, e.seed_observation_id);
390 if let Some(f) = &features[c] {
391 ensure!(
392 (
393 f.mass,
394 f.mz,
395 f.charge,
396 f.rt,
397 f.im,
398 f.combined_score,
399 f.n_contributing_runs
400 ) == (
401 e.mass,
402 e.mz,
403 e.charge,
404 e.rt,
405 e.im,
406 e.combined_score,
407 e.n_contributing_runs
408 ) && seeds[c] == Some(seed),
409 "inconsistent consensus metadata"
410 );
411 } else {
412 features[c] = Some(Feature {
413 mass: e.mass,
414 mz: e.mz,
415 charge: e.charge,
416 rt: e.rt,
417 im: e.im,
418 combined_score: e.combined_score,
419 n_contributing_runs: e.n_contributing_runs,
420 n_isotopes: None,
421 n_scans: None,
422 isoerror: None,
423 isoerror2: None,
424 rt_width: None,
425 cosine_score: None,
426 isotope_score: None,
427 ppm_error: None,
428 neutron_offset: None,
429 intensities: vec![0.0; nr],
430 });
431 seeds[c] = Some(seed);
432 }
433 if e.extraction_kind == "decoy" {
434 ensure!(
435 e.observation_id.is_none() && e.lfq_q_value.is_none(),
436 "synthetic decoy has original observation or target q-value"
437 );
438 continue;
439 }
440 ensure!(
441 e.lfq_is_mbr == e.observation_id.is_none(),
442 "direct/MBR observation mismatch"
443 );
444 if let Some(o) = e.observation_id {
445 ensure!(
446 primary.insert((r, o), c).is_none(),
447 "primary observation reused across cells"
448 );
449 direct[c] += 1;
450 }
451 features[c].as_mut().unwrap().intensities[r] = e.lfq_intensity;
452 }
453 for c in 0..features.len() {
454 ensure!(
455 features[c].is_some() && (0..nr).all(|r| cells_seen.contains(&(c, r, false))),
456 "missing consensus/target cell"
457 );
458 ensure!(
459 direct[c] == features[c].as_ref().unwrap().n_contributing_runs as usize,
460 "direct-cell/support mismatch"
461 );
462 }
463 let seed_lookup: HashMap<_, _> = seeds
464 .iter()
465 .enumerate()
466 .filter_map(|(i, s)| s.map(|s| (s, i)))
467 .collect();
468 ensure!(
469 seed_lookup.len() == seeds.len(),
470 "seed reused across consensus groups"
471 );
472 let mut seed_seen = HashSet::new();
473 let mut primary_seen = HashSet::new();
474 let mut member_runs = HashSet::new();
475 let mut representatives: HashMap<usize, Observation> = HashMap::new();
476 self.observations(|o| {
477 let key = (o.run_id, o.observation_id);
478 if let Some(c) = o.consensus_id {
479 member_runs.insert((c, o.run_id));
480 }
481 ensure!(
482 o.is_primary == primary.contains_key(&key),
483 "primary observation flag/link mismatch"
484 );
485 ensure!(
486 o.is_seed == seed_lookup.contains_key(&key),
487 "seed observation flag/link mismatch"
488 );
489 if let Some(&c) = primary.get(&key) {
490 ensure!(o.consensus_id == Some(c), "primary links wrong group");
491 primary_seen.insert(key);
492 }
493 if let Some(&c) = seed_lookup.get(&key) {
494 ensure!(
495 o.consensus_id == Some(c) && o.is_primary,
496 "seed is not a primary member"
497 );
498 let f = features[c].as_mut().unwrap();
499 ensure!(
500 o.feature_charge == f.charge && o.feature_combined_score == f.combined_score,
501 "seed quality/charge mismatch"
502 );
503 if quality {
504 o.apply_quality(f);
505 }
506 seed_seen.insert(key);
507 }
508 if prefer_isotopes && o.feature_n_isotopes.is_some_and(|n| n >= 3.0) {
509 if let Some(c) = o.consensus_id {
510 if representatives
511 .get(&c)
512 .is_none_or(|prior| o.feature_combined_score > prior.feature_combined_score)
513 {
514 representatives.insert(c, o);
515 }
516 }
517 }
518 Ok(())
519 })?;
520 ensure!(
521 primary_seen.len() == primary.len() && seed_seen.len() == seed_lookup.len(),
522 "missing original observation link"
523 );
524 let mut support = vec![0usize; features.len()];
525 for (c, _) in member_runs {
526 support[c] += 1;
527 }
528 ensure!(support == direct, "member/direct run support mismatch");
529 for (c, o) in representatives {
530 let f = features[c].as_mut().unwrap();
531 f.mass = o
532 .aligned_mass
533 .context("representative requires aligned mass")?;
534 f.mz = (f.mass + f.charge as f64 * 1.007_276_466_621) / f.charge as f64;
535 f.rt = o.aligned_rt.context("representative requires aligned RT")?;
536 f.im = o.aligned_im;
537 f.combined_score = o.feature_combined_score;
538 o.apply_quality(f);
539 }
540 let mut retained = Vec::new();
541 for f in features.into_iter().flatten() {
542 if f.n_contributing_runs < min_runs {
543 continue;
544 }
545 let mut f = f;
546 if legacy_values {
547 f.mass = format!("{:.6}", f.mass).parse()?;
548 f.mz = format!("{:.6}", f.mz).parse()?;
549 f.rt = format!("{:.6}", f.rt).parse()?;
550 f.im = f.im.map(|x| format!("{x:.6}").parse().unwrap());
551 f.combined_score = format!("{:.6}", f.combined_score).parse()?;
552 for x in &mut f.intensities {
553 *x = format!("{x:.5e}").parse()?;
554 }
555 }
556 retained.push(f);
557 }
558 Ok(FeatureMatrix {
559 run_names: self.runs.iter().map(|r| r.name.clone()).collect(),
560 features: retained,
561 matrix_evidence: None,
562 joint_evidence: None,
563 joint_cell_evidence: None,
564 })
565 }
566
567 fn validate_release_evidence(&self, e: &Cell) -> Result<()> {
568 if self.schema_version >= 2 {
569 let score = e.group_score.context("schema 2/3 requires group_score")?;
570 let q = e.group_qvalue.context("schema 2/3 requires group_qvalue")?;
571 let limit = self
572 .config
573 .as_ref()
574 .and_then(|c| c.pointer("/lfq/consensus/max_group_qvalue"))
575 .and_then(serde_json::Value::as_f64)
576 .context("schema 2/3 requires max_group_qvalue in manifest config")?;
577 ensure!(
578 score.is_finite()
579 && q.is_finite()
580 && (0.0..=1.0).contains(&q)
581 && limit.is_finite()
582 && (0.0..=1.0).contains(&limit),
583 "invalid group confidence"
584 );
585 ensure!(
586 q <= limit && e.n_contributing_runs >= 2,
587 "group fails declared confidence or original-run support requirement"
588 );
589 }
590 if self.schema_version >= 3 {
591 let status = e
592 .lfq_ownership_status
593 .as_deref()
594 .context("schema 3 requires ownership status")?;
595 ensure!(
596 matches!(
597 status,
598 "exclusive" | "residual" | "shared_signal_only" | "ambiguous_residual"
599 ),
600 "invalid LFQ ownership status"
601 );
602 let owned = e
603 .lfq_owned_samples
604 .context("schema 3 requires owned-sample count")?;
605 e.lfq_excluded_samples
606 .context("schema 3 requires excluded-sample count")?;
607 let fraction = e
608 .lfq_preceding_signal_fraction
609 .context("schema 3 requires preceding signal fraction")?;
610 ensure!(
611 fraction.is_finite() && (0.0..=1.0).contains(&fraction),
612 "invalid preceding signal fraction"
613 );
614 ensure!(
615 e.lfq_competing_consensus_id
616 .is_none_or(|c| c < self.n_consensus && c != e.consensus_id),
617 "invalid competing consensus identity"
618 );
619 if matches!(status, "shared_signal_only" | "ambiguous_residual") {
620 ensure!(
621 e.lfq_intensity == 0.0 && owned == 0,
622 "suppressed LFQ signal must have zero intensity and ownership"
623 );
624 }
625 }
626 Ok(())
627 }
628}
629
630#[derive(Deserialize)]
631struct Cell {
632 consensus_id: usize,
633 run_id: usize,
634 extraction_kind: String,
635 observation_id: Option<usize>,
636 seed_run_id: usize,
637 seed_observation_id: usize,
638 mass: f64,
639 mz: f64,
640 charge: u8,
641 rt: f64,
642 im: Option<f64>,
643 combined_score: f64,
644 n_contributing_runs: u32,
645 group_score: Option<f64>,
646 group_qvalue: Option<f64>,
647 lfq_intensity: f64,
648 lfq_q_value: Option<f64>,
649 lfq_is_mbr: bool,
650 lfq_expected_rt: Option<f64>,
651 lfq_apex_rt: Option<f64>,
652 lfq_expected_mz: Option<f64>,
653 lfq_observed_mz: Option<f64>,
654 lfq_hybrid_score: Option<f64>,
655 lfq_isotope_pattern: Option<f64>,
656 lfq_coelution: Option<f64>,
657 lfq_ownership_status: Option<String>,
658 lfq_owned_samples: Option<usize>,
659 lfq_excluded_samples: Option<usize>,
660 lfq_competing_consensus_id: Option<usize>,
661 lfq_preceding_signal_fraction: Option<f64>,
662}
663#[derive(Deserialize)]
664struct Observation {
665 run_id: usize,
666 observation_id: usize,
667 consensus_id: Option<usize>,
668 is_primary: bool,
669 is_seed: bool,
670 feature_mass: Option<f64>,
671 feature_mz: f64,
672 feature_charge: u8,
673 feature_rt: f64,
674 feature_im: Option<f64>,
675 feature_combined_score: f64,
676 feature_n_isotopes: Option<f64>,
677 feature_n_scans: Option<f64>,
678 feature_cosine_score: Option<f64>,
679 feature_isotope_score: Option<f64>,
680 feature_ppm_error: Option<f64>,
681 feature_neutron_offset: Option<f64>,
682 feature_rt_width: Option<f64>,
683 feature_isoerror: Option<f64>,
684 feature_isoerror2: Option<f64>,
685 feature_intensity_apex: f64,
686 aligned_mass: Option<f64>,
687 aligned_rt: Option<f64>,
688 aligned_im: Option<f64>,
689}
690impl Observation {
691 fn validate(&self) -> Result<()> {
692 ensure!(
693 [
694 self.feature_mz,
695 self.feature_rt,
696 self.feature_combined_score,
697 self.feature_intensity_apex
698 ]
699 .iter()
700 .all(|x| x.is_finite()),
701 "nonfinite original feature"
702 );
703 ensure!(
704 self.feature_mz > 0.0
705 && self.feature_intensity_apex >= 0.0
706 && (0.0..=1.0).contains(&self.feature_combined_score),
707 "invalid original feature"
708 );
709 ensure!(
710 self.feature_charge == 0 || self.feature_mass.is_some_and(|x| x > 0.0),
711 "missing original mass"
712 );
713 for x in [
714 self.feature_mass,
715 self.aligned_mass,
716 self.aligned_rt,
717 self.aligned_im,
718 self.feature_im,
719 self.feature_n_isotopes,
720 self.feature_n_scans,
721 self.feature_cosine_score,
722 self.feature_isotope_score,
723 self.feature_ppm_error,
724 self.feature_neutron_offset,
725 self.feature_rt_width,
726 self.feature_isoerror,
727 self.feature_isoerror2,
728 ]
729 .into_iter()
730 .flatten()
731 {
732 ensure!(x.is_finite(), "nonfinite original descriptor");
733 }
734 for count in [self.feature_n_isotopes, self.feature_n_scans]
735 .into_iter()
736 .flatten()
737 {
738 ensure!(
739 count >= 0.0 && count.fract() == 0.0,
740 "invalid original feature count"
741 );
742 }
743 for fraction in [self.feature_cosine_score, self.feature_isotope_score]
744 .into_iter()
745 .flatten()
746 {
747 ensure!(
748 (0.0..=1.0).contains(&fraction),
749 "invalid original quality fraction"
750 );
751 }
752 ensure!(
753 self.feature_rt_width.is_none_or(|x| x >= 0.0),
754 "negative original peak width"
755 );
756 ensure!(
757 self.feature_charge == 0 || self.aligned_mass.is_none_or(|x| x > 0.0),
758 "invalid aligned mass"
759 );
760 Ok(())
761 }
762 fn apply_quality(&self, f: &mut Feature) {
763 f.n_isotopes = self.feature_n_isotopes;
764 f.n_scans = self.feature_n_scans;
765 f.cosine_score = self.feature_cosine_score;
766 f.isotope_score = self.feature_isotope_score;
767 f.ppm_error = self.feature_ppm_error;
768 f.neutron_offset = self.feature_neutron_offset;
769 f.rt_width = self.feature_rt_width;
770 f.isoerror = self.feature_isoerror;
771 f.isoerror2 = self.feature_isoerror2;
772 }
773 fn feature(&self) -> Feature {
774 let mut f = Feature {
775 mass: self.feature_mass.unwrap_or(0.0),
776 mz: self.feature_mz,
777 charge: self.feature_charge,
778 rt: self.feature_rt,
779 im: self.feature_im,
780 combined_score: self.feature_combined_score,
781 n_contributing_runs: 1,
782 n_isotopes: None,
783 n_scans: None,
784 cosine_score: None,
785 isotope_score: None,
786 ppm_error: None,
787 neutron_offset: None,
788 rt_width: None,
789 isoerror: None,
790 isoerror2: None,
791 intensities: vec![self.feature_intensity_apex],
792 };
793 self.apply_quality(&mut f);
794 f
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801 struct Fixture(PathBuf);
802 impl Drop for Fixture {
803 fn drop(&mut self) {
804 let _ = std::fs::remove_dir_all(&self.0);
805 }
806 }
807 impl Fixture {
808 fn new() -> Self {
809 let p = std::env::temp_dir().join(format!(
810 "uno-long-{}-{}",
811 std::process::id(),
812 std::time::SystemTime::now()
813 .duration_since(std::time::UNIX_EPOCH)
814 .unwrap()
815 .as_nanos()
816 ));
817 std::fs::create_dir(&p).unwrap();
818 std::fs::write(p.join("observations.tsv"),concat!(
819 "run_id\tobservation_id\tconsensus_id\tis_primary\tis_seed\tfeature_mass\tfeature_mz\tfeature_charge\tfeature_rt\tfeature_combined_score\tfeature_n_isotopes\tfeature_intensity_apex\n",
820 "0\t0\t0\ttrue\ttrue\t1000.123456789\t501.069004861\t2\t10.123456789\t0.9\t3\t123456.789\n",
821 "0\t1\t0\tfalse\tfalse\t1000.124\t501.069276466\t2\t10.2\t0.7\t2\t55\n",
822 "1\t0\t0\ttrue\tfalse\t1000.125\t501.069776466\t2\t10.3\t0.8\t4\t65\n",
823 "2\t0\t\tfalse\tfalse\t1100\t551.007276466\t2\t12\t0.4\t2\t75\n")).unwrap();
824 std::fs::write(p.join("cells.tsv"),concat!(
825 "consensus_id\trun_id\textraction_kind\tobservation_id\tseed_run_id\tseed_observation_id\tmass\tmz\tcharge\trt\tcombined_score\tn_contributing_runs\tlfq_intensity\tlfq_q_value\tlfq_is_mbr\n",
826 "0\t0\ttarget\t0\t0\t0\t1000.123456789\t501.069004861\t2\t10.123456789\t0.9\t2\t123456.789\t0.001\tfalse\n",
827 "0\t1\ttarget\t0\t0\t0\t1000.123456789\t501.069004861\t2\t10.123456789\t0.9\t2\t65\t0.001\tfalse\n",
828 "0\t2\ttarget\t\t0\t0\t1000.123456789\t501.069004861\t2\t10.123456789\t0.9\t2\t5\t0.9\ttrue\n")).unwrap();
829 Self(p)
830 }
831 fn open(&self) -> Bundle {
832 let m = serde_json::json!({"schema_version":1,"n_consensus":1,"reference_run_id":0,
833 "runs":[{"run_id":0,"name":"A","n_observations":2},{"run_id":1,"name":"B","n_observations":1},{"run_id":2,"name":"C","n_observations":1}],
834 "observations":{"path":"observations.tsv","sha256":file_sha(&self.0.join("observations.tsv")).unwrap()},
835 "cells":{"path":"cells.tsv","sha256":file_sha(&self.0.join("cells.tsv")).unwrap()}});
836 std::fs::write(
837 self.0.join("matrix_manifest.json"),
838 serde_json::to_vec(&m).unwrap(),
839 )
840 .unwrap();
841 Bundle::open(&self.0).unwrap()
842 }
843 }
844 fn with_aligned_coordinates(f: &Fixture) {
845 let p = f.0.join("observations.tsv");
846 let text = std::fs::read_to_string(&p).unwrap();
847 let mut out = String::new();
848 for (i, line) in text.lines().enumerate() {
849 if i == 0 {
850 out.push_str(&format!("{line}\taligned_mass\taligned_rt\n"));
851 } else {
852 let cols: Vec<_> = line.split('\t').collect();
853 out.push_str(&format!("{line}\t{}\t{}\n", cols[5], cols[8]));
854 }
855 }
856 std::fs::write(p, out).unwrap();
857 }
858
859 fn release_fixture(version: u32) -> Fixture {
860 let f = Fixture::new();
861 with_aligned_coordinates(&f);
862 let p = f.0.join("cells.tsv");
863 let text = std::fs::read_to_string(&p).unwrap();
864 let mut out = String::new();
865 for (i, line) in text.lines().enumerate() {
866 let columns = if i == 0 {
867 "group_score\tgroup_qvalue"
868 } else {
869 "2.3\t0.03"
870 };
871 out.push_str(&format!("{line}\t{columns}"));
872 if version >= 3 {
873 out.push_str(if i == 0 {
874 "\tlfq_ownership_status\tlfq_owned_samples\tlfq_excluded_samples\tlfq_competing_consensus_id\tlfq_preceding_signal_fraction"
875 } else { "\texclusive\t10\t0\t\t0" });
876 }
877 out.push('\n');
878 }
879 std::fs::write(p, out).unwrap();
880 f
881 }
882
883 fn open_release(f: &Fixture, version: u32) -> Result<Bundle> {
884 f.open(); let path = f.0.join("matrix_manifest.json");
886 let mut manifest: serde_json::Value =
887 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
888 manifest["schema_version"] = version.into();
889 manifest["config"] = serde_json::json!({"lfq":{"consensus":{"max_group_qvalue":0.05}}});
890 std::fs::write(path, serde_json::to_vec(&manifest).unwrap()).unwrap();
891 Bundle::open(&f.0)
892 }
893
894 #[test]
895 fn release_schemas_preserve_originals_and_quantities() {
896 for version in [2, 3] {
897 let f = release_fixture(version);
898 let old = f.open().load_joint(1).unwrap();
899 let b = open_release(&f, version).unwrap();
900 let new = b.load_joint(1).unwrap();
901 assert_eq!(old.features, new.features);
902 assert_eq!(old.joint_cell_evidence, new.joint_cell_evidence);
903 assert_eq!(new.joint_evidence.unwrap()[0].len(), 3);
904 assert_eq!(b.load_run(0).unwrap().features.len(), 2);
905 }
906 assert!(open_release(&release_fixture(3), 4).is_err());
907 }
908
909 #[test]
910 fn release_group_confidence_replaces_only_the_removed_seed_floor() {
911 let f = release_fixture(3);
912 for name in ["cells.tsv", "observations.tsv"] {
913 let p = f.0.join(name);
914 let text = std::fs::read_to_string(&p)
915 .unwrap()
916 .replace("\t0.9\t", "\t0.6\t");
917 std::fs::write(p, text).unwrap();
918 }
919 let legacy = f.open().load_joint(1).unwrap();
920 assert!(legacy.joint_evidence.unwrap()[0]
921 .iter()
922 .all(|o| !o.bootstrap_eligible));
923 let release = open_release(&f, 3).unwrap().load_joint(1).unwrap();
924 assert!(release.joint_evidence.unwrap()[0]
925 .iter()
926 .all(|o| o.bootstrap_eligible));
927 assert_eq!(legacy.features, release.features);
928 }
929
930 #[test]
931 fn suppressed_lfq_retains_original_evidence_but_not_quantitative_support() {
932 let f = release_fixture(3);
933 let p = f.0.join("cells.tsv");
934 let text = std::fs::read_to_string(&p)
935 .unwrap()
936 .replace("\t123456.789\t0.001\tfalse", "\t0\t0.001\tfalse")
937 .replacen(
938 "\texclusive\t10\t0\t\t0",
939 "\tshared_signal_only\t0\t10\t\t1",
940 1,
941 );
942 std::fs::write(p, text).unwrap();
943 let m = open_release(&f, 3).unwrap().load_joint(1).unwrap();
944 assert_eq!(m.features[0].intensities, vec![0.0, 65.0, 5.0]);
945 assert_eq!(m.joint_evidence.unwrap()[0].len(), 3);
946 assert_eq!(m.joint_cell_evidence.unwrap()[0][3], 1.0 / 3.0);
947 }
948
949 #[test]
950 fn release_rejects_bad_group_confidence_and_ownership() {
951 for (old, new) in [
952 ("\t2.3\t0.03", "\t2.3\t0.06"), ("\t2.3\t0.03", "\tNaN\t0.03"),
954 ("group_qvalue", "missing_group_qvalue"),
955 ("lfq_competing_consensus_id", "missing_competitor_column"),
956 (
957 "\texclusive\t10\t0\t\t0",
958 "\tambiguous_residual\t0\t10\t\t1",
959 ), ("\texclusive\t10\t0\t\t0", "\texclusive\t10\t0\t1\t0"), ("\texclusive\t10\t0\t\t0", "\texclusive\t10\t0\t\t1.1"),
962 ] {
963 let f = release_fixture(3);
964 let p = f.0.join("cells.tsv");
965 let text = std::fs::read_to_string(&p).unwrap().replace(old, new);
966 std::fs::write(p, text).unwrap();
967 assert!(
968 open_release(&f, 3).unwrap().load_joint(1).is_err(),
969 "accepted {new}"
970 );
971 }
972 let f = release_fixture(3);
973 let p = f.0.join("cells.tsv");
974 let text = std::fs::read_to_string(&p)
975 .unwrap()
976 .replacen("\t2.3\t0.03", "\t2.4\t0.03", 1);
977 std::fs::write(p, text).unwrap();
978 assert!(open_release(&f, 3).unwrap().load_joint(1).is_err());
979 }
980 #[test]
981 fn joint_loader_preserves_alternatives_and_quant_cells_without_mbr_evidence() {
982 let f = Fixture::new();
983 with_aligned_coordinates(&f);
984 let m = f.open().load_joint(1).unwrap();
985 assert_eq!(m.len(), 1);
986 let g = &m.joint_evidence.as_ref().unwrap()[0];
987 assert_eq!(g.len(), 3); assert_eq!(m.features[0].intensities, vec![123456.789, 65.0, 5.0]);
989 assert_eq!(m.features[0].n_contributing_runs, 2);
990 assert!(f.open().load_joint(3).unwrap().is_empty());
991 }
992 #[test]
993 fn joint_loader_rejects_missing_aligned_coordinates() {
994 let f = Fixture::new();
995 assert!(f.open().load_joint(1).is_err());
996 }
997 #[test]
998 fn joint_cells_use_extraction_q_without_adding_observations_or_decoy_cells() {
999 let f = Fixture::new();
1000 with_aligned_coordinates(&f);
1001 let before = f.open().load_joint(1).unwrap();
1002 let values = before.joint_cell_evidence.as_ref().unwrap()[0];
1003 assert_eq!(values[0], 1.0);
1004 assert_eq!(values[1], 1.0 / 3.0);
1005 assert_eq!(values[2], 0.0);
1006 assert_eq!(values[3], 2.0 / 3.0);
1007 assert_eq!(&values[4..], &[0.001, 0.001, 1.0, 1.0, 0.9, 0.9, 1.0, 0.0]);
1008 let p = f.0.join("cells.tsv");
1010 let mut text = std::fs::read_to_string(&p).unwrap();
1011 text.push_str("0\t2\tdecoy\t\t0\t0\t1000.123456789\t501.069004861\t2\t10.123456789\t0.9\t2\t999999\t\ttrue\n");
1012 std::fs::write(p, text).unwrap();
1013 let after = f.open().load_joint(1).unwrap();
1014 assert_eq!(before.features, after.features);
1015 assert_eq!(before.joint_cell_evidence, after.joint_cell_evidence);
1016 assert_eq!(after.joint_evidence.as_ref().unwrap()[0].len(), 3);
1017 assert!(f
1018 .open()
1019 .load_joint(3)
1020 .unwrap()
1021 .joint_cell_evidence
1022 .unwrap()
1023 .is_empty());
1024 }
1025 #[test]
1026 fn duplicate_sources_do_not_inflate_cell_support() {
1027 let f = Fixture::new();
1028 with_aligned_coordinates(&f);
1029 let mut b = f.open();
1030 b.runs[0].sha256 = Some("same-source".into());
1031 b.runs[1].sha256 = Some("same-source".into());
1032 let m = b.load_joint(1).unwrap();
1033 let v = m.joint_cell_evidence.unwrap()[0];
1034 assert_eq!(v[0], 1.0);
1035 assert_eq!(v[1], 0.5);
1036 assert_eq!(v[3], 0.5);
1037 }
1038 #[test]
1039 fn weak_groups_remain_searchable_without_defining_bootstrap_calibration() {
1040 let f = Fixture::new();
1041 with_aligned_coordinates(&f);
1042 for name in ["observations.tsv", "cells.tsv"] {
1043 let p = f.0.join(name);
1044 let text = std::fs::read_to_string(&p)
1045 .unwrap()
1046 .replace("\t0.9\t", "\t0.7\t")
1047 .replace("\t0.8\t", "\t0.6\t");
1048 std::fs::write(p, text).unwrap();
1049 }
1050 let m = f.open().load_joint(1).unwrap();
1051 assert_eq!(m.len(), 1);
1052 assert!(m.joint_evidence.as_ref().unwrap()[0]
1053 .iter()
1054 .all(|o| !o.bootstrap_eligible));
1055 }
1056 #[test]
1057 fn joint_loader_rejects_member_charge_mismatch() {
1058 let f = Fixture::new();
1059 with_aligned_coordinates(&f);
1060 let p = f.0.join("observations.tsv");
1061 let text = std::fs::read_to_string(&p)
1062 .unwrap()
1063 .replace("501.069276466\t2", "501.069276466\t3");
1064 std::fs::write(p, text).unwrap();
1065 assert!(f.open().load_joint(1).is_err());
1066 }
1067 #[test]
1068 fn joint_matching_searches_member_windows_once_and_treats_decoys_equally() {
1069 let f = Fixture::new();
1070 with_aligned_coordinates(&f);
1071 let m = f.open().load_joint(1).unwrap();
1072 let db = crate::PeptideDb {
1073 n_targets: 2,
1074 n_decoys: 1,
1075 peptides: [(1000.123456789, false), (1000.125, false), (1000.125, true)]
1076 .iter()
1077 .map(|&(mass, decoy)| crate::TheoPeptide {
1078 sequence: "PEPTIDE".into(),
1079 mass,
1080 decoy,
1081 missed_cleavages: 0,
1082 proteins: vec!["protein".into()],
1083 })
1084 .collect(),
1085 };
1086 let cfg = crate::MatchConfig {
1087 ppm_tolerance: 0.1,
1088 isotope_offsets: vec![0],
1089 ..Default::default()
1090 };
1091 let matches = crate::match_features_topk(&m, &db, &cfg, 50);
1092 assert_eq!(
1093 matches.iter().map(|p| p.peptide_idx).collect::<Vec<_>>(),
1094 vec![0, 1, 2]
1095 );
1096 let mut single = m.clone();
1097 single.joint_evidence = None;
1098 assert_eq!(crate::match_features_topk(&single, &db, &cfg, 50).len(), 1);
1099 let strict = crate::MatchConfig {
1100 min_isotopes: 4,
1101 ..cfg
1102 };
1103 let matches = crate::match_features_topk(&m, &db, &strict, 50);
1104 assert_eq!(
1105 matches.iter().map(|p| p.peptide_idx).collect::<Vec<_>>(),
1106 vec![1, 2]
1107 );
1108 }
1109 #[test]
1110 fn matching_quality_gates_also_restrict_joint_scoring_observations() {
1111 let f = Fixture::new();
1112 with_aligned_coordinates(&f);
1113 let mut m = f.open().load_joint(1).unwrap();
1114 let before = m.features.clone();
1115 crate::joint::restrict_observations(&mut m, &crate::MatchConfig::default());
1116 assert_eq!(before, m.features);
1117 let cells = m.joint_cell_evidence.clone();
1118 let intensities = m.features[0].intensities.clone();
1119 let cfg = crate::MatchConfig {
1120 min_isotopes: 4,
1121 ..Default::default()
1122 };
1123 crate::joint::restrict_observations(&mut m, &cfg);
1124 assert_eq!(m.features[0].n_isotopes, Some(4.0));
1125 assert_eq!(m.features[0].intensities, intensities);
1126 assert_eq!(m.joint_evidence.as_ref().unwrap()[0].len(), 1);
1127 assert_eq!(m.joint_cell_evidence, cells);
1128 let cfg = crate::MatchConfig {
1129 min_isotopes: 5,
1130 ..Default::default()
1131 };
1132 crate::joint::restrict_observations(&mut m, &cfg);
1133 assert!(m.is_empty());
1134 assert!(m.joint_cell_evidence.unwrap().is_empty());
1135 }
1136
1137 #[test]
1138 fn original_quality_and_alternatives_survive_without_double_counting() {
1139 let f = Fixture::new();
1140 let b = f.open();
1141 let m = b.load_consensus(2, true, false).unwrap();
1142 assert_eq!(m.features[0].n_isotopes, Some(3.0));
1143 assert_eq!(m.features[0].intensities, vec![123456.789, 65.0, 5.0]);
1144 assert_eq!(m.features[0].n_contributing_runs, 2);
1145 let original = b.load_run(0).unwrap();
1146 assert_eq!(original.len(), 2);
1147 assert_eq!(original.features[1].n_isotopes, Some(2.0));
1148 assert_eq!(b.load_run(2).unwrap().features[0].mass, 1100.0); assert!(b.load_consensus(2, false, false).unwrap().features[0]
1150 .n_isotopes
1151 .is_none());
1152 let legacy = b.load_consensus(2, false, true).unwrap();
1153 assert_eq!(legacy.features[0].mass, 1000.123457);
1154 assert_eq!(legacy.features[0].intensities[0], 123457.0);
1155 assert!(b.load_consensus(3, true, false).unwrap().is_empty());
1156 }
1157 #[test]
1158 fn duplicate_or_missing_cells_are_rejected() {
1159 let f = Fixture::new();
1160 let path = f.0.join("cells.tsv");
1161 let text = std::fs::read_to_string(&path).unwrap();
1162 std::fs::write(&path, format!("{}{}\n", text, text.lines().nth(1).unwrap())).unwrap();
1163 assert!(f
1164 .open()
1165 .load_consensus(1, true, false)
1166 .unwrap_err()
1167 .to_string()
1168 .contains("duplicate"));
1169 std::fs::write(
1170 &path,
1171 text.lines().take(3).collect::<Vec<_>>().join("\n") + "\n",
1172 )
1173 .unwrap();
1174 assert!(f
1175 .open()
1176 .load_consensus(1, true, false)
1177 .unwrap_err()
1178 .to_string()
1179 .contains("missing"));
1180 }
1181 #[test]
1182 fn corrupt_hash_and_broken_member_links_are_rejected() {
1183 let f = Fixture::new();
1184 let _ = f.open();
1185 let path = f.0.join("observations.tsv");
1186 let text = std::fs::read_to_string(&path)
1187 .unwrap()
1188 .replace("0\t1\t0\tfalse", "0\t1\t0\ttrue");
1189 std::fs::write(path, text).unwrap();
1190 assert!(Bundle::open(&f.0)
1191 .err()
1192 .unwrap()
1193 .to_string()
1194 .contains("checksum"));
1195 assert!(f
1196 .open()
1197 .load_consensus(1, true, false)
1198 .unwrap_err()
1199 .to_string()
1200 .contains("primary"));
1201 }
1202 #[test]
1203 fn isotope_representative_uses_one_real_members_coordinates_and_quality() {
1204 let fixture = Fixture::new();
1205 let path = fixture.0.join("observations.tsv");
1206 let mut reader = csv::ReaderBuilder::new()
1207 .delimiter(b'\t')
1208 .from_path(&path)
1209 .unwrap();
1210 let header = reader.headers().unwrap().clone();
1211 let rows: Vec<_> = reader.records().map(|r| r.unwrap()).collect();
1212 drop(reader);
1213 let mut writer = csv::WriterBuilder::new()
1214 .delimiter(b'\t')
1215 .from_path(&path)
1216 .unwrap();
1217 let mut names: Vec<_> = header.iter().map(str::to_owned).collect();
1218 names.extend([
1219 "aligned_mass".into(),
1220 "aligned_rt".into(),
1221 "aligned_im".into(),
1222 ]);
1223 writer.write_record(names).unwrap();
1224 let col = |n| header.iter().position(|h| h == n).unwrap();
1225 for (i, row) in rows.iter().enumerate() {
1226 let mut values: Vec<_> = row.iter().map(str::to_owned).collect();
1227 if i == 0 {
1228 values[col("feature_n_isotopes")] = "2".into();
1229 }
1230 values.extend([
1231 row[col("feature_mass")].to_owned(),
1232 row[col("feature_rt")].to_owned(),
1233 String::new(),
1234 ]);
1235 writer.write_record(values).unwrap();
1236 }
1237 writer.flush().unwrap();
1238 drop(writer);
1239 let bundle = fixture.open();
1240 let seed = bundle.load_consensus(2, true, false).unwrap();
1241 let actual = bundle
1242 .load_consensus_with_representative(2, true, false, true)
1243 .unwrap();
1244 assert_eq!(actual.features[0].mass, 1000.125);
1245 assert_eq!(actual.features[0].rt, 10.3);
1246 assert_eq!(actual.features[0].combined_score, 0.8);
1247 assert_eq!(actual.features[0].n_isotopes, Some(4.0));
1248 assert_eq!(actual.features[0].intensities, seed.features[0].intensities);
1249 assert_eq!(
1250 actual.features[0].n_contributing_runs,
1251 seed.features[0].n_contributing_runs
1252 );
1253 }
1254
1255 #[test]
1256 fn negative_counts_are_rejected_before_row_filtering() {
1257 let fixture = Fixture::new();
1258 let path = fixture.0.join("observations.tsv");
1259 let text = std::fs::read_to_string(&path)
1260 .unwrap()
1261 .replace("0.9\t3\t123456.789", "0.9\t-1\t123456.789");
1262 std::fs::write(path, text).unwrap();
1263 assert!(fixture
1264 .open()
1265 .load_consensus(99, false, false)
1266 .unwrap_err()
1267 .to_string()
1268 .contains("feature count"));
1269 }
1270}