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