1mod 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#[derive(Debug, Clone, Default)]
56pub struct Classification {
57 subsumers: HashMap<SctId, HashSet<SctId>>,
58}
59
60impl Classification {
61 pub fn subsumers(&self, concept: SctId) -> impl Iterator<Item = SctId> + '_ {
66 self.subsumers.get(&concept).into_iter().flatten().copied()
67 }
68
69 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 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 pub fn concepts(&self) -> impl Iterator<Item = SctId> + '_ {
87 self.subsumers.keys().copied()
88 }
89}
90
91#[derive(Debug, Clone)]
95pub struct ClassificationReport {
96 pub classification: Classification,
97 pub skipped: Vec<SkippedConstruct>,
98}
99
100pub 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; };
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 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)); assert!(report.classification.is_subsumed_by(mi, mi)); assert!(!report.classification.is_subsumed_by(finding, mi));
165 }
166
167 #[test]
168 fn intersection_definition_propagates_through_role_successors() {
169 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 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 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 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 }
268
269 #[test]
270 fn property_chain_composes_two_distinct_roles() {
271 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 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}