Skip to main content

koth_ms/lfq/
targets.rs

1//! Identification-guided extraction. Search confidence selects donor targets;
2//! it is never used as extraction evidence or as a transfer q-value.
3use std::{
4    collections::{BTreeMap, HashSet},
5    path::Path,
6};
7
8use anyhow::{bail, ensure, Context, Result};
9use serde::Serialize;
10
11use super::{consensus::ConsensusFeature, IntensityMatrix, LfqConfig};
12use crate::{
13    alignment::{AlignmentResult, RunInput},
14    models::PROTON_MASS,
15    output::long_matrix::{sha256, source_rows},
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TargetFormat {
21    Generic,
22    Sage,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct Identification {
27    pub modified_peptide: String,
28    pub run_idx: usize,
29    pub charge: u8,
30    pub neutral_mass: f64,
31    pub rt_minutes: f64,
32    pub im: f64,
33    pub id_qvalue: f64,
34    pub source_row: usize,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct ImportSummary {
39    pub path: String,
40    pub sha256: String,
41    pub format: TargetFormat,
42    pub max_id_qvalue: f64,
43    pub ignore_target_im: bool,
44    pub rows_read: usize,
45    pub rows_filtered: usize,
46    pub rows_retained: usize,
47}
48
49/// Normalize only known raw-data suffixes, preserving dots inside sample names.
50fn run_key(name: &str) -> String {
51    let name = name.rsplit(['/', '\\']).next().unwrap_or(name);
52    let mut key = name.to_owned();
53    for suffix in [".gz", ".mzml", ".raw", ".d"] {
54        if key.to_ascii_lowercase().ends_with(suffix) {
55            key.truncate(key.len() - suffix.len());
56        }
57    }
58    key
59}
60
61pub fn read_identifications(
62    path: &Path,
63    format: TargetFormat,
64    runs: &[RunInput],
65    max_q: f64,
66    ignore_target_im: bool,
67) -> Result<(Vec<Identification>, ImportSummary)> {
68    ensure!(
69        max_q.is_finite() && (0.0..=1.0).contains(&max_q),
70        "max ID q-value must be in [0, 1]"
71    );
72    let mut run_ids = BTreeMap::new();
73    for (i, run) in runs.iter().enumerate() {
74        ensure!(
75            run_ids.insert(run_key(&run.name), i).is_none(),
76            "Ambiguous normalized run name: {}",
77            run.name
78        );
79    }
80    let mut summary = ImportSummary {
81        path: path.display().to_string(),
82        sha256: sha256(path)?,
83        format,
84        max_id_qvalue: max_q,
85        ignore_target_im,
86        rows_read: 0,
87        rows_filtered: 0,
88        rows_retained: 0,
89    };
90    let mut records = Vec::new();
91    source_rows(path, |header, row| {
92        summary.rows_read += 1;
93        let parse = || -> Result<Option<Identification>> {
94            let unique: HashSet<_> = header.iter().collect();
95            ensure!(unique.len() == header.len(), "duplicate column names");
96            let get = |name: &str| -> Result<&str> {
97                let i = header
98                    .iter()
99                    .position(|h| h == name)
100                    .with_context(|| format!("missing column '{name}'"))?;
101                row.get(i)
102                    .with_context(|| format!("missing value for '{name}'"))
103            };
104            let number = |name: &str| -> Result<f64> {
105                let v: f64 = get(name)?
106                    .parse()
107                    .with_context(|| format!("invalid {name}"))?;
108                ensure!(v.is_finite(), "{name} must be finite");
109                Ok(v)
110            };
111            let qvalue = |name: &str| -> Result<f64> {
112                let v = number(name)?;
113                ensure!((0.0..=1.0).contains(&v), "{name} must be in [0, 1]");
114                Ok(v)
115            };
116            let (peptide_col, run_col, mass_col, rt_col, q) = match format {
117                TargetFormat::Generic => (
118                    "modified_peptide",
119                    "run",
120                    "neutral_mass",
121                    "rt_minutes",
122                    qvalue("id_qvalue")?,
123                ),
124                TargetFormat::Sage => {
125                    let decoy = if header.iter().any(|h| h == "is_decoy") {
126                        match get("is_decoy")?.to_ascii_lowercase().as_str() {
127                            "true" | "1" => true,
128                            "false" | "0" => false,
129                            _ => bail!("is_decoy must be true/false or 1/0"),
130                        }
131                    } else {
132                        match get("label")? {
133                            "1" => false,
134                            "-1" => true,
135                            _ => bail!("label must be 1 or -1"),
136                        }
137                    };
138                    let rank: u32 = get("rank")?.parse().context("invalid rank")?;
139                    if decoy || rank != 1 {
140                        return Ok(None);
141                    }
142                    (
143                        "peptide",
144                        "filename",
145                        "calcmass",
146                        "rt",
147                        qvalue("peptide_q")?.max(qvalue("spectrum_q")?),
148                    )
149                }
150            };
151            if q > max_q {
152                return Ok(None);
153            }
154            let modified_peptide = get(peptide_col)?.trim().to_owned();
155            ensure!(!modified_peptide.is_empty(), "empty modified peptide");
156            let key = run_key(get(run_col)?);
157            let run_idx = *run_ids
158                .get(&key)
159                .with_context(|| format!("unknown run '{}'", get(run_col).unwrap_or("")))?;
160            let charge: u8 = get("charge")?.parse().context("invalid charge")?;
161            ensure!(charge > 0, "charge must be positive");
162            let neutral_mass = number(mass_col)?;
163            let rt_minutes = number(rt_col)?;
164            ensure!(
165                neutral_mass > 0.0 && rt_minutes >= 0.0,
166                "mass must be positive and RT nonnegative"
167            );
168            let im_col = if format == TargetFormat::Sage {
169                "ion_mobility"
170            } else {
171                "im"
172            };
173            let im = if !ignore_target_im
174                && header.iter().any(|h| h == im_col)
175                && !get(im_col)?.is_empty()
176            {
177                number(im_col)?
178            } else {
179                0.0
180            };
181            ensure!(im >= 0.0, "IM must be nonnegative native 1/K0, or empty");
182            Ok(Some(Identification {
183                modified_peptide,
184                run_idx,
185                charge,
186                neutral_mass,
187                rt_minutes,
188                im,
189                id_qvalue: q,
190                source_row: summary.rows_read,
191            }))
192        };
193        match parse()
194            .with_context(|| format!("{}: data row {}", path.display(), summary.rows_read))?
195        {
196            Some(id) => records.push(id),
197            None => summary.rows_filtered += 1,
198        }
199        Ok(())
200    })?;
201    summary.rows_retained = records.len();
202    ensure!(
203        !records.is_empty(),
204        "No identifications passed the input filters"
205    );
206    Ok((records, summary))
207}
208
209#[derive(Debug, Clone, Serialize)]
210pub struct SearchTarget {
211    pub modified_peptide: String,
212    pub charge: u8,
213    /// One best accepted PSM per run; source row is a 1-based data-row number.
214    pub donors: Vec<Option<Identification>>,
215    pub seed_run_idx: usize,
216    pub transfer_eligible: bool,
217    /// Recipient runs this target must not be transferred into, even when it is
218    /// otherwise transfer eligible. Empty unless per-run gating is enabled.
219    #[serde(skip_serializing_if = "Vec::is_empty")]
220    pub transfer_blocked_runs: Vec<usize>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub rt_rescue: Option<RtRescue>,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub inferred_charge: Option<InferredCharge>,
225}
226
227#[derive(Debug, Clone, Serialize)]
228pub struct InferredCharge {
229    /// A real PSM at its original charge, never a fabricated identification.
230    pub seed: Identification,
231    pub native_anchors: Vec<Option<Identification>>,
232    pub mz_eligible: Vec<bool>,
233}
234
235#[derive(Debug, Clone, Serialize)]
236pub struct RtRescue {
237    pub excluded_source_rows: Vec<usize>,
238    pub cross_run_rt_ambiguous: bool,
239}
240
241/// Bounded, non-chaining RT clusters. A conflicting run is retained only if
242/// one cluster has a strict majority of PSMs and at least two observations.
243/// Tied/unsupported clusters withhold that run; one target per peptide/charge
244/// avoids duplicate quantification of multiple chromatographic components.
245fn supported_rt_ids(
246    ids: Vec<Identification>,
247    runs: &[RunInput],
248    rt_ranges: &[(f64, f64)],
249    fraction: f64,
250) -> (Vec<Identification>, Vec<usize>, Vec<bool>) {
251    let mut retained = Vec::new();
252    let mut excluded = Vec::new();
253    // A run that offered identifications but kept none has genuinely ambiguous
254    // chromatography for this peptide, so it must not receive a transfer.
255    let mut withheld = vec![false; runs.len()];
256    for r in 0..runs.len() {
257        let mut rows: Vec<_> = ids.iter().filter(|id| id.run_idx == r).collect();
258        rows.sort_by(|a, b| {
259            a.rt_minutes
260                .total_cmp(&b.rt_minutes)
261                .then(a.source_row.cmp(&b.source_row))
262        });
263        let (lo, hi) = rt_ranges[r];
264        let limit = (hi - lo) * fraction;
265        let mut windows = Vec::new();
266        for start in 0..rows.len() {
267            let end = rows.partition_point(|id| id.rt_minutes <= rows[start].rt_minutes + limit);
268            windows.push((end - start, start, end));
269        }
270        let maximum = windows.iter().map(|w| w.0).max().unwrap_or(0);
271        let best: Vec<_> = windows.iter().filter(|w| w.0 == maximum).collect();
272        let supported = best.len() == 1
273            && (maximum == rows.len() || (maximum >= 2 && maximum * 2 > rows.len()));
274        for (i, id) in rows.iter().enumerate() {
275            if supported
276                && i >= best[0].1
277                && i < best[0].2
278                && id.rt_minutes >= lo
279                && id.rt_minutes <= hi
280            {
281                retained.push((*id).clone());
282            } else {
283                excluded.push(id.source_row);
284            }
285        }
286    }
287    excluded.sort_unstable();
288    for (r, flag) in withheld.iter_mut().enumerate() {
289        let offered = ids.iter().any(|id| id.run_idx == r);
290        *flag = offered && !retained.iter().any(|id| id.run_idx == r);
291    }
292    (retained, excluded, withheld)
293}
294
295#[derive(Debug, Clone, Serialize)]
296pub struct RejectedTarget {
297    pub modified_peptide: String,
298    pub charge: u8,
299    pub reason: String,
300}
301
302#[derive(Debug, Clone, Serialize)]
303pub struct SearchGuidance {
304    pub import: ImportSummary,
305    pub mbr: bool,
306    pub aligned_runs: Vec<bool>,
307    pub targets: Vec<SearchTarget>,
308    pub rejected_targets: Vec<RejectedTarget>,
309}
310
311impl SearchGuidance {
312    pub fn is_direct(&self, target: usize, run: usize) -> bool {
313        self.targets[target].donors[run].is_some()
314    }
315    pub fn attempted(&self, target: usize, run: usize) -> bool {
316        self.is_direct(target, run)
317            || self.native_anchor(target, run).is_some()
318            || (self.mbr
319                && self.aligned_runs[run]
320                && self.targets[target].transfer_eligible
321                && !self.targets[target].transfer_blocked_runs.contains(&run)
322                && self.targets[target]
323                    .inferred_charge
324                    .as_ref()
325                    .is_none_or(|i| i.mz_eligible[run]))
326    }
327    pub fn native_anchor(&self, target: usize, run: usize) -> Option<&Identification> {
328        self.targets[target].donors[run].as_ref().or_else(|| {
329            self.targets[target]
330                .inferred_charge
331                .as_ref()
332                .and_then(|i| i.native_anchors[run].as_ref())
333        })
334    }
335    pub fn is_inferred(&self, target: usize, run: usize) -> bool {
336        let t = &self.targets[target];
337        !self.is_direct(target, run)
338            && t.inferred_charge.as_ref().is_some_and(|i| {
339                i.native_anchors[run].is_some() || t.donors.iter().all(Option::is_none)
340            })
341    }
342}
343
344/// Build peptide/charge targets without requiring a detected isotope envelope.
345/// Coordinate-only alignment is retained; unreliable alignment blocks transfers.
346/// Incompatible RT/IM observations are rejected instead of merging isomers.
347pub fn build_targets(
348    records: Vec<Identification>,
349    import: ImportSummary,
350    runs: &[RunInput],
351    alignment: &AlignmentResult,
352    config: &LfqConfig,
353    min_anchors: usize,
354    mbr: bool,
355) -> Result<(Vec<ConsensusFeature>, SearchGuidance)> {
356    ensure!(
357        !runs.is_empty() && alignment.reference_idx < runs.len(),
358        "invalid reference run"
359    );
360    let aligned_runs: Vec<_> = runs
361        .iter()
362        .enumerate()
363        .map(|(r, run)| {
364            r == alignment.reference_idx
365                || alignment.alignments.get(&run.name).is_some_and(|a| {
366                    a.rt_active.iter().filter(|&&v| v).count() >= min_anchors.max(2)
367                })
368        })
369        .collect();
370    let mut grouped: BTreeMap<(String, u8), Vec<Identification>> = BTreeMap::new();
371    for id in records {
372        grouped
373            .entry((id.modified_peptide.clone(), id.charge))
374            .or_default()
375            .push(id);
376    }
377    let mut guidance = SearchGuidance {
378        import,
379        mbr,
380        aligned_runs,
381        targets: Vec::new(),
382        rejected_targets: Vec::new(),
383    };
384    let mut consensus = Vec::new();
385    // `rt_range` rescans a run's whole feature list on every call and the loops
386    // below ask for it per target per run. Compute it once; the LFQ loop takes
387    // the same precaution.
388    let rt_ranges: Vec<(f64, f64)> = runs.iter().map(|r| r.rt_range()).collect();
389    let range = rt_ranges[alignment.reference_idx];
390    let rt_limit = (range.1 - range.0) * config.consensus.rt_window_pct;
391    for ((peptide, charge), mut ids) in grouped {
392        // Never use RT rescue to hide incompatible mass or mobility evidence.
393        let mass_min = ids
394            .iter()
395            .map(|id| id.neutral_mass)
396            .fold(f64::INFINITY, f64::min);
397        let mass_max = ids
398            .iter()
399            .map(|id| id.neutral_mass)
400            .fold(f64::NEG_INFINITY, f64::max);
401        let mass_conflict = (mass_max - mass_min) / mass_min * 1e6 > config.consensus.mz_ppm;
402        let mut excluded_source_rows = Vec::new();
403        let mut withheld_runs = vec![false; runs.len()];
404        if config.search_rt_rescue && !mass_conflict && ids.iter().all(|id| id.im == 0.0) {
405            (ids, excluded_source_rows, withheld_runs) =
406                supported_rt_ids(ids, runs, &rt_ranges, config.consensus.rt_window_pct);
407            if ids.is_empty() {
408                guidance.rejected_targets.push(RejectedTarget {
409                    modified_peptide: peptide,
410                    charge,
411                    reason: "no_supported_native_rt_cluster".into(),
412                });
413                continue;
414            }
415        }
416        ids.sort_by(|a, b| {
417            a.id_qvalue
418                .total_cmp(&b.id_qvalue)
419                .then_with(|| runs[a.run_idx].name.cmp(&runs[b.run_idx].name))
420                .then_with(|| a.rt_minutes.total_cmp(&b.rt_minutes))
421                .then_with(|| a.im.total_cmp(&b.im))
422                .then_with(|| a.neutral_mass.total_cmp(&b.neutral_mass))
423                .then(a.source_row.cmp(&b.source_row))
424        });
425        let transferable = |id: &Identification| {
426            let (lo, hi) = rt_ranges[id.run_idx];
427            guidance.aligned_runs[id.run_idx] && id.rt_minutes >= lo && id.rt_minutes <= hi
428        };
429        let seed = ids.iter().find(|id| transferable(id)).unwrap_or(&ids[0]);
430        let project = |id: &Identification| {
431            if id.run_idx == alignment.reference_idx {
432                (id.rt_minutes, id.im)
433            } else {
434                let a = &alignment.alignments[&runs[id.run_idx].name];
435                (
436                    a.warp_rt(id.rt_minutes),
437                    if id.im > 0.0 {
438                        a.correct_im(id.im, id.rt_minutes)
439                    } else {
440                        0.0
441                    },
442                )
443            }
444        };
445        let (ref_rt, ref_im) = project(seed);
446        let mut reason = None;
447        let mut rt_values = Vec::new();
448        let mut im_values = Vec::new();
449        for id in &ids {
450            if (id.neutral_mass - seed.neutral_mass).abs() / seed.neutral_mass * 1e6
451                > config.consensus.mz_ppm
452            {
453                reason = Some("inconsistent_mass");
454            }
455            // Within-run ambiguity remains observable even when alignment failed.
456            if id.run_idx == seed.run_idx || guidance.aligned_runs[id.run_idx] {
457                let (rt, im) = project(id);
458                rt_values.push(rt);
459                if im > 0.0 {
460                    im_values.push(im);
461                }
462            }
463        }
464        let span = |v: &[f64]| {
465            v.iter().copied().fold(f64::NEG_INFINITY, f64::max)
466                - v.iter().copied().fold(f64::INFINITY, f64::min)
467        };
468        if span(&rt_values) > rt_limit {
469            reason = Some("ambiguous_retention_time");
470        }
471        if span(&im_values) > config.consensus.im_tolerance {
472            reason = Some("ambiguous_ion_mobility");
473        }
474        // Check repeated PSMs in every donor, including unaligned runs.
475        for r in 0..runs.len() {
476            let native_rt: Vec<_> = ids
477                .iter()
478                .filter(|id| id.run_idx == r)
479                .map(|id| id.rt_minutes)
480                .collect();
481            let native_im: Vec<_> = ids
482                .iter()
483                .filter(|id| id.run_idx == r && id.im > 0.0)
484                .map(|id| id.im)
485                .collect();
486            let range = rt_ranges[r];
487            if span(&native_rt) > (range.1 - range.0) * config.consensus.rt_window_pct {
488                reason = Some("ambiguous_retention_time");
489            }
490            if span(&native_im) > config.consensus.im_tolerance {
491                reason = Some("ambiguous_ion_mobility");
492            }
493        }
494        let rt_rescued = config.search_rt_rescue
495            && !mass_conflict
496            && ids.iter().all(|id| id.im == 0.0)
497            && reason == Some("ambiguous_retention_time");
498        if let Some(reason) = reason.filter(|_| !rt_rescued) {
499            guidance.rejected_targets.push(RejectedTarget {
500                modified_peptide: peptide,
501                charge,
502                reason: reason.into(),
503            });
504            continue;
505        }
506        let mut donors = vec![None; runs.len()];
507        for id in &ids {
508            if donors[id.run_idx].is_none() {
509                donors[id.run_idx] = Some(id.clone());
510            }
511        }
512        consensus.push(ConsensusFeature {
513            ref_mz: seed.neutral_mass / f64::from(charge) + PROTON_MASS,
514            ref_rt,
515            ref_im,
516            charge,
517            theoretical_pattern: config
518                .isotope_model
519                .model()
520                .distribution(seed.neutral_mass)
521                .to_vec(),
522            neutral_mass: seed.neutral_mass,
523            seed_combined_score: f64::NAN,
524            seed_run_idx: seed.run_idx,
525            n_contributing_runs: 0,
526            per_run_feature: vec![None; runs.len()],
527            members: Vec::new(),
528            seed_feature_idx: u32::MAX,
529            group_score: f64::NAN,
530            group_qvalue: f64::NAN,
531        });
532        // Cross-run disagreement invalidates the consensus coordinate a transfer
533        // would be centred on, so it still withdraws the target everywhere. A
534        // pruned row in one run does not, once eligibility is decided per run.
535        let per_run = config.search_rt_rescue && config.search_rt_rescue_per_run_transfers;
536        let transfer_eligible =
537            transferable(seed) && !rt_rescued && (per_run || excluded_source_rows.is_empty());
538        let transfer_blocked_runs: Vec<usize> = if per_run {
539            withheld_runs
540                .iter()
541                .enumerate()
542                .filter(|(_, blocked)| **blocked)
543                .map(|(r, _)| r)
544                .collect()
545        } else {
546            Vec::new()
547        };
548        guidance.targets.push(SearchTarget {
549            modified_peptide: peptide,
550            charge,
551            donors,
552            seed_run_idx: seed.run_idx,
553            transfer_eligible,
554            transfer_blocked_runs,
555            rt_rescue: config.search_rt_rescue.then_some(RtRescue {
556                excluded_source_rows,
557                cross_run_rt_ambiguous: rt_rescued,
558            }),
559            inferred_charge: None,
560        });
561    }
562    if config.search_expand_charges {
563        expand_charges(
564            &mut consensus,
565            &mut guidance,
566            runs,
567            &rt_ranges,
568            config,
569            rt_limit,
570        );
571    }
572    // Empty candidate sets still produce an auditable rejection report.
573    Ok((consensus, guidance))
574}
575
576/// Explore 2+ through 4+ only, bounded by each run's observed feature/hill m/z
577/// range. Conflicting peptide RT/mass or IM evidence disables expansion.
578fn expand_charges(
579    consensus: &mut Vec<ConsensusFeature>,
580    g: &mut SearchGuidance,
581    runs: &[RunInput],
582    rt_ranges: &[(f64, f64)],
583    config: &LfqConfig,
584    rt_limit: f64,
585) {
586    let original = g.targets.clone();
587    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
588    for (i, t) in original.iter().enumerate() {
589        groups
590            .entry(t.modified_peptide.clone())
591            .or_default()
592            .push(i);
593    }
594    let bounds: Vec<_> = runs
595        .iter()
596        .map(|r| {
597            let mz: Vec<_> = r
598                .features
599                .iter()
600                .map(|f| f.feature.monoisotopic_mz())
601                .chain(r.hills.iter().map(|h| h.mz))
602                .filter(|v| v.is_finite())
603                .collect();
604            (
605                mz.iter().copied().fold(f64::INFINITY, f64::min),
606                mz.iter().copied().fold(f64::NEG_INFINITY, f64::max),
607            )
608        })
609        .collect();
610    for (peptide, indices) in groups {
611        // Do not silently recreate a rejected charge/isomer under expansion.
612        if g.rejected_targets
613            .iter()
614            .any(|t| t.modified_peptide == peptide)
615        {
616            continue;
617        }
618        let seed_idx = indices[0];
619        let source = consensus[seed_idx].clone();
620        let rt_min = indices
621            .iter()
622            .map(|&i| consensus[i].ref_rt)
623            .fold(f64::INFINITY, f64::min);
624        let rt_max = indices
625            .iter()
626            .map(|&i| consensus[i].ref_rt)
627            .fold(f64::NEG_INFINITY, f64::max);
628        if rt_max - rt_min > rt_limit
629            || indices.iter().any(|&i| {
630                !original[i].transfer_eligible
631                    || consensus[i].ref_im != 0.0
632                    || (consensus[i].neutral_mass - source.neutral_mass).abs() / source.neutral_mass
633                        * 1e6
634                        > config.consensus.mz_ppm
635            })
636        {
637            continue;
638        }
639        let Some(seed) = original[seed_idx].donors[source.seed_run_idx].clone() else {
640            continue;
641        };
642        for charge in 2..=4 {
643            let mz = source.neutral_mass / f64::from(charge) + PROTON_MASS;
644            let anchors: Vec<_> = runs
645                .iter()
646                .enumerate()
647                .map(|(r, _run)| {
648                    if mz < bounds[r].0 || mz > bounds[r].1 {
649                        return None;
650                    }
651                    let ids: Vec<_> = indices
652                        .iter()
653                        .filter_map(|&i| original[i].donors[r].as_ref())
654                        .collect();
655                    let lo = ids
656                        .iter()
657                        .map(|id| id.rt_minutes)
658                        .fold(f64::INFINITY, f64::min);
659                    let hi = ids
660                        .iter()
661                        .map(|id| id.rt_minutes)
662                        .fold(f64::NEG_INFINITY, f64::max);
663                    let range = rt_ranges[r];
664                    if hi - lo > (range.1 - range.0) * config.consensus.rt_window_pct
665                        || ids.iter().any(|id| id.im != 0.0)
666                    {
667                        return None;
668                    }
669                    ids.into_iter()
670                        .min_by(|a, b| {
671                            a.id_qvalue
672                                .total_cmp(&b.id_qvalue)
673                                .then(a.charge.cmp(&b.charge))
674                                .then(a.source_row.cmp(&b.source_row))
675                        })
676                        .cloned()
677                })
678                .collect();
679            if anchors.iter().all(Option::is_none) {
680                continue;
681            }
682            let provenance = InferredCharge {
683                seed: seed.clone(),
684                native_anchors: anchors,
685                mz_eligible: bounds
686                    .iter()
687                    .map(|&(lo, hi)| mz >= lo && mz <= hi)
688                    .collect(),
689            };
690            if let Some(&i) = indices.iter().find(|&&i| original[i].charge == charge) {
691                g.targets[i].inferred_charge = Some(provenance);
692            } else {
693                let mut cf = source.clone();
694                cf.charge = charge;
695                cf.ref_mz = mz;
696                consensus.push(cf);
697                g.targets.push(SearchTarget {
698                    modified_peptide: peptide.clone(),
699                    charge,
700                    donors: vec![None; runs.len()],
701                    seed_run_idx: source.seed_run_idx,
702                    transfer_eligible: true,
703                    transfer_blocked_runs: Vec::new(),
704                    rt_rescue: None,
705                    inferred_charge: Some(provenance),
706                });
707            }
708        }
709    }
710}
711
712fn finite(v: f64) -> String {
713    if v.is_finite() {
714        v.to_string()
715    } else {
716        String::new()
717    }
718}
719
720/// Write peptide identity, donor provenance, and distinct ID/extraction confidence.
721/// Raw intensities remain available; the peptide matrix applies an explicit cell gate.
722pub fn write_search_outputs(
723    matrix: &IntensityMatrix,
724    out: &Path,
725    max_cell_q: f64,
726    run_tdc: bool,
727    gate_transfers_only: bool,
728) -> Result<()> {
729    let g = matrix
730        .search_guidance
731        .as_ref()
732        .context("missing search guidance")?;
733    let mut w = csv::WriterBuilder::new()
734        .delimiter(b'\t')
735        .from_path(out.join("peptide_quant.tsv"))?;
736    w.write_record([
737        "target_id",
738        "modified_peptide",
739        "charge",
740        "run",
741        "evidence",
742        "status",
743        "intensity",
744        "accepted_intensity",
745        "extraction_qvalue",
746        "direct_id_qvalue",
747        "seed_run",
748        "seed_id_qvalue",
749        "direct_source_row",
750        "seed_source_row",
751    ])?;
752    let mut wide = csv::WriterBuilder::new()
753        .delimiter(b'\t')
754        .from_path(out.join("peptide_intensity_matrix.tsv"))?;
755    let mut header = vec![
756        "target_id".into(),
757        "modified_peptide".into(),
758        "charge".into(),
759    ];
760    header.extend(matrix.run_names.clone());
761    wide.write_record(header)?;
762    for (i, t) in g.targets.iter().enumerate() {
763        let seed = t.donors[t.seed_run_idx]
764            .as_ref()
765            .or_else(|| t.inferred_charge.as_ref().map(|i| &i.seed))
766            .context("target missing seed ID")?;
767        let mut row = vec![
768            i.to_string(),
769            t.modified_peptide.clone(),
770            t.charge.to_string(),
771        ];
772        for r in 0..matrix.n_runs {
773            let direct = t.donors[r].as_ref();
774            let attempted = g.attempted(i, r);
775            let intensity = matrix.intensity(i, r);
776            let q = matrix.q_value(i, r);
777            // A same-run MS2 identification is evidence the extraction score
778            // cannot overturn; only inferred cells are gated on it.
779            let gated = !(gate_transfers_only && direct.is_some());
780            let accepted = attempted
781                && intensity > 0.0
782                && run_tdc
783                && (!gated || (q.is_finite() && q <= max_cell_q));
784            let value = if accepted { intensity } else { 0.0 };
785            row.push(value.to_string());
786            let status = if !attempted {
787                "not_attempted"
788            } else if intensity <= 0.0 {
789                "no_signal"
790            } else if !run_tdc {
791                "unscored"
792            } else if accepted {
793                "accepted"
794            } else {
795                "rejected"
796            };
797            w.write_record(vec![
798                i.to_string(),
799                t.modified_peptide.clone(),
800                t.charge.to_string(),
801                matrix.run_names[r].clone(),
802                if direct.is_some() {
803                    "direct_ms2"
804                } else if g.is_inferred(i, r) {
805                    if g.native_anchor(i, r).is_some() {
806                        "inferred_charge"
807                    } else {
808                        "mbr_inferred_charge"
809                    }
810                } else {
811                    "mbr"
812                }
813                .into(),
814                status.into(),
815                intensity.to_string(),
816                value.to_string(),
817                if attempted && run_tdc {
818                    finite(q)
819                } else {
820                    String::new()
821                },
822                direct.map(|d| finite(d.id_qvalue)).unwrap_or_default(),
823                matrix.run_names[t.seed_run_idx].clone(),
824                finite(seed.id_qvalue),
825                direct.map(|d| d.source_row.to_string()).unwrap_or_default(),
826                seed.source_row.to_string(),
827            ])?;
828        }
829        wide.write_record(row)?;
830    }
831    w.flush()?;
832    wide.flush()?;
833    let mut rejected = csv::WriterBuilder::new()
834        .delimiter(b'\t')
835        .from_path(out.join("search_rejections.tsv"))?;
836    rejected.write_record(["modified_peptide", "charge", "reason"])?;
837    for t in &g.rejected_targets {
838        rejected.write_record([
839            t.modified_peptide.clone(),
840            t.charge.to_string(),
841            t.reason.clone(),
842        ])?;
843    }
844    rejected.flush()?;
845    let manifest = serde_json::json!({"schema_version":1,"koth_version":crate::VERSION,"search":g,
846        "run_names":matrix.run_names,"max_cell_qvalue":max_cell_q,"run_tdc":run_tdc,
847        "gate_transfers_only":gate_transfers_only,
848        "confidence":"ID q-values are imported; extraction q-values are exploratory and separately ranked for direct and transferred cells. No calibrated peptide-transfer FDR is claimed.",
849        "outputs":{"peptide_quant.tsv":sha256(&out.join("peptide_quant.tsv"))?,
850            "peptide_intensity_matrix.tsv":sha256(&out.join("peptide_intensity_matrix.tsv"))?,
851            "search_rejections.tsv":sha256(&out.join("search_rejections.tsv"))?}});
852    let temp = out.join("search_manifest.json.tmp");
853    std::fs::write(&temp, serde_json::to_vec_pretty(&manifest)?)?;
854    std::fs::rename(temp, out.join("search_manifest.json"))?;
855    Ok(())
856}
857
858#[cfg(test)]
859#[path = "targets_tests.rs"]
860mod tests;