Skip to main content

data_beans/
qc_lib.rs

1//! Cell-axis quality control (library-only): robust (MAD) outlier detection
2//! plus the near-empty floor. Built on the streaming stat collectors in the
3//! sibling `qc` module and consumed by senna / pinto — NOT by the data-beans
4//! CLI — so it lives in its own module that the binary never compiles (keeps
5//! `qc` fully bin-used and avoids dead-code in the bin target).
6
7use crate::qc::{collect_column_stat_across_vec, collect_row_stat_across_vec};
8use crate::sparse_io_stack::SparseIoStack;
9use crate::sparse_io_vector::SparseIoVec;
10use legume_numeric::matrix::common_io::write_lines;
11use legume_numeric::matrix::traits::RunningStatOps;
12use log::warn;
13use regex::Regex;
14
15///////////////////////////////////////////////////////////////////////////////////
16// Cell-axis quality control: robust (MAD) outlier detection + near-empty floor. //
17///////////////////////////////////////////////////////////////////////////////////
18//
19// This MAY DROP CELLS (columns): callers that enable QC end up with fewer
20// cells in the working set and/or in the per-cell outputs than the input had,
21// so downstream consumers must not assume a 1:1 positional mapping to the
22// input barcodes — join by cell name. (Feature/row QC, off by default, may
23// likewise drop genes/rows.)
24//
25// Shared by senna and pinto. Two-tier policy (see `compute_qc`):
26//   * near-empty cells (`nnz < min_cell_nnz`) are kept in training but
27//     dropped from the *output* (gem-style; see `senna gem --min-cell-nnz`);
28//   * non-near-empty MAD outliers are dropped from *training* via
29//     `SparseIoVec::mask_columns` (so they leave the outputs too).
30
31/// Which side(s) of the robust band count as outliers.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Tail {
34    /// Flag values far *below* the median (e.g. low counts / few genes).
35    Lower,
36    /// Flag values far *above* the median (e.g. high mito fraction).
37    Upper,
38    /// Flag both extremes.
39    Both,
40}
41
42/// Configuration for cell QC. Plain struct (no clap) so non-clap crates
43/// can construct it directly; the clap surface is [`QcArgs`].
44#[derive(Clone, Debug)]
45pub struct QcConfig {
46    /// MAD multiplier for the robust band (larger = more permissive).
47    pub n_mads: f32,
48    /// Near-empty floor: cells with fewer than this many detected features
49    /// (nnz across all rows) are masked at output, not dropped from
50    /// training. 0 disables the floor.
51    pub min_cell_nnz: usize,
52    /// Hard floor on total counts per cell, in addition to MAD. 0 disables.
53    pub min_counts_per_cell: f32,
54    /// Regex over row (feature) names selecting mitochondrial genes; enables
55    /// the per-cell mito-fraction metric. `None` disables it.
56    pub mito_pattern: Option<String>,
57    /// Optional hard max mitochondrial fraction (0..1).
58    pub mito_max_frac: Option<f32>,
59    /// Regex selecting ribosomal genes (enables ribo-fraction metric).
60    pub ribo_pattern: Option<String>,
61    /// Optional hard max ribosomal fraction (0..1).
62    pub ribo_max_frac: Option<f32>,
63    /// Per-cell metrics that drive MAD outlier flagging.
64    pub mad_on_n_genes: bool,
65    pub mad_on_counts: bool,
66    pub mad_on_mito: bool,
67    /// Feature-axis QC (off by default): drop genes expressed in fewer than
68    /// this many cells. 0 disables.
69    pub feature_min_cells: usize,
70    /// Master switch for the MAD train-drop tier. When `false`, only the
71    /// near-empty floor (output mask) is computed — used by inference
72    /// (predict/impute) so query cells are never silently dropped.
73    pub drop_outliers: bool,
74    /// Automatic cell calling: pick the per-cell nnz cutoff at the trough
75    /// between the ambient and the cell peak ([`crate::qc::suggest_nnz_cutoff`])
76    /// and **train-drop** every cell below it. So called-out ambient is removed
77    /// up front (via the caller's `mask_columns`), not just output-masked — it
78    /// never shapes the model. The cutoff is authoritative; `min_cell_nnz` does
79    /// NOT floor it (it only drives the separate near-empty output mask). No-op
80    /// when there is no trough (unimodal) or when `drop_outliers` is false
81    /// (inference). Unlike the loader's per-file empty-barcode gate, this runs
82    /// on the pooled cell axis.
83    pub auto_cell_cutoff: bool,
84    /// Print the per-cell nnz histogram + the suggested/applied cutoff (the
85    /// same ASCII summary as `data-beans squeeze --show-histogram`).
86    pub qc_histogram: bool,
87}
88
89impl Default for QcConfig {
90    fn default() -> Self {
91        Self {
92            n_mads: 5.0,
93            min_cell_nnz: 2,
94            min_counts_per_cell: 0.0,
95            mito_pattern: None,
96            mito_max_frac: None,
97            ribo_pattern: None,
98            ribo_max_frac: None,
99            mad_on_n_genes: true,
100            mad_on_counts: true,
101            mad_on_mito: false,
102            feature_min_cells: 0,
103            drop_outliers: true,
104            auto_cell_cutoff: false,
105            qc_histogram: false,
106        }
107    }
108}
109
110/// Median of a non-empty slice via `O(n)` quickselect (reorders in place).
111fn median_in_place(xs: &mut [f32]) -> f32 {
112    let mid = xs.len() / 2;
113    xs.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
114    xs[mid]
115}
116
117/// Robust outlier keep-mask: `keep[i] = false` when `values[i]` falls
118/// outside `median ± n_mads · MAD · 1.4826` on the requested `tail`.
119/// Count-like metrics should pass `log1p = true` so the band is symmetric
120/// on the multiplicative scale. Consolidates the median+MAD idiom used in
121/// `cnv::hmm`.
122/// `consider` (when `Some`) restricts the median/MAD band to the cells it
123/// marks `true` — so e.g. near-empty cells don't contaminate the robust
124/// center — while the keep decision is still returned for every cell.
125pub fn robust_outlier_keep(
126    values: &[f32],
127    n_mads: f32,
128    tail: Tail,
129    log1p: bool,
130    consider: Option<&[bool]>,
131) -> Vec<bool> {
132    let n = values.len();
133    if n == 0 {
134        return vec![];
135    }
136    let xform = |v: f32| if log1p { v.max(0.0).ln_1p() } else { v };
137
138    // Band statistics computed over the considered subset only.
139    let mut xs: Vec<f32> = values
140        .iter()
141        .enumerate()
142        .filter(|(i, _)| consider.is_none_or(|c| c[*i]))
143        .map(|(_, &v)| xform(v))
144        .filter(|v| v.is_finite())
145        .collect();
146    if xs.is_empty() {
147        return vec![true; n];
148    }
149    // O(n) median via quickselect (no full sort).
150    let median = median_in_place(&mut xs);
151
152    let mut dev: Vec<f32> = xs.iter().map(|&v| (v - median).abs()).collect();
153    let mad = (median_in_place(&mut dev) * 1.4826).max(1e-8);
154
155    let lo = median - n_mads * mad;
156    let hi = median + n_mads * mad;
157    values
158        .iter()
159        .map(|&raw| {
160            let v = xform(raw);
161            if !v.is_finite() {
162                return false;
163            }
164            match tail {
165                Tail::Lower => v >= lo,
166                Tail::Upper => v <= hi,
167                Tail::Both => v >= lo && v <= hi,
168            }
169        })
170        .collect()
171}
172
173/// Resolve a regex over row (feature) names into matching row indices, in
174/// the same compact-row order as `SparseIoVec::row_names()`. Feeds
175/// `collect_column_stat_across_vec(.., Some(&rows), ..)` for subset sums.
176pub fn resolve_rows_by_regex(row_names: &[Box<str>], pattern: &str) -> anyhow::Result<Vec<usize>> {
177    let re = Regex::new(pattern)?;
178    Ok(row_names
179        .iter()
180        .enumerate()
181        .filter(|(_, name)| re.is_match(name))
182        .map(|(i, _)| i)
183        .collect())
184}
185
186/// Per-cell / per-feature QC outcome.
187pub struct QcReport {
188    /// Cells to retain in training (len = num_columns). `false` only for
189    /// non-near-empty MAD outliers — drives `SparseIoVec::mask_columns`.
190    pub train_keep: Vec<bool>,
191    /// Near-empty cells (len = num_columns): kept in training, masked at
192    /// output via [`QcReport::output_keep_idx`].
193    pub near_empty: Vec<bool>,
194    /// Features to retain (len = num_rows). All `true` unless
195    /// `feature_min_cells > 0`.
196    pub feature_keep: Vec<bool>,
197    pub n_genes: Vec<f32>,
198    pub total_counts: Vec<f32>,
199    pub mito_frac: Option<Vec<f32>>,
200    pub ribo_frac: Option<Vec<f32>>,
201    pub n_cells_dropped: usize,
202    pub n_features_dropped: usize,
203}
204
205impl QcReport {
206    /// Indices, in *post-`mask_columns`* column order, of the cells to emit
207    /// at output: surviving (training-kept) cells that are not near-empty.
208    /// Apply with `Mat::select_rows` on per-cell output matrices and filter
209    /// the barcode slice by the same indices.
210    pub fn output_keep_idx(&self) -> Vec<usize> {
211        let mut idx = Vec::new();
212        let mut new_pos = 0usize;
213        for c in 0..self.train_keep.len() {
214            if !self.train_keep[c] {
215                continue; // dropped from training entirely
216            }
217            if !self.near_empty[c] {
218                idx.push(new_pos); // c survives at compact index new_pos
219            }
220            new_pos += 1;
221        }
222        idx
223    }
224
225    /// Emit-keep indices in the **original** (unmasked) column order: cells
226    /// that are neither MAD-dropped nor near-empty. For paths that keep every
227    /// cell in training and instead `select_rows` the per-cell outputs
228    /// directly (e.g. bge's `UnifiedData`, which has no `mask_columns`).
229    pub fn emit_idx_unmasked(&self) -> Vec<usize> {
230        (0..self.train_keep.len())
231            .filter(|&c| self.train_keep[c] && !self.near_empty[c])
232            .collect()
233    }
234}
235
236/// Compute the two-tier cell-QC report. `block_size` controls the
237/// streaming stat passes (`None` = default chunking).
238pub fn compute_qc(
239    data: &SparseIoVec,
240    cfg: &QcConfig,
241    block_size: Option<usize>,
242) -> anyhow::Result<QcReport> {
243    compute_qc_exempting(data, cfg, block_size, None)
244}
245
246/// [`compute_qc`] with columns that are **not cells** exempted — a prior
247/// run's carried pseudobulks. `exempt[c] = true` keeps column `c` out of all
248/// band statistics and out of every verdict. See `qc_from_metrics`.
249pub fn compute_qc_exempting(
250    data: &SparseIoVec,
251    cfg: &QcConfig,
252    block_size: Option<usize>,
253    exempt: Option<&[bool]>,
254) -> anyhow::Result<QcReport> {
255    let row_names = data.row_names()?;
256
257    // Per-cell totals across all rows (nnz = n_genes, tot = total_counts).
258    // Use the running-stat accessors directly to skip the unused mean/std.
259    let col_stat = collect_column_stat_across_vec(data, None, block_size)?;
260    let n_genes = col_stat.count_positives();
261    let total_counts = col_stat.sum();
262
263    // Optional mito / ribo fractions via row-name regex subsets.
264    let frac_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<f32>>> {
265        let Some(pat) = pattern else {
266            return Ok(None);
267        };
268        let rows = resolve_rows_by_regex(&row_names, pat)?;
269        if rows.is_empty() {
270            warn!(
271                "QC: pattern `{}` matched no features — metric disabled",
272                pat
273            );
274            return Ok(None);
275        }
276        let sub_tot = collect_column_stat_across_vec(data, Some(&rows), block_size)?.sum();
277        let frac = sub_tot
278            .iter()
279            .zip(total_counts.iter())
280            .map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
281            .collect::<Vec<f32>>();
282        Ok(Some(frac))
283    };
284    let mito_frac = frac_for(&cfg.mito_pattern)?;
285    let ribo_frac = frac_for(&cfg.ribo_pattern)?;
286
287    // Feature axis stat (only when feature QC is enabled).
288    let feature_n_cells = if cfg.feature_min_cells > 0 {
289        Some(collect_row_stat_across_vec(data, block_size)?.count_positives())
290    } else {
291        None
292    };
293
294    Ok(qc_from_metrics(
295        QcMetrics {
296            n_genes,
297            total_counts,
298            mito_frac,
299            ribo_frac,
300            feature_n_cells,
301            n_rows: data.num_rows(),
302        },
303        cfg,
304        exempt,
305    ))
306}
307
308/// Modality-agnostic cell QC for a [`SparseIoStack`]: per-cell `n_genes` /
309/// `total_counts` are **summed across all member modalities** (mirrors
310/// `senna gem`'s "a cell rich in any one modality is kept"). Mito/ribo and
311/// feature-axis QC are skipped on stacks (row names are per-modality).
312pub fn compute_qc_stack(
313    stack: &SparseIoStack,
314    cfg: &QcConfig,
315    block_size: Option<usize>,
316) -> anyhow::Result<QcReport> {
317    let n_cols = stack.num_columns()?;
318    let mut n_genes = vec![0f32; n_cols];
319    let mut total_counts = vec![0f32; n_cols];
320    for member in stack.stack.iter() {
321        let cs = collect_column_stat_across_vec(member, None, block_size)?;
322        let ng = cs.count_positives();
323        let ct = cs.sum();
324        for c in 0..n_cols {
325            n_genes[c] += ng[c];
326            total_counts[c] += ct[c];
327        }
328    }
329    if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
330        warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
331    }
332    Ok(qc_from_metrics(
333        QcMetrics {
334            n_genes,
335            total_counts,
336            mito_frac: None,
337            ribo_frac: None,
338            feature_n_cells: None,
339            n_rows: 0, // feature axis not masked on stacks
340        },
341        cfg,
342        None,
343    ))
344}
345
346/// Pre-computed per-cell / per-feature metrics fed to [`qc_from_metrics`].
347/// Lets the single-modality and stacked QC paths share one decision rule.
348struct QcMetrics {
349    n_genes: Vec<f32>,
350    total_counts: Vec<f32>,
351    mito_frac: Option<Vec<f32>>,
352    ribo_frac: Option<Vec<f32>>,
353    /// `None` when feature-axis QC is disabled.
354    feature_n_cells: Option<Vec<f32>>,
355    n_rows: usize,
356}
357
358/// Apply the two-tier QC decision (near-empty floor + MAD outliers) to
359/// pre-computed metrics. Shared by [`compute_qc`] and [`compute_qc_stack`].
360///
361/// `exempt`, when given, marks columns that are **not cells** — a prior run's
362/// carried pseudobulks. They are excluded from every band statistic (a few
363/// hundred smooth averages otherwise drag the MAD center and can guillotine
364/// the real cells wholesale — measured: 400 of 400 real cells dropped as
365/// "outliers" of the carried columns' band) and they receive no verdict:
366/// never near-empty, never dropped. Their exclusion from OUTPUT is a separate
367/// concern handled by the caller.
368fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
369    let QcMetrics {
370        n_genes,
371        total_counts,
372        mito_frac,
373        ribo_frac,
374        feature_n_cells,
375        n_rows,
376    } = m;
377    let n_cols = n_genes.len();
378
379    let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
380
381    // Tier 1: near-empty floor. Exempt columns are never near-empty.
382    let near_empty: Vec<bool> = n_genes
383        .iter()
384        .enumerate()
385        .map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
386        .collect();
387
388    // Tier 2: MAD outliers among non-near-empty cells. The band statistics
389    // are fit over the non-near-empty, non-exempt cells only, so neither
390    // near-empty cells nor carried pseudobulks contaminate the robust center.
391    let not_near_empty: Vec<bool> = near_empty
392        .iter()
393        .enumerate()
394        .map(|(c, &e)| !e && !is_exempt(c))
395        .collect();
396    let consider = Some(not_near_empty.as_slice());
397    let mut outlier = vec![false; n_cols];
398    if cfg.drop_outliers {
399        let mut bands: Vec<Vec<bool>> = Vec::new();
400        if cfg.mad_on_n_genes {
401            bands.push(robust_outlier_keep(
402                &n_genes,
403                cfg.n_mads,
404                Tail::Lower,
405                true,
406                consider,
407            ));
408        }
409        if cfg.mad_on_counts {
410            bands.push(robust_outlier_keep(
411                &total_counts,
412                cfg.n_mads,
413                Tail::Lower,
414                true,
415                consider,
416            ));
417        }
418        if cfg.mad_on_mito {
419            if let Some(mf) = mito_frac.as_ref() {
420                bands.push(robust_outlier_keep(
421                    mf,
422                    cfg.n_mads,
423                    Tail::Upper,
424                    false,
425                    consider,
426                ));
427            }
428        }
429        for c in 0..n_cols {
430            if near_empty[c] || is_exempt(c) {
431                continue; // floor takes precedence; exempt columns get no verdict
432            }
433            let mut fail = total_counts[c] < cfg.min_counts_per_cell;
434            if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
435                fail |= mf[c] > cap;
436            }
437            if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
438                fail |= rf[c] > cap;
439            }
440            for band in bands.iter() {
441                if !band[c] {
442                    fail = true;
443                    break;
444                }
445            }
446            outlier[c] = fail;
447        }
448
449        // Automatic cell calling: the trough of n_genes picks the ambient↔real
450        // boundary and train-drops every cell below it, so the caller's
451        // `mask_columns(train_keep)` removes ambient up front. The cutoff
452        // is authoritative — there is NO redundant `min_cell_nnz` floor on it.
453        // When the data is unimodal the trough search returns `None`, so no auto
454        // cutoff is applied and the near-empty floor / MAD tiers stand alone.
455        // The histogram + cutoff are optionally printed.
456        if cfg.auto_cell_cutoff || cfg.qc_histogram {
457            let suggested = crate::qc::suggest_nnz_cutoff(&n_genes);
458            // Display cutoff: the trough suggestion if found, else the
459            // near-empty floor (what the non-auto tier would use).
460            let shown = suggested.unwrap_or(cfg.min_cell_nnz);
461            crate::qc::print_nnz_summary("Cell", "nnz", &n_genes, shown, suggested);
462            if cfg.auto_cell_cutoff {
463                if let Some(cut) = suggested {
464                    for (c, &g) in n_genes.iter().enumerate() {
465                        if (g as usize) < cut {
466                            outlier[c] = true;
467                        }
468                    }
469                }
470            }
471        }
472    }
473
474    let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
475    let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
476
477    // Guardrail: never produce an empty matrix.
478    if n_cols > 0 && n_cells_dropped >= n_cols {
479        warn!(
480            "QC would drop all {} cells — keeping all (check thresholds)",
481            n_cols
482        );
483        train_keep = vec![true; n_cols];
484        n_cells_dropped = 0;
485    }
486
487    // Feature axis (only when feature_n_cells was computed).
488    let (feature_keep, n_features_dropped) = match feature_n_cells {
489        Some(n_cells_expr) => {
490            let keep: Vec<bool> = n_cells_expr
491                .iter()
492                .map(|&c| (c as usize) >= cfg.feature_min_cells)
493                .collect();
494            let dropped = keep.iter().filter(|&&k| !k).count();
495            if !keep.is_empty() && dropped >= keep.len() {
496                warn!("QC would drop all features — keeping all");
497                (vec![true; keep.len()], 0)
498            } else {
499                (keep, dropped)
500            }
501        }
502        None => (vec![true; n_rows], 0),
503    };
504
505    QcReport {
506        train_keep,
507        near_empty,
508        feature_keep,
509        n_genes,
510        total_counts,
511        mito_frac,
512        ribo_frac,
513        n_cells_dropped,
514        n_features_dropped,
515    }
516}
517
518/// Filter a per-cell `Vec<T>` in lockstep with a cell keep-mask. Used to
519/// keep batch labels / coordinates aligned after `mask_columns`.
520pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
521    items
522        .iter()
523        .zip(keep.iter())
524        .filter(|&(_, &k)| k)
525        .map(|(x, _)| x.clone())
526        .collect()
527}
528
529/// Write a per-cell QC table (TSV): name, n_genes, total_counts,
530/// [mito_frac], [ribo_frac], near_empty (0/1), train_keep (0/1).
531pub fn write_qc_report(
532    path: &str,
533    cell_names: &[Box<str>],
534    report: &QcReport,
535) -> anyhow::Result<()> {
536    use std::fmt::Write as _;
537    let n = report.train_keep.len();
538    anyhow::ensure!(
539        cell_names.len() == n,
540        "write_qc_report: {} names != {} cells",
541        cell_names.len(),
542        n
543    );
544
545    let mut header = String::from("#cell\tn_genes\ttotal_counts");
546    if report.mito_frac.is_some() {
547        header.push_str("\tmito_frac");
548    }
549    if report.ribo_frac.is_some() {
550        header.push_str("\tribo_frac");
551    }
552    header.push_str("\tnear_empty\ttrain_keep");
553
554    let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
555    lines.push(header.into_boxed_str());
556    for c in 0..n {
557        let mut line = String::new();
558        let _ = write!(
559            line,
560            "{}\t{}\t{}",
561            cell_names[c], report.n_genes[c], report.total_counts[c]
562        );
563        if let Some(mf) = report.mito_frac.as_ref() {
564            let _ = write!(line, "\t{}", mf[c]);
565        }
566        if let Some(rf) = report.ribo_frac.as_ref() {
567            let _ = write!(line, "\t{}", rf[c]);
568        }
569        let _ = write!(
570            line,
571            "\t{}\t{}",
572            report.near_empty[c] as u8, report.train_keep[c] as u8
573        );
574        lines.push(line.into_boxed_str());
575    }
576    write_lines(&lines, path)
577}
578
579/// Clap surface for cell QC, shared by senna and pinto subcommands.
580///
581/// **Cell QC is ON by default and CAN DROP CELLS (columns).** Low-quality
582/// cells are removed: near-empty cells are omitted from the per-cell outputs,
583/// and robust (MAD) outlier cells are excluded from training entirely. As a
584/// result, the per-cell output files (`*.latent.parquet`, `*.cell_proj.parquet`,
585/// `*.cell_to_pb.parquet`, `*.cell_embedding.parquet`, pinto propensity, etc.)
586/// **may contain fewer rows (cells) than the input** — do not assume a 1:1,
587/// positional correspondence with the input barcodes; always join by the cell
588/// name/barcode column. Pass `--no-qc` to disable and keep every cell, or
589/// `--qc-report <path>` to dump the per-cell keep/drop flags. (Feature/row QC
590/// is OFF unless `--qc-feature-min-cells` is set.)
591#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
592#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
593pub struct QcArgs {
594    /// Disable cell QC entirely (keep every input cell).
595    #[arg(
596        long = "no-qc",
597        default_value_t = false,
598        long_help = "Disable cell quality control entirely and keep every input cell.\n\
599                     \n\
600                     By DEFAULT (without this flag) cell QC is:\n  \
601                     - a near-empty nnz floor (--qc-min-cell-nnz), and\n  \
602                     - MAD-outlier drops on detected features and total counts\n    \
603                     (--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
604                     \n\
605                     Empty barcodes are dropped earlier, per input file, by the loader.\n\
606                     A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
607                     \n\
608                     Outputs may therefore have FEWER ROWS than the input.\n\
609                     Join by the cell/barcode name column, never by position.\n\
610                     \n\
611                     Use --qc-report to see exactly what was dropped.\n\
612                     For the older near-empty-floor-only gate,\n\
613                     pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
614    )]
615    pub no_qc: bool,
616
617    /// MAD multiplier for the robust outlier band; smaller = drops more cells.
618    #[arg(long = "qc-mads", default_value_t = 5.0)]
619    pub qc_mads: f32,
620
621    #[arg(
622        long = "qc-min-cell-nnz",
623        default_value_t = 2,
624        help = "Near-empty floor on a cell's detected-feature count",
625        long_help = "Near-empty floor on the detected-feature count.\n\
626                     Cells below it are dropped from the per-cell outputs.\n\
627                     They are still kept in training."
628    )]
629    pub qc_min_cell_nnz: usize,
630
631    #[arg(
632        long = "qc-min-counts",
633        hide = true,
634        default_value_t = 0.0,
635        help = "Hard floor on total counts per cell",
636        long_help = "Hard floor on total counts per cell.\n\
637                     Cells below it are dropped from training. 0 disables the floor."
638    )]
639    pub qc_min_counts: f32,
640
641    #[arg(
642        long = "qc-mito-pattern",
643        hide = true,
644        help = "Regex over feature names selecting mitochondrial genes",
645        long_help = "Regex over feature names selecting mitochondrial genes.\n\
646                     It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
647    )]
648    pub qc_mito_pattern: Option<String>,
649
650    /// Hard max mitochondrial fraction (0..1).
651    #[arg(long = "qc-mito-max-frac", hide = true)]
652    pub qc_mito_max_frac: Option<f32>,
653
654    /// Regex over feature names selecting ribosomal genes.
655    #[arg(long = "qc-ribo-pattern", hide = true)]
656    pub qc_ribo_pattern: Option<String>,
657
658    /// Hard max ribosomal fraction (0..1).
659    #[arg(long = "qc-ribo-max-frac", hide = true)]
660    pub qc_ribo_max_frac: Option<f32>,
661
662    #[arg(
663        long = "qc-feature-min-cells",
664        hide = true,
665        default_value_t = 0,
666        help = "Feature/row QC: drop genes expressed in too few cells",
667        long_help = "Feature/row QC; off by default.\n\
668                     It DROPS gene rows expressed in fewer than this many cells.\n\
669                     \n\
670                     Not every consumer applies it.\n\
671                     `bge` does not, since QC there is cell-only.\n\
672                     `pinto` does not either: it reads only the cell verdict,\n\
673                     so setting this costs a stats pass and changes nothing."
674    )]
675    pub qc_feature_min_cells: usize,
676
677    #[arg(
678        long = "qc-report",
679        help = "Write a per-cell QC table (.tsv)",
680        long_help = "Write a per-cell QC table, as .tsv.\n\
681                     It carries the metrics plus near_empty and train_keep flags.\n\
682                     You can then see exactly which cells were dropped."
683    )]
684    pub qc_report: Option<Box<str>>,
685
686    #[arg(
687        long = "qc-histogram",
688        hide = true,
689        default_value_t = false,
690        help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
691        long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
692                     The suggested trough cutoff is marked.\n\
693                     It is the same summary as `data-beans squeeze --show-histogram`.\n\
694                     \n\
695                     This is purely diagnostic. The cutoff is shown, not applied.\n\
696                     The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
697                     Use the histogram to pick --qc-min-cell-nnz by hand."
698    )]
699    pub qc_histogram: bool,
700
701    #[arg(
702        long = "qc-mad-on-genes",
703        hide = true,
704        default_value_t = true,
705        help = "MAD-outlier drop on the per-cell detected-feature count",
706        long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
707                     \n\
708                     ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
709                     with no way to enable them. That also made --qc-mads inert.\n\
710                     Only a set --qc-mito-pattern revived it.\n\
711                     \n\
712                     Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
713                     That is the conservative near-empty nnz gate alone."
714    )]
715    pub qc_mad_on_genes: bool,
716
717    #[arg(
718        long = "qc-mad-on-counts",
719        hide = true,
720        default_value_t = true,
721        help = "MAD-outlier drop on per-cell total counts",
722        long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
723                     ON by default; see --qc-mad-on-genes."
724    )]
725    pub qc_mad_on_counts: bool,
726
727    #[arg(
728        long = "qc-auto-cutoff",
729        hide = true,
730        default_value_t = false,
731        help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
732        long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
733                     It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
734                     Without this flag it is only reported, via --qc-histogram.\n\
735                     \n\
736                     OFF by default. The intended gate is the conservative near-empty floor,\n\
737                     plus the model's own empty-call.\n\
738                     This flag was referenced in the docs before it existed."
739    )]
740    pub qc_auto_cutoff: bool,
741}
742
743impl QcArgs {
744    /// QC config, or `None` under `--no-qc`.
745    pub fn to_config(&self) -> Option<QcConfig> {
746        (!self.no_qc).then(|| QcConfig {
747            n_mads: self.qc_mads,
748            min_cell_nnz: self.qc_min_cell_nnz,
749            min_counts_per_cell: self.qc_min_counts,
750            mito_pattern: self.qc_mito_pattern.clone(),
751            mito_max_frac: self.qc_mito_max_frac,
752            ribo_pattern: self.qc_ribo_pattern.clone(),
753            ribo_max_frac: self.qc_ribo_max_frac,
754            // MAD gates are ON by default and settable. They were hardcoded
755            // `false` here with no CLI path, which also made `--qc-mads` a dead
756            // flag unless `--qc-mito-pattern` was set (nothing else reads
757            // `n_mads`). Mito stays implicit: it can only run when a pattern
758            // selects the rows to measure.
759            mad_on_n_genes: self.qc_mad_on_genes,
760            mad_on_counts: self.qc_mad_on_counts,
761            mad_on_mito: self.qc_mito_pattern.is_some(),
762            feature_min_cells: self.qc_feature_min_cells,
763            drop_outliers: true,
764            // Off by default: the near-empty floor plus the model's own
765            // empty-call (bge's embedding-norm two-step; topic/masked-topic's
766            // flag-don't-drop) is the intended gate. `--qc-auto-cutoff` opts in.
767            auto_cell_cutoff: self.qc_auto_cutoff,
768            qc_histogram: self.qc_histogram,
769        })
770    }
771}
772
773#[cfg(test)]
774mod qc_tests {
775    use super::*;
776
777    #[test]
778    fn robust_lower_flags_low_outlier() {
779        let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
780        let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
781        assert!(!keep[5], "the value 1.0 should be a lower outlier");
782        assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
783        // upper tail must not flag a low value
784        let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
785        assert!(keep_up[5], "lower outlier kept under Tail::Upper");
786    }
787
788    #[test]
789    fn robust_uniform_keeps_all() {
790        let v = vec![7.0; 20];
791        let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
792        assert!(keep.iter().all(|&k| k));
793    }
794
795    #[test]
796    fn auto_cutoff_train_drops_ambient() {
797        // Bimodal nnz: 30 ambient (~2) + 30 real (~100). With auto cell calling on
798        // (MAD tiers off so the auto floor is the only decider), the trough
799        // cutoff lands between the modes and the ambient cells are train-dropped.
800        let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
801        let cfg = QcConfig {
802            auto_cell_cutoff: true,
803            qc_histogram: false,
804            drop_outliers: true,
805            mad_on_n_genes: false,
806            mad_on_counts: false,
807            mad_on_mito: false,
808            min_cell_nnz: 0,
809            ..QcConfig::default()
810        };
811        let report = qc_from_metrics(
812            QcMetrics {
813                n_genes: n_genes.clone(),
814                total_counts: n_genes,
815                mito_frac: None,
816                ribo_frac: None,
817                feature_n_cells: None,
818                n_rows: 0,
819            },
820            &cfg,
821            None,
822        );
823        assert_eq!(
824            report.train_keep,
825            [vec![false; 30], vec![true; 30]].concat()
826        );
827        assert_eq!(report.n_cells_dropped, 30);
828    }
829
830    #[test]
831    fn robust_consider_excludes_contaminants_from_band() {
832        // A real cluster around 100 plus a large block of near-empty 0s that
833        // would drag a naive median/MAD down. With `consider` masking the
834        // zeros out of the band, a genuine low real cell (40) is still flagged.
835        let mut v = vec![0.0; 12];
836        v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
837        let mut consider = vec![false; 12];
838        consider.extend([true; 6]);
839        let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
840        assert!(
841            !keep[17],
842            "40 is a lower outlier of the real cluster (~100)"
843        );
844        // Without `consider`, the zeros dominate the median and 40 survives.
845        let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
846        assert!(
847            keep_naive[17],
848            "naive band (contaminated by zeros) keeps 40"
849        );
850    }
851
852    #[test]
853    fn output_keep_idx_skips_dropped_and_near_empty() {
854        // cells: 0 keep, 1 near-empty (kept in training), 2 MAD-drop, 3 keep
855        let report = QcReport {
856            train_keep: vec![true, true, false, true],
857            near_empty: vec![false, true, false, false],
858            feature_keep: vec![],
859            n_genes: vec![],
860            total_counts: vec![],
861            mito_frac: None,
862            ribo_frac: None,
863            n_cells_dropped: 1,
864            n_features_dropped: 0,
865        };
866        // post-mask order: cell0->0, cell1->1, cell3->2 (cell2 dropped)
867        // emit non-near-empty survivors: 0 and 2
868        assert_eq!(report.output_keep_idx(), vec![0, 2]);
869    }
870}
871
872#[cfg(test)]
873#[path = "qc_lib_tests.rs"]
874mod tests;