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;