1use crate::aux::ontology::Ontology;
14use crate::utilities::name_matching::GeneIndex;
15use anyhow::{Context, Result};
16use legume_numeric::matrix::common_io::open_buf_reader;
17use rustc_hash::{FxHashMap, FxHashSet};
18use std::io::BufRead;
19
20#[derive(Default)]
23pub struct GeneSets {
24 pub names: FxHashMap<Box<str>, Box<str>>,
26 pub term_genes: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
28 pub gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
31}
32
33impl GeneSets {
34 #[must_use]
36 pub fn n_terms(&self) -> usize {
37 self.term_genes.len()
38 }
39
40 #[must_use]
42 pub fn n_annotations(&self) -> usize {
43 self.term_genes.values().map(FxHashSet::len).sum()
44 }
45
46 #[must_use]
48 pub fn n_genes(&self) -> usize {
49 self.gene_aliases.len()
50 }
51}
52
53pub fn read_gmt(path: &str) -> Result<GeneSets> {
58 let reader = open_buf_reader(path).with_context(|| format!("failed to open GMT: {path}"))?;
59 let mut gs = GeneSets::default();
60 for line in reader.lines() {
61 let line = line?;
62 let line = line.trim_end();
63 if line.is_empty() || line.starts_with('#') {
64 continue;
65 }
66 let mut it = line.split('\t');
67 let term = it.next().map(str::trim).unwrap_or_default();
68 if term.is_empty() {
69 continue;
70 }
71 let desc = it.next().map(str::trim).unwrap_or_default();
72 let genes: FxHashSet<Box<str>> = it
73 .map(str::trim)
74 .filter(|g| !g.is_empty())
75 .map(|g| g.to_uppercase().into_boxed_str())
76 .collect();
77 if genes.is_empty() {
78 continue;
79 }
80 let term: Box<str> = term.into();
81 for g in &genes {
82 gs.gene_aliases
83 .entry(g.clone())
84 .or_default()
85 .insert(g.clone());
86 }
87 if !desc.is_empty() {
88 gs.names.insert(term.clone(), desc.into());
89 }
90 gs.term_genes.entry(term).or_default().extend(genes);
91 }
92 Ok(gs)
93}
94
95pub fn read_membership_pairs(path: &str) -> Result<Vec<(Box<str>, Box<str>)>> {
102 let lines =
103 legume_numeric::matrix::common_io::read_lines_of_words_delim(path, &['\t', ','][..], -1)
104 .with_context(|| format!("reading membership pairs from {path}"))?
105 .lines;
106 Ok(lines
107 .into_iter()
108 .filter_map(|words| {
109 let gene = words.first()?.trim();
110 let label = words.get(1)?.trim();
111 let gl = gene.to_lowercase();
112 if gene.is_empty()
113 || gene.starts_with('#')
114 || label.is_empty()
115 || gl == "gene"
116 || gl == "symbol"
117 {
118 return None;
119 }
120 Some((Box::from(gene), Box::from(label)))
121 })
122 .collect())
123}
124
125#[derive(Default, Clone, Copy)]
127pub struct GafOpts {
128 pub no_iea: bool,
131}
132
133pub struct GafRaw {
136 gene2direct: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
137 gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
138}
139
140pub fn read_gaf(path: &str, opts: &GafOpts) -> Result<GafRaw> {
146 let reader = open_buf_reader(path).with_context(|| format!("failed to open GAF: {path}"))?;
147 let mut gene2direct: FxHashMap<Box<str>, FxHashSet<Box<str>>> = FxHashMap::default();
148 let mut n_other_objects = 0usize;
149 let mut gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>> = FxHashMap::default();
150 for line in reader.lines() {
151 let line = line?;
152 if line.starts_with('!') {
153 continue;
154 }
155 let f: Vec<&str> = line.split('\t').collect();
156 if f.len() < 15 {
157 continue; }
159 let symbol = f[2].trim();
160 let go = f[4].trim();
161 if symbol.is_empty() || !go.starts_with("GO:") {
162 continue;
163 }
164 if f[3].split('|').any(|q| q.trim() == "NOT") {
165 continue;
166 }
167 if opts.no_iea && f[6].trim() == "IEA" {
168 continue;
169 }
170 if !matches!(f[11].trim(), "protein" | "gene" | "gene_product" | "") {
174 n_other_objects += 1;
175 continue;
176 }
177 let key: Box<str> = symbol.to_uppercase().into();
178 let aliases = gene_aliases.entry(key.clone()).or_default();
179 aliases.insert(key.clone());
180 let acc = f[1].trim();
181 if !acc.is_empty() {
182 aliases.insert(acc.to_uppercase().into());
183 }
184 for syn in f[10].split('|').map(str::trim).filter(|s| !s.is_empty()) {
185 aliases.insert(syn.to_uppercase().into());
186 }
187 gene2direct.entry(key).or_default().insert(go.into());
188 }
189 if n_other_objects > 0 {
190 log::info!(
191 "{path}: {n_other_objects} rows on non-protein objects (RNAs, complexes) skipped"
192 );
193 }
194 Ok(GafRaw {
195 gene2direct,
196 gene_aliases,
197 })
198}
199
200impl GafRaw {
201 #[must_use]
205 pub fn into_gene_sets(self, onto: Option<&Ontology>) -> GeneSets {
206 let mut gs = GeneSets {
207 gene_aliases: self.gene_aliases,
208 ..Default::default()
209 };
210 for (gene, direct) in &self.gene2direct {
211 let mut full: FxHashSet<Box<str>> = FxHashSet::default();
212 for go in direct {
213 match onto {
214 Some(o) if o.contains(go) => {
215 full.extend(o.ancestors_or_self_with_part_of(go));
216 }
217 _ => {
218 full.insert(go.clone());
219 }
220 }
221 }
222 for t in full {
223 gs.term_genes.entry(t).or_default().insert(gene.clone());
224 }
225 }
226 if let Some(o) = onto {
227 let terms: Vec<Box<str>> = gs.term_genes.keys().cloned().collect();
228 for t in terms {
229 if let Some(n) = o.name(&t) {
230 gs.names.insert(t, n.into());
231 }
232 }
233 }
234 gs
235 }
236}
237
238pub struct Reconciled {
242 pub term_rows: FxHashMap<Box<str>, Vec<usize>>,
245 pub names: FxHashMap<Box<str>, Box<str>>,
247 pub universe: Vec<usize>,
252 pub n_genes_total: usize,
254 pub n_genes_matched: usize,
256 pub n_terms_kept: usize,
258 pub n_terms_total: usize,
260}
261
262impl GeneSets {
263 #[must_use]
272 pub fn reconcile(
273 &self,
274 index: &GeneIndex,
275 min_members: usize,
276 max_members: Option<usize>,
277 ) -> Reconciled {
278 let mut gene_row: FxHashMap<&str, usize> = FxHashMap::default();
280 for (key, aliases) in &self.gene_aliases {
281 let row = index
282 .match_gene(key)
283 .or_else(|| aliases.iter().find_map(|a| index.match_gene(a)));
284 if let Some(r) = row {
285 gene_row.insert(key, r);
286 }
287 }
288 let n_genes_matched = gene_row.len();
289 let mut universe: Vec<usize> = gene_row.values().copied().collect();
292 universe.sort_unstable();
293 universe.dedup();
294
295 let mut term_rows: FxHashMap<Box<str>, Vec<usize>> = FxHashMap::default();
296 for (term, genes) in &self.term_genes {
297 let mut rows: Vec<usize> = genes
298 .iter()
299 .filter_map(|g| gene_row.get(g.as_ref()).copied())
300 .collect();
301 rows.sort_unstable();
302 rows.dedup();
303 let n = rows.len();
304 if n >= min_members && max_members.is_none_or(|mx| n <= mx) {
305 term_rows.insert(term.clone(), rows);
306 }
307 }
308
309 Reconciled {
310 n_terms_kept: term_rows.len(),
311 n_terms_total: self.term_genes.len(),
312 n_genes_total: self.gene_aliases.len(),
313 n_genes_matched,
314 names: self.names.clone(),
315 universe,
316 term_rows,
317 }
318 }
319}
320
321pub const COVERAGE_WARN_FRAC: f32 = 0.5;
324
325impl Reconciled {
326 #[must_use]
328 pub fn match_frac(&self) -> f32 {
329 if self.n_genes_total == 0 {
330 0.0
331 } else {
332 self.n_genes_matched as f32 / self.n_genes_total as f32
333 }
334 }
335
336 #[must_use]
338 pub fn n_memberships(&self) -> usize {
339 self.term_rows.values().map(Vec::len).sum()
340 }
341
342 pub fn log_coverage(&self) {
345 let frac = self.match_frac();
346 let msg = format!(
347 "gene-set coverage: {}/{} genes matched ({:.1}%); {}/{} terms kept (≥min members); {} memberships",
348 self.n_genes_matched,
349 self.n_genes_total,
350 100.0 * frac,
351 self.n_terms_kept,
352 self.n_terms_total,
353 self.n_memberships(),
354 );
355 if frac < COVERAGE_WARN_FRAC {
356 log::warn!("{msg}");
357 } else {
358 log::info!("{msg}");
359 }
360 }
361
362 pub fn ensure_coverage(&self, min_frac: f32, min_terms: usize) -> Result<()> {
364 let frac = self.match_frac();
365 anyhow::ensure!(
366 frac >= min_frac && self.n_terms_kept >= min_terms,
367 "insufficient gene→term coverage: {:.1}% of genes matched (need ≥{:.0}%), \
368 {} terms kept (need ≥{}). Check that gene-set ids (HGNC/ENSG) match the \
369 expression dictionary's gene names.",
370 100.0 * frac,
371 100.0 * min_frac,
372 self.n_terms_kept,
373 min_terms,
374 );
375 Ok(())
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::io::Write;
383
384 #[test]
385 fn gaf_rows_on_rnas_and_complexes_are_skipped() {
386 let row = |sym: &str, go: &str, ty: &str| {
387 let mut c = vec![""; 17];
388 c[0] = "UniProtKB";
389 c[1] = "P1";
390 c[2] = sym;
391 c[3] = "involved_in";
392 c[4] = go;
393 c[5] = "PMID:1";
394 c[6] = "IDA";
395 c[8] = "P";
396 c[10] = sym;
397 c[11] = ty;
398 c[12] = "taxon:9606";
399 c[13] = "20200101";
400 c[14] = "UniProt";
401 format!("{}\n", c.join("\t"))
402 };
403 let text = format!(
404 "!gaf-version: 2.2\n{}{}{}{}",
405 row("TP53", "GO:1", "protein"),
406 row("hsa-miR-21-5p", "GO:1", "miRNA"),
407 row("abeta-42-oligomer_human", "GO:1", "protein_complex"),
408 row("BAX", "GO:2", "gene")
409 );
410 let f = tmp(&text, ".gaf");
411 let gs = read_gaf(f.path().to_str().unwrap(), &GafOpts::default())
412 .unwrap()
413 .into_gene_sets(None);
414 let mut genes: Vec<&str> = gs.gene_aliases.keys().map(|k| k.as_ref()).collect();
415 genes.sort();
416 assert_eq!(genes, vec!["BAX", "TP53"]);
417 }
418
419 #[test]
420 fn membership_pairs_skip_headers_comments_and_short_rows_and_keep_labels_verbatim() {
421 let f = tmp(
422 "gene\tcelltype\n# note\nCD3E\tT cell\nMS4A1,B cell\nLONELY\n\nSymbol\tx\nCD14\t Monocyte \n",
423 ".tsv",
424 );
425 let pairs = read_membership_pairs(f.path().to_str().unwrap()).unwrap();
426 assert_eq!(
427 pairs,
428 vec![
429 (Box::from("CD3E"), Box::from("T cell")),
430 (Box::from("MS4A1"), Box::from("B cell")),
431 (Box::from("CD14"), Box::from("Monocyte")),
432 ]
433 );
434 }
435
436 fn tmp(contents: &str, suffix: &str) -> tempfile::NamedTempFile {
437 let mut f = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
438 f.write_all(contents.as_bytes()).unwrap();
439 f.flush().unwrap();
440 f
441 }
442
443 #[test]
444 fn gmt_round_trip() {
445 let f = tmp(
446 "# comment\n\
447 SET_A\tset A desc\tCD3D\tcd8a\tGZMK\n\
448 SET_B\tset B desc\tMS4A1\tCD79A\n",
449 ".gmt",
450 );
451 let gs = read_gmt(f.path().to_str().unwrap()).unwrap();
452 assert_eq!(gs.n_terms(), 2);
453 assert_eq!(gs.names.get("SET_A").map(|n| &**n), Some("set A desc"));
454 assert!(gs.term_genes["SET_A"].contains("CD8A"));
456 assert_eq!(gs.term_genes["SET_A"].len(), 3);
457 assert!(gs.gene_aliases.contains_key("CD3D"));
458 }
459
460 fn write_go_obo() -> tempfile::NamedTempFile {
462 tmp(
463 "format-version: 1.2\n\n\
464 [Term]\nid: GO:0000002\nname: parent process\n\n\
465 [Term]\nid: GO:0000001\nname: leaf process\nis_a: GO:0000002 ! parent process\n",
466 ".obo",
467 )
468 }
469
470 #[test]
471 fn gaf_parse_filters_and_propagates() {
472 let rows = "\
475UniProtKB\tP11111\tFOO\t\tGO:0000001\tPMID:1\tIDA\t\tP\tFoo protein\tFOO_ALT|ENSG00000011111\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n\
476UniProtKB\tP22222\tBAR\tNOT|enables\tGO:0000001\tPMID:2\tIDA\t\tP\tBar protein\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n\
477UniProtKB\tP33333\tBAZ\t\tGO:0000001\tPMID:3\tIEA\t\tP\tBaz protein\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n";
478 let gaf = tmp(rows, ".gaf");
479 let raw = read_gaf(gaf.path().to_str().unwrap(), &GafOpts { no_iea: true }).unwrap();
480
481 let onto = Ontology::load_obo(write_go_obo().path().to_str().unwrap()).unwrap();
482 let gs = raw.into_gene_sets(Some(&onto));
483
484 assert_eq!(gs.n_genes(), 1);
486 assert!(gs.term_genes["GO:0000001"].contains("FOO"));
488 assert!(gs.term_genes["GO:0000002"].contains("FOO"));
489 assert_eq!(
491 gs.names.get("GO:0000002").map(|n| &**n),
492 Some("parent process")
493 );
494 let al = &gs.gene_aliases["FOO"];
496 assert!(al.contains("P11111"));
497 assert!(al.contains("ENSG00000011111"));
498 }
499
500 #[test]
501 fn reconcile_matches_aliases_and_filters() {
502 let f = tmp("SET_A\tdesc\tCD8A\tMS4A1\tGHOSTGENE\n", ".gmt");
503 let gs = read_gmt(f.path().to_str().unwrap()).unwrap();
504 let dict: Vec<Box<str>> = ["ENSG00000153563_CD8A", "MS4A1"]
506 .iter()
507 .map(|s| Box::from(*s))
508 .collect();
509 let idx = GeneIndex::build(&dict);
510
511 let rec = gs.reconcile(&idx, 1, None);
512 assert_eq!(rec.n_genes_total, 3);
514 assert_eq!(rec.n_genes_matched, 2);
515 assert_eq!(rec.universe, vec![0, 1]); assert_eq!(rec.term_rows["SET_A"].len(), 2);
517 assert!(rec.ensure_coverage(0.5, 1).is_ok());
518
519 let rec2 = gs.reconcile(&idx, 3, None);
521 assert!(rec2.term_rows.is_empty());
522 assert!(rec2.ensure_coverage(0.5, 1).is_err());
523
524 let rec3 = gs.reconcile(&idx, 1, Some(1));
526 assert!(rec3.term_rows.is_empty());
527 }
528
529 #[test]
530 fn gaf_without_ontology_keeps_direct() {
531 let rows = "\
532UniProtKB\tP11111\tFOO\t\tGO:0000001\tPMID:1\tIDA\t\tP\tFoo\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n";
533 let gaf = tmp(rows, ".gaf");
534 let raw = read_gaf(gaf.path().to_str().unwrap(), &GafOpts::default()).unwrap();
535 let gs = raw.into_gene_sets(None);
536 assert_eq!(gs.n_terms(), 1); assert!(gs.term_genes["GO:0000001"].contains("FOO"));
538 }
539}