Skip to main content

uno_core/
long_input.rs

1//! KOTH long-format bundle ingestion. Quantities are attached once per cell;
2//! original observations remain separately available for per-run batch search.
3use 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    /// Original source order and measurements reproduce the single-file route.
133    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    /// One matrix search retaining every original member (including alternatives).
151    /// KOTH-aligned coordinates supply a common calibration frame; no independent
152    /// run searches or protein lists are involved. Missing/MBR cells supply no IDs.
153    pub fn load_joint(&self, min_runs: u32) -> Result<FeatureMatrix> {
154        // Reuse complete cell/membership validation, keeping consensus index order.
155        let mut matrix = self.load_consensus(0, true, false)?;
156        let mut groups = vec![Vec::new(); self.n_consensus];
157        // Schema 1 used a seed-quality admission rule. New bundles have already
158        // passed KOTH's cross-run group-confidence gate; do not silently restore
159        // the removed seed floor. Original feature anchor checks still apply.
160        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        // Cell table was fully validated by load_consensus above. Canonical source
208        // IDs prevent duplicate input files from inflating extraction support.
209        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            // The same source table submitted twice must not become two pieces of
228            // evidence; retain the first source's aligned observation deterministically.
229            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            // A real member supplies calibration coordinates AND its own shape.
235            // Three-isotope members qualify for UNO's existing anchor criterion.
236            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; // exactly one integrated LFQ value per cell
255            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    /// Optional diagnostic: choose the best actual member that clears UNO's
281    /// existing three-isotope anchor criterion, falling back to the seed.
282    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(); // refresh table hashes after each fixture edit
885        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"), // beyond declared group threshold
953            ("\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            ), // nonzero suppressed intensity
960            ("\texclusive\t10\t0\t\t0", "\texclusive\t10\t0\t1\t0"), // nonexistent competitor
961            ("\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); // two originals in run A, one in B, no MBR observation
988        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        // Synthetic extraction decoys have no target q and never enter summaries.
1009        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); // filtered original remains available
1149        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}