Skip to main content

lfq_refinement/
lfq_refinement.rs

1//! Controlled scoring/control ablations; intentionally outside the production CLI.
2#[path = "support/lfq_matrices.rs"]
3mod lfq_matrices;
4use clap::Parser;
5use koth_ms::{
6    alignment::{align_runs, RunInput},
7    config::AlignConfig,
8    input::{check_batch_mobility_scales, discover_runs, read_features, read_hills},
9    lfq::{
10        consensus::{audit_consensus, VARIANT_NAMES},
11        quantify_consensus,
12    },
13    output::{build_align_report, write_align_report, AlignTiming},
14};
15use lfq_matrices::matrices;
16use std::{io::Write, path::PathBuf, time::Instant};
17
18#[derive(Parser)]
19struct Args {
20    batch: PathBuf,
21    config: PathBuf,
22    output: PathBuf,
23    /// Evenly spaced run subset (original names are preserved).
24    #[arg(long)]
25    take: Option<usize>,
26    /// Comma-separated variant indices to quantify. Empty = audit only.
27    #[arg(long, default_value = "")]
28    quantify: String,
29    /// Only the first five global controls; full audit defaults to 25 controls.
30    #[arg(long)]
31    quick: bool,
32    /// Use the research quality-conditioned controls for extraction.
33    #[arg(long)]
34    conditioned: bool,
35}
36
37fn main() -> anyhow::Result<()> {
38    env_logger::init();
39    let args = Args::parse();
40    anyhow::ensure!(
41        !(args.quick && args.conditioned),
42        "conditioned controls require the full audit"
43    );
44    let config = AlignConfig::from_toml(&args.config)?;
45    let all_paths = discover_runs(&args.batch)?;
46    anyhow::ensure!(!all_paths.is_empty(), "no runs");
47    let count = args.take.unwrap_or(all_paths.len());
48    anyhow::ensure!(
49        count >= 2 && count <= all_paths.len(),
50        "take must be 2..=number of runs"
51    );
52    let selected: Vec<_> = (0..count).map(|i| i * all_paths.len() / count).collect();
53    let paths: Vec<_> = selected.iter().map(|&i| &all_paths[i]).collect();
54    let mut runs = Vec::new();
55    for p in &paths {
56        runs.push(RunInput {
57            name: p.name.clone(),
58            features: read_features(&p.features_path)?,
59            hills: Vec::new(),
60            scan_times: Vec::new(),
61            rt_bounds: None,
62        });
63    }
64    check_batch_mobility_scales(
65        paths
66            .iter()
67            .zip(&runs)
68            .map(|(p, r)| (*p, r.features.as_slice())),
69    )?;
70    if let Some(name) = &config.alignment.reference_run {
71        anyhow::ensure!(
72            runs.iter().any(|r| &r.name == name),
73            "reference run is not selected"
74        );
75    }
76    std::fs::create_dir_all(&args.output)?;
77    std::fs::write(args.output.join("runs.json"),serde_json::to_vec_pretty(&paths.iter().enumerate().map(|(id,p)|
78        serde_json::json!({"run_id":id,"name":p.name,"features":p.features_path})).collect::<Vec<_>>())?)?;
79    let t = Instant::now();
80    let alignment = align_runs(&runs, &config.alignment);
81    let alignment_sec = t.elapsed().as_secs_f64();
82    let t = Instant::now();
83    let audit = audit_consensus(&runs, &alignment, &config.lfq, !args.quick);
84    let audit_sec = t.elapsed().as_secs_f64();
85    std::fs::write(
86        args.output.join("audit.json"),
87        serde_json::to_vec_pretty(&serde_json::json!({
88        "variants":VARIANT_NAMES,"controls":audit.null_group_counts,"full_controls":!args.quick,"alignment_sec":alignment_sec,"audit_sec":audit_sec,
89        "n_runs":runs.len(),"n_groups":audit.groups.len(),"reference":alignment.reference_name}))?,
90    )?;
91    let mut out = csv::WriterBuilder::new()
92        .delimiter(b'\t')
93        .from_path(args.output.join("candidates.tsv"))?;
94    let mut header: Vec<String> = [
95        "candidate_id",
96        "massCalib",
97        "mz",
98        "charge",
99        "rtApex",
100        "im",
101        "combined_score",
102        "n_contributing_runs",
103        "weak_members",
104    ]
105    .map(String::from)
106    .to_vec();
107    for v in VARIANT_NAMES {
108        for field in [
109            "score",
110            "global",
111            "repeat",
112            "density",
113            "pooled",
114            "conditioned",
115        ] {
116            header.push(format!("{v}_{field}"));
117        }
118    }
119    out.write_record(header)?;
120    let mut members = csv::WriterBuilder::new()
121        .delimiter(b'\t')
122        .from_path(args.output.join("members.tsv"))?;
123    members.write_record(["candidate_id", "run_id", "feature_idx", "is_primary"])?;
124    for (i, g) in audit.groups.iter().enumerate() {
125        let weak = g
126            .members
127            .iter()
128            .filter(|&&(r, f)| runs[r].features[f as usize].combined_score < 0.5)
129            .count();
130        let mut row = vec![
131            i.to_string(),
132            g.neutral_mass.to_string(),
133            g.ref_mz.to_string(),
134            g.charge.to_string(),
135            g.ref_rt.to_string(),
136            g.ref_im.to_string(),
137            g.seed_combined_score.to_string(),
138            g.n_contributing_runs.to_string(),
139            weak.to_string(),
140        ];
141        for v in 0..4 {
142            row.extend(
143                [
144                    audit.scores[i][v],
145                    audit.q_global[i][v],
146                    audit.q_repeat[i][v],
147                    audit.q_density[i][v],
148                    audit.q_pooled[i][v],
149                    audit.q_conditioned[i][v],
150                ]
151                .map(|x| x.to_string()),
152            );
153        }
154        out.write_record(row)?;
155        for &(r, f) in &g.members {
156            members.write_record([
157                i.to_string(),
158                r.to_string(),
159                f.to_string(),
160                (g.per_run_feature[r] == Some(f)).to_string(),
161            ])?;
162        }
163    }
164    out.flush()?;
165    members.flush()?;
166    let selected_q = if args.conditioned {
167        &audit.q_conditioned
168    } else {
169        &audit.q_global
170    };
171    for variant in args.quantify.split(',').filter(|s| !s.is_empty()) {
172        let v: usize = variant.parse()?;
173        anyhow::ensure!(v < 4, "variant must be 0..3");
174        let folder = args.output.join(VARIANT_NAMES[v]);
175        std::fs::create_dir_all(&folder)?;
176        let groups = audit
177            .groups
178            .iter()
179            .enumerate()
180            .filter(|(i, g)| {
181                g.n_contributing_runs >= 2
182                    && selected_q[*i][v] <= config.lfq.consensus.max_group_qvalue
183            })
184            .map(|(i, g)| {
185                let mut g = g.clone();
186                g.group_score = audit.scores[i][v];
187                g.group_qvalue = selected_q[i][v];
188                g
189            })
190            .collect();
191        let t = Instant::now();
192        let matrix = quantify_consensus(&runs, &alignment, &config.lfq, groups, |i| {
193            read_hills(&paths[i].hills_path).expect("read hills")
194        });
195        let lfq_sec = t.elapsed().as_secs_f64();
196        matrices(&matrix, &folder)?;
197        std::fs::write(folder.join("align_config.toml"), config.to_toml_string()?)?;
198        let report = build_align_report(
199            &runs,
200            &alignment,
201            &matrix,
202            &config,
203            AlignTiming {
204                alignment_sec,
205                lfq_sec,
206                total_sec: alignment_sec + audit_sec + lfq_sec,
207            },
208        );
209        write_align_report(&report, &folder.join("align_report.json"))?;
210        let mut meta = std::fs::File::create(folder.join("variant.json"))?;
211        writeln!(
212            meta,
213            "{}",
214            serde_json::json!({"variant":VARIANT_NAMES[v],"group_gate":config.lfq.consensus.max_group_qvalue,"control": if args.conditioned { "ten quality-conditioned rank shifts" } else { "first five global shifts" }})
215        )?;
216    }
217    Ok(())
218}