//! The five deterministic checks — a verbatim port of `qc/checks.py`. Every title / threshold / //! detail / evidence-note string is copied exactly (including the U+2013 en-dash in the //! r1_length detail and the U+2014 em-dash in the poly-G note). Check order and skip conditions //! match the Python registry: r1_length, whitelist_hit_rate (skip w/o whitelist), tso_at_r2_start //! (skip w/o TSO oligo), r2_adapter_readthrough, r2_polyg_tail (skip w/o dark_base). use crate::fmt::{pct1, round4}; use crate::model::{Evidence, Finding}; use crate::spec::SpecInfo; use crate::stream::{Acc, OrderedHist}; /// `qc.checks._tri` fn tri(v: f64, warn: f64, fail: f64) -> &'static str { if v >= fail { "fail" } else if v >= warn { "warn" } else { "pass" } } fn ev(spec_ref: &str, note: String) -> Vec { vec![Evidence { spec_ref: spec_ref.to_string(), note, }] } pub fn build_findings(spec: &SpecInfo, acc: &Acc, h1: &OrderedHist, have_whitelist: bool) -> Vec { let mut out: Vec = Vec::new(); let n = acc.n as f64; // ---- 1. r1_length — spec-driven: gate only when R1 is fully fixed-length, else report ---- { let modal = h1.modal(); let span = if h1.is_empty() { "no reads".to_string() } else { // U+2013 en-dash format!("R1 lengths span {}\u{2013}{} bp", h1.min(), h1.max()) }; match spec.expected_r1_len { Some(exp) => { let ok = h1.distinct() == 1 && h1.contains(exp as u32); out.push(Finding { check_id: "r1_length".to_string(), title: "R1 length matches the expected fixed structure".to_string(), verdict: (if ok { "pass" } else { "fail" }).to_string(), value: modal as f64, unit: "bp".to_string(), threshold: format!("== {}", exp), affected_fraction: None, severity: if ok { 0.0 } else { 0.9 }, evidence: ev( "read_structure.R1", format!("R1 should be exactly {} bp (sum of its fixed-length segments)", exp), ), detail: span, }); } None => { // R1 carries a variable-length insert — report the distribution, do not gate. out.push(Finding { check_id: "r1_length".to_string(), title: "R1 length distribution".to_string(), verdict: "pass".to_string(), value: modal as f64, unit: "bp".to_string(), threshold: "informational".to_string(), affected_fraction: None, severity: 0.0, evidence: ev( "read_structure.R1", "R1 carries a variable-length insert; length is reported, not gated".to_string(), ), detail: span, }); } } } // ---- 1b. anchor presence — spec-driven: each fixed-offset constant should be carried ---- for (i, a) in spec.anchors.iter().enumerate() { let frac = if acc.n > 0 { acc.anchor_hits[i] as f64 / n } else { 0.0 }; let seq = String::from_utf8_lossy(&a.seq).to_string(); let verdict = if frac >= 0.8 { "pass" } else if frac >= 0.5 { "warn" } else { "fail" }; out.push(Finding { check_id: format!("anchor_{}_{}", a.read.to_ascii_lowercase(), i), title: format!("{} carries the expected {} anchor", a.read, seq), verdict: verdict.to_string(), value: round4(frac), unit: "fraction".to_string(), threshold: ">= 0.8".to_string(), affected_fraction: Some(round4((1.0 - frac).max(0.0))), severity: round4((0.8 - frac).max(0.0)), evidence: ev( &format!("read_structure.{}", a.read), format!("{} should carry the constant {} at position {}", a.read, seq, a.offset + 1), ), detail: format!( "{} of {} carry {} at position {} (the rest are off-target / mispriming)", pct1(frac), a.read, seq, a.offset + 1 ), }); } // ---- 2. whitelist_hit_rate (skip without a whitelist) ---- if have_whitelist { let rate = if acc.n > 0 { acc.wl as f64 / n } else { 0.0 }; let verdict = if rate >= 0.85 { "pass" } else if rate >= 0.5 { "warn" } else { "fail" }; out.push(Finding { check_id: "whitelist_hit_rate".to_string(), title: "Cell barcodes on the 10x whitelist".to_string(), verdict: verdict.to_string(), value: round4(rate), unit: "fraction".to_string(), threshold: ">= 0.85".to_string(), affected_fraction: None, severity: round4((0.85 - rate).max(0.0)), evidence: ev( "whitelists.cell_barcode_3M_feb2018", "R1[0:16] should match the 3M-february-2018 gel-bead barcode whitelist".to_string(), ), detail: format!("{} of cell barcodes are on the whitelist", pct1(rate)), }); } // ---- 3. tso_at_r2_start (skip without the TSO oligo) ---- if spec.tso.is_some() { let frac = if acc.n > 0 { acc.tso as f64 / n } else { 0.0 }; out.push(Finding { check_id: "tso_at_r2_start".to_string(), title: "R2 reads beginning with the TSO (adapter-dimer / short insert)".to_string(), verdict: tri(frac, 0.05, 0.15).to_string(), value: round4(frac), unit: "fraction".to_string(), threshold: "< 0.05".to_string(), affected_fraction: Some(frac), severity: round4((frac * 2.0).min(1.0)), evidence: ev( "read_structure.R2.readthrough_chain[tso_5prime]", "R2 should start with cDNA; a leading TSO is the hallmark of empty/short-insert products".to_string(), ), detail: format!("{} of R2 start with the TSO", pct1(frac)), }); } // ---- 4. adapter read-through — spec-driven: the spec's actual 3' adapter, both mates ---- if !spec.adapters.is_empty() { let adapter_str = String::from_utf8_lossy(&spec.adapters[0]).to_string(); for (id, name, hits) in [("r1", "R1", acc.r1_adapter), ("r2", "R2", acc.r2_adapter)] { let frac = if acc.n > 0 { hits as f64 / n } else { 0.0 }; out.push(Finding { check_id: format!("{}_adapter_readthrough", id), title: format!("{} read-through into the 3' adapter", name), verdict: tri(frac, 0.02, 0.10).to_string(), value: round4(frac), unit: "fraction".to_string(), threshold: "< 0.02".to_string(), affected_fraction: Some(round4(frac)), severity: round4((frac * 2.0).min(1.0)), evidence: ev( "oligos[role=read_through_adapter]", format!( "the {} adapter stem in {} means the insert was shorter than the read length", adapter_str, name ), ), detail: format!("{} of {} contain the adapter stem {}", pct1(frac), name, adapter_str), }); } } // ---- 5. r2_polyg_tail (skip without a dark_base) ---- if let Some(base) = spec.dark_base { let base = base as char; let frac = if acc.n > 0 { acc.polyg as f64 / n } else { 0.0 }; out.push(Finding { check_id: "r2_polyg_tail".to_string(), title: format!("R2 with a poly-{} no-signal tail", base), verdict: tri(frac, 0.01, 0.05).to_string(), value: round4(frac), unit: "fraction".to_string(), threshold: "< 0.01".to_string(), affected_fraction: Some(frac), severity: round4((frac * 3.0).min(1.0)), evidence: ev( "platform_params.dark_base", // U+2014 em-dash, verbatim format!("a poly-{} 3' tail on two-color instruments is 'no signal' \u{2014} typical of empty/short fragments", base), ), detail: format!("{} of R2 have a poly-{} tail", pct1(frac), base), }); } out }