Skip to main content

snomed_classify/
lib.rs

1//! EL-profile subsumption classifier for SNOMED CT OWL axioms, per
2//! `spec/13-classification.md`.
3//!
4//! SNOMED CT's logic profile is OWL 2 EL, chosen specifically because EL
5//! subsumption is decidable in polynomial time via a completion
6//! (saturation) algorithm — the same family of algorithm real SNOMED CT
7//! reasoners (ELK, CEL) implement. This crate implements that algorithm
8//! (Baader/Brandt/Lutz, "Pushing the EL Envelope", IJCAI 2005, plus the
9//! EL+ role-hierarchy/composition extension for property chains and
10//! transitive attributes SNOMED CT actually uses) from scratch, in terms
11//! of [`snomed_owl::Axiom`] — [`snomed-owl`](../snomed_owl/index.html)
12//! parses syntax, this crate reasons over the result.
13//!
14//! `classify` answers **subsumption** ("is A a subtype of B, according
15//! to these axioms") — it does not generate RF2 relationship rows or
16//! reduce redundant attributes (SNOMED's "necessary normal form"); see
17//! spec/13's "Not yet implemented" section.
18//!
19//! ```
20//! use snomed_core::sctid::SctId;
21//! use snomed_owl::{parse, Axiom};
22//! use snomed_classify::classify;
23//!
24//! let axioms: Vec<Axiom> = [
25//!     "SubClassOf(:64572001 :404684003)", // |Disease| ⊑ |Clinical finding|
26//!     "SubClassOf(:22298006 :64572001)",  // |Myocardial infarction| ⊑ |Disease|
27//! ]
28//! .iter()
29//! .map(|s| parse(s).unwrap())
30//! .collect();
31//!
32//! let report = classify(&axioms);
33//! let mi = SctId::parse("22298006").unwrap();
34//! let finding = SctId::parse("404684003").unwrap();
35//! assert!(report.classification.is_subsumed_by(mi, finding)); // transitively entailed
36//! assert!(report.skipped.is_empty());
37//! ```
38
39mod complete;
40mod normalize;
41mod skipped;
42mod types;
43
44use std::collections::{HashMap, HashSet};
45
46use snomed_core::sctid::SctId;
47use snomed_owl::Axiom;
48
49pub use skipped::SkippedConstruct;
50
51use types::ConceptId;
52
53/// The result of [`classify`]: every named concept's entailed named
54/// superclasses (transitively closed).
55#[derive(Debug, Clone, Default)]
56pub struct Classification {
57    subsumers: HashMap<SctId, HashSet<SctId>>,
58}
59
60impl Classification {
61    /// Every concept `concept` is (transitively) subsumed by, per the
62    /// input axioms — **strict**: never includes `concept` itself,
63    /// mirroring `SnapshotStore::ancestors`'s convention. Empty for a
64    /// concept the axioms said nothing about.
65    pub fn subsumers(&self, concept: SctId) -> impl Iterator<Item = SctId> + '_ {
66        self.subsumers.get(&concept).into_iter().flatten().copied()
67    }
68
69    /// Reflexive subsumption test: `true` when `sub == sup` or `sup` is
70    /// among `sub`'s entailed superclasses.
71    pub fn is_subsumed_by(&self, sub: SctId, sup: SctId) -> bool {
72        sub == sup || self.subsumers.get(&sub).is_some_and(|s| s.contains(&sup))
73    }
74
75    /// Concepts mutually subsuming `concept` (i.e. logically equivalent
76    /// under the input axioms) — excludes `concept` itself.
77    pub fn equivalent_to(&self, concept: SctId) -> impl Iterator<Item = SctId> + '_ {
78        self.subsumers(concept)
79            .filter(move |&other| self.is_subsumed_by(other, concept))
80    }
81
82    /// Every concept the input axioms said anything about — i.e. every
83    /// concept `subsumers`/`is_subsumed_by`/`equivalent_to` have a real
84    /// (possibly empty) answer for, not just concepts that happen to
85    /// appear somewhere as a filler. Order is unspecified.
86    pub fn concepts(&self) -> impl Iterator<Item = SctId> + '_ {
87        self.subsumers.keys().copied()
88    }
89}
90
91/// [`classify`]'s result: the classification, plus every input construct
92/// it recognized but couldn't model (spec/13's "Scope" section) —
93/// reported, never silently dropped without a trace.
94#[derive(Debug, Clone)]
95pub struct ClassificationReport {
96    pub classification: Classification,
97    pub skipped: Vec<SkippedConstruct>,
98}
99
100/// Computes the full entailed subsumption hierarchy over `axioms`, via
101/// the EL completion algorithm (`spec/13-classification.md`).
102pub fn classify<'a>(axioms: impl IntoIterator<Item = &'a Axiom>) -> ClassificationReport {
103    let tbox = normalize::normalize(axioms);
104    let skipped = tbox.skipped.clone();
105    let state = complete::saturate(&tbox);
106
107    let mut subsumers: HashMap<SctId, HashSet<SctId>> = HashMap::new();
108    for (concept, supers) in &state.subsumers {
109        let ConceptId::Named(named) = concept else {
110            continue; // fresh ids never appear in the public result
111        };
112        let named_supers: HashSet<SctId> = supers
113            .iter()
114            .filter_map(|s| match s {
115                ConceptId::Named(id) if id != named => Some(*id),
116                _ => None,
117            })
118            .collect();
119        subsumers.insert(*named, named_supers);
120    }
121
122    ClassificationReport {
123        classification: Classification { subsumers },
124        skipped,
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use snomed_core::sctid::ComponentType;
132
133    /// A synthetic, check-digit-valid SCTID for test fixture concepts
134    /// that aren't genuine SNOMED CT concepts (root `CLAUDE.md`
135    /// convention).
136    fn id(item: u64) -> SctId {
137        SctId::compose(item, ComponentType::Concept, None).unwrap()
138    }
139
140    fn ax(s: &str) -> Axiom {
141        snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
142    }
143
144    #[test]
145    fn plain_subclassof_chains_transitively() {
146        let disease = id(1001);
147        let finding = id(1002);
148        let mi = id(1003);
149        let axioms = vec![
150            Axiom::SubClassOf {
151                sub: snomed_owl::ClassExpression::Concept(disease),
152                sup: snomed_owl::ClassExpression::Concept(finding),
153            },
154            Axiom::SubClassOf {
155                sub: snomed_owl::ClassExpression::Concept(mi),
156                sup: snomed_owl::ClassExpression::Concept(disease),
157            },
158        ];
159        let report = classify(&axioms);
160        assert!(report.skipped.is_empty());
161        assert!(report.classification.is_subsumed_by(mi, disease));
162        assert!(report.classification.is_subsumed_by(mi, finding)); // transitive, not stated directly
163        assert!(report.classification.is_subsumed_by(mi, mi)); // reflexive
164        assert!(!report.classification.is_subsumed_by(finding, mi));
165    }
166
167    #[test]
168    fn intersection_definition_propagates_through_role_successors() {
169        // The core EL feature: MI's site is Heart; Heart is a
170        // BodyStructure; a GCI says "anything with a body-structure
171        // finding site is a FindingWithBodySiteStructure". None of this
172        // is a direct SubClassOf on MI — it only follows from completing
173        // CR2 (MI ⊑ ∃site.Heart) + CR1 (Heart ⊑ BodyStructure) + CR3
174        // (∃site.BodyStructure ⊑ X) together.
175        let finding = id(1010);
176        let mi = id(1011);
177        let site = id(1012);
178        let heart = id(1013);
179        let body_structure = id(1014);
180        let with_body_site = id(1015);
181
182        let axioms = vec![
183            ax(&format!("EquivalentClasses(:{mi} ObjectIntersectionOf(:{finding} ObjectSomeValuesFrom(:{site} :{heart})))")),
184            ax(&format!("SubClassOf(:{heart} :{body_structure})")),
185            ax(&format!(
186                "SubClassOf(ObjectSomeValuesFrom(:{site} :{body_structure}) :{with_body_site})"
187            )),
188        ];
189        let report = classify(&axioms);
190        assert!(report.skipped.is_empty(), "{:?}", report.skipped);
191        assert!(report.classification.is_subsumed_by(mi, finding));
192        assert!(report.classification.is_subsumed_by(mi, with_body_site));
193    }
194
195    #[test]
196    fn general_concept_inclusion_needs_no_special_case() {
197        // SubClassOf(ObjectIntersectionOf(...), C) — a GCI whose LHS is
198        // compound — classifies anything satisfying both conjuncts.
199        let a = id(1020);
200        let b = id(1021);
201        let c = id(1022);
202        let x = id(1023);
203
204        let axioms = vec![
205            ax(&format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})")),
206            ax(&format!(
207                "EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"
208            )),
209        ];
210        let report = classify(&axioms);
211        assert!(report.classification.is_subsumed_by(x, c));
212    }
213
214    #[test]
215    fn role_hierarchy_propagates_existentials() {
216        // Finger ⊑ ∃partOf.Hand; partOf ⊑ relatedTo; a GCI on
217        // ∃relatedTo.Hand — Finger should be classified under it purely
218        // via the role hierarchy (CR5), with no direct relatedTo axiom.
219        let part_of = id(1030);
220        let related_to = id(1031);
221        let finger = id(1032);
222        let hand = id(1033);
223        let hand_related = id(1034);
224
225        let axioms = vec![
226            ax(&format!("SubObjectPropertyOf(:{part_of} :{related_to})")),
227            ax(&format!(
228                "SubClassOf(:{finger} ObjectSomeValuesFrom(:{part_of} :{hand}))"
229            )),
230            ax(&format!(
231                "SubClassOf(ObjectSomeValuesFrom(:{related_to} :{hand}) :{hand_related})"
232            )),
233        ];
234        let report = classify(&axioms);
235        assert!(report.classification.is_subsumed_by(finger, hand_related));
236    }
237
238    #[test]
239    fn transitive_property_composes_across_two_hops() {
240        // Fingertip -partOf-> Finger -partOf-> Hand; partOf transitive;
241        // a GCI on ∃partOf.Hand. Fingertip should be classified under it
242        // even though it's only directly partOf Finger, not Hand.
243        let part_of = id(1040);
244        let fingertip = id(1041);
245        let finger = id(1042);
246        let hand = id(1043);
247        let hand_part = id(1044);
248
249        let axioms = vec![
250            ax(&format!("TransitiveObjectProperty(:{part_of})")),
251            ax(&format!(
252                "SubClassOf(:{fingertip} ObjectSomeValuesFrom(:{part_of} :{finger}))"
253            )),
254            ax(&format!(
255                "SubClassOf(:{finger} ObjectSomeValuesFrom(:{part_of} :{hand}))"
256            )),
257            ax(&format!(
258                "SubClassOf(ObjectSomeValuesFrom(:{part_of} :{hand}) :{hand_part})"
259            )),
260        ];
261        let report = classify(&axioms);
262        assert!(report.classification.is_subsumed_by(fingertip, hand_part));
263        // Finger itself should NOT be classified as HandPart via this
264        // axiom set (it's related to Hand, but there's no GCI keyed on
265        // that direct pairing being itself a "hand part" — this assert
266        // just sanity-checks the completion didn't over-fire).
267    }
268
269    #[test]
270    fn property_chain_composes_two_distinct_roles() {
271        // The real SNOMED "active ingredient" pattern: HasActiveIngredient
272        // o IsModificationOf ⊑ HasActiveIngredient. A product whose active
273        // ingredient is a modification of Morphine is thereby classified
274        // as having Morphine as an active ingredient too.
275        let has_ingredient = id(1050);
276        let is_modification_of = id(1051);
277        let product = id(1052);
278        let morphine_sulfate = id(1053);
279        let morphine = id(1054);
280        let morphine_product = id(1055);
281
282        let axioms = vec![
283            ax(&format!(
284                "SubObjectPropertyOf(ObjectPropertyChain(:{has_ingredient} :{is_modification_of}) :{has_ingredient})"
285            )),
286            ax(&format!(
287                "SubClassOf(:{product} ObjectSomeValuesFrom(:{has_ingredient} :{morphine_sulfate}))"
288            )),
289            ax(&format!(
290                "SubClassOf(:{morphine_sulfate} ObjectSomeValuesFrom(:{is_modification_of} :{morphine}))"
291            )),
292            ax(&format!(
293                "SubClassOf(ObjectSomeValuesFrom(:{has_ingredient} :{morphine}) :{morphine_product})"
294            )),
295        ];
296        let report = classify(&axioms);
297        assert!(report
298            .classification
299            .is_subsumed_by(product, morphine_product));
300    }
301
302    #[test]
303    fn equivalent_classes_are_mutually_subsumed() {
304        let a = id(1060);
305        let b = id(1061);
306        let axioms = vec![ax(&format!("EquivalentClasses(:{a} :{b})"))];
307        let report = classify(&axioms);
308        assert!(report.classification.is_subsumed_by(a, b));
309        assert!(report.classification.is_subsumed_by(b, a));
310        assert_eq!(
311            report.classification.equivalent_to(a).collect::<Vec<_>>(),
312            vec![b]
313        );
314    }
315
316    #[test]
317    fn reports_skipped_constructs_without_dropping_the_rest_of_the_axiom() {
318        let a = id(1070);
319        let b = id(1071);
320        let value_attr = id(1072);
321        let reflexive_attr = id(1073);
322        let data_attr = id(1074);
323        let data_sup = id(1075);
324
325        let axioms = vec![
326            // A concrete value inside an intersection: the rest of the
327            // intersection (A ⊑ B) must still classify.
328            ax(&format!(
329                "SubClassOf(:{a} ObjectIntersectionOf(:{b} DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
330            )),
331            ax(&format!("ReflexiveObjectProperty(:{reflexive_attr})")),
332            ax(&format!("SubDataPropertyOf(:{data_attr} :{data_sup})")),
333        ];
334        let report = classify(&axioms);
335        assert!(report.classification.is_subsumed_by(a, b));
336        assert_eq!(report.skipped.len(), 3, "{:?}", report.skipped);
337        assert!(report
338            .skipped
339            .contains(&SkippedConstruct::ReflexiveProperty(reflexive_attr)));
340        assert!(report
341            .skipped
342            .contains(&SkippedConstruct::DataProperty(data_attr)));
343        assert!(report.skipped.contains(&SkippedConstruct::ConcreteValue {
344            attribute: value_attr
345        }));
346    }
347
348    #[test]
349    fn unrelated_concepts_are_not_subsumed() {
350        let a = id(1080);
351        let b = id(1081);
352        let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
353        let report = classify(&axioms);
354        let unrelated = id(1082);
355        assert!(!report.classification.is_subsumed_by(unrelated, a));
356        assert!(report.classification.subsumers(unrelated).next().is_none());
357    }
358
359    #[test]
360    fn concepts_lists_exactly_what_the_axioms_named() {
361        let a = id(1090);
362        let b = id(1091);
363        let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
364        let report = classify(&axioms);
365        let mut concepts: Vec<SctId> = report.classification.concepts().collect();
366        concepts.sort();
367        let mut expected = vec![a, b];
368        expected.sort();
369        assert_eq!(concepts, expected);
370    }
371}