use anyhow::{Context, Result};
use fastobo::ast::{EntityFrame, TermClause};
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::EdgeRef;
use petgraph::Direction::Outgoing;
use rustc_hash::{FxHashMap, FxHashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rel {
IsA,
PartOf,
}
pub struct Ontology {
graph: DiGraph<Box<str>, Rel>,
idx: FxHashMap<Box<str>, NodeIndex>,
names: FxHashMap<Box<str>, Box<str>>,
defs: FxHashMap<Box<str>, Box<str>>,
}
struct ParsedTerm {
id: Box<str>,
name: Option<Box<str>>,
def: Option<Box<str>>,
is_a: Vec<Box<str>>,
part_of: Vec<Box<str>>,
}
impl Ontology {
pub fn load_obo(path: &str) -> Result<Self> {
let doc = fastobo::from_file(path)
.with_context(|| format!("failed to parse OBO file: {path}"))?;
let mut terms: Vec<ParsedTerm> = Vec::new();
for frame in doc.entities() {
let EntityFrame::Term(term) = frame else {
continue;
};
let id: Box<str> = term.id().to_string().trim().into();
let mut name: Option<Box<str>> = None;
let mut def: Option<Box<str>> = None;
let mut is_a: Vec<Box<str>> = Vec::new();
let mut part_of: Vec<Box<str>> = Vec::new();
let mut obsolete = false;
for line in term.clauses() {
match &**line {
TermClause::Name(n) => name = Some(n.to_string().trim().into()),
TermClause::Def(d) => {
let text = d.text().as_str().trim();
if !text.is_empty() {
def = Some(text.into());
}
}
TermClause::IsObsolete(b) => obsolete = obsolete || *b,
TermClause::IsA(parent) => is_a.push(parent.to_string().trim().into()),
TermClause::Relationship(rel, target) => {
if rel.to_string().trim() == "part_of" {
part_of.push(target.to_string().trim().into());
}
}
_ => {}
}
}
if !obsolete {
terms.push(ParsedTerm {
id,
name,
def,
is_a,
part_of,
});
}
}
let mut graph: DiGraph<Box<str>, Rel> = DiGraph::new();
let mut idx: FxHashMap<Box<str>, NodeIndex> = FxHashMap::default();
let mut names: FxHashMap<Box<str>, Box<str>> = FxHashMap::default();
let mut defs: FxHashMap<Box<str>, Box<str>> = FxHashMap::default();
for term in &terms {
let node = graph.add_node(term.id.clone());
idx.insert(term.id.clone(), node);
if let Some(n) = &term.name {
names.insert(term.id.clone(), n.clone());
}
if let Some(d) = &term.def {
defs.insert(term.id.clone(), d.clone());
}
}
for term in &terms {
let child = idx[&term.id];
for (parents, rel) in [(&term.is_a, Rel::IsA), (&term.part_of, Rel::PartOf)] {
for p in parents {
if let Some(&parent) = idx.get(p) {
graph.add_edge(child, parent, rel);
}
}
}
}
Ok(Self {
graph,
idx,
names,
defs,
})
}
pub fn ids(&self) -> impl Iterator<Item = &str> + '_ {
self.idx.keys().map(|k| &**k)
}
#[must_use]
pub fn def(&self, id: &str) -> Option<&str> {
self.defs.get(id).map(|d| &**d)
}
pub fn edges(&self) -> impl Iterator<Item = (&str, &str, Rel)> + '_ {
self.graph.edge_references().map(|e| {
(
&*self.graph[e.source()],
&*self.graph[e.target()],
*e.weight(),
)
})
}
#[must_use]
pub fn len(&self) -> usize {
self.graph.node_count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.graph.node_count() == 0
}
#[must_use]
pub fn contains(&self, id: &str) -> bool {
self.idx.contains_key(id)
}
#[must_use]
pub fn name(&self, id: &str) -> Option<&str> {
self.names.get(id).map(|n| &**n)
}
#[must_use]
pub fn ancestors_or_self(&self, id: &str) -> FxHashSet<Box<str>> {
self.ancestors_impl(id, false)
}
#[must_use]
pub fn ancestors_or_self_with_part_of(&self, id: &str) -> FxHashSet<Box<str>> {
self.ancestors_impl(id, true)
}
fn ancestors_impl(&self, id: &str, with_part_of: bool) -> FxHashSet<Box<str>> {
let Some(&start) = self.idx.get(id) else {
return FxHashSet::default();
};
let mut seen: FxHashSet<NodeIndex> = FxHashSet::default();
seen.insert(start);
let mut stack = vec![start];
while let Some(n) = stack.pop() {
for edge in self.graph.edges_directed(n, Outgoing) {
let follow = matches!(edge.weight(), Rel::IsA)
|| (with_part_of && matches!(edge.weight(), Rel::PartOf));
if follow && seen.insert(edge.target()) {
stack.push(edge.target());
}
}
}
seen.iter().map(|&n| self.graph[n].clone()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_obo() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(
f,
"format-version: 1.2\n\n\
[Term]\nid: CL:0000000\nname: cell\n\n\
[Term]\nid: CL:0000542\nname: lymphocyte\nis_a: CL:0000000 ! cell\n\n\
[Term]\nid: CL:0000084\nname: T cell\ndef: \"A lymphocyte with a \\\"TCR\\\", made in the thymus.\" [GOC:add]\nis_a: CL:0000542 {{is_inferred=\"true\"}} ! lymphocyte\n\n\
[Term]\nid: CL:0000624\nname: CD4 T\nis_a: CL:0000084 ! T cell\n\n\
[Term]\nid: CL:0000625\nname: CD8 T\nis_a: CL:0000084 ! T cell\nrelationship: part_of CL:1000000 ! compartment\n\n\
[Term]\nid: CL:1000000\nname: immune compartment\n\n\
[Term]\nid: CL:0000236\nname: B cell\nis_a: CL:0000542 ! lymphocyte\n\n\
[Term]\nid: CL:9999999\nname: dead\nis_obsolete: true\n"
)
.unwrap();
f.flush().unwrap();
f
}
#[test]
fn parses_and_resolves_ancestry() {
let f = write_obo();
let onto = Ontology::load_obo(f.path().to_str().unwrap()).unwrap();
assert_eq!(onto.len(), 7);
assert!(!onto.contains("CL:9999999"));
assert_eq!(onto.name("CL:0000084"), Some("T cell"));
let anc = onto.ancestors_or_self("CL:0000624");
for a in ["CL:0000624", "CL:0000084", "CL:0000542", "CL:0000000"] {
assert!(anc.contains(a), "missing ancestor {a}");
}
assert!(!anc.contains("CL:0000236"));
}
#[test]
fn definitions_are_kept_unescaped_and_the_hierarchy_is_exposed_as_edges() {
let f = write_obo();
let onto = Ontology::load_obo(f.path().to_str().unwrap()).unwrap();
assert_eq!(
onto.def("CL:0000084"),
Some("A lymphocyte with a \"TCR\", made in the thymus.")
);
assert_eq!(onto.def("CL:0000000"), None, "no def: line");
assert_eq!(onto.def("CL:9999999"), None, "obsolete");
let mut edges: Vec<(String, String, Rel)> = onto
.edges()
.map(|(c, p, r)| (c.to_string(), p.to_string(), r))
.collect();
edges.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
assert_eq!(edges.len(), 6, "5 is_a + 1 part_of among live terms");
assert!(edges.contains(&("CL:0000625".into(), "CL:1000000".into(), Rel::PartOf)));
assert!(edges.contains(&("CL:0000084".into(), "CL:0000542".into(), Rel::IsA)));
assert!(edges
.iter()
.all(|(c, p, _)| onto.contains(c) && onto.contains(p)));
}
#[test]
fn part_of_only_followed_on_demand() {
let f = write_obo();
let onto = Ontology::load_obo(f.path().to_str().unwrap()).unwrap();
let isa = onto.ancestors_or_self("CL:0000625");
assert!(isa.contains("CL:0000084"), "is_a ancestor missing");
assert!(
!isa.contains("CL:1000000"),
"part_of must not leak into is_a-only walk"
);
let full = onto.ancestors_or_self_with_part_of("CL:0000625");
assert!(full.contains("CL:0000084"), "is_a ancestor missing");
assert!(full.contains("CL:1000000"), "part_of ancestor missing");
}
}