use std::cmp::Ordering;
use rayon::prelude::*;
use crate::config::{CosineAnchor, FeaturesConfig, FileConfig, ImToleranceType, ToleranceType};
use crate::models::Hill;
use crate::models::Polarity;
use crate::scoring::averagine;
use crate::scoring::model::IsotopeModel;
use super::cosine::cosine_similarity;
use super::recalibration::MzRecalModel;
pub(super) struct ChainCtx<'a> {
pub(super) sorted_hills: &'a [&'a Hill],
pub(super) mz_array: &'a [f64],
pub(super) im_array: &'a [f64],
pub(super) scan_starts: &'a [usize],
pub(super) scan_ends: &'a [usize],
pub(super) config: &'a FeaturesConfig,
pub(super) file: &'a FileConfig,
pub(super) use_im: bool,
pub(super) min_intensity: f64,
pub(super) recal: Option<&'a MzRecalModel>,
}
pub(super) struct Candidate {
pub(super) hill_indices: Vec<usize>, pub(super) charge: u8,
}
pub(super) fn build_charge_candidate(
ctx: &ChainCtx,
seed_idx: usize,
charge: u8,
) -> Option<Candidate> {
let ChainCtx {
sorted_hills,
config,
file,
min_intensity,
recal,
..
} = *ctx;
let seed_hill = sorted_hills[seed_idx];
let seed_mz = seed_hill.mz;
let seed_rt = seed_hill.rt;
let recal_mz = |expected: f64| -> f64 {
match recal {
Some(m) => expected * (1.0 + m.predict(expected, seed_rt) / 1e6),
None => expected,
}
};
let use_ppm_tol = matches!(file.mz_tolerance_type, ToleranceType::Ppm);
let effective_tol_ppm = match (use_ppm_tol, recal) {
(true, Some(m)) => {
let floor = file.mz_recalibration_tol_floor_ppm.min(file.mz_tolerance);
(file.mz_recalibration_tol_sigma_mult * m.predict_sigma(seed_mz, seed_rt))
.clamp(floor, file.mz_tolerance)
}
_ => file.mz_tolerance,
};
let mz_tol = if use_ppm_tol {
seed_mz * effective_tol_ppm / 1e6
} else {
file.mz_tolerance
};
let cosine_anchor = config.cosine_anchor_mode();
let step = config.neutron_mass / charge as f64;
{
let neutral_mass_seed_mono = file.polarity.neutral_mass(seed_mz, charge);
let template = config
.isotope_model
.model()
.distribution(neutral_mass_seed_mono);
let template_mono = template[0].max(1e-12);
let seed_intensity = seed_hill.intensity_max;
let mut right_chain: Vec<usize> = Vec::new();
for iso in 1..=config.max_isotopes {
if iso >= template.len() {
break;
}
if config.chain_predicted_intensity_gate {
let expected = seed_intensity * (template[iso] / template_mono);
if expected < min_intensity {
break;
}
}
let target_mz = recal_mz(seed_mz + step * iso as f64);
let ref_hill = right_chain
.last()
.map(|&i| sorted_hills[i])
.unwrap_or(seed_hill);
let mut exclude = Vec::with_capacity(right_chain.len() + 1);
exclude.push(seed_idx);
exclude.extend_from_slice(&right_chain);
let cands = find_neighbors(
ctx,
target_mz,
mz_tol,
ref_hill,
config.min_isotope_step_ratio,
&exclude,
);
if cands.is_empty() {
break;
}
let cos_ref_hill = match cosine_anchor {
CosineAnchor::Adjacent => ref_hill,
CosineAnchor::Seed => seed_hill,
};
let (best_c, best_cos) = cands
.iter()
.map(|&c| {
(
c,
cosine_similarity(cos_ref_hill, sorted_hills[c], config.min_scan_overlap),
)
})
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.unwrap();
if best_cos < config.min_chain_cosine {
break;
}
if config.max_isotope_log2_ratio.is_finite() {
let pred_int = ref_hill.intensity_max;
let cand_int = sorted_hills[best_c].intensity_max;
let theo_pred = template[iso - 1].max(1e-12);
let theo_cand = template[iso].max(1e-12);
if pred_int > 0.0 && cand_int > 0.0 {
let observed_ratio = cand_int / pred_int;
let expected_ratio = theo_cand / theo_pred;
if (observed_ratio / expected_ratio).log2().abs()
> config.max_isotope_log2_ratio
{
break;
}
}
}
right_chain.push(best_c);
}
if right_chain.is_empty() {
return None; }
let chain: Vec<usize> = std::iter::once(seed_idx)
.chain(right_chain.iter().copied())
.collect();
Some(Candidate {
hill_indices: chain,
charge,
})
}
}
fn prefix_log_evidence(
hills: &[&Hill],
charge: u8,
config: &FeaturesConfig,
polarity: Polarity,
) -> Vec<f64> {
let mut scores = vec![f64::NEG_INFINITY; hills.len()];
if hills.len() < 2 || charge == 0 {
return scores;
}
let seed_apex = hills[0].intensity_max;
if !seed_apex.is_finite() || seed_apex <= 0.0 {
return scores;
}
let model = config.isotope_model.model();
let neutral_mass = polarity.neutral_mass(hills[0].mz, charge);
let (c, h, n, o, s) = model.counts(neutral_mass);
let sulfur_counts = if config.sulfur_offsets.is_empty() || !model.has_sulfur() {
vec![s]
} else {
averagine::resolve_sulfur_counts(&model, neutral_mass, &config.sulfur_offsets)
};
let observations: Vec<(f64, f64)> = hills[1..]
.iter()
.map(|hill| {
let log_ratio = hill.intensity_max.log2() - seed_apex.log2();
let cosine = cosine_similarity(hills[0], hill, config.min_scan_overlap);
(log_ratio, cosine)
})
.collect();
for s in sulfur_counts {
let template = crate::scoring::elements::cache().distribution(c, h, n, o, s);
let mut sum = 0.0;
for (k, &(observed, cosine)) in observations.iter().enumerate() {
let expected = template.get(k + 1).copied().unwrap_or(0.0);
let contribution = if expected <= 0.0 || template[0] <= 0.0 {
f64::NEG_INFINITY
} else {
let residual = observed - (expected.log2() - template[0].log2());
isotope_log_evidence(residual, cosine, config)
};
sum += contribution;
scores[k + 1] = scores[k + 1].max(sum);
}
}
scores
}
fn rescore_chain(
chain: &[usize],
charge: u8,
sorted_hills: &[&Hill],
config: &FeaturesConfig,
polarity: Polarity,
) -> Option<HeapItem> {
let hills: Vec<&Hill> = chain.iter().map(|&i| sorted_hills[i]).collect();
let scores = prefix_log_evidence(&hills, charge, config, polarity);
let mut ends: Vec<usize> = (1..chain.len()).collect();
ends.sort_by(|&a, &b| scores[b].total_cmp(&scores[a]).then(a.cmp(&b)));
for end in ends {
if !scores[end].is_finite() {
continue;
}
if config.exhaustive_min_isotope_score > 0.0
&& score_chain(
&hills[..=end],
charge,
&config.sulfur_offsets,
&config.isotope_model.model(),
polarity,
) < config.exhaustive_min_isotope_score
{
continue;
}
return Some(HeapItem {
chain: chain[..=end].to_vec(),
charge,
log_evidence: scores[end],
});
}
None
}
fn isotope_log_evidence(log2_ratio_residual: f64, cosine: f64, config: &FeaturesConfig) -> f64 {
if !log2_ratio_residual.is_finite() || !cosine.is_finite() || cosine <= 0.0 {
return f64::NEG_INFINITY;
}
let signal_sigma = config.isotope_evidence_ratio_sigma;
const NOISE_SIGMA: f64 = 2.0;
let ratio_llr = (NOISE_SIGMA / signal_sigma).ln()
- 0.5 * log2_ratio_residual.powi(2) * (signal_sigma.powi(-2) - NOISE_SIGMA.powi(-2));
let shape = config.isotope_evidence_cosine_shape;
let cosine_llr = shape.ln() + (shape - 1.0) * cosine.min(1.0).ln();
ratio_llr + cosine_llr
}
struct HeapItem {
chain: Vec<usize>, charge: u8,
log_evidence: f64,
}
impl HeapItem {
fn mono(&self) -> usize {
self.chain[0]
}
}
impl PartialEq for HeapItem {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for HeapItem {}
impl PartialOrd for HeapItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeapItem {
fn cmp(&self, other: &Self) -> Ordering {
self.log_evidence
.total_cmp(&other.log_evidence)
.then_with(|| other.mono().cmp(&self.mono()))
.then_with(|| other.charge.cmp(&self.charge))
}
}
pub(super) fn resolve_exhaustive(ctx: &ChainCtx) -> Vec<Candidate> {
use std::collections::BinaryHeap;
let sorted_hills = ctx.sorted_hills;
let config = ctx.config;
let polarity = ctx.file.polarity;
let items: Vec<HeapItem> = (0..sorted_hills.len())
.into_par_iter()
.flat_map_iter(|seed_idx| {
(config.min_charge..=config.max_charge).filter_map(move |charge| {
build_charge_candidate(ctx, seed_idx, charge).and_then(|c| {
rescore_chain(&c.hill_indices, c.charge, sorted_hills, config, polarity)
})
})
})
.collect();
let mut heap: BinaryHeap<HeapItem> = BinaryHeap::from(items);
let mut claimed = vec![false; sorted_hills.len()];
let mut accepted: Vec<Candidate> = Vec::new();
while let Some(item) = heap.pop() {
if claimed[item.mono()] {
continue;
}
let mut k = 0usize;
while k < item.chain.len() && !claimed[item.chain[k]] {
k += 1;
}
if k == item.chain.len() {
for &i in &item.chain {
claimed[i] = true;
}
accepted.push(Candidate {
hill_indices: item.chain,
charge: item.charge,
});
} else if k >= 2 {
if let Some(prefix) = rescore_chain(
&item.chain[..k],
item.charge,
sorted_hills,
config,
polarity,
) {
debug_assert!(prefix.log_evidence <= item.log_evidence);
heap.push(prefix);
}
}
}
accepted
}
fn score_chain(
chain_hills: &[&Hill],
charge: u8,
sulfur_offsets: &[i8],
model: &IsotopeModel,
polarity: Polarity,
) -> f64 {
if chain_hills.len() <= 1 || charge == 0 {
return 0.0;
}
let mut sorted: Vec<&Hill> = chain_hills.to_vec();
sorted.sort_by(|a, b| a.mz.partial_cmp(&b.mz).unwrap_or(Ordering::Equal));
let neutral_mass = polarity.neutral_mass(sorted[0].mz, charge);
let min_scan = sorted.iter().map(|h| h.scan_start).min().unwrap_or(0);
let max_scan = sorted.iter().map(|h| h.scan_end).max().unwrap_or(0);
if max_scan < min_scan {
return 0.0;
}
let n = max_scan - min_scan + 1;
let mut combined = vec![0.0f64; n];
for hill in &sorted {
for (i, &v) in hill.intensity_profile.iter().enumerate() {
let idx = hill.scan_start + i - min_scan;
if idx < n {
combined[idx] += v as f64;
}
}
}
let apex_scan = min_scan
+ combined
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
let obs: Vec<f64> = sorted
.iter()
.map(|h| h.intensity_at_scan(apex_scan))
.collect();
let k = obs.len().min(10);
if sulfur_offsets.is_empty() {
let template = model.distribution(neutral_mass);
averagine::bhattacharyya_score(&obs[..k], &template)
} else {
averagine::bhattacharyya_score_best_sulfur(&obs[..k], neutral_mass, sulfur_offsets, model).0
}
}
fn find_neighbors(
ctx: &ChainCtx,
target_mz: f64,
mz_tol: f64,
ref_hill: &Hill,
max_decrease: f64,
exclude_indices: &[usize],
) -> Vec<usize> {
let ChainCtx {
mz_array,
im_array,
scan_starts,
scan_ends,
sorted_hills,
file,
use_im,
..
} = *ctx;
let lo = mz_array.partition_point(|&x| x < target_mz - mz_tol);
let hi = mz_array.partition_point(|&x| x <= target_mz + mz_tol);
if lo >= hi {
return Vec::new();
}
let min_intensity = ref_hill.intensity_max * max_decrease;
(lo..hi)
.filter(|&i| {
if exclude_indices.contains(&i) {
return false;
}
if scan_ends[i] < ref_hill.scan_start || scan_starts[i] > ref_hill.scan_end {
return false;
}
if use_im {
let im_tol = match file.im_tolerance_type {
ImToleranceType::Absolute => file.im_tolerance,
ImToleranceType::Relative => ref_hill.im.max(im_array[i]) * file.im_tolerance,
};
if (im_array[i] - ref_hill.im).abs() > im_tol {
return false;
}
}
sorted_hills[i].intensity_max >= min_intensity
})
.collect()
}
#[cfg(test)]
#[path = "assemble_tests.rs"]
mod tests;