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