Skip to main content

data_beans/utilities/
name_matching.rs

1use crate::sparse_io::ROW_SEP;
2use genomic_data::coordinates::{import_interval, is_locus, locus_key};
3use rayon::prelude::*;
4use rustc_hash::FxHashMap as HashMap;
5
6/// Make duplicate names unique by appending `-1`, `-2`, etc. to repeated entries.
7/// Similar to scanpy's `var_names_make_unique()`.
8pub fn make_names_unique(names: &mut [Box<str>]) -> usize {
9    let mut counts: HashMap<Box<str>, usize> = HashMap::default();
10    let mut num_duped = 0usize;
11    for name in names.iter_mut() {
12        if let Some(count) = counts.get_mut(name.as_ref()) {
13            if *count == 1 {
14                num_duped += 1;
15            }
16            *name = format!("{}-{}", name, count).into_boxed_str();
17            *count += 1;
18        } else {
19            counts.insert(name.clone(), 1);
20        }
21    }
22    if num_duped > 0 {
23        log::warn!(
24            "{} names had duplicates and were made unique with -N suffixes",
25            num_duped
26        );
27    }
28    num_duped
29}
30
31/// The composite name of one row: `id{ROW_SEP}name`, or the ID alone when
32/// the name is empty or already equals it (e.g. 10x ATAC peaks, where both
33/// `features/id` and `features/name` are `chr1:1000-2000`).
34pub fn id_name(id: &str, name: &str) -> Box<str> {
35    if name.is_empty() || name == id {
36        id.into()
37    } else {
38        format!("{id}{ROW_SEP}{name}").into_boxed_str()
39    }
40}
41
42/// [`id_name`] of each row.
43pub fn compose_id_name(ids: Vec<Box<str>>, names: Vec<Box<str>>) -> Vec<Box<str>> {
44    ids.iter()
45        .zip(&names)
46        .map(|(id, name)| id_name(id, name))
47        .collect()
48}
49
50/// Import boundary for feature rows: peak rows get their id and name
51/// rewritten in the colon locus form, whatever spelling the producer used
52/// (`chr1-100-200` and `chr1_100_200` become `chr1:100-200`). With feature
53/// types, a row is a peak when its type names peaks or ATAC; without them,
54/// the file is a peak list only when every id reads as an interval. A gene
55/// id that happens to end in two numbers is therefore left alone. Returns
56/// how many rows were rewritten.
57pub fn colon_peak_names(
58    ids: &mut [Box<str>],
59    names: &mut [Box<str>],
60    types: Option<&[Box<str>]>,
61) -> usize {
62    let is_peak = |i: usize| match types {
63        Some(types) => {
64            contains_ignore_ascii_case(&types[i], "peak")
65                || contains_ignore_ascii_case(&types[i], "atac")
66        }
67        None => true,
68    };
69    if types.is_none() {
70        let n_read = ids
71            .iter()
72            .filter(|id| import_interval(id).is_some())
73            .count();
74        if n_read < ids.len() {
75            if n_read * 2 >= ids.len() {
76                log::info!(
77                    "{n_read} of {} untyped rows read as intervals, but not all; \
78                     the names are kept as written",
79                    ids.len()
80                );
81            }
82            return 0;
83        }
84    }
85    let mut n = 0;
86    for (i, (id, name)) in ids.iter_mut().zip(names.iter_mut()).enumerate() {
87        if !is_peak(i) {
88            continue;
89        }
90        let mut changed = false;
91        for s in [id, name] {
92            if let Some(l) = import_interval(s) {
93                let colon = l.to_string().into_boxed_str();
94                changed |= *s != colon;
95                *s = colon;
96            }
97        }
98        n += usize::from(changed);
99    }
100    n
101}
102
103/// Inverse of [`compose_id_name`]: split a composite `id{ROW_SEP}name` display
104/// name back into `(id, name)` on the first `ROW_SEP`. When there is no
105/// separator (a bare symbol, or an id-only composite where name was empty or
106/// equalled id) both parts are the whole string, so a 10x `features.tsv` still
107/// gets a non-empty gene name.
108pub fn split_id_name(composite: &str) -> (&str, &str) {
109    match composite.split_once(ROW_SEP) {
110        Some((id, name)) if !name.is_empty() => (id, name),
111        _ => (composite, composite),
112    }
113}
114
115/// Comma-separated case-insensitive substring filter, parsed once and matched
116/// many times. Used by `--select-row-type` / `--remove-row-type` /
117/// `--hto-row-type` so callers can pass e.g. `"gene,peak"` to match either
118/// "Gene Expression" or "Peaks".
119pub struct RowTypeFilter {
120    patterns: Vec<Box<str>>,
121}
122
123impl RowTypeFilter {
124    pub fn parse(s: &str) -> Self {
125        let patterns = s
126            .split(',')
127            .map(|p| p.trim())
128            .filter(|p| !p.is_empty())
129            .map(|p| p.to_ascii_lowercase().into_boxed_str())
130            .collect();
131        Self { patterns }
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.patterns.is_empty()
136    }
137
138    /// True if any pattern is an ASCII-case-insensitive substring of `s`.
139    /// Bytewise scan — does not allocate, so callers can pass row types
140    /// straight from the backend without an intermediate lowercase copy.
141    pub fn matches(&self, s: &str) -> bool {
142        self.patterns
143            .iter()
144            .any(|p| contains_ignore_ascii_case(s, p))
145    }
146}
147
148/// Bytewise case-insensitive substring search. ASCII only; non-ASCII bytes
149/// compare verbatim. Allocation-free.
150pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
151    let n = needle.len();
152    if n == 0 {
153        return true;
154    }
155    let h = haystack.as_bytes();
156    if h.len() < n {
157        return false;
158    }
159    h.windows(n)
160        .any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
161}
162
163/// Return indices of rows whose type passes select/remove filtering.
164/// - `select`: comma-separated patterns; row passes if any pattern is a
165///   case-insensitive substring of the row type. Empty keeps all rows.
166/// - `remove`: comma-separated patterns; row is dropped if any pattern matches.
167pub fn filter_row_indices_by_type(
168    row_types: &[Box<str>],
169    select: &str,
170    remove: &str,
171) -> Vec<usize> {
172    let sel = RowTypeFilter::parse(select);
173    let rem = RowTypeFilter::parse(remove);
174    if sel.is_empty() && rem.is_empty() {
175        return (0..row_types.len()).collect();
176    }
177    row_types
178        .iter()
179        .enumerate()
180        .filter_map(|(i, x)| {
181            let selected = sel.is_empty() || sel.matches(x);
182            let removed = !rem.is_empty() && rem.matches(x);
183            if selected && !removed {
184                Some(i)
185            } else {
186                None
187            }
188        })
189        .collect()
190}
191
192/// Flexible gene name matching (case-insensitive, underscore-delimited)
193/// Returns true if `query` matches `target` with these rules:
194/// - Exact match (case-insensitive)
195/// - Suffix match: target ends with `_query`
196/// - Prefix match: target starts with `query_`
197/// - Segment match: target contains `_query_`
198///
199/// Example: "CD8A" matches "ENSG00000153563_CD8A", "CD8A_variant1", "chr1_CD8A_isoform2"
200#[allow(dead_code)]
201pub fn flexible_name_match(query: &str, target: &str) -> bool {
202    let q = query.to_lowercase();
203    let t = target.to_lowercase();
204    t == q
205        || t.ends_with(&format!("_{}", q))
206        || t.starts_with(&format!("{}_", q))
207        || t.contains(&format!("_{}_", q))
208}
209
210/// Heuristic: a lower-cased Ensembl-style stable id (`ensg…`, `ensmusg…`,
211/// `enst…`). Used to index/look up the *leading* id segment of an
212/// `ENSG…_SYMBOL` name so bare-`ENSG` and `ENSG_SYMBOL` forms reconcile both
213/// ways without a linear scan.
214fn is_ensembl_id(s: &str) -> bool {
215    s.len() >= 8 && s.starts_with("ens") && s.bytes().any(|b| b.is_ascii_digit())
216}
217
218/// Curated HGNC **old symbol → current symbol** renames, lower-cased.
219///
220/// A symbol match is exact, so a marker panel written against an older HGNC release
221/// silently loses every gene HGNC has since renamed — the gene is in the matrix under its
222/// new name, but the panel asks for the old one and gets nothing back. That is invisible in
223/// the output: the type just scores on fewer genes (or is dropped entirely). This table is
224/// what closes the gap.
225///
226/// It is **curated, not exhaustive** — the families that actually recur in single-cell
227/// marker panels (histones, the `MARCH`/`SEPT` families that Excel also mangles into dates,
228/// the selenoproteins, and the well-known one-off renames). Systematic families are handled
229/// by rule in [`alias_candidates`] rather than enumerated here. Entries are one-directional
230/// in this table but matched **both ways** at lookup, so it does not matter whether the
231/// matrix or the panel is the one carrying the old name.
232static HGNC_RENAMES: &[(&str, &str)] = &[
233    // Histones — HGNC's 2019 systematic renaming; heavily used as cell-cycle / S-phase
234    // markers, so a stale panel loses much of its S-phase signature.
235    ("h1f0", "h1-0"),
236    ("h1fx", "h1-10"),
237    ("hist1h1b", "h1-5"),
238    ("hist1h1c", "h1-2"),
239    ("hist1h1d", "h1-3"),
240    ("hist1h1e", "h1-4"),
241    ("hist1h2ac", "h2ac6"),
242    ("hist1h2bk", "h2bc12"),
243    ("hist1h4c", "h4c3"),
244    ("hist2h2be", "h2bc21"),
245    ("hist3h2a", "h2ac25"),
246    ("h2afx", "h2ax"),
247    ("h2afv", "h2az2"),
248    ("h2afz", "h2az1"),
249    ("h2afy", "macroh2a1"),
250    ("h3f3a", "h3-3a"),
251    ("h3f3b", "h3-3b"),
252    // Mitochondrial amidoxime-reducing components (note: NOT the MARCH family below).
253    ("marc1", "mtarc1"),
254    ("marc2", "mtarc2"),
255    // Selenoproteins.
256    ("sepp1", "selenop"),
257    ("selt", "selenot"),
258    ("sepw1", "selenow"),
259    // One-off renames common in immune / proliferation panels.
260    ("fam129a", "niban1"),
261    ("fam129b", "niban2"),
262    ("fam129c", "niban3"),
263    ("rarres3", "plaat4"),
264    ("fyb", "fyb1"),
265    ("cd97", "adgre5"),
266    ("gpr56", "adgrg1"),
267    ("kiaa0101", "pclaf"),
268    ("c10orf54", "vsir"),
269    ("tmem66", "saraf"),
270    ("atpif1", "atp5if1"),
271    ("fam46c", "tent5c"),
272    ("whsc1", "nsd2"),
273];
274
275/// `HGNC_RENAMES` as a lookup: old → new (`fwd`) and new → old (`rev`).
276///
277/// Kept as two maps rather than one seeded in both directions. The table's key sets happen to
278/// be disjoint today, so one map would give identical answers — but the moment someone adds a
279/// *chained* rename (`A→B` alongside an existing `B→C`), a single map has two entries for `B`
280/// and silently keeps whichever was inserted last. Two maps cannot lose that way.
281struct RenameMaps {
282    fwd: HashMap<&'static str, &'static str>,
283    rev: HashMap<&'static str, &'static str>,
284}
285
286/// The lazily-built rename lookups. Built once; every `match_gene` miss consults them.
287fn rename_maps() -> &'static RenameMaps {
288    static MAPS: std::sync::OnceLock<RenameMaps> = std::sync::OnceLock::new();
289    MAPS.get_or_init(|| RenameMaps {
290        fwd: HGNC_RENAMES.iter().copied().collect(),
291        rev: HGNC_RENAMES.iter().map(|&(o, n)| (n, o)).collect(),
292    })
293}
294
295/// The numeric suffix of a `{prefix}{n}` symbol (`numeric_suffix("march12", "march") == 12`),
296/// or `None` if `sym` does not have exactly that shape.
297fn numeric_suffix(sym: &str, prefix: &str) -> Option<u32> {
298    sym.strip_prefix(prefix)
299        .filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
300        .and_then(|d| d.parse().ok())
301}
302
303/// Alternative HGNC symbols for `sym` (already lower-cased): the table above in both
304/// directions, plus the two rule-based families whose members are too numerous to enumerate
305/// and whose rename is purely mechanical — `MARCH{n}` ↔ `MARCHF{n}` (membrane-associated
306/// ring-CH E3 ligases) and `SEPT{n}` ↔ `SEPTIN{n}` (septins). Both families were renamed
307/// precisely because spreadsheets kept coercing them to dates, so panels in the wild carry
308/// either form.
309///
310/// `MARCH1`/`MARC1` do not collide: `MARC1` is in the table (→ `MTARC1`) and the `march`
311/// rule only fires on the literal `march` prefix.
312fn alias_candidates(sym: &str) -> Vec<String> {
313    let maps = rename_maps();
314    let mut out = Vec::new();
315    if let Some(&new) = maps.fwd.get(sym) {
316        out.push(new.to_string());
317    }
318    if let Some(&old) = maps.rev.get(sym) {
319        out.push(old.to_string());
320    }
321    for (old, new) in [("march", "marchf"), ("sept", "septin")] {
322        if let Some(n) = numeric_suffix(sym, old) {
323            out.push(format!("{new}{n}"));
324        }
325        if let Some(n) = numeric_suffix(sym, new) {
326            out.push(format!("{old}{n}"));
327        }
328    }
329    out
330}
331
332/// Pre-built index over a gene-name vocabulary for fast marker→row matching.
333/// Resolves a query gene in tiers, returning the first matching row:
334///   0. a locus query (`chr:start-end`) matches only a locus row, by its
335///      locus key with the chromosome case kept, and stops here: loci never
336///      go through the case-insensitive or fuzzy tiers below,
337///   1. exact (case-insensitive) full-name match,
338///   2. last `_`-segment symbol match (`CD8A` ↔ `ENSG…_CD8A`),
339///   3. leading Ensembl-id segment match (`ENSG…` ↔ `ENSG…_CD8A`),
340///   4. decompose a combined `ENSG…_SYMBOL` query and retry tiers 2–3 per part,
341///   5. HGNC alias retry ([`alias_candidates`]: `HIST1H4C` ↔ `H4C3`, `MARCH2` ↔ `MARCHF2`),
342///   6. fallback to the general [`flexible_name_match`] (prefix / `_x_` segment).
343///
344/// Tiers 1–5 are O(1) hash lookups; only the rare fallback scans the
345/// vocabulary. Build once, match many — replaces the O(genes × markers)
346/// `.position(flexible_name_match)` scan. Tier 1 preferring an exact match
347/// over an earlier-indexed suffix match is the one intended refinement vs a
348/// pure positional scan. Tiers 3–4 make HGNC / ENSG / `ENSG_HGNC` reconcile
349/// in either direction (gene-set sources mix these conventions), and tier 5
350/// does the same across HGNC *releases* — the matrix and the marker panel are
351/// routinely built against different ones.
352#[allow(dead_code)] // consumed by downstream crates (geu, senna), not the data-beans bin
353pub struct GeneIndex {
354    lowered: Vec<String>,
355    exact: HashMap<String, usize>,
356    symbol: HashMap<String, usize>,
357    ensg: HashMap<String, usize>,
358    /// Rows with a locus part ([`locus_part`]), by their name as written,
359    /// then by locus key plus the rest of the name, then (for rows with a
360    /// rest) by the bare locus key. Loci match only here, case kept.
361    locus_raw: HashMap<Box<str>, usize>,
362    locus: HashMap<Box<str>, usize>,
363}
364
365/// A name's locus part and the rest after it: the whole name when it is a
366/// locus, its `/`-core (`chr1:1-2/count/spliced`), or the locus before an
367/// `id{ROW_SEP}name` join (`chr1:1-2_GENE1`). `None` when no part is a locus.
368fn locus_part(name: &str) -> Option<(&str, &str)> {
369    if is_locus(name) {
370        return Some((name, ""));
371    }
372    let core = name.split('/').next().unwrap_or(name);
373    let cut = if is_locus(core) {
374        core.len()
375    } else {
376        let colon = core.find(':')?;
377        colon + 1 + core[colon + 1..].find(ROW_SEP)?
378    };
379    let (part, rest) = name.split_at(cut);
380    is_locus(part).then_some((part, rest))
381}
382
383/// The key a name with a locus part is matched by: the locus key, then the
384/// rest of the name as written.
385fn locus_match_key(part: &str, rest: &str) -> Option<Box<str>> {
386    let key = locus_key(part)?;
387    Some(if rest.is_empty() {
388        key
389    } else {
390        format!("{key}{rest}").into_boxed_str()
391    })
392}
393
394#[allow(dead_code)] // consumed by downstream crates (geu, senna), not the data-beans bin
395impl GeneIndex {
396    /// Build the index from the dictionary's gene-name order. The first row
397    /// wins on duplicate keys (matching positional-scan semantics).
398    #[must_use]
399    pub fn build(gene_names: &[Box<str>]) -> Self {
400        // A whole-locus row gets an empty lowered name, which keeps it out
401        // of every gene tier, the fallback scan included. A row with a
402        // locus part plus a rest stays in the gene tiers for its full name.
403        let lowered: Vec<String> = gene_names
404            .par_iter()
405            .map(|g| {
406                if is_locus(g) {
407                    String::new()
408                } else {
409                    g.to_lowercase()
410                }
411            })
412            .collect();
413        let mut locus_raw: HashMap<Box<str>, usize> = HashMap::default();
414        let mut locus: HashMap<Box<str>, usize> = HashMap::default();
415        let mut bare: Vec<(Box<str>, usize)> = Vec::new();
416        for (i, g) in gene_names.iter().enumerate() {
417            let Some((part, rest)) = locus_part(g) else {
418                continue;
419            };
420            locus_raw.entry(g.clone()).or_insert(i);
421            if let Some(key) = locus_match_key(part, rest) {
422                locus.entry(key).or_insert(i);
423            }
424            if !rest.is_empty() {
425                bare.extend(locus_key(part).map(|k| (k, i)));
426            }
427        }
428        // A whole-locus row wins its key over a row that only starts with it.
429        for (key, i) in bare {
430            locus.entry(key).or_insert(i);
431        }
432        let mut exact: HashMap<String, usize> = HashMap::default();
433        let mut symbol: HashMap<String, usize> = HashMap::default();
434        let mut ensg: HashMap<String, usize> = HashMap::default();
435        for (i, low) in lowered.iter().enumerate() {
436            if low.is_empty() {
437                continue;
438            }
439            exact.entry(low.clone()).or_insert(i);
440            // Strip a faba-style aux suffix first (`SYMBOL/count/spliced` →
441            // symbol is the leading `/`-segment), then an Ensembl-style prefix
442            // (`ENSG…_CD8A` → symbol is the trailing `_`-segment). Handles
443            // either convention, or both combined (`ENSG…_CD8A/count/spliced`).
444            let core = low.split('/').next().unwrap_or(low);
445            if let Some(sym) = core.rsplit('_').next() {
446                symbol.entry(sym.to_string()).or_insert(i);
447            }
448            // Also index the *leading* segment when it is an Ensembl id, so a
449            // bare `ENSG…` query resolves to an `ENSG…_SYMBOL` row (and back).
450            let head = core.split('_').next().unwrap_or(core);
451            if is_ensembl_id(head) {
452                ensg.entry(head.to_string()).or_insert(i);
453            }
454        }
455        Self {
456            lowered,
457            exact,
458            symbol,
459            ensg,
460            locus_raw,
461            locus,
462        }
463    }
464
465    /// Row index for `gene`, or `None` if unmatched (tiers above).
466    #[must_use]
467    pub fn match_gene(&self, gene: &str) -> Option<usize> {
468        // A name with a locus part matches only a row with one: the same
469        // name as written first, then by locus key plus the rest. Strictly:
470        // no case folding, aliasing or prefix fallback.
471        if let Some((part, rest)) = locus_part(gene) {
472            if let Some(&i) = self.locus_raw.get(gene) {
473                return Some(i);
474            }
475            return locus_match_key(part, rest).and_then(|k| self.locus.get(&k).copied());
476        }
477        let gl = gene.to_lowercase();
478        if let Some(&i) = self.exact.get(&gl) {
479            return Some(i);
480        }
481        if let Some(&i) = self.symbol.get(&gl) {
482            return Some(i);
483        }
484        if let Some(&i) = self.ensg.get(&gl) {
485            return Some(i);
486        }
487        // Decompose a combined `ENSG…_SYMBOL[/aux]` query: match its trailing
488        // symbol or leading Ensembl id against the per-part indices.
489        let core = gl.split('/').next().unwrap_or(&gl);
490        if let Some(sym) = core.rsplit('_').next() {
491            if sym != gl {
492                if let Some(&i) = self.symbol.get(sym) {
493                    return Some(i);
494                }
495            }
496        }
497        let head = core.split('_').next().unwrap_or(core);
498        if is_ensembl_id(head) {
499            if let Some(&i) = self.ensg.get(head) {
500                return Some(i);
501            }
502        }
503        // HGNC alias retry: the query and the vocabulary can be built against different HGNC
504        // releases (`HIST1H4C` in the panel, `H4C3` in the matrix, or the reverse). Retry the
505        // exact/symbol tiers under each alternative symbol before falling back to the scan.
506        let sym = core.rsplit('_').next().unwrap_or(core);
507        for alias in alias_candidates(sym) {
508            if let Some(&i) = self.exact.get(&alias).or_else(|| self.symbol.get(&alias)) {
509                return Some(i);
510            }
511        }
512        // Allocation-free flexible fallback: `flexible_name_match` re-lowercases
513        // both sides and builds three `format!` needles *per comparison*, which
514        // is catastrophic when scanning a 30k+ vocabulary for each of thousands
515        // of unmatched gene-set genes. The vocabulary is already lowercased and
516        // `gl` is lowercase, so build the needles once and scan with plain
517        // byte-level `ends_with`/`starts_with`/`contains`.
518        // (an exact `*t == gl` match is already handled by the `exact` tier above)
519        let suffix = format!("_{gl}");
520        let prefix = format!("{gl}_");
521        let middle = format!("_{gl}_");
522        self.lowered
523            .iter()
524            .position(|t| t.ends_with(&suffix) || t.starts_with(&prefix) || t.contains(&middle))
525    }
526}
527
528/// Inverse-document-frequency marker weight `ln(C / df)`: a gene claimed by
529/// all `C` types gets weight 0 (removed from scoring), a type-exclusive gene
530/// the maximum `ln(C)`.
531#[allow(dead_code)] // consumed by downstream crates (geu, senna), not the data-beans bin
532#[must_use]
533pub fn idf_weight(n_types: usize, df: usize) -> f32 {
534    (n_types as f32 / df.max(1) as f32).ln()
535}
536
537/// Match names by substring queries and return matched indices and names
538///
539/// # Arguments
540/// * `all_names` - All available names to search through
541/// * `queries` - Substring queries to match against
542/// * `entity_type` - Description of what's being matched (e.g., "column", "row") for error messages
543///
544/// # Returns
545/// A tuple of (matched_indices, matched_names)
546pub fn match_by_substring(
547    all_names: &[Box<str>],
548    queries: &[Box<str>],
549    entity_type: &str,
550) -> anyhow::Result<(Vec<usize>, Vec<Box<str>>)> {
551    let mut matched_indices = Vec::new();
552
553    for query in queries.iter() {
554        for (idx, name) in all_names.iter().enumerate() {
555            if name.contains(query.as_ref()) {
556                matched_indices.push(idx);
557            }
558        }
559    }
560
561    if matched_indices.is_empty() {
562        return Err(anyhow::anyhow!(
563            "No {} names matched the provided queries",
564            entity_type
565        ));
566    }
567
568    let matched_names: Vec<Box<str>> = matched_indices
569        .iter()
570        .map(|&i| all_names[i].clone())
571        .collect();
572
573    Ok((matched_indices, matched_names))
574}
575
576#[cfg(test)]
577mod tests;