Skip to main content

koth_core/scoring/
mod.rs

1pub mod averagine;
2pub mod elements;
3pub mod model;
4
5use crate::config::ScoringConfig;
6use crate::models::{Feature, Polarity, ScoredFeature};
7use averagine::bhattacharyya_score;
8use model::IsotopeModel;
9
10/// Score features using averagine theoretical isotope patterns.
11///
12/// For each feature:
13/// 1. Look up theoretical isotope pattern by neutral mass
14/// 2. Try neutron offsets in [offset_min, offset_max]
15/// 3. Score each offset with Bhattacharyya coefficient + zero-offset bonus
16/// 4. Keep best offset; if below `min_isotope_score_for_offset`, fall back to offset=0
17pub fn score_features(
18    features: &[Feature],
19    config: &ScoringConfig,
20    sulfur_offsets: &[i8],
21    model: &IsotopeModel,
22    polarity: Polarity,
23) -> Vec<ScoredFeature> {
24    log::info!("Scoring {} features", features.len());
25
26    let all_scored: Vec<ScoredFeature> = features
27        .iter()
28        .map(|feature| score_one(feature, config, sulfur_offsets, model, polarity))
29        .collect();
30
31    if log::log_enabled!(log::Level::Debug) {
32        let charged: Vec<&ScoredFeature> = all_scored
33            .iter()
34            .filter(|sf| sf.feature.charge > 0)
35            .collect();
36        if !charged.is_empty() {
37            let max_score = charged
38                .iter()
39                .map(|sf| sf.combined_score)
40                .fold(f64::NEG_INFINITY, f64::max);
41            let mean_score =
42                charged.iter().map(|sf| sf.combined_score).sum::<f64>() / charged.len() as f64;
43            let above = charged
44                .iter()
45                .filter(|sf| sf.isotope_score >= config.min_isotope_score_for_offset)
46                .count();
47            log::debug!(
48                "score diag: {} charged features, max_combined={:.3}, mean_combined={:.3}, isotope>={}: {}",
49                charged.len(), max_score, mean_score, config.min_isotope_score_for_offset, above
50            );
51            // First 5 charged features for inspection
52            for sf in charged.iter().take(5) {
53                let obs = sf.feature.isotope_profile_apex();
54                log::debug!(
55                    "  charge={} n_isotopes={} obs={:?} iso={:.4} cos={:.4} comb={:.4}",
56                    sf.feature.charge,
57                    sf.feature.hills.len(),
58                    obs.iter().map(|x| format!("{:.0}", x)).collect::<Vec<_>>(),
59                    sf.isotope_score,
60                    sf.cosine_score,
61                    sf.combined_score,
62                );
63            }
64        }
65    }
66
67    // Return all charged features; output filtering by score is applied by the
68    // caller via FeaturesConfig::min_isotope_score / min_cosine_score /
69    // min_combined_score.
70    let scored: Vec<ScoredFeature> = all_scored
71        .into_iter()
72        .filter(|sf| sf.feature.charge > 0)
73        .collect();
74
75    log::info!("Scored {} features", scored.len());
76    scored
77}
78
79fn score_one(
80    feature: &Feature,
81    config: &ScoringConfig,
82    sulfur_offsets: &[i8],
83    model: &IsotopeModel,
84    polarity: Polarity,
85) -> ScoredFeature {
86    let neutral_mass = match feature.monoisotopic_neutral_mass(polarity) {
87        Some(m) => m,
88        None => {
89            // Unknown charge — return unscored
90            return ScoredFeature {
91                feature: feature.clone(),
92                neutron_offset: 0,
93                isotope_score: 0.0,
94                cosine_score: 0.0,
95                combined_score: 0.0,
96                theoretical_pattern: Vec::new(),
97            };
98        }
99    };
100
101    let template = model.distribution(neutral_mass);
102    let obs = feature.isotope_profile_apex();
103    let k = obs.len().min(10);
104
105    let mut best_combined = f64::NEG_INFINITY;
106    let mut best_bc = 0.0f64;
107    let mut best_offset: i8 = 0;
108
109    let score_obs = |o: &[f64]| -> f64 {
110        if sulfur_offsets.is_empty() {
111            bhattacharyya_score(o, &template)
112        } else {
113            averagine::bhattacharyya_score_best_sulfur(o, neutral_mass, sulfur_offsets, model).0
114        }
115    };
116
117    // `isotope_offset_enabled` = search neutron offsets [-1, +1]; disabled =
118    // test only offset 0 (no monoisotopic reassignment).
119    let (offset_min, offset_max): (i8, i8) = if config.isotope_offset_enabled {
120        (-1, 1)
121    } else {
122        (0, 0)
123    };
124    for o in offset_min..=offset_max {
125        // Shift obs: obs_aligned[j] = obs[j + o] (shift left by o)
126        let mut obs_aligned = vec![0.0f64; k];
127        for j in 0..k {
128            let src = j as i32 + o as i32;
129            if src >= 0 && (src as usize) < k {
130                obs_aligned[j] = obs[src as usize];
131            }
132        }
133
134        let sc = score_obs(&obs_aligned);
135        // Bonus is used only to prefer offset=0 when scores are close; never stored.
136        let combined = sc
137            + if o == 0 {
138                config.offset_zero_bonus
139            } else {
140                0.0
141            };
142        if combined > best_combined {
143            best_combined = combined;
144            best_bc = sc;
145            best_offset = o;
146        }
147    }
148
149    // If below the isotope-score floor, reset to offset=0 (no neutron reassignment).
150    if best_combined < config.min_isotope_score_for_offset {
151        best_offset = 0;
152        best_bc = score_obs(&obs);
153    }
154
155    // Build normalized theoretical pattern for output
156    let theo_slice = &template[..k];
157    let theo_sum: f64 = theo_slice.iter().sum();
158    let theoretical_pattern: Vec<f64> = if theo_sum > 0.0 {
159        theo_slice.iter().map(|&x| x / theo_sum).collect()
160    } else {
161        vec![0.0; k]
162    };
163
164    // Three quality scores carried independently so downstream filters can
165    // gate on any one of them:
166    //   isotope_score  — Bhattacharyya isotope-pattern match (this stage)
167    //   cosine_score   — chromatographic co-elution of isotope hills (feature stage)
168    //   combined_score — product, the default "quality" knob
169    //
170    // The chromatographic cosine is now computed over the mutual scan
171    // OVERLAP only (see features::cosine::cosine_similarity), which
172    // removes the systematic penalty against short low-abundance hills
173    // that the previous union-padded form imposed. With that fix the
174    // multiplicative composition cleanly separates real features from
175    // noise on the PXD003881 ground-truth set.
176    let isotope_score = best_bc.clamp(0.0, 1.0);
177    let cosine_score = feature.cosine_score.clamp(0.0, 1.0);
178    let combined_score = (isotope_score * cosine_score).clamp(0.0, 1.0);
179
180    ScoredFeature {
181        feature: feature.clone(),
182        neutron_offset: best_offset,
183        isotope_score,
184        cosine_score,
185        combined_score,
186        theoretical_pattern,
187    }
188}