1use std::collections::{HashMap, HashSet};
8
9use snomed_core::sctid::SctId;
10use snomed_owl::Axiom;
11
12use crate::skipped::SkippedConstruct;
13use crate::stated_profile::{self, Attribute as RawAttribute, StatedProfile};
14use crate::{classify, normalize, Classification};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Attribute {
23 pub group: u32,
24 pub type_id: SctId,
25 pub destination_id: SctId,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct NecessaryNormalForm {
33 pub is_a: Vec<SctId>,
34 pub attributes: Vec<Attribute>,
35}
36
37#[derive(Debug, Clone)]
42pub struct NecessaryNormalFormReport {
43 pub forms: HashMap<SctId, NecessaryNormalForm>,
44 pub skipped: Vec<SkippedConstruct>,
45}
46
47pub fn necessary_normal_form(axioms: &[Axiom]) -> NecessaryNormalFormReport {
53 let classification_report = classify(axioms);
54 let classification = classification_report.classification;
55
56 let (profiles, mut skipped) = stated_profile::extract_stated_profiles(axioms);
57 skipped.extend(classification_report.skipped.iter().copied());
58 dedup_unordered(&mut skipped);
59
60 let role_ancestors = role_ancestor_closure(axioms);
61
62 let mut concepts: HashSet<SctId> = classification.concepts().collect();
63 concepts.extend(profiles.keys().copied());
64
65 let proximal: HashMap<SctId, Vec<SctId>> = concepts
66 .iter()
67 .map(|&c| (c, proximal_parents(c, &classification)))
68 .collect();
69
70 let mut ctx = Context {
71 profiles: &profiles,
72 classification: &classification,
73 role_ancestors: &role_ancestors,
74 proximal: &proximal,
75 cache: HashMap::new(),
76 in_progress: HashSet::new(),
77 };
78
79 let mut forms = HashMap::new();
80 for &c in &concepts {
81 let candidates = groups_for(c, &mut ctx);
82 let is_a = proximal.get(&c).cloned().unwrap_or_default();
83 forms.insert(c, finalize(is_a, candidates));
84 }
85
86 NecessaryNormalFormReport { forms, skipped }
87}
88
89fn dedup_unordered<T: PartialEq + Copy>(items: &mut Vec<T>) {
90 let mut unique = Vec::with_capacity(items.len());
91 for item in items.drain(..) {
92 if !unique.contains(&item) {
93 unique.push(item);
94 }
95 }
96 *items = unique;
97}
98
99fn proximal_parents(c: SctId, classification: &Classification) -> Vec<SctId> {
109 let all: Vec<SctId> = classification.subsumers(c).collect();
110 all.iter()
111 .filter(|&&p| {
112 !all.iter().any(|&q| {
113 if q == p || !classification.is_subsumed_by(q, p) {
114 return false;
115 }
116 !classification.is_subsumed_by(p, q) || q < p
120 })
121 })
122 .copied()
123 .collect()
124}
125
126fn role_ancestor_closure(axioms: &[Axiom]) -> HashMap<SctId, HashSet<SctId>> {
130 let tbox = normalize::normalize(axioms);
131 let mut direct: HashMap<SctId, Vec<SctId>> = HashMap::new();
132 for (sub, sup) in &tbox.role_hierarchy {
133 if let (crate::types::RoleId::Named(s), crate::types::RoleId::Named(t)) = (sub, sup) {
134 direct.entry(*s).or_default().push(*t);
135 }
136 }
137
138 let mut closure: HashMap<SctId, HashSet<SctId>> = HashMap::new();
139 for &role in direct.keys() {
140 let mut seen = HashSet::from([role]);
141 let mut queue = vec![role];
142 while let Some(r) = queue.pop() {
143 for &next in direct.get(&r).map(Vec::as_slice).unwrap_or(&[]) {
144 if seen.insert(next) {
145 queue.push(next);
146 }
147 }
148 }
149 closure.insert(role, seen);
150 }
151 closure
152}
153
154struct Context<'a> {
155 profiles: &'a HashMap<SctId, StatedProfile>,
156 classification: &'a Classification,
157 role_ancestors: &'a HashMap<SctId, HashSet<SctId>>,
158 proximal: &'a HashMap<SctId, Vec<SctId>>,
159 cache: HashMap<SctId, Vec<GroupCandidate>>,
160 in_progress: HashSet<SctId>,
161}
162
163#[derive(Debug, Clone)]
164struct GroupCandidate {
165 group0: bool,
168 fragments: Vec<RawAttribute>,
169}
170
171fn groups_for(c: SctId, ctx: &mut Context<'_>) -> Vec<GroupCandidate> {
178 if let Some(cached) = ctx.cache.get(&c) {
179 return cached.clone();
180 }
181 if !ctx.in_progress.insert(c) {
182 return Vec::new();
183 }
184
185 let mut candidates: Vec<GroupCandidate> = Vec::new();
186
187 if let Some(profile) = ctx.profiles.get(&c) {
188 for &attr in &profile.ungrouped {
189 insert_candidate(
190 &mut candidates,
191 GroupCandidate {
192 group0: true,
193 fragments: vec![attr],
194 },
195 ctx.role_ancestors,
196 ctx.classification,
197 );
198 }
199 for group in profile.groups.clone() {
200 insert_candidate(
201 &mut candidates,
202 GroupCandidate {
203 group0: false,
204 fragments: group,
205 },
206 ctx.role_ancestors,
207 ctx.classification,
208 );
209 }
210 }
211
212 let parents = ctx.proximal.get(&c).cloned().unwrap_or_default();
213 for parent in parents {
214 for inherited in groups_for(parent, ctx) {
215 insert_candidate(
216 &mut candidates,
217 inherited,
218 ctx.role_ancestors,
219 ctx.classification,
220 );
221 }
222 }
223
224 ctx.in_progress.remove(&c);
225 ctx.cache.insert(c, candidates.clone());
226 candidates
227}
228
229fn insert_candidate(
233 candidates: &mut Vec<GroupCandidate>,
234 new: GroupCandidate,
235 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
236 classification: &Classification,
237) {
238 if candidates
239 .iter()
240 .any(|g| group_is_same_or_stronger(g, &new, role_ancestors, classification))
241 {
242 return;
243 }
244 candidates.retain(|g| !group_is_same_or_stronger(&new, g, role_ancestors, classification));
245 candidates.push(new);
246}
247
248fn group_is_same_or_stronger(
252 candidate: &GroupCandidate,
253 other: &GroupCandidate,
254 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
255 classification: &Classification,
256) -> bool {
257 other.fragments.iter().all(|&weaker| {
258 candidate.fragments.iter().any(|&stronger| {
259 fragment_is_same_or_stronger(stronger, weaker, role_ancestors, classification)
260 })
261 })
262}
263
264fn fragment_is_same_or_stronger(
268 stronger: RawAttribute,
269 weaker: RawAttribute,
270 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
271 classification: &Classification,
272) -> bool {
273 let (s, d) = stronger;
274 let (r, c) = weaker;
275
276 let type_ok = s == r
277 || role_ancestors
278 .get(&s)
279 .is_some_and(|ancestors| ancestors.contains(&r));
280 if !type_ok {
281 return false;
282 }
283
284 d == c || classification.is_subsumed_by(d, c)
285}
286
287fn finalize(mut is_a: Vec<SctId>, candidates: Vec<GroupCandidate>) -> NecessaryNormalForm {
288 is_a.sort();
289 is_a.dedup();
290
291 let mut ungrouped: Vec<RawAttribute> = Vec::new();
292 let mut numbered_groups: Vec<Vec<RawAttribute>> = Vec::new();
293 for candidate in candidates {
294 if candidate.group0 {
295 ungrouped.extend(candidate.fragments);
296 } else {
297 numbered_groups.push(candidate.fragments);
298 }
299 }
300 ungrouped.sort();
301
302 for group in &mut numbered_groups {
304 group.sort();
305 }
306 numbered_groups.sort();
307
308 let mut attributes: Vec<Attribute> = ungrouped
309 .into_iter()
310 .map(|(type_id, destination_id)| Attribute {
311 group: 0,
312 type_id,
313 destination_id,
314 })
315 .collect();
316 for (index, group) in numbered_groups.into_iter().enumerate() {
317 let group_number = (index + 1) as u32;
318 attributes.extend(
319 group
320 .into_iter()
321 .map(|(type_id, destination_id)| Attribute {
322 group: group_number,
323 type_id,
324 destination_id,
325 }),
326 );
327 }
328
329 NecessaryNormalForm { is_a, attributes }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use snomed_core::sctid::ComponentType;
336
337 fn id(item: u64) -> SctId {
340 SctId::compose(item, ComponentType::Concept, None).unwrap()
341 }
342
343 fn ax(s: &str) -> Axiom {
344 snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
345 }
346
347 fn axioms(strs: &[String]) -> Vec<Axiom> {
348 strs.iter().map(|s| ax(s)).collect()
349 }
350
351 #[test]
352 fn proximal_parents_exclude_transitively_redundant_ancestors() {
353 let a = id(2001);
354 let b = id(2002);
355 let c = id(2003);
356 let input = axioms(&[
357 format!("SubClassOf(:{a} :{b})"),
358 format!("SubClassOf(:{b} :{c})"),
359 ]);
360 let report = necessary_normal_form(&input);
361
362 let nnf_a = &report.forms[&a];
365 assert_eq!(nnf_a.is_a, vec![b]);
366 let nnf_b = &report.forms[&b];
367 assert_eq!(nnf_b.is_a, vec![c]);
368 }
369
370 #[test]
371 fn own_specific_attribute_makes_inherited_general_one_redundant() {
372 let parent = id(2010);
377 let child = id(2011);
378 let site = id(2012);
379 let general = id(2013);
380 let specific = id(2014);
381 let input = axioms(&[
382 format!("SubClassOf(:{specific} :{general})"),
383 format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{site} :{general}))"),
384 format!("SubClassOf(:{child} :{parent})"),
385 format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{site} :{specific}))"),
386 ]);
387 let report = necessary_normal_form(&input);
388
389 let nnf_child = &report.forms[&child];
390 assert_eq!(
391 nnf_child.attributes,
392 vec![Attribute {
393 group: 0,
394 type_id: site,
395 destination_id: specific
396 }]
397 );
398 }
399
400 #[test]
401 fn role_hierarchy_makes_general_attribute_type_redundant() {
402 let parent = id(2020);
407 let child = id(2021);
408 let generic_attr = id(2022);
409 let specific_attr = id(2023);
410 let value = id(2024);
411 let input = axioms(&[
412 format!("SubObjectPropertyOf(:{specific_attr} :{generic_attr})"),
413 format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{generic_attr} :{value}))"),
414 format!("SubClassOf(:{child} :{parent})"),
415 format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{specific_attr} :{value}))"),
416 ]);
417 let report = necessary_normal_form(&input);
418
419 let nnf_child = &report.forms[&child];
420 assert_eq!(
421 nnf_child.attributes,
422 vec![Attribute {
423 group: 0,
424 type_id: specific_attr,
425 destination_id: value
426 }]
427 );
428 }
429
430 #[test]
431 fn role_group_is_reconstructed_from_the_owl_encoding() {
432 let concept = id(2030);
435 let parent = id(2031);
436 let finding_site = id(2032);
437 let site_value = id(2033);
438 let morphology = id(2034);
439 let morphology_value = id(2035);
440 let input = axioms(&[format!(
441 "EquivalentClasses(:{concept} ObjectIntersectionOf(:{parent} \
442 ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
443 ObjectSomeValuesFrom(:{finding_site} :{site_value}) \
444 ObjectSomeValuesFrom(:{morphology} :{morphology_value})))))"
445 )]);
446 let report = necessary_normal_form(&input);
447
448 let nnf = &report.forms[&concept];
449 assert_eq!(nnf.is_a, vec![parent]);
450 let mut attrs = nnf.attributes.clone();
451 attrs.sort_by_key(|a| a.type_id);
452 assert_eq!(
453 attrs,
454 vec![
455 Attribute {
456 group: 1,
457 type_id: finding_site,
458 destination_id: site_value
459 },
460 Attribute {
461 group: 1,
462 type_id: morphology,
463 destination_id: morphology_value
464 },
465 ]
466 );
467 }
468
469 #[test]
470 fn ungrouped_attribute_stays_group_zero() {
471 let concept = id(2040);
474 let attr = id(2041);
475 let value = id(2042);
476 let input = axioms(&[format!(
477 "SubClassOf(:{concept} ObjectSomeValuesFrom(:{attr} :{value}))"
478 )]);
479 let report = necessary_normal_form(&input);
480
481 let nnf = &report.forms[&concept];
482 assert_eq!(
483 nnf.attributes,
484 vec![Attribute {
485 group: 0,
486 type_id: attr,
487 destination_id: value
488 }]
489 );
490 }
491
492 #[test]
493 fn group_redundant_across_two_whole_groups_is_eliminated() {
494 let parent = id(2050);
499 let child = id(2051);
500 let site_attr = id(2052);
501 let general_site = id(2053);
502 let specific_site = id(2054);
503 let extra_attr = id(2055);
504 let extra_value = id(2056);
505 let input = axioms(&[
506 format!("SubClassOf(:{specific_site} :{general_site})"),
507 format!(
508 "SubClassOf(:{parent} ObjectSomeValuesFrom(:609096000 \
509 ObjectSomeValuesFrom(:{site_attr} :{general_site})))"
510 ),
511 format!("SubClassOf(:{child} :{parent})"),
512 format!(
513 "SubClassOf(:{child} ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
514 ObjectSomeValuesFrom(:{site_attr} :{specific_site}) \
515 ObjectSomeValuesFrom(:{extra_attr} :{extra_value}))))"
516 ),
517 ]);
518 let report = necessary_normal_form(&input);
519
520 let nnf_child = &report.forms[&child];
521 let mut attrs = nnf_child.attributes.clone();
522 attrs.sort_by_key(|a| a.type_id);
523 assert_eq!(
524 attrs,
525 vec![
526 Attribute {
527 group: 1,
528 type_id: site_attr,
529 destination_id: specific_site
530 },
531 Attribute {
532 group: 1,
533 type_id: extra_attr,
534 destination_id: extra_value
535 },
536 ]
537 );
538 }
539
540 #[test]
541 fn unmodeled_attribute_shape_is_reported_not_silently_dropped() {
542 let concept = id(2060);
544 let attr = id(2061);
545 let value_attr = id(2062);
546 let input = axioms(&[format!(
547 "SubClassOf(:{concept} ObjectSomeValuesFrom(:609096000 \
548 DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
549 )]);
550 let report = necessary_normal_form(&input);
551
552 let nnf = &report.forms[&concept];
553 assert!(nnf.attributes.is_empty());
554 assert!(report
555 .skipped
556 .contains(&SkippedConstruct::UnmodeledAttributeShape { concept }));
557 let _ = attr; }
559
560 #[test]
561 fn gci_contributes_only_via_subsumption_never_a_direct_profile() {
562 let a = id(2070);
566 let b = id(2071);
567 let c = id(2072);
568 let x = id(2073);
569 let attr = id(2074);
570 let value = id(2075);
571 let input = axioms(&[
572 format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})"),
573 format!("SubClassOf(:{c} ObjectSomeValuesFrom(:{attr} :{value}))"),
574 format!("EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"),
575 ]);
576 let report = necessary_normal_form(&input);
577
578 let nnf_x = &report.forms[&x];
579 assert!(nnf_x.is_a.contains(&c));
580 assert_eq!(
581 nnf_x.attributes,
582 vec![Attribute {
583 group: 0,
584 type_id: attr,
585 destination_id: value
586 }]
587 );
588 }
589
590 #[test]
591 fn equivalent_parents_do_not_eliminate_each_other() {
592 let a = id(1090);
597 let b = id(1091);
598 let c = id(1092);
599 assert!(b < c, "the test relies on b sorting before c");
600 let report = necessary_normal_form(&[
601 ax(&format!("EquivalentClasses(:{b} :{c})")),
602 ax(&format!("SubClassOf(:{a} :{b})")),
603 ]);
604 assert_eq!(report.forms[&a].is_a, vec![b]);
605 }
606}