Skip to main content

data_beans/aux/
feature_names.rs

1//! Feature-name kind + canonicalizer hooks for multi-file data
2//! alignment.
3//!
4//! NOT to be confused with the sibling [`crate::aux::feature_rows`]. That module
5//! defines the row-name **grammar** faba's producers emit
6//! (`{unit}/{modality}/{subunit}/{channel}`); this one **canonicalizes** a name
7//! that already exists, so the same gene or locus matches across files that
8//! spell it differently. Reach for `feature_rows` to build or split a row, and
9//! for this module to decide whether two spellings are the same feature.
10//!
11//! Loaders that union rows across multiple sparse backends
12//! ([`crate::aux::data_loading::read_data_on_shared_rows`]) can opt into
13//! generous matching by passing a [`FeatureNameKind`] — same row-name
14//! canonicalization machinery used by `senna marker` /
15//! `FeaturePairGraph::from_edge_list` (via
16//! [`legume_numeric::matrix::membership::GeneIndexResolver`]) but plumbed at the
17//! [`crate::sparse_io_vector::SparseIoVec`] level so the row
18//! intersection itself sees aligned names.
19//!
20//! The two flavors cover what biology pipelines see in practice:
21//!
22//! - [`FeatureNameKind::Gene`] for gene rows in scRNA / spatial-RNA
23//!   data, where the same gene shows up as `TGFB1`, `ENSG00000105329`,
24//!   or `ENSG00000105329_TGFB1` across cohorts;
25//! - [`FeatureNameKind::Locus`] for chromosome-coordinate rows in ATAC
26//!   / chickpea-style data, where `chr1:1000-2000` and `1:1000-2000`
27//!   should resolve to the same peak. Only the colon form is a locus;
28//!   `chr1_1000_2000` and `chr1-1000-2000` are not.
29
30use std::sync::Arc;
31
32use crate::sparse_io_vector::RowNameCanonicalizer;
33use genomic_data::coordinates::{self, chr_stripped, PeakCoord};
34use rustc_hash::FxHashMap as HashMap;
35
36/// Per-name canonicalization rule for cross-backend row alignment.
37/// Concrete strategy only — no "request" variants. Callers that want
38/// auto-detection pass [`None`] (or whatever wrapping enum they choose)
39/// and call [`FeatureNameKind::auto_detect`] once row names are in hand.
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub enum FeatureNameKind {
42    /// Strict string match — no canonicalization. Default.
43    #[default]
44    Exact,
45    /// Gene-symbol rule: register every `delim`-split component as an
46    /// alias of the full name. `ENSG00000105329_TGFB1` and `TGFB1`
47    /// resolve to the same row.
48    Gene { delim: char },
49    /// Genomic-locus rule. A colon-form locus becomes its key
50    /// (`chr1:1000-2000` and `1:1000-2000` → `1:1000-2000`); other names
51    /// pass through. If `merge_overlapping`, intervals that overlap on
52    /// the same chromosome additionally collapse into one cluster
53    /// (`chr1:1-20` ∪ `chr1:15-30` → `1:1-30`). Useful for ATAC peak
54    /// sets called independently across datasets.
55    Locus { merge_overlapping: bool },
56    /// Heterogeneous axis: dispatch per row name. Names that parse as
57    /// loci go through [`FeatureNameKind::Locus`] with overlap merging;
58    /// gene-style names (see [`FeatureNameKind::Gene`]) take the gene
59    /// rule; the rest pass through. Picked automatically when [`auto_detect`] finds both
60    /// signatures in the same axis (e.g. paired RNA + ATAC union).
61    Mixed,
62}
63
64impl FeatureNameKind {
65    /// Canonicalize a single name under this kind's per-name rule.
66    /// [`Locus { merge_overlapping: true }`] and [`Mixed`] only describe
67    /// the per-name part here (the locus key for loci, last-token split
68    /// for gene-style); the global cluster step lives in
69    /// [`build_locus_overlap_canonical_map`] and is installed by
70    /// [`build_canonicalizer`].
71    pub fn canonicalize(&self, name: &str) -> Box<str> {
72        match self {
73            FeatureNameKind::Exact => name.into(),
74            FeatureNameKind::Gene { delim } => gene_canonicalize(name, *delim),
75            FeatureNameKind::Locus { .. } => locus_key(name).unwrap_or_else(|| name.into()),
76            FeatureNameKind::Mixed => mixed_canonicalize(name),
77        }
78    }
79
80    /// True iff this kind is [`Exact`] — no canonicalizer needed.
81    pub fn is_exact(&self) -> bool {
82        matches!(self, FeatureNameKind::Exact)
83    }
84
85    /// True iff installing the canonicalizer requires peeking every row
86    /// name across all backends first (to build the locus-overlap cluster
87    /// map). Loaders branch on this.
88    pub fn needs_global_pass(&self) -> bool {
89        matches!(
90            self,
91            FeatureNameKind::Locus {
92                merge_overlapping: true
93            } | FeatureNameKind::Mixed
94        )
95    }
96
97    /// Sniff `names` and pick the right kind. Tallies:
98    /// `n_locus` = count where `parse_locus` matches; `n_gene_like` =
99    /// count of remaining names the gene rule applies to. Decision:
100    /// both ≥ 10% → [`Mixed`]; else loci ≥ 50% →
101    /// `Locus { merge_overlapping: true }`; else gene-like ≥ 50% →
102    /// `Gene { delim: '_' }`; else [`Exact`].
103    pub fn auto_detect(names: &[Box<str>]) -> Self {
104        let n = names.len();
105        if n == 0 {
106            return Self::Exact;
107        }
108        let mut n_locus = 0usize;
109        let mut n_gene_like = 0usize;
110        for name in names {
111            if coordinates::is_locus(name) {
112                n_locus += 1;
113            } else if is_gene_like(name, '_') {
114                n_gene_like += 1;
115            }
116        }
117        let pct_locus = n_locus as f32 / n as f32;
118        let pct_gene = n_gene_like as f32 / n as f32;
119        if pct_locus < 0.50 {
120            warn_non_colon_loci(names);
121        }
122        if pct_locus >= 0.10 && pct_gene >= 0.10 {
123            Self::Mixed
124        } else if pct_locus >= 0.50 {
125            Self::Locus {
126                merge_overlapping: true,
127            }
128        } else if pct_gene >= 0.50 {
129            Self::Gene { delim: '_' }
130        } else {
131            Self::Exact
132        }
133    }
134
135    /// The one kind to install for a set of files that were each sniffed
136    /// with [`auto_detect`](Self::auto_detect) on their own.
137    ///
138    /// Sniffing the POOLED names does not work: the signature usually lives
139    /// on one side only. A raw `ENSG_SYM` cohort pooled with a reference
140    /// already on the bare-symbol axis (a carried `pb_reference`, a
141    /// symbol-keyed panel) leaves the gene-like share under half, the pair
142    /// sniffs as `Exact`, and every gene becomes two rows. Canonicalizing
143    /// under `Gene` is a no-op for names lacking the delimiter, so adopting
144    /// the informative side is safe for both.
145    ///
146    /// Gene-style and locus-style files together dispatch per name
147    /// (`Mixed`), which is what `auto_detect` would pick on one axis holding
148    /// both; `Mixed` anywhere stays `Mixed`; all-`Exact` stays `Exact`.
149    #[must_use]
150    pub fn reconcile(kinds: &[FeatureNameKind]) -> FeatureNameKind {
151        if kinds.iter().any(|k| matches!(k, FeatureNameKind::Mixed)) {
152            return FeatureNameKind::Mixed;
153        }
154        let gene = kinds
155            .iter()
156            .find(|k| matches!(k, FeatureNameKind::Gene { .. }));
157        let locus = kinds
158            .iter()
159            .find(|k| matches!(k, FeatureNameKind::Locus { .. }));
160        match (gene, locus) {
161            (Some(_), Some(_)) => FeatureNameKind::Mixed,
162            _ => gene.or(locus).cloned().unwrap_or(FeatureNameKind::Exact),
163        }
164    }
165
166    /// Build a `RowNameCanonicalizer` suitable for
167    /// [`SparseIoVec::with_row_canonicalizer`]. Returns `None` for
168    /// [`FeatureNameKind::Exact`] so callers don't install a no-op
169    /// closure. **Does not** handle the LocusOverlap global step — for
170    /// that, use [`build_locus_overlap_canonicalizer`].
171    pub fn into_canonicalizer(self) -> Option<RowNameCanonicalizer> {
172        if self.is_exact() {
173            return None;
174        }
175        Some(Arc::new(move |name: &str| self.canonicalize(name)))
176    }
177}
178
179/// Parse a row name as `(chr, start, end)` under the shared locus grammar
180/// ([`coordinates::parse_interval`]): colon form only, `chr1:1000-2000` or
181/// `1:1000-2000`. The chromosome comes back with its `chr` prefix dropped
182/// and its case kept (`chrX` and `X` match, `x` does not). Returns `None`
183/// for anything that doesn't match; those names pass through the overlap
184/// pass untouched.
185pub fn parse_locus(name: &str) -> Option<(Box<str>, u64, u64)> {
186    let (chr, start, end) = coordinates::split_interval(name)?;
187    Some((chr_stripped(chr).into(), start as u64, end as u64))
188}
189
190/// Canonical key of one locus (`chrX:0-100` and `CHRX:0-100` become
191/// `X:0-100`), or `None` when `name` is not a locus: the one answer to "is
192/// this row a locus, and under which key".
193pub use genomic_data::coordinates::locus_key;
194
195/// Per-name rule of [`FeatureNameKind::Mixed`]: locus key, else the gene
196/// rule for gene-style names, else the name unchanged.
197fn mixed_canonicalize(name: &str) -> Box<str> {
198    let untagged = strip_feature_type_suffix(name, '_');
199    locus_key(untagged).unwrap_or_else(|| gene_canonicalize(name, '_'))
200}
201
202/// Build the overlap-merge canonical map from a flat list of row names
203/// across all input backends. Names that parse as `(chr, start, end)`
204/// are grouped per chromosome, sorted by start, and clustered by
205/// transitive overlap (any interval whose start falls before the
206/// running cluster's max end). The cluster canonical is
207/// `{chr}:{min_start}-{max_end}` so every member name maps to a single
208/// well-defined string.
209///
210/// Names that fail to parse are not entered into the map; the caller
211/// falls back to the per-name rule for those.
212pub fn build_locus_overlap_canonical_map(names: &[Box<str>]) -> HashMap<Box<str>, Box<str>> {
213    let n = names.len();
214    let parsed: Vec<Option<PeakCoord>> = names
215        .iter()
216        .map(|n| coordinates::parse_interval(n))
217        .collect();
218
219    // Bucket valid indices by chromosome (`chr1` and `1` together).
220    let mut by_chr: HashMap<&str, Vec<usize>> = HashMap::default();
221    for (i, p) in parsed.iter().enumerate() {
222        if let Some(p) = p {
223            by_chr.entry(chr_stripped(&p.chr)).or_default().push(i);
224        }
225    }
226
227    // Union-find with path compression.
228    let mut parent: Vec<usize> = (0..n).collect();
229    fn find(p: &mut [usize], mut x: usize) -> usize {
230        while p[x] != x {
231            let g = p[p[x]];
232            p[x] = g;
233            x = g;
234        }
235        x
236    }
237
238    // Per chr: sort by start, sweep, union anything overlapping the running cluster.
239    let mut cluster_extent: HashMap<usize, (i64, i64)> = HashMap::default();
240    for (_, mut idxs) in by_chr {
241        idxs.sort_by_key(|&i| parsed[i].as_ref().map_or(0, |p| p.start));
242        let mut current_root: Option<usize> = None;
243        let mut current_min_start: i64 = 0;
244        let mut current_max_end: i64 = 0;
245        for i in idxs {
246            let PeakCoord {
247                start: s, end: e, ..
248            } = parsed[i].as_ref().unwrap();
249            match current_root {
250                Some(root) if *s < current_max_end => {
251                    let ra = find(&mut parent, root);
252                    let rb = find(&mut parent, i);
253                    if ra != rb {
254                        parent[rb] = ra;
255                    }
256                    current_max_end = current_max_end.max(*e);
257                    cluster_extent
258                        .insert(find(&mut parent, i), (current_min_start, current_max_end));
259                }
260                _ => {
261                    current_root = Some(i);
262                    current_min_start = *s;
263                    current_max_end = *e;
264                    cluster_extent.insert(i, (*s, *e));
265                }
266            }
267        }
268    }
269
270    // Build name → canonical map: the cluster extent under `locus_key`.
271    let mut out: HashMap<Box<str>, Box<str>> = HashMap::default();
272    for (i, p) in parsed.iter().enumerate() {
273        if let Some(p) = p {
274            let root = find(&mut parent, i);
275            let (start, end) = cluster_extent.get(&root).copied().unwrap_or((0, 0));
276            // The member's own chromosome spelling: `locus_key` strips it
277            // once, as on every per-name path.
278            let cluster = PeakCoord {
279                chr: p.chr.clone(),
280                start,
281                end,
282            };
283            out.insert(names[i].clone(), cluster.locus_key());
284        }
285    }
286    if out.is_empty() {
287        warn_non_colon_loci(names);
288    }
289    out
290}
291
292/// Warn when many of `names` read as intervals only in a non-colon spelling
293/// (`chr1-100-200`): they are not loci, so a locus rule passes them through.
294fn warn_non_colon_loci(names: &[Box<str>]) {
295    let n_spelled = names
296        .iter()
297        .filter(|name| !coordinates::is_locus(name) && coordinates::import_interval(name).is_some())
298        .count();
299    if n_spelled > 0 && n_spelled * 10 >= names.len() {
300        log::warn!(
301            "{n_spelled} of {} row names read as intervals only in a non-colon spelling \
302             (e.g. `chr1-100-200`); they are not loci here. Re-import them so peaks are named \
303             `chr:start-end`.",
304            names.len()
305        );
306    }
307}
308
309/// Build a `RowNameCanonicalizer` for [`FeatureNameKind::LocusOverlap`].
310/// `names` should be the concatenation of every input backend's row
311/// names (in any order). The returned canonicalizer does
312/// `map.get(name).cloned()` first, falling back to per-name
313/// locus canonical for names outside the map.
314pub fn build_locus_overlap_canonicalizer(names: &[Box<str>]) -> RowNameCanonicalizer {
315    let map = Arc::new(build_locus_overlap_canonical_map(names));
316    Arc::new(move |name: &str| {
317        map.get(name)
318            .cloned()
319            .or_else(|| locus_key(name))
320            .unwrap_or_else(|| name.into())
321    })
322}
323
324/// Per-name dispatcher for **mixed-kind** axes (e.g. multiome with peaks
325/// ∪ genes in one feature axis). For each name:
326///   • parses as `(chr, start, end)` → LocusOverlap canonical
327///     (cluster representative from `names`).
328///   • gene-style (see [`FeatureNameKind::Gene`]) → gene rule: last token
329///     after the rightmost `_`.
330///   • else → passthrough.
331///
332/// Use this when the auto-detector sees significant evidence of BOTH
333/// loci and gene-style names in the same axis.
334pub fn build_mixed_kind_canonicalizer(names: &[Box<str>]) -> RowNameCanonicalizer {
335    let map = Arc::new(build_locus_overlap_canonical_map(names));
336    Arc::new(move |name: &str| {
337        map.get(name)
338            .cloned()
339            .unwrap_or_else(|| mixed_canonicalize(name))
340    })
341}
342
343/// Gene-symbol canonicalization with Cell Ranger feature-type suffix
344/// awareness. 10x Cell Ranger HDF5 row names commonly arrive as
345/// `ENSG..._SYMBOL_<feature_type>` where the third component is a
346/// sanitized `feature_type` tag (e.g. `Gene` for `Gene Expression`).
347/// A naive `rsplit(delim).next()` would return that constant tag,
348/// canonicalizing *every* row to the same string and collapsing the
349/// row intersection to one global key. Strip the known tag suffix
350/// first so the actual symbol becomes the rsplit target.
351fn gene_canonicalize(name: &str, delim: char) -> Box<str> {
352    let (head, rest) = gene_part(name);
353    // A coordinate keeps itself, without a feature-type tag
354    // (`chr1:0-100_Peaks` matches `chr1:0-100`).
355    let untagged = strip_feature_type_suffix(head, delim);
356    let symbol = if coordinates::is_region(untagged) {
357        Some(untagged)
358    } else {
359        gene_symbol(head, delim)
360    };
361    match symbol {
362        Some(symbol) if rest.is_empty() => symbol.into(),
363        Some(symbol) => format!("{symbol}{rest}").into_boxed_str(),
364        None => name.into(),
365    }
366}
367
368/// The gene rule applies: see [`gene_symbol`].
369fn is_gene_like(name: &str, delim: char) -> bool {
370    gene_symbol(gene_part(name).0, delim).is_some()
371}
372
373/// A row name's gene part, its first `/`-segment, and the rest from the
374/// first `/` on (`ENSG_GENE1/m6a/chr1:100/methylated` gives `ENSG_GENE1`
375/// and `/m6a/chr1:100/methylated`). The gene rule reads only the first;
376/// the rest is kept as written.
377fn gene_part(name: &str) -> (&str, &str) {
378    name.find('/').map_or((name, ""), |i| name.split_at(i))
379}
380
381/// The symbol the gene rule keys a gene part on: its last `delim` component
382/// once a Cell Ranger feature-type tag is stripped. `None` (the name is kept
383/// whole) when there is no `delim`, when the part is a coordinate (a locus
384/// `chr:start-end` or a position `chr:pos`, see [`coordinates::is_region`]),
385/// or when the symbol is all digits: cutting `chrUn_CTG1v1:0-100` or
386/// `chr1_CTG1v1_random:12345` would key it on its contig tail, and
387/// `chr1_100_200` or `chr1_100_200_Peaks` (not colon form, so not loci)
388/// would collapse onto `200`.
389fn gene_symbol(head: &str, delim: char) -> Option<&str> {
390    if !head.contains(delim) {
391        return None;
392    }
393    let stripped = strip_feature_type_suffix(head, delim);
394    if coordinates::is_region(stripped) {
395        return None;
396    }
397    let symbol = stripped.rsplit(delim).next().unwrap_or(stripped);
398    (!symbol.bytes().all(|b| b.is_ascii_digit())).then_some(symbol)
399}
400
401/// Cell Ranger sanitizes `features/feature_type` into the row name as
402/// the trailing component. Strip the known tags so the actual gene
403/// symbol becomes the rsplit target. Conservative list — only the
404/// shapes we've actually seen in the wild — so an unknown tag falls
405/// through untouched rather than corrupting a real symbol.
406fn strip_feature_type_suffix(name: &str, delim: char) -> &str {
407    // Names come pre-sanitized in different ways depending on the
408    // producer (Cell Ranger's own h5, scanpy/anndata exports, R-side
409    // tools), so accept both `_Gene` and `_Gene_Expression` plus the
410    // common companion tags.
411    const TAGS: &[&str] = &[
412        "Gene_Expression",
413        "Gene",
414        "Antibody_Capture",
415        "CRISPR_Guide_Capture",
416        "Multiplexing_Capture",
417        "Custom",
418        "Peaks",
419    ];
420    for tag in TAGS {
421        // Only strip if the suffix sits behind `delim` (otherwise we'd
422        // mangle a real symbol that happens to end in "Gene").
423        if let Some(rest) = name.strip_suffix(tag).and_then(|r| r.strip_suffix(delim)) {
424            return rest;
425        }
426    }
427    name
428}
429
430/// Clap-facing spelling of [`FeatureNameKind`].
431///
432/// The rule and the flag that selects it belong together: every crate that
433/// aligns feature names across files exposes the same `--feature-name-kind`
434/// vocabulary, so `senna` and anything after it agree on what
435/// `gene` or `locus` means without each inventing a local rule.
436#[derive(clap::ValueEnum, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
437#[serde(rename_all = "kebab-case")]
438pub enum FeatureNameKindArg {
439    #[default]
440    Auto,
441    Exact,
442    Gene,
443    Locus,
444    LocusOverlap,
445    Mixed,
446}
447
448impl FeatureNameKindArg {
449    /// Resolve to a concrete [`FeatureNameKind`], defaulting `Auto` to
450    /// `Gene { delim: '_' }` — the standard for gene-keyed pre-train
451    /// inputs (bge / fne / topic-family dictionaries).
452    pub fn resolve_or_gene(&self) -> FeatureNameKind {
453        Option::<FeatureNameKind>::from(self.clone())
454            .unwrap_or(FeatureNameKind::Gene { delim: '_' })
455    }
456}
457
458impl From<FeatureNameKindArg> for Option<FeatureNameKind> {
459    fn from(arg: FeatureNameKindArg) -> Self {
460        match arg {
461            FeatureNameKindArg::Auto => None,
462            FeatureNameKindArg::Exact => Some(FeatureNameKind::Exact),
463            FeatureNameKindArg::Gene => Some(FeatureNameKind::Gene { delim: '_' }),
464            FeatureNameKindArg::Locus => Some(FeatureNameKind::Locus {
465                merge_overlapping: false,
466            }),
467            FeatureNameKindArg::LocusOverlap => Some(FeatureNameKind::Locus {
468                merge_overlapping: true,
469            }),
470            FeatureNameKindArg::Mixed => Some(FeatureNameKind::Mixed),
471        }
472    }
473}
474
475#[cfg(test)]
476#[path = "feature_names_tests.rs"]
477mod feature_names_tests;
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn exact_passthrough() {
485        let k = FeatureNameKind::Exact;
486        assert_eq!(
487            k.canonicalize("ENSG00000000003_TSPAN6").as_ref(),
488            "ENSG00000000003_TSPAN6"
489        );
490        assert!(k.is_exact());
491        assert!(k.into_canonicalizer().is_none());
492    }
493
494    #[test]
495    fn gene_takes_last_underscore_component() {
496        let k = FeatureNameKind::Gene { delim: '_' };
497        assert_eq!(k.canonicalize("ENSG00000000003_TSPAN6").as_ref(), "TSPAN6");
498        // Symbol-only inputs survive unchanged.
499        assert_eq!(k.canonicalize("TSPAN6").as_ref(), "TSPAN6");
500        // Unknown trailing tokens still get rsplit — caller's responsibility
501        // to pick a sensible delim if a non-feature-type trailing token matters.
502        assert_eq!(k.canonicalize("A_B_C").as_ref(), "C");
503        assert!(!k.is_exact());
504        assert!(k.into_canonicalizer().is_some());
505    }
506
507    #[test]
508    fn gene_strips_cell_ranger_feature_type_suffix() {
509        let k = FeatureNameKind::Gene { delim: '_' };
510        // 10x Cell Ranger HDF5: `ENSG..._SYMBOL_Gene`. Trailing `_Gene`
511        // would otherwise collapse every gene to the literal "Gene".
512        assert_eq!(
513            k.canonicalize("ENSG00000187634_SAMD11_Gene").as_ref(),
514            "SAMD11"
515        );
516        // Full `Gene_Expression` tag variant.
517        assert_eq!(
518            k.canonicalize("ENSG00000187634_SAMD11_Gene_Expression")
519                .as_ref(),
520            "SAMD11"
521        );
522        // A real gene whose name happens to end in "Gene" *without* the
523        // delimiter shouldn't be stripped (no underscore in front of "Gene").
524        assert_eq!(k.canonicalize("FakeGene").as_ref(), "FakeGene");
525    }
526
527    #[test]
528    fn locus_strips_chr_and_leaves_other_spellings_alone() {
529        let k = FeatureNameKind::Locus {
530            merge_overlapping: false,
531        };
532        assert_eq!(k.canonicalize("chr1:1000-2000").as_ref(), "1:1000-2000");
533        assert_eq!(k.canonicalize("ChrX:5000-6000").as_ref(), "X:5000-6000");
534        // Not colon form: not a locus, so the name passes through.
535        assert_eq!(k.canonicalize("1_1000_2000").as_ref(), "1_1000_2000");
536    }
537
538    // -- Genomic region parsing edge cases ----------------------------------
539
540    #[test]
541    fn parse_locus_accepts_common_formats() {
542        // colon-dash, bare chromosome, chr prefix in any case, chrX caps.
543        assert_eq!(
544            parse_locus("chr1:1000-2000"),
545            Some(("1".into(), 1000, 2000))
546        );
547        assert_eq!(parse_locus("1:1000-2000"), Some(("1".into(), 1000, 2000)));
548        assert_eq!(
549            parse_locus("CHR1:1000-2000"),
550            Some(("1".into(), 1000, 2000))
551        );
552        assert_eq!(
553            parse_locus("chrX:5000-6000"),
554            Some(("X".into(), 5000, 6000))
555        );
556        assert_eq!(parse_locus("chrMT:1-100"), Some(("MT".into(), 1, 100)));
557    }
558
559    #[test]
560    fn parse_locus_keeps_contig_names_with_separators() {
561        assert_eq!(
562            parse_locus("chrUn_CTG1v1:0-100"),
563            Some(("Un_CTG1v1".into(), 0, 100))
564        );
565        // The canonical key parses back to the same locus.
566        assert_eq!(
567            parse_locus("Un_CTG1v1:0-100"),
568            Some(("Un_CTG1v1".into(), 0, 100))
569        );
570    }
571
572    #[test]
573    fn contig_peaks_stay_loci_on_a_mixed_axis() {
574        let names: Vec<Box<str>> = vec![
575            "chr1_CTG1v1_random:5-10".into(),
576            "chr4_CTG2v2_random:5-10".into(),
577            "ENSG000_GENE1".into(),
578        ];
579        let canon = build_mixed_kind_canonicalizer(&names);
580        assert_eq!(canon(&names[0]).as_ref(), "1_CTG1v1_random:5-10");
581        assert_eq!(canon(&names[1]).as_ref(), "4_CTG2v2_random:5-10");
582        assert_eq!(canon(&names[2]).as_ref(), "GENE1");
583    }
584
585    #[test]
586    fn every_locus_path_gives_one_key() {
587        let names: Vec<Box<str>> = vec!["chrChr1:0-100".into(), "chr1:0-100".into()];
588        let map = build_locus_overlap_canonical_map(&names);
589        let k = FeatureNameKind::Locus {
590            merge_overlapping: false,
591        };
592        for name in &names {
593            assert_eq!(map.get(name).unwrap(), &k.canonicalize(name));
594        }
595    }
596
597    #[test]
598    fn locus_canonical_keeps_case_on_every_path() {
599        let names: Vec<Box<str>> = vec!["chrX:0-100".into(), "chr1:0-100".into()];
600        let map = build_locus_overlap_canonical_map(&names);
601        assert_eq!(map.get(&names[0]).unwrap().as_ref(), "X:0-100");
602        assert_eq!(map.get(&names[1]).unwrap().as_ref(), "1:0-100");
603        // Names outside the map land on the same key.
604        let canon = build_locus_overlap_canonicalizer(&names);
605        assert_eq!(canon("chrX:200-300").as_ref(), "X:200-300");
606        let mixed = build_mixed_kind_canonicalizer(&names);
607        assert_eq!(mixed("chrX:0-100").as_ref(), "X:0-100");
608        assert_eq!(mixed("chrM:200-300").as_ref(), "M:200-300");
609        let k = FeatureNameKind::Locus {
610            merge_overlapping: false,
611        };
612        assert_eq!(k.canonicalize("chrM:0-100").as_ref(), "M:0-100");
613    }
614
615    #[test]
616    fn parse_locus_rejects_non_loci() {
617        assert!(parse_locus("TGFB1").is_none()); // gene symbol
618        assert!(parse_locus("ENSG00000105329").is_none()); // ensembl
619        assert!(parse_locus("chr1:bad-2000").is_none()); // non-numeric start
620        assert!(parse_locus("chr1:1000").is_none()); // missing end
621        assert!(parse_locus("chr1:2000-1000").is_none()); // end < start
622        assert!(parse_locus("").is_none()); // empty
623        assert!(parse_locus("chr1").is_none()); // chr-only
624        assert!(parse_locus("chr:1-2").is_none()); // empty chromosome
625        assert!(parse_locus("ENSG000_GENE1").is_none()); // gene-style
626        assert!(parse_locus("GENE1-AS1").is_none()); // antisense symbol
627        assert!(parse_locus("chr1_1000_2000").is_none()); // underscore form
628        assert!(parse_locus("chr1-1000-2000").is_none()); // dash form
629    }
630
631    #[test]
632    fn overlap_map_merges_two_overlapping_intervals() {
633        // User's motivating example: chr1:1-20 and chr1:15-30 → same cluster.
634        let names = vec![
635            "chr1:1-20".to_string().into_boxed_str(),
636            "chr1:15-30".to_string().into_boxed_str(),
637        ];
638        let map = build_locus_overlap_canonical_map(&names);
639        let c0 = map.get(&names[0]).unwrap();
640        let c1 = map.get(&names[1]).unwrap();
641        assert_eq!(c0, c1, "both inputs should map to the same canonical");
642        assert_eq!(c0.as_ref(), "1:1-30"); // union-range canonical
643    }
644
645    #[test]
646    fn overlap_map_keeps_non_overlapping_separate() {
647        let names = vec![
648            "chr1:1-20".to_string().into_boxed_str(),
649            "chr1:100-200".to_string().into_boxed_str(),
650            "chr2:1-20".to_string().into_boxed_str(),
651        ];
652        let map = build_locus_overlap_canonical_map(&names);
653        assert_eq!(map.get(&names[0]).unwrap().as_ref(), "1:1-20");
654        assert_eq!(map.get(&names[1]).unwrap().as_ref(), "1:100-200");
655        // different chromosome — separate cluster even if start overlaps
656        assert_eq!(map.get(&names[2]).unwrap().as_ref(), "2:1-20");
657    }
658
659    #[test]
660    fn overlap_map_handles_transitive_chain() {
661        // A overlaps B (1-20 vs 15-30), B overlaps C (15-30 vs 25-40),
662        // A does NOT overlap C directly — but they should still cluster
663        // via transitive closure through B.
664        let names = vec![
665            "chr1:1-20".to_string().into_boxed_str(),
666            "chr1:15-30".to_string().into_boxed_str(),
667            "chr1:25-40".to_string().into_boxed_str(),
668        ];
669        let map = build_locus_overlap_canonical_map(&names);
670        let c0 = map.get(&names[0]).unwrap();
671        let c1 = map.get(&names[1]).unwrap();
672        let c2 = map.get(&names[2]).unwrap();
673        assert_eq!(c0, c1);
674        assert_eq!(c1, c2);
675        assert_eq!(c0.as_ref(), "1:1-40"); // union of full chain
676    }
677
678    #[test]
679    fn overlap_map_handles_full_containment() {
680        // chr1:1-100 contains chr1:30-50 — should cluster.
681        let names = vec![
682            "chr1:1-100".to_string().into_boxed_str(),
683            "chr1:30-50".to_string().into_boxed_str(),
684        ];
685        let map = build_locus_overlap_canonical_map(&names);
686        let c0 = map.get(&names[0]).unwrap();
687        let c1 = map.get(&names[1]).unwrap();
688        assert_eq!(c0, c1);
689        assert_eq!(c0.as_ref(), "1:1-100");
690    }
691
692    #[test]
693    fn overlap_map_treats_adjacent_as_separate() {
694        // chr1:1-20 and chr1:20-30 are *touching* but not overlapping
695        // (end of first == start of second, exclusive end convention).
696        let names = vec![
697            "chr1:1-20".to_string().into_boxed_str(),
698            "chr1:20-30".to_string().into_boxed_str(),
699        ];
700        let map = build_locus_overlap_canonical_map(&names);
701        assert_ne!(map.get(&names[0]).unwrap(), map.get(&names[1]).unwrap());
702    }
703
704    #[test]
705    fn overlap_map_normalizes_chr_prefix_within_cluster() {
706        // chr1:1-20 and 1:15-30 (no chr prefix) should still cluster
707        // because parse_locus normalizes both to chr="1".
708        let names = vec![
709            "chr1:1-20".to_string().into_boxed_str(),
710            "1:15-30".to_string().into_boxed_str(),
711        ];
712        let map = build_locus_overlap_canonical_map(&names);
713        let c0 = map.get(&names[0]).unwrap();
714        let c1 = map.get(&names[1]).unwrap();
715        assert_eq!(c0, c1);
716        assert_eq!(c0.as_ref(), "1:1-30");
717    }
718
719    #[test]
720    fn overlap_map_leaves_non_colon_spellings_out() {
721        // chr1_15_30 is not a locus, so it neither joins nor extends the cluster.
722        let names = vec![
723            "chr1:1-20".to_string().into_boxed_str(),
724            "chr1_15_30".to_string().into_boxed_str(),
725        ];
726        let map = build_locus_overlap_canonical_map(&names);
727        assert_eq!(map.get(&names[0]).unwrap().as_ref(), "1:1-20");
728        assert!(!map.contains_key(&names[1]));
729    }
730
731    #[test]
732    fn underscore_peaks_are_not_collapsed_by_the_gene_rule() {
733        // Neither loci nor genes: an axis of these is Exact, and the gene
734        // rule leaves them whole rather than keying every row on `200`.
735        let names: Vec<Box<str>> = (1..=20)
736            .map(|i| format!("chr{i}_100_200").into_boxed_str())
737            .collect();
738        assert_eq!(FeatureNameKind::auto_detect(&names), FeatureNameKind::Exact);
739        let mixed = build_mixed_kind_canonicalizer(&names);
740        assert_eq!(mixed("chr2_100_200").as_ref(), "chr2_100_200");
741        let gene = FeatureNameKind::Gene { delim: '_' };
742        assert_eq!(gene.canonicalize("chr2_100_200").as_ref(), "chr2_100_200");
743        assert_eq!(gene.canonicalize("ENSG000_GENE1").as_ref(), "GENE1");
744        assert_eq!(gene.canonicalize("GENE1_Gene").as_ref(), "GENE1");
745        assert_eq!(
746            gene.canonicalize("chr2_100_200_Peaks").as_ref(),
747            "chr2_100_200_Peaks"
748        );
749        // Only the gene part (first `/`-segment) is read, and a position
750        // there is a coordinate, not a gene.
751        assert_eq!(
752            gene.canonicalize("chr1_CTG1v1_random:12345/baf/alt")
753                .as_ref(),
754            "chr1_CTG1v1_random:12345/baf/alt"
755        );
756        assert_eq!(
757            gene.canonicalize("ENSG000_GENE1/m6a/chr1_CTG1v1_random:123/methylated")
758                .as_ref(),
759            "GENE1/m6a/chr1_CTG1v1_random:123/methylated"
760        );
761        assert_eq!(
762            gene.canonicalize("ENSG000_GENE1/count/spliced").as_ref(),
763            "GENE1/count/spliced"
764        );
765        // A tagged locus loses its tag and nothing else.
766        assert_eq!(gene.canonicalize("chr1:0-100_Peaks").as_ref(), "chr1:0-100");
767        assert_eq!(
768            FeatureNameKind::Mixed
769                .canonicalize("chr1:0-100_Peaks")
770                .as_ref(),
771            "1:0-100"
772        );
773        // A locus is never cut at `_`, even when its contig name has one or
774        // it carries a feature-type tag.
775        assert_eq!(
776            gene.canonicalize("chrUn_CTG1v1:0-100_Peaks").as_ref(),
777            "chrUn_CTG1v1:0-100"
778        );
779        assert_eq!(
780            gene.canonicalize("chrUn_CTG1v1:0-100").as_ref(),
781            "chrUn_CTG1v1:0-100"
782        );
783    }
784
785    #[test]
786    fn overlap_map_ignores_non_locus_names() {
787        // Non-locus names should not appear in the map; caller falls
788        // back to the per-name rule.
789        let names = vec![
790            "TGFB1".to_string().into_boxed_str(),
791            "chr1:1-20".to_string().into_boxed_str(),
792        ];
793        let map = build_locus_overlap_canonical_map(&names);
794        assert!(!map.contains_key(&names[0]));
795        assert!(map.contains_key(&names[1]));
796    }
797
798    #[test]
799    fn overlap_map_skips_empty_intervals() {
800        // chr1:1000-1000 holds no base, so it is not a locus.
801        let names = vec!["chr1:1000-1000".to_string().into_boxed_str()];
802        let map = build_locus_overlap_canonical_map(&names);
803        assert!(map.is_empty());
804    }
805
806    #[test]
807    fn overlap_canonicalizer_falls_back_for_unmatched() {
808        let names = vec!["chr1:1-20".to_string().into_boxed_str()];
809        let canon = build_locus_overlap_canonicalizer(&names);
810        // In-cluster name → cluster canonical.
811        assert_eq!(canon("chr1:1-20").as_ref(), "1:1-20");
812        // Unrelated locus not in the map → its own key.
813        assert_eq!(canon("chr2:500-600").as_ref(), "2:500-600");
814        // Non-locus → unchanged.
815        assert_eq!(canon("GENE1").as_ref(), "GENE1");
816    }
817
818    // -- Auto-detect & Mixed dispatcher -------------------------------------
819
820    #[test]
821    fn auto_detect_pure_locus_axis() {
822        let names: Vec<Box<str>> = (0..100)
823            .map(|i| format!("chr1:{}-{}", i * 100, i * 100 + 50).into_boxed_str())
824            .collect();
825        assert!(matches!(
826            FeatureNameKind::auto_detect(&names),
827            FeatureNameKind::Locus {
828                merge_overlapping: true
829            }
830        ));
831    }
832
833    #[test]
834    fn auto_detect_pure_gene_axis() {
835        let names: Vec<Box<str>> = (0..100)
836            .map(|i| format!("ENSG000_GENE{}", i).into_boxed_str())
837            .collect();
838        assert!(matches!(
839            FeatureNameKind::auto_detect(&names),
840            FeatureNameKind::Gene { delim: '_' }
841        ));
842    }
843
844    #[test]
845    fn auto_detect_mixed_axis() {
846        // 80 loci + 20 gene-style → both fractions ≥ 10% → Mixed.
847        let mut names: Vec<Box<str>> = (0..80)
848            .map(|i| format!("chr1:{}-{}", i * 1000, i * 1000 + 500).into_boxed_str())
849            .collect();
850        names.extend((0..20).map(|i| format!("ENSG000_GENE{}", i).into_boxed_str()));
851        assert!(matches!(
852            FeatureNameKind::auto_detect(&names),
853            FeatureNameKind::Mixed
854        ));
855    }
856
857    #[test]
858    fn auto_detect_empty_or_exact() {
859        assert!(matches!(
860            FeatureNameKind::auto_detect(&[]),
861            FeatureNameKind::Exact
862        ));
863        let names = vec!["TGFB1".into(), "CD4".into(), "IL2".into(), "GAPDH".into()];
864        assert!(matches!(
865            FeatureNameKind::auto_detect(&names),
866            FeatureNameKind::Exact
867        ));
868    }
869
870    #[test]
871    fn mixed_dispatcher_canonicalizes_each_name_by_kind() {
872        let names: Vec<Box<str>> = vec![
873            "chr1:1-20".into(),     // locus → cluster canonical
874            "chr1:15-30".into(),    // locus, overlaps above → same cluster
875            "ENSG000_TGFB1".into(), // gene-style → "TGFB1"
876            "CD4".into(),           // plain symbol → passthrough
877        ];
878        let canon = build_mixed_kind_canonicalizer(&names);
879        assert_eq!(canon("chr1:1-20").as_ref(), "1:1-30");
880        assert_eq!(canon("chr1:15-30").as_ref(), "1:1-30");
881        assert_eq!(canon("ENSG000_TGFB1").as_ref(), "TGFB1");
882        assert_eq!(canon("CD4").as_ref(), "CD4");
883    }
884}