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;