1mod 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#[derive(Debug, Clone, Default)]
62pub struct Classification {
63 subsumers: HashMap<SctId, HashSet<SctId>>,
64}
65
66impl Classification {
67 pub fn subsumers(&self, concept: SctId) -> impl Iterator<Item = SctId> + '_ {
72 self.subsumers.get(&concept).into_iter().flatten().copied()
73 }
74
75 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 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 pub fn concepts(&self) -> impl Iterator<Item = SctId> + '_ {
93 self.subsumers.keys().copied()
94 }
95}
96
97#[derive(Debug, Clone)]
101#[non_exhaustive]
102pub struct ClassificationReport {
103 pub classification: Classification,
104 pub skipped: Vec<SkippedConstruct>,
105}
106
107pub 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; };
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 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)); assert!(report.classification.is_subsumed_by(mi, mi)); assert!(!report.classification.is_subsumed_by(finding, mi));
172 }
173
174 #[test]
175 fn intersection_definition_propagates_through_role_successors() {
176 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 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 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 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 }
275
276 #[test]
277 fn property_chain_composes_two_distinct_roles() {
278 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 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 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}