1#![forbid(unsafe_code)]
50mod 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#[derive(Debug, Clone, Default)]
75pub struct Classification {
76 subsumers: HashMap<SctId, HashSet<SctId>>,
77}
78
79impl Classification {
80 pub fn subsumers(&self, concept: SctId) -> impl Iterator<Item = SctId> + '_ {
85 self.subsumers.get(&concept).into_iter().flatten().copied()
86 }
87
88 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 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 pub fn concepts(&self) -> impl Iterator<Item = SctId> + '_ {
106 self.subsumers.keys().copied()
107 }
108}
109
110#[derive(Debug, Clone)]
114#[non_exhaustive]
115pub struct ClassificationReport {
116 pub classification: Classification,
117 pub skipped: Vec<SkippedConstruct>,
118}
119
120pub 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; };
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 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)); assert!(report.classification.is_subsumed_by(mi, mi)); assert!(!report.classification.is_subsumed_by(finding, mi));
185 }
186
187 #[test]
188 fn intersection_definition_propagates_through_role_successors() {
189 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 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 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 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 }
288
289 #[test]
290 fn property_chain_composes_two_distinct_roles() {
291 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 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 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}