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