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