Skip to main content

data_beans/utilities/
name_matching.rs

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