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;
5use rustc_hash::FxHashSet as HashSet;
6
7/// Make duplicate names unique by appending `-1`, `-2`, etc. to repeated entries.
8/// Similar to scanpy's `var_names_make_unique()`. A suffixed name is never one
9/// the list already holds (`ABC, ABC-1, ABC` gives `ABC, ABC-1, ABC-2`), so
10/// the result has no duplicates.
11pub fn make_names_unique(names: &mut [Box<str>]) -> usize {
12 let mut used: HashSet<Box<str>> = names.iter().cloned().collect();
13 if used.len() == names.len() {
14 return 0;
15 }
16 let mut counts: HashMap<Box<str>, usize> = HashMap::default();
17 let mut num_duped = 0usize;
18 for name in names.iter_mut() {
19 if let Some(count) = counts.get_mut(name.as_ref()) {
20 if *count == 1 {
21 num_duped += 1;
22 }
23 // Skip the suffixes another name already took.
24 *name = loop {
25 let candidate = format!("{}-{}", name, count).into_boxed_str();
26 *count += 1;
27 if used.insert(candidate.clone()) {
28 break candidate;
29 }
30 };
31 } else {
32 counts.insert(name.clone(), 1);
33 }
34 }
35 if num_duped > 0 {
36 log::warn!(
37 "{} names had duplicates and were made unique with -N suffixes",
38 num_duped
39 );
40 }
41 num_duped
42}
43
44/// The composite name of one row: `id{ROW_SEP}name`, or the ID alone when
45/// the name is empty or already equals it (e.g. 10x ATAC peaks, where both
46/// `features/id` and `features/name` are `chr1:1000-2000`).
47pub fn id_name(id: &str, name: &str) -> Box<str> {
48 if name.is_empty() || name == id {
49 id.into()
50 } else {
51 format!("{id}{ROW_SEP}{name}").into_boxed_str()
52 }
53}
54
55/// [`id_name`] of each row.
56pub fn compose_id_name(ids: Vec<Box<str>>, names: Vec<Box<str>>) -> Vec<Box<str>> {
57 ids.iter()
58 .zip(&names)
59 .map(|(id, name)| id_name(id, name))
60 .collect()
61}
62
63/// Import boundary for feature rows: peak rows get their id and name
64/// rewritten in the colon locus form, whatever spelling the producer used
65/// (`chr1-100-200` and `chr1_100_200` become `chr1:100-200`). With feature
66/// types, a row is a peak when its type names peaks or ATAC; without them,
67/// the file is a peak list only when every id reads as an interval. A gene
68/// id that happens to end in two numbers is therefore left alone. Returns
69/// how many rows were rewritten.
70pub fn colon_peak_names(
71 ids: &mut [Box<str>],
72 names: &mut [Box<str>],
73 types: Option<&[Box<str>]>,
74) -> usize {
75 let is_peak = |i: usize| match types {
76 Some(types) => {
77 contains_ignore_ascii_case(&types[i], "peak")
78 || contains_ignore_ascii_case(&types[i], "atac")
79 }
80 None => true,
81 };
82 if types.is_none() {
83 let n_read = ids
84 .iter()
85 .filter(|id| import_interval(id).is_some())
86 .count();
87 if n_read < ids.len() {
88 if n_read * 2 >= ids.len() {
89 log::info!(
90 "{n_read} of {} untyped rows read as intervals, but not all; \
91 the names are kept as written",
92 ids.len()
93 );
94 }
95 return 0;
96 }
97 }
98 let mut n = 0;
99 for (i, (id, name)) in ids.iter_mut().zip(names.iter_mut()).enumerate() {
100 if !is_peak(i) {
101 continue;
102 }
103 let mut changed = false;
104 for s in [id, name] {
105 if let Some(l) = import_interval(s) {
106 let colon = l.to_string().into_boxed_str();
107 changed |= *s != colon;
108 *s = colon;
109 }
110 }
111 n += usize::from(changed);
112 }
113 n
114}
115
116/// Inverse of [`compose_id_name`]: split a composite `id{ROW_SEP}name` display
117/// name back into `(id, name)` on the first `ROW_SEP`. When there is no
118/// separator (a bare symbol, or an id-only composite where name was empty or
119/// equalled id) both parts are the whole string, so a 10x `features.tsv` still
120/// gets a non-empty gene name.
121///
122/// A locus id is not cut at a `ROW_SEP` of its contig name: an id-only
123/// peak row comes back whole, whether the contig is an unplaced scaffold
124/// (`chrUn_KI270742v1:100-200`, `chr1_KI270706v1_random:5-9`) or carries a
125/// genome tag as multi-genome references do (`GRCh38_chr1:100-200`), and a
126/// locus id with a name splits after the locus
127/// (`chrUn_KI270742v1:100-200_GENE1`).
128///
129/// Other ids are cut at their first `ROW_SEP`, so an id-only row whose id
130/// holds one (`Y_RNA`) is not told apart from an `id{ROW_SEP}name` pair.
131/// Nor is an id joined to a locus name (`peak1_chr1:100-200`) told apart
132/// from one locus on the contig `peak1_chr1`: a composite that is one locus
133/// as a whole comes back whole.
134pub fn split_id_name(composite: &str) -> (&str, &str) {
135 let pair = match locus_part(composite) {
136 Some((_, "")) => None,
137 Some((locus, rest)) => rest
138 .strip_prefix(ROW_SEP)
139 .filter(|name| !name.is_empty())
140 .map(|name| (locus, name)),
141 None => composite
142 .split_once(ROW_SEP)
143 .filter(|(_, name)| !name.is_empty()),
144 };
145 pair.unwrap_or((composite, composite))
146}
147
148/// Comma-separated case-insensitive substring filter, parsed once and matched
149/// many times. Used by `--select-row-type` / `--remove-row-type` /
150/// `--hto-row-type` so callers can pass e.g. `"gene,peak"` to match either
151/// "Gene Expression" or "Peaks".
152pub struct RowTypeFilter {
153 patterns: Vec<Box<str>>,
154}
155
156impl RowTypeFilter {
157 pub fn parse(s: &str) -> Self {
158 let patterns = s
159 .split(',')
160 .map(|p| p.trim())
161 .filter(|p| !p.is_empty())
162 .map(|p| p.to_ascii_lowercase().into_boxed_str())
163 .collect();
164 Self { patterns }
165 }
166
167 pub fn is_empty(&self) -> bool {
168 self.patterns.is_empty()
169 }
170
171 /// True if any pattern is an ASCII-case-insensitive substring of `s`.
172 /// Bytewise scan — does not allocate, so callers can pass row types
173 /// straight from the backend without an intermediate lowercase copy.
174 pub fn matches(&self, s: &str) -> bool {
175 self.patterns
176 .iter()
177 .any(|p| contains_ignore_ascii_case(s, p))
178 }
179}
180
181/// Bytewise case-insensitive substring search. ASCII only; non-ASCII bytes
182/// compare verbatim. Allocation-free.
183pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
184 let n = needle.len();
185 if n == 0 {
186 return true;
187 }
188 let h = haystack.as_bytes();
189 if h.len() < n {
190 return false;
191 }
192 h.windows(n)
193 .any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
194}
195
196/// Return indices of rows whose type passes select/remove filtering.
197/// - `select`: comma-separated patterns; row passes if any pattern is a
198/// case-insensitive substring of the row type. Empty keeps all rows.
199/// - `remove`: comma-separated patterns; row is dropped if any pattern matches.
200pub fn filter_row_indices_by_type(
201 row_types: &[Box<str>],
202 select: &str,
203 remove: &str,
204) -> Vec<usize> {
205 let sel = RowTypeFilter::parse(select);
206 let rem = RowTypeFilter::parse(remove);
207 if sel.is_empty() && rem.is_empty() {
208 return (0..row_types.len()).collect();
209 }
210 row_types
211 .iter()
212 .enumerate()
213 .filter_map(|(i, x)| {
214 let selected = sel.is_empty() || sel.matches(x);
215 let removed = !rem.is_empty() && rem.matches(x);
216 if selected && !removed {
217 Some(i)
218 } else {
219 None
220 }
221 })
222 .collect()
223}
224
225/// Flexible gene name matching (case-insensitive, underscore-delimited)
226/// Returns true if `query` matches `target` with these rules:
227/// - Exact match (case-insensitive)
228/// - Suffix match: target ends with `_query`
229/// - Prefix match: target starts with `query_`
230/// - Segment match: target contains `_query_`
231///
232/// Example: "CD8A" matches "ENSG00000153563_CD8A", "CD8A_variant1", "chr1_CD8A_isoform2"
233#[allow(dead_code)]
234pub fn flexible_name_match(query: &str, target: &str) -> bool {
235 let q = query.to_lowercase();
236 let t = target.to_lowercase();
237 t == q
238 || t.ends_with(&format!("_{}", q))
239 || t.starts_with(&format!("{}_", q))
240 || t.contains(&format!("_{}_", q))
241}
242
243/// Heuristic: a lower-cased Ensembl-style stable id (`ensg…`, `ensmusg…`,
244/// `enst…`). Used to index/look up the *leading* id segment of an
245/// `ENSG…_SYMBOL` name so bare-`ENSG` and `ENSG_SYMBOL` forms reconcile both
246/// ways without a linear scan.
247fn is_ensembl_id(s: &str) -> bool {
248 s.len() >= 8 && s.starts_with("ens") && s.bytes().any(|b| b.is_ascii_digit())
249}
250
251/// Curated HGNC **old symbol → current symbol** renames, lower-cased.
252///
253/// A symbol match is exact, so a marker panel written against an older HGNC release
254/// silently loses every gene HGNC has since renamed — the gene is in the matrix under its
255/// new name, but the panel asks for the old one and gets nothing back. That is invisible in
256/// the output: the type just scores on fewer genes (or is dropped entirely). This table is
257/// what closes the gap.
258///
259/// It is **curated, not exhaustive** — the families that actually recur in single-cell
260/// marker panels (histones, the `MARCH`/`SEPT` families that Excel also mangles into dates,
261/// the selenoproteins, and the well-known one-off renames). Systematic families are handled
262/// by rule in [`alias_candidates`] rather than enumerated here. Entries are one-directional
263/// in this table but matched **both ways** at lookup, so it does not matter whether the
264/// matrix or the panel is the one carrying the old name.
265static HGNC_RENAMES: &[(&str, &str)] = &[
266 // Histones — HGNC's 2019 systematic renaming; heavily used as cell-cycle / S-phase
267 // markers, so a stale panel loses much of its S-phase signature.
268 ("h1f0", "h1-0"),
269 ("h1fx", "h1-10"),
270 ("hist1h1b", "h1-5"),
271 ("hist1h1c", "h1-2"),
272 ("hist1h1d", "h1-3"),
273 ("hist1h1e", "h1-4"),
274 ("hist1h2ac", "h2ac6"),
275 ("hist1h2bk", "h2bc12"),
276 ("hist1h4c", "h4c3"),
277 ("hist2h2be", "h2bc21"),
278 ("hist3h2a", "h2ac25"),
279 ("h2afx", "h2ax"),
280 ("h2afv", "h2az2"),
281 ("h2afz", "h2az1"),
282 ("h2afy", "macroh2a1"),
283 ("h3f3a", "h3-3a"),
284 ("h3f3b", "h3-3b"),
285 // Mitochondrial amidoxime-reducing components (note: NOT the MARCH family below).
286 ("marc1", "mtarc1"),
287 ("marc2", "mtarc2"),
288 // Selenoproteins.
289 ("sepp1", "selenop"),
290 ("selt", "selenot"),
291 ("sepw1", "selenow"),
292 // One-off renames common in immune / proliferation panels.
293 ("fam129a", "niban1"),
294 ("fam129b", "niban2"),
295 ("fam129c", "niban3"),
296 ("rarres3", "plaat4"),
297 ("fyb", "fyb1"),
298 ("cd97", "adgre5"),
299 ("gpr56", "adgrg1"),
300 ("kiaa0101", "pclaf"),
301 ("c10orf54", "vsir"),
302 ("tmem66", "saraf"),
303 ("atpif1", "atp5if1"),
304 ("fam46c", "tent5c"),
305 ("whsc1", "nsd2"),
306];
307
308/// `HGNC_RENAMES` as a lookup: old → new (`fwd`) and new → old (`rev`).
309///
310/// Kept as two maps rather than one seeded in both directions. The table's key sets happen to
311/// be disjoint today, so one map would give identical answers — but the moment someone adds a
312/// *chained* rename (`A→B` alongside an existing `B→C`), a single map has two entries for `B`
313/// and silently keeps whichever was inserted last. Two maps cannot lose that way.
314struct RenameMaps {
315 fwd: HashMap<&'static str, &'static str>,
316 rev: HashMap<&'static str, &'static str>,
317}
318
319/// The lazily-built rename lookups. Built once; every `match_gene` miss consults them.
320fn rename_maps() -> &'static RenameMaps {
321 static MAPS: std::sync::OnceLock<RenameMaps> = std::sync::OnceLock::new();
322 MAPS.get_or_init(|| RenameMaps {
323 fwd: HGNC_RENAMES.iter().copied().collect(),
324 rev: HGNC_RENAMES.iter().map(|&(o, n)| (n, o)).collect(),
325 })
326}
327
328/// The numeric suffix of a `{prefix}{n}` symbol (`numeric_suffix("march12", "march") == 12`),
329/// or `None` if `sym` does not have exactly that shape.
330fn numeric_suffix(sym: &str, prefix: &str) -> Option<u32> {
331 sym.strip_prefix(prefix)
332 .filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
333 .and_then(|d| d.parse().ok())
334}
335
336/// Alternative HGNC symbols for `sym` (already lower-cased): the table above in both
337/// directions, plus the two rule-based families whose members are too numerous to enumerate
338/// and whose rename is purely mechanical — `MARCH{n}` ↔ `MARCHF{n}` (membrane-associated
339/// ring-CH E3 ligases) and `SEPT{n}` ↔ `SEPTIN{n}` (septins). Both families were renamed
340/// precisely because spreadsheets kept coercing them to dates, so panels in the wild carry
341/// either form.
342///
343/// `MARCH1`/`MARC1` do not collide: `MARC1` is in the table (→ `MTARC1`) and the `march`
344/// rule only fires on the literal `march` prefix.
345fn alias_candidates(sym: &str) -> Vec<String> {
346 let maps = rename_maps();
347 let mut out = Vec::new();
348 if let Some(&new) = maps.fwd.get(sym) {
349 out.push(new.to_string());
350 }
351 if let Some(&old) = maps.rev.get(sym) {
352 out.push(old.to_string());
353 }
354 for (old, new) in [("march", "marchf"), ("sept", "septin")] {
355 if let Some(n) = numeric_suffix(sym, old) {
356 out.push(format!("{new}{n}"));
357 }
358 if let Some(n) = numeric_suffix(sym, new) {
359 out.push(format!("{old}{n}"));
360 }
361 }
362 out
363}
364
365/// Pre-built index over a gene-name vocabulary for fast marker→row matching.
366/// Resolves a query gene in tiers, returning the first matching row:
367/// 0. a locus query (`chr:start-end`) matches only a locus row, by its
368/// locus key with the chromosome case kept, and stops here: loci never
369/// go through the case-insensitive or fuzzy tiers below,
370/// 1. exact (case-insensitive) full-name match,
371/// 2. last `_`-segment symbol match (`CD8A` ↔ `ENSG…_CD8A`),
372/// 3. leading Ensembl-id segment match (`ENSG…` ↔ `ENSG…_CD8A`),
373/// 4. decompose a combined `ENSG…_SYMBOL` query and retry tiers 2–3 per part,
374/// 5. HGNC alias retry ([`alias_candidates`]: `HIST1H4C` ↔ `H4C3`, `MARCH2` ↔ `MARCHF2`),
375/// 6. fallback to the general [`flexible_name_match`] (prefix / `_x_` segment).
376///
377/// Tiers 1–5 are O(1) hash lookups, and the fallback tests only the rows that
378/// hold the query's first `_`-segment. Build once, match many — replaces the O(genes × markers)
379/// `.position(flexible_name_match)` scan. Tier 1 preferring an exact match
380/// over an earlier-indexed suffix match is the one intended refinement vs a
381/// pure positional scan. Tiers 3–4 make HGNC / ENSG / `ENSG_HGNC` reconcile
382/// in either direction (gene-set sources mix these conventions), and tier 5
383/// does the same across HGNC *releases* — the matrix and the marker panel are
384/// routinely built against different ones.
385#[allow(dead_code)] // consumed by downstream crates (geu, senna), not the data-beans bin
386pub struct GeneIndex {
387 lowered: Vec<String>,
388 exact: HashMap<String, usize>,
389 symbol: HashMap<String, usize>,
390 ensg: HashMap<String, usize>,
391 /// Every `_`-separated segment of a lowered name, with the rows that hold
392 /// it in ascending order: the candidates of the fallback tier.
393 segment: HashMap<String, Vec<usize>>,
394 /// Rows with a locus part ([`locus_part`]), by their name as written,
395 /// then by locus key plus the rest of the name, then (for rows with a
396 /// rest) by the bare locus key. Loci match only here, case kept.
397 locus_raw: HashMap<Box<str>, usize>,
398 locus: HashMap<Box<str>, usize>,
399}
400
401/// A name's locus part and the rest after it: the whole name when it is a
402/// locus, its `/`-core (`chr1:1-2/count/spliced`), or the locus before an
403/// `id{ROW_SEP}name` join (`chr1:1-2_GENE1`). `None` when no part is a locus.
404fn locus_part(name: &str) -> Option<(&str, &str)> {
405 if is_locus(name) {
406 return Some((name, ""));
407 }
408 let core = name.split('/').next().unwrap_or(name);
409 let cut = if is_locus(core) {
410 core.len()
411 } else {
412 let colon = core.find(':')?;
413 colon + 1 + core[colon + 1..].find(ROW_SEP)?
414 };
415 let (part, rest) = name.split_at(cut);
416 is_locus(part).then_some((part, rest))
417}
418
419/// The key a name with a locus part is matched by: the locus key, then the
420/// rest of the name as written.
421fn locus_match_key(part: &str, rest: &str) -> Option<Box<str>> {
422 let key = locus_key(part)?;
423 Some(if rest.is_empty() {
424 key
425 } else {
426 format!("{key}{rest}").into_boxed_str()
427 })
428}
429
430#[allow(dead_code)] // consumed by downstream crates (geu, senna), not the data-beans bin
431impl GeneIndex {
432 /// Build the index from the dictionary's gene-name order. The first row
433 /// wins on duplicate keys (matching positional-scan semantics).
434 #[must_use]
435 pub fn build(gene_names: &[Box<str>]) -> Self {
436 // A whole-locus row gets an empty lowered name, which keeps it out
437 // of every gene tier, the fallback scan included. A row with a
438 // locus part plus a rest stays in the gene tiers for its full name.
439 let lowered: Vec<String> = gene_names
440 .par_iter()
441 .map(|g| {
442 if is_locus(g) {
443 String::new()
444 } else {
445 g.to_lowercase()
446 }
447 })
448 .collect();
449 let mut locus_raw: HashMap<Box<str>, usize> = HashMap::default();
450 let mut locus: HashMap<Box<str>, usize> = HashMap::default();
451 let mut bare: Vec<(Box<str>, usize)> = Vec::new();
452 for (i, g) in gene_names.iter().enumerate() {
453 let Some((part, rest)) = locus_part(g) else {
454 continue;
455 };
456 locus_raw.entry(g.clone()).or_insert(i);
457 if let Some(key) = locus_match_key(part, rest) {
458 locus.entry(key).or_insert(i);
459 }
460 if !rest.is_empty() {
461 bare.extend(locus_key(part).map(|k| (k, i)));
462 }
463 }
464 // A whole-locus row wins its key over a row that only starts with it.
465 for (key, i) in bare {
466 locus.entry(key).or_insert(i);
467 }
468 let mut exact: HashMap<String, usize> = HashMap::default();
469 let mut symbol: HashMap<String, usize> = HashMap::default();
470 let mut ensg: HashMap<String, usize> = HashMap::default();
471 let mut segment: HashMap<String, Vec<usize>> = HashMap::default();
472 for (i, low) in lowered.iter().enumerate() {
473 if low.is_empty() {
474 continue;
475 }
476 exact.entry(low.clone()).or_insert(i);
477 for seg in low.split('_') {
478 let rows = segment.entry(seg.to_string()).or_default();
479 if rows.last() != Some(&i) {
480 rows.push(i);
481 }
482 }
483 // Strip a faba-style aux suffix first (`SYMBOL/count/spliced` →
484 // symbol is the leading `/`-segment), then an Ensembl-style prefix
485 // (`ENSG…_CD8A` → symbol is the trailing `_`-segment). Handles
486 // either convention, or both combined (`ENSG…_CD8A/count/spliced`).
487 let core = low.split('/').next().unwrap_or(low);
488 if let Some(sym) = core.rsplit('_').next() {
489 symbol.entry(sym.to_string()).or_insert(i);
490 }
491 // Also index the *leading* segment when it is an Ensembl id, so a
492 // bare `ENSG…` query resolves to an `ENSG…_SYMBOL` row (and back).
493 let head = core.split('_').next().unwrap_or(core);
494 if is_ensembl_id(head) {
495 ensg.entry(head.to_string()).or_insert(i);
496 }
497 }
498 Self {
499 lowered,
500 exact,
501 symbol,
502 ensg,
503 segment,
504 locus_raw,
505 locus,
506 }
507 }
508
509 /// Row index for `gene`, or `None` if unmatched (tiers above).
510 #[must_use]
511 pub fn match_gene(&self, gene: &str) -> Option<usize> {
512 // A name with a locus part matches only a row with one: the same
513 // name as written first, then by locus key plus the rest. Strictly:
514 // no case folding, aliasing or prefix fallback.
515 if let Some((part, rest)) = locus_part(gene) {
516 if let Some(&i) = self.locus_raw.get(gene) {
517 return Some(i);
518 }
519 return locus_match_key(part, rest).and_then(|k| self.locus.get(&k).copied());
520 }
521 let gl = gene.to_lowercase();
522 if let Some(&i) = self.exact.get(&gl) {
523 return Some(i);
524 }
525 if let Some(&i) = self.symbol.get(&gl) {
526 return Some(i);
527 }
528 if let Some(&i) = self.ensg.get(&gl) {
529 return Some(i);
530 }
531 // Decompose a combined `ENSG…_SYMBOL[/aux]` query: match its trailing
532 // symbol or leading Ensembl id against the per-part indices.
533 let core = gl.split('/').next().unwrap_or(&gl);
534 if let Some(sym) = core.rsplit('_').next() {
535 if sym != gl {
536 if let Some(&i) = self.symbol.get(sym) {
537 return Some(i);
538 }
539 }
540 }
541 let head = core.split('_').next().unwrap_or(core);
542 if is_ensembl_id(head) {
543 if let Some(&i) = self.ensg.get(head) {
544 return Some(i);
545 }
546 }
547 // HGNC alias retry: the query and the vocabulary can be built against different HGNC
548 // releases (`HIST1H4C` in the panel, `H4C3` in the matrix, or the reverse). Retry the
549 // exact/symbol tiers under each alternative symbol before falling back to the scan.
550 let sym = core.rsplit('_').next().unwrap_or(core);
551 for alias in alias_candidates(sym) {
552 if let Some(&i) = self.exact.get(&alias).or_else(|| self.symbol.get(&alias)) {
553 return Some(i);
554 }
555 }
556 self.match_segments(&gl)
557 }
558
559 /// The fallback tier, [`flexible_name_match`] over the lowered
560 /// vocabulary: the first row that ends with `_{gl}`, starts with `{gl}_`
561 /// or holds `_{gl}_`. In each case `gl` sits between `_` boundaries of
562 /// the row, so the row holds `gl`'s first `_`-segment as a whole segment;
563 /// only those rows are tested, in row order. The needles are built once
564 /// and compared bytewise (the vocabulary and `gl` are both lowercase); an
565 /// exact `row == gl` match belongs to the `exact` tier.
566 fn match_segments(&self, gl: &str) -> Option<usize> {
567 let first = gl.split('_').next().unwrap_or(gl);
568 let rows = self.segment.get(first)?;
569 let suffix = format!("_{gl}");
570 let prefix = format!("{gl}_");
571 let middle = format!("_{gl}_");
572 rows.iter().copied().find(|&i| {
573 let t = &self.lowered[i];
574 t.ends_with(&suffix) || t.starts_with(&prefix) || t.contains(&middle)
575 })
576 }
577}
578
579/// Inverse-document-frequency marker weight `ln(C / df)`: a gene claimed by
580/// all `C` types gets weight 0 (removed from scoring), a type-exclusive gene
581/// the maximum `ln(C)`.
582#[allow(dead_code)] // consumed by downstream crates (geu, senna), not the data-beans bin
583#[must_use]
584pub fn idf_weight(n_types: usize, df: usize) -> f32 {
585 (n_types as f32 / df.max(1) as f32).ln()
586}
587
588/// Match names by substring queries and return matched indices and names
589///
590/// Each matched name is returned once, in the order the queries first reach
591/// it, however many queries it matches. An empty query (a blank line in a
592/// name file) is not a query and matches nothing.
593///
594/// # Arguments
595/// * `all_names` - All available names to search through
596/// * `queries` - Substring queries to match against
597/// * `entity_type` - Description of what's being matched (e.g., "column", "row") for error messages
598///
599/// # Returns
600/// A tuple of (matched_indices, matched_names)
601pub fn match_by_substring(
602 all_names: &[Box<str>],
603 queries: &[Box<str>],
604 entity_type: &str,
605) -> anyhow::Result<(Vec<usize>, Vec<Box<str>>)> {
606 let mut matched_indices = Vec::new();
607 let mut is_matched = vec![false; all_names.len()];
608
609 for query in queries.iter().filter(|q| !q.is_empty()) {
610 for (idx, name) in all_names.iter().enumerate() {
611 if !is_matched[idx] && name.contains(query.as_ref()) {
612 is_matched[idx] = true;
613 matched_indices.push(idx);
614 }
615 }
616 }
617
618 if matched_indices.is_empty() {
619 return Err(anyhow::anyhow!(
620 "No {} names matched the provided queries",
621 entity_type
622 ));
623 }
624
625 let matched_names: Vec<Box<str>> = matched_indices
626 .iter()
627 .map(|&i| all_names[i].clone())
628 .collect();
629
630 Ok((matched_indices, matched_names))
631}
632
633#[cfg(test)]
634mod tests;
635
636/// The places of the names `pattern` (a regex, matched case-insensitively)
637/// finds in, in order.
638pub fn names_matching(names: &[Box<str>], pattern: &str) -> anyhow::Result<Vec<usize>> {
639 let re = regex::RegexBuilder::new(pattern)
640 .case_insensitive(true)
641 .build()
642 .map_err(|e| anyhow::anyhow!("invalid pattern `{pattern}`: {e}"))?;
643 Ok(names
644 .iter()
645 .enumerate()
646 .filter_map(|(i, n)| re.is_match(n).then_some(i))
647 .collect())
648}