Skip to main content

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}