1use anyhow::{Context, Result};
17use fastobo::ast::{EntityFrame, TermClause};
18use petgraph::graph::{DiGraph, NodeIndex};
19use petgraph::visit::EdgeRef;
20use petgraph::Direction::Outgoing;
21use rustc_hash::{FxHashMap, FxHashSet};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Rel {
26 IsA,
28 PartOf,
30}
31
32pub struct Ontology {
36 graph: DiGraph<Box<str>, Rel>,
37 idx: FxHashMap<Box<str>, NodeIndex>,
38 names: FxHashMap<Box<str>, Box<str>>,
39 defs: FxHashMap<Box<str>, Box<str>>,
41 namespaces: FxHashMap<Box<str>, Box<str>>,
44}
45
46struct ParsedTerm {
48 id: Box<str>,
49 name: Option<Box<str>>,
50 def: Option<Box<str>>,
51 is_a: Vec<Box<str>>,
53 part_of: Vec<Box<str>>,
55}
56
57impl Ontology {
58 pub fn load_obo(path: &str) -> Result<Self> {
61 let doc = fastobo::from_file(path)
62 .with_context(|| format!("failed to parse OBO file: {path}"))?;
63
64 let default_namespace: Option<Box<str>> = doc.header().iter().find_map(|c| match c {
66 fastobo::ast::HeaderClause::DefaultNamespace(ns) => Some(ns.to_string().trim().into()),
67 _ => None,
68 });
69 let mut terms: Vec<ParsedTerm> = Vec::new();
72 let mut namespaces: FxHashMap<Box<str>, Box<str>> = FxHashMap::default();
73 for frame in doc.entities() {
74 let EntityFrame::Term(term) = frame else {
75 continue;
76 };
77 let id: Box<str> = term.id().to_string().trim().into();
80 let mut name: Option<Box<str>> = None;
81 let mut def: Option<Box<str>> = None;
82 let mut namespace: Option<Box<str>> = None;
83 let mut is_a: Vec<Box<str>> = Vec::new();
84 let mut part_of: Vec<Box<str>> = Vec::new();
85 let mut obsolete = false;
86 for line in term.clauses() {
87 match &**line {
88 TermClause::Name(n) => name = Some(n.to_string().trim().into()),
89 TermClause::Def(d) => {
90 let text = d.text().as_str().trim();
91 if !text.is_empty() {
92 def = Some(text.into());
93 }
94 }
95 TermClause::Namespace(ns) => {
96 namespace = Some(ns.to_string().trim().into());
97 }
98 TermClause::IsObsolete(b) => obsolete = obsolete || *b,
99 TermClause::IsA(parent) => is_a.push(parent.to_string().trim().into()),
100 TermClause::Relationship(rel, target)
101 if rel.to_string().trim() == "part_of" =>
102 {
103 part_of.push(target.to_string().trim().into());
104 }
105 _ => {}
106 }
107 }
108 if let Some(ns) = namespace.or_else(|| default_namespace.clone()) {
109 namespaces.insert(id.clone(), ns);
110 }
111 if !obsolete {
112 terms.push(ParsedTerm {
113 id,
114 name,
115 def,
116 is_a,
117 part_of,
118 });
119 }
120 }
121
122 let mut graph: DiGraph<Box<str>, Rel> = DiGraph::new();
124 let mut idx: FxHashMap<Box<str>, NodeIndex> = FxHashMap::default();
125 let mut names: FxHashMap<Box<str>, Box<str>> = FxHashMap::default();
126 let mut defs: FxHashMap<Box<str>, Box<str>> = FxHashMap::default();
127 for term in &terms {
128 let node = graph.add_node(term.id.clone());
129 idx.insert(term.id.clone(), node);
130 if let Some(n) = &term.name {
131 names.insert(term.id.clone(), n.clone());
132 }
133 if let Some(d) = &term.def {
134 defs.insert(term.id.clone(), d.clone());
135 }
136 }
137 for term in &terms {
138 let child = idx[&term.id];
139 for (parents, rel) in [(&term.is_a, Rel::IsA), (&term.part_of, Rel::PartOf)] {
140 for p in parents {
141 if let Some(&parent) = idx.get(p) {
142 graph.add_edge(child, parent, rel);
143 }
144 }
145 }
146 }
147
148 Ok(Self {
149 graph,
150 idx,
151 names,
152 defs,
153 namespaces,
154 })
155 }
156
157 pub fn ids(&self) -> impl Iterator<Item = &str> + '_ {
159 self.idx.keys().map(|k| &**k)
160 }
161
162 #[must_use]
164 pub fn def(&self, id: &str) -> Option<&str> {
165 self.defs.get(id).map(|d| &**d)
166 }
167
168 pub fn edges(&self) -> impl Iterator<Item = (&str, &str, Rel)> + '_ {
172 self.graph.edge_references().map(|e| {
173 (
174 &*self.graph[e.source()],
175 &*self.graph[e.target()],
176 *e.weight(),
177 )
178 })
179 }
180
181 #[must_use]
183 pub fn len(&self) -> usize {
184 self.graph.node_count()
185 }
186
187 #[must_use]
188 pub fn is_empty(&self) -> bool {
189 self.graph.node_count() == 0
190 }
191
192 #[must_use]
193 pub fn contains(&self, id: &str) -> bool {
194 self.idx.contains_key(id)
195 }
196
197 #[must_use]
199 pub fn name(&self, id: &str) -> Option<&str> {
200 self.names.get(id).map(|n| &**n)
201 }
202
203 #[must_use]
207 pub fn namespace(&self, id: &str) -> Option<&str> {
208 self.namespaces.get(id).map(|n| &**n)
209 }
210
211 #[must_use]
213 pub fn ancestors_or_self(&self, id: &str) -> FxHashSet<Box<str>> {
214 self.ancestors_impl(id, false)
215 }
216
217 #[must_use]
220 pub fn ancestors_or_self_with_part_of(&self, id: &str) -> FxHashSet<Box<str>> {
221 self.ancestors_impl(id, true)
222 }
223
224 fn ancestors_impl(&self, id: &str, with_part_of: bool) -> FxHashSet<Box<str>> {
228 let Some(&start) = self.idx.get(id) else {
229 return FxHashSet::default();
230 };
231 let mut seen: FxHashSet<NodeIndex> = FxHashSet::default();
232 seen.insert(start);
233 let mut stack = vec![start];
234 while let Some(n) = stack.pop() {
235 for edge in self.graph.edges_directed(n, Outgoing) {
236 let follow = matches!(edge.weight(), Rel::IsA)
237 || (with_part_of && matches!(edge.weight(), Rel::PartOf));
238 if follow && seen.insert(edge.target()) {
239 stack.push(edge.target());
240 }
241 }
242 }
243 seen.iter().map(|&n| self.graph[n].clone()).collect()
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use std::io::Write;
251
252 fn write_obo() -> tempfile::NamedTempFile {
257 let mut f = tempfile::NamedTempFile::new().unwrap();
258 writeln!(
259 f,
260 "format-version: 1.2\ndefault-namespace: cell_type\n\n\
261 [Term]\nid: CL:0000000\nname: cell\nnamespace: cell\n\n\
262 [Term]\nid: CL:0000542\nname: lymphocyte\nis_a: CL:0000000 ! cell\n\n\
263 [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\
264 [Term]\nid: CL:0000624\nname: CD4 T\nis_a: CL:0000084 ! T cell\n\n\
265 [Term]\nid: CL:0000625\nname: CD8 T\nis_a: CL:0000084 ! T cell\nrelationship: part_of CL:1000000 ! compartment\n\n\
266 [Term]\nid: CL:1000000\nname: immune compartment\n\n\
267 [Term]\nid: CL:0000236\nname: B cell\nis_a: CL:0000542 ! lymphocyte\n\n\
268 [Term]\nid: CL:9999999\nname: dead\nis_obsolete: true\n"
269 )
270 .unwrap();
271 f.flush().unwrap();
272 f
273 }
274
275 #[test]
276 fn parses_and_resolves_ancestry() {
277 let f = write_obo();
278 let onto = Ontology::load_obo(f.path().to_str().unwrap()).unwrap();
279
280 assert_eq!(onto.len(), 7);
282 assert!(!onto.contains("CL:9999999"));
283 assert_eq!(onto.name("CL:0000084"), Some("T cell"));
284 assert_eq!(onto.namespace("CL:0000000"), Some("cell"));
285 assert_eq!(onto.namespace("CL:0000084"), Some("cell_type"));
286 assert_eq!(onto.namespace("CL:9999999"), Some("cell_type"), "obsolete");
287
288 let anc = onto.ancestors_or_self("CL:0000624");
291 for a in ["CL:0000624", "CL:0000084", "CL:0000542", "CL:0000000"] {
292 assert!(anc.contains(a), "missing ancestor {a}");
293 }
294 assert!(!anc.contains("CL:0000236"));
295 }
296
297 #[test]
298 fn definitions_are_kept_unescaped_and_the_hierarchy_is_exposed_as_edges() {
299 let f = write_obo();
300 let onto = Ontology::load_obo(f.path().to_str().unwrap()).unwrap();
301 assert_eq!(
302 onto.def("CL:0000084"),
303 Some("A lymphocyte with a \"TCR\", made in the thymus.")
304 );
305 assert_eq!(onto.def("CL:0000000"), None, "no def: line");
306 assert_eq!(onto.def("CL:9999999"), None, "obsolete");
307 let mut edges: Vec<(String, String, Rel)> = onto
308 .edges()
309 .map(|(c, p, r)| (c.to_string(), p.to_string(), r))
310 .collect();
311 edges.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
312 assert_eq!(edges.len(), 6, "5 is_a + 1 part_of among live terms");
313 assert!(edges.contains(&("CL:0000625".into(), "CL:1000000".into(), Rel::PartOf)));
314 assert!(edges.contains(&("CL:0000084".into(), "CL:0000542".into(), Rel::IsA)));
315 assert!(edges
316 .iter()
317 .all(|(c, p, _)| onto.contains(c) && onto.contains(p)));
318 }
319
320 #[test]
321 fn part_of_only_followed_on_demand() {
322 let f = write_obo();
323 let onto = Ontology::load_obo(f.path().to_str().unwrap()).unwrap();
324
325 let isa = onto.ancestors_or_self("CL:0000625");
327 assert!(isa.contains("CL:0000084"), "is_a ancestor missing");
328 assert!(
329 !isa.contains("CL:1000000"),
330 "part_of must not leak into is_a-only walk"
331 );
332
333 let full = onto.ancestors_or_self_with_part_of("CL:0000625");
335 assert!(full.contains("CL:0000084"), "is_a ancestor missing");
336 assert!(full.contains("CL:1000000"), "part_of ancestor missing");
337 }
338}