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