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;