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 equivalent_classes_are_mutually_subsumed() {
310 let a = id(1060);
311 let b = id(1061);
312 let axioms = vec![ax(&format!("EquivalentClasses(:{a} :{b})"))];
313 let report = classify(&axioms);
314 assert!(report.classification.is_subsumed_by(a, b));
315 assert!(report.classification.is_subsumed_by(b, a));
316 assert_eq!(
317 report.classification.equivalent_to(a).collect::<Vec<_>>(),
318 vec![b]
319 );
320 }
321
322 #[test]
323 fn reports_skipped_constructs_without_dropping_the_rest_of_the_axiom() {
324 let a = id(1070);
325 let b = id(1071);
326 let value_attr = id(1072);
327 let reflexive_attr = id(1073);
328 let data_attr = id(1074);
329 let data_sup = id(1075);
330
331 let axioms = vec![
332 ax(&format!(
335 "SubClassOf(:{a} ObjectIntersectionOf(:{b} DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
336 )),
337 ax(&format!("ReflexiveObjectProperty(:{reflexive_attr})")),
338 ax(&format!("SubDataPropertyOf(:{data_attr} :{data_sup})")),
339 ];
340 let report = classify(&axioms);
341 assert!(report.classification.is_subsumed_by(a, b));
342 assert_eq!(report.skipped.len(), 3, "{:?}", report.skipped);
343 assert!(report
344 .skipped
345 .contains(&SkippedConstruct::ReflexiveProperty(reflexive_attr)));
346 assert!(report
347 .skipped
348 .contains(&SkippedConstruct::DataProperty(data_attr)));
349 assert!(report.skipped.contains(&SkippedConstruct::ConcreteValue {
350 attribute: value_attr
351 }));
352 }
353
354 #[test]
355 fn unrelated_concepts_are_not_subsumed() {
356 let a = id(1080);
357 let b = id(1081);
358 let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
359 let report = classify(&axioms);
360 let unrelated = id(1082);
361 assert!(!report.classification.is_subsumed_by(unrelated, a));
362 assert!(report.classification.subsumers(unrelated).next().is_none());
363 }
364
365 #[test]
366 fn concepts_lists_exactly_what_the_axioms_named() {
367 let a = id(1090);
368 let b = id(1091);
369 let axioms = vec![ax(&format!("SubClassOf(:{a} :{b})"))];
370 let report = classify(&axioms);
371 let mut concepts: Vec<SctId> = report.classification.concepts().collect();
372 concepts.sort();
373 let mut expected = vec![a, b];
374 expected.sort();
375 assert_eq!(concepts, expected);
376 }
377}