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)]
101pub struct ClassificationReport {
102 pub classification: Classification,
103 pub skipped: Vec<SkippedConstruct>,
104}
105
106pub fn classify<'a>(axioms: impl IntoIterator<Item = &'a Axiom>) -> ClassificationReport {
109 let tbox = normalize::normalize(axioms);
110 let skipped = tbox.skipped.clone();
111 let state = complete::saturate(&tbox);
112
113 let mut subsumers: HashMap<SctId, HashSet<SctId>> = HashMap::new();
114 for (concept, supers) in &state.subsumers {
115 let ConceptId::Named(named) = concept else {
116 continue; };
118 let named_supers: HashSet<SctId> = supers
119 .iter()
120 .filter_map(|s| match s {
121 ConceptId::Named(id) if id != named => Some(*id),
122 _ => None,
123 })
124 .collect();
125 subsumers.insert(*named, named_supers);
126 }
127
128 ClassificationReport {
129 classification: Classification { subsumers },
130 skipped,
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use snomed_core::sctid::ComponentType;
138
139 fn id(item: u64) -> SctId {
143 SctId::compose(item, ComponentType::Concept, None).unwrap()
144 }
145
146 fn ax(s: &str) -> Axiom {
147 snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
148 }
149
150 #[test]
151 fn plain_subclassof_chains_transitively() {
152 let disease = id(1001);
153 let finding = id(1002);
154 let mi = id(1003);
155 let axioms = vec![
156 Axiom::SubClassOf {
157 sub: snomed_owl::ClassExpression::Concept(disease),
158 sup: snomed_owl::ClassExpression::Concept(finding),
159 },
160 Axiom::SubClassOf {
161 sub: snomed_owl::ClassExpression::Concept(mi),
162 sup: snomed_owl::ClassExpression::Concept(disease),
163 },
164 ];
165 let report = classify(&axioms);
166 assert!(report.skipped.is_empty());
167 assert!(report.classification.is_subsumed_by(mi, disease));
168 assert!(report.classification.is_subsumed_by(mi, finding)); assert!(report.classification.is_subsumed_by(mi, mi)); assert!(!report.classification.is_subsumed_by(finding, mi));
171 }
172
173 #[test]
174 fn intersection_definition_propagates_through_role_successors() {
175 let finding = id(1010);
182 let mi = id(1011);
183 let site = id(1012);
184 let heart = id(1013);
185 let body_structure = id(1014);
186 let with_body_site = id(1015);
187
188 let axioms = vec![
189 ax(&format!("EquivalentClasses(:{mi} ObjectIntersectionOf(:{finding} ObjectSomeValuesFrom(:{site} :{heart})))")),
190 ax(&format!("SubClassOf(:{heart} :{body_structure})")),
191 ax(&format!(
192 "SubClassOf(ObjectSomeValuesFrom(:{site} :{body_structure}) :{with_body_site})"
193 )),
194 ];
195 let report = classify(&axioms);
196 assert!(report.skipped.is_empty(), "{:?}", report.skipped);
197 assert!(report.classification.is_subsumed_by(mi, finding));
198 assert!(report.classification.is_subsumed_by(mi, with_body_site));
199 }
200
201 #[test]
202 fn general_concept_inclusion_needs_no_special_case() {
203 let a = id(1020);
206 let b = id(1021);
207 let c = id(1022);
208 let x = id(1023);
209
210 let axioms = vec![
211 ax(&format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})")),
212 ax(&format!(
213 "EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"
214 )),
215 ];
216 let report = classify(&axioms);
217 assert!(report.classification.is_subsumed_by(x, c));
218 }
219
220 #[test]
221 fn role_hierarchy_propagates_existentials() {
222 let part_of = id(1030);
226 let related_to = id(1031);
227 let finger = id(1032);
228 let hand = id(1033);
229 let hand_related = id(1034);
230
231 let axioms = vec![
232 ax(&format!("SubObjectPropertyOf(:{part_of} :{related_to})")),
233 ax(&format!(
234 "SubClassOf(:{finger} ObjectSomeValuesFrom(:{part_of} :{hand}))"
235 )),
236 ax(&format!(
237 "SubClassOf(ObjectSomeValuesFrom(:{related_to} :{hand}) :{hand_related})"
238 )),
239 ];
240 let report = classify(&axioms);
241 assert!(report.classification.is_subsumed_by(finger, hand_related));
242 }
243
244 #[test]
245 fn transitive_property_composes_across_two_hops() {
246 let part_of = id(1040);
250 let fingertip = id(1041);
251 let finger = id(1042);
252 let hand = id(1043);
253 let hand_part = id(1044);
254
255 let axioms = vec![
256 ax(&format!("TransitiveObjectProperty(:{part_of})")),
257 ax(&format!(
258 "SubClassOf(:{fingertip} ObjectSomeValuesFrom(:{part_of} :{finger}))"
259 )),
260 ax(&format!(
261 "SubClassOf(:{finger} ObjectSomeValuesFrom(:{part_of} :{hand}))"
262 )),
263 ax(&format!(
264 "SubClassOf(ObjectSomeValuesFrom(:{part_of} :{hand}) :{hand_part})"
265 )),
266 ];
267 let report = classify(&axioms);
268 assert!(report.classification.is_subsumed_by(fingertip, hand_part));
269 }
274
275 #[test]
276 fn property_chain_composes_two_distinct_roles() {
277 let has_ingredient = id(1050);
282 let is_modification_of = id(1051);
283 let product = id(1052);
284 let morphine_sulfate = id(1053);
285 let morphine = id(1054);
286 let morphine_product = id(1055);
287
288 let axioms = vec![
289 ax(&format!(
290 "SubObjectPropertyOf(ObjectPropertyChain(:{has_ingredient} :{is_modification_of}) :{has_ingredient})"
291 )),
292 ax(&format!(
293 "SubClassOf(:{product} ObjectSomeValuesFrom(:{has_ingredient} :{morphine_sulfate}))"
294 )),
295 ax(&format!(
296 "SubClassOf(:{morphine_sulfate} ObjectSomeValuesFrom(:{is_modification_of} :{morphine}))"
297 )),
298 ax(&format!(
299 "SubClassOf(ObjectSomeValuesFrom(:{has_ingredient} :{morphine}) :{morphine_product})"
300 )),
301 ];
302 let report = classify(&axioms);
303 assert!(report
304 .classification
305 .is_subsumed_by(product, morphine_product));
306 }
307
308 #[test]
309 fn degenerate_role_chains_do_not_panic() {
310 let r = id(1070);
315 let s = id(1071);
316 let subject = id(1072);
317 let filler = id(1073);
318 let target = id(1074);
319
320 let one_operand = vec![
321 Axiom::SubObjectPropertyOf {
322 sub: snomed_owl::ObjectPropertyExpression::Chain(vec![r]),
323 sup: s,
324 },
325 ax(&format!(
326 "SubClassOf(:{subject} ObjectSomeValuesFrom(:{r} :{filler}))"
327 )),
328 ax(&format!(
329 "SubClassOf(ObjectSomeValuesFrom(:{s} :{filler}) :{target})"
330 )),
331 ];
332 let report = classify(&one_operand);
333 assert!(
334 report.classification.is_subsumed_by(subject, target),
335 "a one-operand chain must behave as `r ⊑ s`"
336 );
337 assert!(report.skipped.is_empty());
338
339 let empty = vec![Axiom::SubObjectPropertyOf {
340 sub: snomed_owl::ObjectPropertyExpression::Chain(Vec::new()),
341 sup: s,
342 }];
343 let report = classify(&empty);
344 assert_eq!(report.skipped, vec![SkippedConstruct::EmptyRoleChain(s)]);
345 }
346
347 #[test]
348 fn equivalent_classes_are_mutually_subsumed() {
349 let a = id(1060);
350 let b = id(1061);
351 let axioms = vec![ax(&format!("EquivalentClasses(:{a} :{b})"))];
352 let report = classify(&axioms);
353 assert!(report.classification.is_subsumed_by(a, b));
354 assert!(report.classification.is_subsumed_by(b, a));
355 assert_eq!(
356 report.classification.equivalent_to(a).collect::<Vec<_>>(),
357 vec![b]
358 );
359 }
360
361 #[test]
362 fn reports_skipped_constructs_without_dropping_the_rest_of_the_axiom() {
363 let a = id(1070);
364 let b = id(1071);
365 let value_attr = id(1072);
366 let reflexive_attr = id(1073);
367 let data_attr = id(1074);
368 let data_sup = id(1075);
369
370 let axioms = vec![
371 ax(&format!(
374 "SubClassOf(:{a} ObjectIntersectionOf(:{b} DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
375 )),
376 ax(&format!("ReflexiveObjectProperty(:{reflexive_attr})")),
377 ax(&format!("SubDataPropertyOf(:{data_attr} :{data_sup})")),
378 ];
379 let report = classify(&axioms);
380 assert!(report.classification.is_subsumed_by(a, b));
381 assert_eq!(report.skipped.len(), 3, "{:?}", report.skipped);
382 assert!(report
383 .skipped
384 .contains(&SkippedConstruct::ReflexiveProperty(reflexive_attr)));
385 assert!(report
386 .skipped
387 .contains(&SkippedConstruct::DataProperty(data_attr)));
388 assert!(report.skipped.contains(&SkippedConstruct::ConcreteValue {
389 attribute: value_attr
390 }));
391 }
392
393 #[test]
394 fn unrelated_concepts_are_not_subsumed() {
395 let a = id(1080);
396 let b = id(1081);
397 let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
398 let report = classify(&axioms);
399 let unrelated = id(1082);
400 assert!(!report.classification.is_subsumed_by(unrelated, a));
401 assert!(report.classification.subsumers(unrelated).next().is_none());
402 }
403
404 #[test]
405 fn concepts_lists_exactly_what_the_axioms_named() {
406 let a = id(1090);
407 let b = id(1091);
408 let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
409 let report = classify(&axioms);
410 let mut concepts: Vec<SctId> = report.classification.concepts().collect();
411 concepts.sort();
412 let mut expected = vec![a, b];
413 expected.sort();
414 assert_eq!(concepts, expected);
415 }
416}