Skip to main content

data_beans/aux/
feature_rows.rs

1//! Canonical feature-row (sparse-matrix row) convention for every modality.
2//!
3//! NOT to be confused with the sibling [`crate::aux::feature_names`], which is about a
4//! different problem. This module fixes the row-name **grammar** a producer emits
5//! and a consumer splits; `feature_names` **canonicalizes** an already-emitted
6//! name so the same gene or locus matches across files that spell it differently
7//! (`FeatureNameKind`). Rows here are built and parsed; names there are matched.
8//!
9//! It lives in `data_beans::aux` rather than beside its producers because the
10//! grammar has readers on both sides of the BAM/model boundary: faba writes these
11//! rows, senna's embedding and association steps split them back apart.
12//!
13//! All per-cell matrices name their rows
14//!
15//! ```text
16//! {unit}/{modality}/{channel}              unit-level (no subunit)
17//! {unit}/{modality}/{subunit}/{channel}    sub-unit (component or site)
18//! ```
19//!
20//! - `unit` — the modelling unit. For every gene-resolution modality this is
21//!   the gene, `{gene_id}_{gene_name}` (`gene_count::splice::format_gene_key`).
22//!   [`BAF`] is the exception: a variant is a coordinate, not a gene. It does
23//!   not belong to one, and two overlapping genes would otherwise give the same
24//!   variant two row names, so its unit is the `{chr}:{pos}` locus.
25//! - `modality` — the lowercase subcommand name: [`COUNT`] / [`M6A`] / [`ATOI`]
26//!   / [`APA`], or [`BAF`].
27//! - `subunit` — optional sub-gene id: a single-base `{chr}:{pos}` site (m6A and
28//!   A-to-I sites are one base pair) or an EM mixture `{component}` index.
29//!   Omitted for gene-level pooled rows. It sits **above** the channel: a
30//!   component/site is a position cluster fit once per `(gene, modality)` and
31//!   shared by both channels, so the channel nests inside it.
32//! - `channel` — the innermost (last) field: the two read-states that modality
33//!   contrasts (gene counts split [`SPLICED`]/[`UNSPLICED`]; m6A
34//!   [`METHYLATED`]/[`UNMETHYLATED`]; ATOI [`EDITED`]/[`UNEDITED`]; APA
35//!   [`PROXIMAL`]/[`DISTAL`]; BAF [`ALT`]/[`DEPTH`]). Omitted by the one
36//!   producer whose contrast lives across the units rather than within the row —
37//!   see [`unit_row`].
38//!
39//! Putting the channel last means a unit's two channel rows share a contiguous
40//! prefix (the unit), and "strip the trailing field" recovers the unit.
41//!
42//! Every channelized modality keeps both states in ONE matrix rather than in a
43//! pair of same-shaped files, so a ratio is a division within one unit's rows
44//! and no consumer has to open two files and trust their row orders agree.
45//!
46//! Most channel pairs PARTITION the coverage — the two states are exclusive and
47//! sum to the total. [`BAF`] is the exception: [`ALT`] is nested inside
48//! [`DEPTH`] (`alt ≤ depth`), so BAF is `alt / depth` and NOT `alt / (alt +
49//! depth)`. Any consumer that sums a unit's channels to recover coverage is
50//! wrong on this modality alone.
51//!
52//! This module is the single source of truth. Consumers split rows with
53//! [`parse_feature_row`]; producers build them with [`feature_row`] /
54//! [`unit_row`] rather than hand-spelling the tokens. The gene-count, APA, SNP
55//! and quant producers all go through it; the editing / mixture / pileup
56//! producers still emit their rows inline and are the remaining migration.
57//!
58//! One consumer still parses by hand: `faba::quant::extract_gene_key` strips a
59//! trailing `/count/{channel}` with `rfind`. That is safe *there* — it runs only
60//! over faba's own gene matrices, and its job is to group every row of a gene
61//! (including the pooled `total` track) under one key for QC, which is what its
62//! callers want. Contrast `senna::gem::rows`, which must additionally decide
63//! WHICH track a row is: there the same shortcut put `total` in the spliced
64//! bucket and double-counted the gene, so that one goes through
65//! [`parse_feature_row`].
66//!
67//! The unit of a parsed row is [`FeatureRow::unit`], and the gene is
68//! [`FeatureRow::gene`] — read those fields rather than re-splitting the string.
69//! `unit.split('/').next()` USED to recover the gene and no longer does: a unit
70//! may itself contain `/`, because gene symbols do (see [`parse_feature_row`]),
71//! so that recipe truncates such a gene at its first slash.
72
73///////////////////////////////
74// modality tokens (field 1) //
75///////////////////////////////
76pub const COUNT: &str = "count";
77pub const M6A: &str = "m6a";
78pub const ATOI: &str = "atoi";
79pub const APA: &str = "apa";
80/// Per-cell allele frequency at a called variant locus. Named for what the
81/// matrix measures (B-allele frequency), not for the calling step that chose the
82/// positions: the call set — genotype, GQ, rsid — is `snp_sites.parquet` /
83/// `snp_sites.vcf.gz`, and a row here carries none of it, only two read counts.
84pub const BAF: &str = "baf";
85
86//////////////////////////////
87// channel tokens (field 2) //
88//////////////////////////////
89pub const SPLICED: &str = "spliced";
90pub const UNSPLICED: &str = "unspliced";
91/// Gene-count total (spliced + unspliced) — used by the pooled gene-QC track.
92pub const TOTAL: &str = "total";
93pub const METHYLATED: &str = "methylated";
94pub const UNMETHYLATED: &str = "unmethylated";
95pub const EDITED: &str = "edited";
96pub const UNEDITED: &str = "unedited";
97/// APA channels come from the 2-site PDUI decomposition (proximal vs distal
98/// poly-A in the 3'UTR). The K-component poly-A *mixture* is a separate count
99/// matrix, NOT channelized — it does not follow this convention.
100pub const PROXIMAL: &str = "proximal";
101pub const DISTAL: &str = "distal";
102/// BAF numerator: reads carrying the called alt allele.
103pub const ALT: &str = "alt";
104/// BAF denominator: ALL reads over the locus, alt included. The only channel
105/// pair that nests rather than partitions — see the module docs.
106pub const DEPTH: &str = "depth";
107
108/// A modality's two channels, in the order the module docs list them: the
109/// state the modality counts first ([`METHYLATED`], [`EDITED`], [`ALT`]),
110/// then its counterpart. `None` for a token outside the vocabulary.
111pub fn channels(modality: &str) -> Option<(&'static str, &'static str)> {
112    match modality {
113        COUNT => Some((SPLICED, UNSPLICED)),
114        M6A => Some((METHYLATED, UNMETHYLATED)),
115        ATOI => Some((EDITED, UNEDITED)),
116        APA => Some((PROXIMAL, DISTAL)),
117        BAF => Some((ALT, DEPTH)),
118        _ => None,
119    }
120}
121
122/// Format a feature row. Pass `subunit = None` for a gene-level (pooled) row
123/// `{gene}/{modality}/{channel}`, or `Some(site_or_component)` for a sub-gene row
124/// `{gene}/{modality}/{subunit}/{channel}` (channel innermost). The `subunit` must
125/// not contain `/` (sites use the single-base `chr:pos`, components are integers),
126/// so the row round-trips through [`parse_feature_row`].
127pub fn feature_row(gene: &str, modality: &str, channel: &str, subunit: Option<&str>) -> Box<str> {
128    match subunit {
129        Some(s) => format!("{gene}/{modality}/{s}/{channel}").into(),
130        None => format!("{gene}/{modality}/{channel}").into(),
131    }
132}
133
134/// Format a channel-less UNIT row `{gene}/{modality}/{subunit}`.
135///
136/// One producer names a unit with no channel, because its contrast lives ACROSS
137/// the units rather than within the row: the APA poly-A mixture,
138/// `{gene}/apa/{component}` — usage is relative across the components of a gene,
139/// so no component has a counterpart channel.
140///
141/// SNP allele counts were the second such producer, splitting alt and depth
142/// across two same-shaped matrices that a consumer had to open together. They
143/// are now [`BAF`], channelized on [`ALT`]/[`DEPTH`] inside one matrix.
144///
145/// Such a row is indistinguishable from a gene-level one under
146/// [`parse_feature_row`]: both are three fields, and the subunit lands in the
147/// `channel` slot. A consumer has to know which matrix it is reading. Prefer
148/// [`feature_row`] wherever the modality does have two channels.
149pub fn unit_row(gene: &str, modality: &str, subunit: &str) -> Box<str> {
150    format!("{gene}/{modality}/{subunit}").into()
151}
152
153/// A feature row split into its fields, borrowing from the source string.
154#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155pub struct FeatureRow<'a> {
156    pub gene: &'a str,
157    pub modality: &'a str,
158    pub channel: &'a str,
159    pub subunit: Option<&'a str>,
160}
161
162impl FeatureRow<'_> {
163    /// The modelling unit of this row: the bare gene at gene resolution, or
164    /// `{gene}/{modality}/{subunit}` at sub-gene resolution.
165    ///
166    /// The gene is NOT recoverable by splitting this on `/` — a gene symbol may
167    /// contain one. Use [`Self::gene`], which is already the parsed field.
168    pub fn unit(&self) -> Box<str> {
169        match self.subunit {
170            Some(s) => format!("{}/{}/{}", self.gene, self.modality, s).into(),
171            None => self.gene.into(),
172        }
173    }
174}
175
176/// The closed modality vocabulary, used to LOCATE the modality field rather
177/// than to validate it — see [`parse_feature_row`].
178const MODALITIES: [&str; 5] = [COUNT, M6A, ATOI, APA, BAF];
179
180/// Split a feature row into its fields. The channel is the innermost (last)
181/// field, so a 3-field row is gene-level (`{gene}/{modality}/{channel}`) and a
182/// 4-field row carries a subunit before the channel
183/// (`{gene}/{modality}/{subunit}/{channel}`).
184///
185/// # A unit may contain `/`
186///
187/// Counting fields alone is not enough, because **real gene symbols contain
188/// slashes** — standard human references ship at least one. Such a gene's count
189/// row has four fields and used to parse as `gene = {id}_GENE1`,
190/// `modality = GENE1B`, `subunit = count`: not an error, just a different gene,
191/// so the two channel rows of that gene stopped pairing and nothing said so.
192///
193/// So the modality is located by NAME, scanned from the right against
194/// [`MODALITIES`], and whatever precedes it is the unit however many slashes it
195/// contains. The subunit and channel still may not contain `/` — they are a
196/// `chr:pos`, a component index, and a fixed token.
197///
198/// The two candidate positions are tried nearest-first, so a row whose unit ENDS
199/// in a modality token reads as the gene-level form: `A/count/count/spliced` is
200/// the gene `A/count`, not the gene `A` with a subunit called `count`. That
201/// ambiguity is unreachable in practice — a subunit is a `chr:pos` or a component
202/// index, never a modality name.
203///
204/// When NEITHER candidate position holds a known modality the old positional
205/// rule applies unchanged, so the producers that still emit their rows inline
206/// with a modality token outside the constant list (see the module docs) keep
207/// parsing exactly as they did. The vocabulary can only make a row parse
208/// BETTER, never make a row that parsed stop parsing.
209///
210/// Returns `None` for anything with fewer than three fields, or more than four
211/// when no known modality locates the split.
212pub fn parse_feature_row(name: &str) -> Option<FeatureRow<'_>> {
213    let is_modality = |s: &str| MODALITIES.contains(&s);
214
215    // Peel the channel, then look one and two fields further left for the
216    // modality. `rsplit_once` keeps every field a slice of `name`, so a
217    // multi-field unit costs no allocation.
218    if let Some((head, channel)) = name.rsplit_once('/') {
219        if let Some((left, mid)) = head.rsplit_once('/') {
220            if is_modality(mid) && !left.is_empty() {
221                return Some(FeatureRow {
222                    gene: left,
223                    modality: mid,
224                    channel,
225                    subunit: None,
226                });
227            }
228            if let Some((gene, modality)) = left.rsplit_once('/') {
229                if is_modality(modality) && !gene.is_empty() {
230                    return Some(FeatureRow {
231                        gene,
232                        modality,
233                        channel,
234                        subunit: Some(mid),
235                    });
236                }
237            }
238        }
239    }
240
241    let parts: Vec<&str> = name.split('/').collect();
242    match parts.as_slice() {
243        [gene, modality, channel] => Some(FeatureRow {
244            gene,
245            modality,
246            channel,
247            subunit: None,
248        }),
249        [gene, modality, subunit, channel] => Some(FeatureRow {
250            gene,
251            modality,
252            channel,
253            subunit: Some(subunit),
254        }),
255        _ => None,
256    }
257}
258
259///////////////////////////////////////////////
260// gene-count rows, interned to a gene axis  //
261///////////////////////////////////////////////
262
263/// Split a gene-level count row `{gene}/count/{spliced|unspliced}` into its gene
264/// key and whether it is the **nascent** (unspliced) track. `None` when the row is
265/// not a gene-level count row at all.
266///
267/// Goes through [`parse_feature_row`] rather than matching on `/count/` directly,
268/// because a bare `rsplit_once` **cannot tell "spliced" apart from "not a count
269/// row"** — both fall to the same branch. It used to, and the consequence was
270/// silent: `GENE1/m6a/methylated` became a mature gene literally named
271/// `GENE1/m6a/methylated`, and the sub-gene form `{gene}/count/{site}/{channel}`
272/// became a mature row of the right gene.
273///
274/// [`TOTAL`] is rejected along with everything else: it already IS
275/// `spliced + unspliced`, so interning it as a third track would count the gene
276/// twice. A `subunit` is rejected because a per-site or per-component row is not
277/// a thing that pairs across tracks at gene resolution.
278#[must_use]
279pub fn split_count_row(name: &str) -> Option<(&str, bool)> {
280    let row = parse_feature_row(name)?;
281    if row.modality != COUNT || row.subunit.is_some() {
282        return None;
283    }
284    match row.channel {
285        SPLICED => Some((row.gene, false)),
286        UNSPLICED => Some((row.gene, true)),
287        _ => None,
288    }
289}
290
291/// [`CountRowMap::row_to_gene`] entry for a row that was left off the gene axis.
292/// Only ever produced under [`UnparsedRowPolicy::Reject`].
293pub const NO_GENE: u32 = u32::MAX;
294
295/// What [`intern_count_rows`] does with a row that is not
296/// `{gene}/count/{spliced|unspliced}`.
297///
298/// The two consumers want opposite things, and neither is more correct: a
299/// gene-keyed model can carry a stray row harmlessly as its own single-track
300/// gene, while a consumer that POOLS the two tracks cannot — it would have to
301/// decide which track the stray row is, and every answer is wrong.
302#[derive(Clone, Copy, PartialEq, Eq, Debug)]
303pub enum UnparsedRowPolicy {
304    /// Give the row its own single-track gene id, keyed on the whole row name,
305    /// so every index still lines up with the matrix and the row can never be
306    /// paired with a real gene. Ids stay assigned in row order across both
307    /// kinds. The caller is expected to warn.
308    OwnGene,
309    /// Leave the row off the gene axis: [`NO_GENE`] in `row_to_gene`, and its
310    /// index in [`CountRowMap::unparsed`]. The caller decides whether that is
311    /// fatal.
312    Reject,
313}
314
315/// A count matrix's feature axis interned onto a dense gene axis.
316///
317/// Row order is the matrix's own and is never permuted; gene ids are assigned in
318/// first-seen row order, so `gene_names` is stable for a given input.
319pub struct CountRowMap {
320    /// `row_to_gene[r]` = gene id of row `r`, or [`NO_GENE`].
321    pub row_to_gene: Vec<u32>,
322    /// `row_is_nascent[r]` = true when row `r` is the unspliced track. Always
323    /// `false` for an unparsed row under either policy — such a row is not a
324    /// nascent row, it is not a count row.
325    pub row_is_nascent: Vec<bool>,
326    /// Gene keys in id order.
327    pub gene_names: Vec<Box<str>>,
328    /// Indices of rows that are not `{gene}/count/{spliced|unspliced}`, in row
329    /// order. Empty on a well-formed gene-count matrix.
330    pub unparsed: Vec<usize>,
331}
332
333impl CountRowMap {
334    #[must_use]
335    pub fn n_genes(&self) -> usize {
336        self.gene_names.len()
337    }
338
339    #[must_use]
340    pub fn n_rows(&self) -> usize {
341        self.row_to_gene.len()
342    }
343
344    /// Rows on the nascent track. Zero on a spliced-only matrix — a legitimate
345    /// input that simply identifies no nascent-minus-mature contrast.
346    #[must_use]
347    pub fn n_nascent_rows(&self) -> usize {
348        self.row_is_nascent.iter().filter(|&&n| n).count()
349    }
350}
351
352/// Intern a count matrix's feature axis onto a dense gene axis, pairing a gene's
353/// two channel rows under one id.
354#[must_use]
355pub fn intern_count_rows(feature_names: &[Box<str>], policy: UnparsedRowPolicy) -> CountRowMap {
356    let mut ids: rustc_hash::FxHashMap<Box<str>, u32> = rustc_hash::FxHashMap::default();
357    let mut row_to_gene = Vec::with_capacity(feature_names.len());
358    let mut row_is_nascent = Vec::with_capacity(feature_names.len());
359    let mut gene_names: Vec<Box<str>> = Vec::new();
360    let mut unparsed: Vec<usize> = Vec::new();
361
362    for (r, name) in feature_names.iter().enumerate() {
363        let Some((gene, is_nascent)) = split_count_row(name) else {
364            unparsed.push(r);
365            match policy {
366                UnparsedRowPolicy::Reject => row_to_gene.push(NO_GENE),
367                UnparsedRowPolicy::OwnGene => {
368                    let g = gene_names.len() as u32;
369                    ids.insert(name.clone(), g);
370                    gene_names.push(name.clone());
371                    row_to_gene.push(g);
372                }
373            }
374            row_is_nascent.push(false);
375            continue;
376        };
377        let gid = match ids.get(gene) {
378            Some(&g) => g,
379            None => {
380                let g = gene_names.len() as u32;
381                ids.insert(gene.into(), g);
382                gene_names.push(gene.into());
383                g
384            }
385        };
386        row_to_gene.push(gid);
387        row_is_nascent.push(is_nascent);
388    }
389
390    CountRowMap {
391        row_to_gene,
392        row_is_nascent,
393        gene_names,
394        unparsed,
395    }
396}
397
398#[cfg(test)]
399mod tests;