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)]
101#[non_exhaustive]
102pub struct ClassificationReport {
103    pub classification: Classification,
104    pub skipped: Vec<SkippedConstruct>,
105}
106
107/// Computes the full entailed subsumption hierarchy over `axioms`, via
108/// the EL completion algorithm (`spec/13-classification.md`).
109pub fn classify<'a>(axioms: impl IntoIterator<Item = &'a Axiom>) -> ClassificationReport {
110    let tbox = normalize::normalize(axioms);
111    let skipped = tbox.skipped.clone();
112    let state = complete::saturate(&tbox);
113
114    let mut subsumers: HashMap<SctId, HashSet<SctId>> = HashMap::new();
115    for (concept, supers) in &state.subsumers {
116        let ConceptId::Named(named) = concept else {
117            continue; // fresh ids never appear in the public result
118        };
119        let named_supers: HashSet<SctId> = supers
120            .iter()
121            .filter_map(|s| match s {
122                ConceptId::Named(id) if id != named => Some(*id),
123                _ => None,
124            })
125            .collect();
126        subsumers.insert(*named, named_supers);
127    }
128
129    ClassificationReport {
130        classification: Classification { subsumers },
131        skipped,
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use snomed_core::sctid::ComponentType;
139
140    /// A synthetic, check-digit-valid SCTID for test fixture concepts
141    /// that aren't genuine SNOMED CT concepts (root `CLAUDE.md`
142    /// convention).
143    fn id(item: u64) -> SctId {
144        SctId::compose(item, ComponentType::Concept, None).unwrap()
145    }
146
147    fn ax(s: &str) -> Axiom {
148        snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
149    }
150
151    #[test]
152    fn plain_subclassof_chains_transitively() {
153        let disease = id(1001);
154        let finding = id(1002);
155        let mi = id(1003);
156        let axioms = vec![
157            Axiom::SubClassOf {
158                sub: snomed_owl::ClassExpression::Concept(disease),
159                sup: snomed_owl::ClassExpression::Concept(finding),
160            },
161            Axiom::SubClassOf {
162                sub: snomed_owl::ClassExpression::Concept(mi),
163                sup: snomed_owl::ClassExpression::Concept(disease),
164            },
165        ];
166        let report = classify(&axioms);
167        assert!(report.skipped.is_empty());
168        assert!(report.classification.is_subsumed_by(mi, disease));
169        assert!(report.classification.is_subsumed_by(mi, finding)); // transitive, not stated directly
170        assert!(report.classification.is_subsumed_by(mi, mi)); // reflexive
171        assert!(!report.classification.is_subsumed_by(finding, mi));
172    }
173
174    #[test]
175    fn intersection_definition_propagates_through_role_successors() {
176        // The core EL feature: MI's site is Heart; Heart is a
177        // BodyStructure; a GCI says "anything with a body-structure
178        // finding site is a FindingWithBodySiteStructure". None of this
179        // is a direct SubClassOf on MI — it only follows from completing
180        // CR2 (MI ⊑ ∃site.Heart) + CR1 (Heart ⊑ BodyStructure) + CR3
181        // (∃site.BodyStructure ⊑ X) together.
182        let finding = id(1010);
183        let mi = id(1011);
184        let site = id(1012);
185        let heart = id(1013);
186        let body_structure = id(1014);
187        let with_body_site = id(1015);
188
189        let axioms = vec![
190            ax(&format!("EquivalentClasses(:{mi} ObjectIntersectionOf(:{finding} ObjectSomeValuesFrom(:{site} :{heart})))")),
191            ax(&format!("SubClassOf(:{heart} :{body_structure})")),
192            ax(&format!(
193                "SubClassOf(ObjectSomeValuesFrom(:{site} :{body_structure}) :{with_body_site})"
194            )),
195        ];
196        let report = classify(&axioms);
197        assert!(report.skipped.is_empty(), "{:?}", report.skipped);
198        assert!(report.classification.is_subsumed_by(mi, finding));
199        assert!(report.classification.is_subsumed_by(mi, with_body_site));
200    }
201
202    #[test]
203    fn general_concept_inclusion_needs_no_special_case() {
204        // SubClassOf(ObjectIntersectionOf(...), C) — a GCI whose LHS is
205        // compound — classifies anything satisfying both conjuncts.
206        let a = id(1020);
207        let b = id(1021);
208        let c = id(1022);
209        let x = id(1023);
210
211        let axioms = vec![
212            ax(&format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})")),
213            ax(&format!(
214                "EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"
215            )),
216        ];
217        let report = classify(&axioms);
218        assert!(report.classification.is_subsumed_by(x, c));
219    }
220
221    #[test]
222    fn role_hierarchy_propagates_existentials() {
223        // Finger ⊑ ∃partOf.Hand; partOf ⊑ relatedTo; a GCI on
224        // ∃relatedTo.Hand — Finger should be classified under it purely
225        // via the role hierarchy (CR5), with no direct relatedTo axiom.
226        let part_of = id(1030);
227        let related_to = id(1031);
228        let finger = id(1032);
229        let hand = id(1033);
230        let hand_related = id(1034);
231
232        let axioms = vec![
233            ax(&format!("SubObjectPropertyOf(:{part_of} :{related_to})")),
234            ax(&format!(
235                "SubClassOf(:{finger} ObjectSomeValuesFrom(:{part_of} :{hand}))"
236            )),
237            ax(&format!(
238                "SubClassOf(ObjectSomeValuesFrom(:{related_to} :{hand}) :{hand_related})"
239            )),
240        ];
241        let report = classify(&axioms);
242        assert!(report.classification.is_subsumed_by(finger, hand_related));
243    }
244
245    #[test]
246    fn transitive_property_composes_across_two_hops() {
247        // Fingertip -partOf-> Finger -partOf-> Hand; partOf transitive;
248        // a GCI on ∃partOf.Hand. Fingertip should be classified under it
249        // even though it's only directly partOf Finger, not Hand.
250        let part_of = id(1040);
251        let fingertip = id(1041);
252        let finger = id(1042);
253        let hand = id(1043);
254        let hand_part = id(1044);
255
256        let axioms = vec![
257            ax(&format!("TransitiveObjectProperty(:{part_of})")),
258            ax(&format!(
259                "SubClassOf(:{fingertip} ObjectSomeValuesFrom(:{part_of} :{finger}))"
260            )),
261            ax(&format!(
262                "SubClassOf(:{finger} ObjectSomeValuesFrom(:{part_of} :{hand}))"
263            )),
264            ax(&format!(
265                "SubClassOf(ObjectSomeValuesFrom(:{part_of} :{hand}) :{hand_part})"
266            )),
267        ];
268        let report = classify(&axioms);
269        assert!(report.classification.is_subsumed_by(fingertip, hand_part));
270        // Finger itself should NOT be classified as HandPart via this
271        // axiom set (it's related to Hand, but there's no GCI keyed on
272        // that direct pairing being itself a "hand part" — this assert
273        // just sanity-checks the completion didn't over-fire).
274    }
275
276    #[test]
277    fn property_chain_composes_two_distinct_roles() {
278        // The real SNOMED "active ingredient" pattern: HasActiveIngredient
279        // o IsModificationOf ⊑ HasActiveIngredient. A product whose active
280        // ingredient is a modification of Morphine is thereby classified
281        // as having Morphine as an active ingredient too.
282        let has_ingredient = id(1050);
283        let is_modification_of = id(1051);
284        let product = id(1052);
285        let morphine_sulfate = id(1053);
286        let morphine = id(1054);
287        let morphine_product = id(1055);
288
289        let axioms = vec![
290            ax(&format!(
291                "SubObjectPropertyOf(ObjectPropertyChain(:{has_ingredient} :{is_modification_of}) :{has_ingredient})"
292            )),
293            ax(&format!(
294                "SubClassOf(:{product} ObjectSomeValuesFrom(:{has_ingredient} :{morphine_sulfate}))"
295            )),
296            ax(&format!(
297                "SubClassOf(:{morphine_sulfate} ObjectSomeValuesFrom(:{is_modification_of} :{morphine}))"
298            )),
299            ax(&format!(
300                "SubClassOf(ObjectSomeValuesFrom(:{has_ingredient} :{morphine}) :{morphine_product})"
301            )),
302        ];
303        let report = classify(&axioms);
304        assert!(report
305            .classification
306            .is_subsumed_by(product, morphine_product));
307    }
308
309    #[test]
310    fn degenerate_role_chains_do_not_panic() {
311        // spec/13 rule 1: `Axiom` is public, so a caller can hand-build a
312        // chain the OWL parser would have rejected. A one-operand chain is
313        // exactly a role hierarchy axiom; an empty one implies nothing and
314        // is reported as skipped. Neither may panic.
315        let r = id(1070);
316        let s = id(1071);
317        let subject = id(1072);
318        let filler = id(1073);
319        let target = id(1074);
320
321        let one_operand = vec![
322            Axiom::SubObjectPropertyOf {
323                sub: snomed_owl::ObjectPropertyExpression::Chain(vec![r]),
324                sup: s,
325            },
326            ax(&format!(
327                "SubClassOf(:{subject} ObjectSomeValuesFrom(:{r} :{filler}))"
328            )),
329            ax(&format!(
330                "SubClassOf(ObjectSomeValuesFrom(:{s} :{filler}) :{target})"
331            )),
332        ];
333        let report = classify(&one_operand);
334        assert!(
335            report.classification.is_subsumed_by(subject, target),
336            "a one-operand chain must behave as `r ⊑ s`"
337        );
338        assert!(report.skipped.is_empty());
339
340        let empty = vec![Axiom::SubObjectPropertyOf {
341            sub: snomed_owl::ObjectPropertyExpression::Chain(Vec::new()),
342            sup: s,
343        }];
344        let report = classify(&empty);
345        assert_eq!(report.skipped, vec![SkippedConstruct::EmptyRoleChain(s)]);
346    }
347
348    #[test]
349    fn equivalent_classes_are_mutually_subsumed() {
350        let a = id(1060);
351        let b = id(1061);
352        let axioms = vec![ax(&format!("EquivalentClasses(:{a} :{b})"))];
353        let report = classify(&axioms);
354        assert!(report.classification.is_subsumed_by(a, b));
355        assert!(report.classification.is_subsumed_by(b, a));
356        assert_eq!(
357            report.classification.equivalent_to(a).collect::<Vec<_>>(),
358            vec![b]
359        );
360    }
361
362    #[test]
363    fn reports_skipped_constructs_without_dropping_the_rest_of_the_axiom() {
364        let a = id(1070);
365        let b = id(1071);
366        let value_attr = id(1072);
367        let reflexive_attr = id(1073);
368        let data_attr = id(1074);
369        let data_sup = id(1075);
370
371        let axioms = vec![
372            // A concrete value inside an intersection: the rest of the
373            // intersection (A ⊑ B) must still classify.
374            ax(&format!(
375                "SubClassOf(:{a} ObjectIntersectionOf(:{b} DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
376            )),
377            ax(&format!("ReflexiveObjectProperty(:{reflexive_attr})")),
378            ax(&format!("SubDataPropertyOf(:{data_attr} :{data_sup})")),
379        ];
380        let report = classify(&axioms);
381        assert!(report.classification.is_subsumed_by(a, b));
382        assert_eq!(report.skipped.len(), 3, "{:?}", report.skipped);
383        assert!(report
384            .skipped
385            .contains(&SkippedConstruct::ReflexiveProperty(reflexive_attr)));
386        assert!(report
387            .skipped
388            .contains(&SkippedConstruct::DataProperty(data_attr)));
389        assert!(report.skipped.contains(&SkippedConstruct::ConcreteValue {
390            attribute: value_attr
391        }));
392    }
393
394    #[test]
395    fn unrelated_concepts_are_not_subsumed() {
396        let a = id(1080);
397        let b = id(1081);
398        let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
399        let report = classify(&axioms);
400        let unrelated = id(1082);
401        assert!(!report.classification.is_subsumed_by(unrelated, a));
402        assert!(report.classification.subsumers(unrelated).next().is_none());
403    }
404
405    #[test]
406    fn concepts_lists_exactly_what_the_axioms_named() {
407        let a = id(1090);
408        let b = id(1091);
409        let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
410        let report = classify(&axioms);
411        let mut concepts: Vec<SctId> = report.classification.concepts().collect();
412        concepts.sort();
413        let mut expected = vec![a, b];
414        expected.sort();
415        assert_eq!(concepts, expected);
416    }
417}