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> {
102 let all: Vec<SctId> = classification.subsumers(c).collect();
103 all.iter()
104 .filter(|&&p| {
105 !all.iter()
106 .any(|&q| q != p && classification.is_subsumed_by(q, p))
107 })
108 .copied()
109 .collect()
110}
111
112fn role_ancestor_closure(axioms: &[Axiom]) -> HashMap<SctId, HashSet<SctId>> {
116 let tbox = normalize::normalize(axioms);
117 let mut direct: HashMap<SctId, Vec<SctId>> = HashMap::new();
118 for (sub, sup) in &tbox.role_hierarchy {
119 if let (crate::types::RoleId::Named(s), crate::types::RoleId::Named(t)) = (sub, sup) {
120 direct.entry(*s).or_default().push(*t);
121 }
122 }
123
124 let mut closure: HashMap<SctId, HashSet<SctId>> = HashMap::new();
125 for &role in direct.keys() {
126 let mut seen = HashSet::from([role]);
127 let mut queue = vec![role];
128 while let Some(r) = queue.pop() {
129 for &next in direct.get(&r).map(Vec::as_slice).unwrap_or(&[]) {
130 if seen.insert(next) {
131 queue.push(next);
132 }
133 }
134 }
135 closure.insert(role, seen);
136 }
137 closure
138}
139
140struct Context<'a> {
141 profiles: &'a HashMap<SctId, StatedProfile>,
142 classification: &'a Classification,
143 role_ancestors: &'a HashMap<SctId, HashSet<SctId>>,
144 proximal: &'a HashMap<SctId, Vec<SctId>>,
145 cache: HashMap<SctId, Vec<GroupCandidate>>,
146 in_progress: HashSet<SctId>,
147}
148
149#[derive(Debug, Clone)]
150struct GroupCandidate {
151 group0: bool,
154 fragments: Vec<RawAttribute>,
155}
156
157fn groups_for(c: SctId, ctx: &mut Context<'_>) -> Vec<GroupCandidate> {
164 if let Some(cached) = ctx.cache.get(&c) {
165 return cached.clone();
166 }
167 if !ctx.in_progress.insert(c) {
168 return Vec::new();
169 }
170
171 let mut candidates: Vec<GroupCandidate> = Vec::new();
172
173 if let Some(profile) = ctx.profiles.get(&c) {
174 for &attr in &profile.ungrouped {
175 insert_candidate(
176 &mut candidates,
177 GroupCandidate {
178 group0: true,
179 fragments: vec![attr],
180 },
181 ctx.role_ancestors,
182 ctx.classification,
183 );
184 }
185 for group in profile.groups.clone() {
186 insert_candidate(
187 &mut candidates,
188 GroupCandidate {
189 group0: false,
190 fragments: group,
191 },
192 ctx.role_ancestors,
193 ctx.classification,
194 );
195 }
196 }
197
198 let parents = ctx.proximal.get(&c).cloned().unwrap_or_default();
199 for parent in parents {
200 for inherited in groups_for(parent, ctx) {
201 insert_candidate(
202 &mut candidates,
203 inherited,
204 ctx.role_ancestors,
205 ctx.classification,
206 );
207 }
208 }
209
210 ctx.in_progress.remove(&c);
211 ctx.cache.insert(c, candidates.clone());
212 candidates
213}
214
215fn insert_candidate(
219 candidates: &mut Vec<GroupCandidate>,
220 new: GroupCandidate,
221 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
222 classification: &Classification,
223) {
224 if candidates
225 .iter()
226 .any(|g| group_is_same_or_stronger(g, &new, role_ancestors, classification))
227 {
228 return;
229 }
230 candidates.retain(|g| !group_is_same_or_stronger(&new, g, role_ancestors, classification));
231 candidates.push(new);
232}
233
234fn group_is_same_or_stronger(
238 candidate: &GroupCandidate,
239 other: &GroupCandidate,
240 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
241 classification: &Classification,
242) -> bool {
243 other.fragments.iter().all(|&weaker| {
244 candidate.fragments.iter().any(|&stronger| {
245 fragment_is_same_or_stronger(stronger, weaker, role_ancestors, classification)
246 })
247 })
248}
249
250fn fragment_is_same_or_stronger(
254 stronger: RawAttribute,
255 weaker: RawAttribute,
256 role_ancestors: &HashMap<SctId, HashSet<SctId>>,
257 classification: &Classification,
258) -> bool {
259 let (s, d) = stronger;
260 let (r, c) = weaker;
261
262 let type_ok = s == r
263 || role_ancestors
264 .get(&s)
265 .is_some_and(|ancestors| ancestors.contains(&r));
266 if !type_ok {
267 return false;
268 }
269
270 d == c || classification.is_subsumed_by(d, c)
271}
272
273fn finalize(mut is_a: Vec<SctId>, candidates: Vec<GroupCandidate>) -> NecessaryNormalForm {
274 is_a.sort();
275 is_a.dedup();
276
277 let mut ungrouped: Vec<RawAttribute> = Vec::new();
278 let mut numbered_groups: Vec<Vec<RawAttribute>> = Vec::new();
279 for candidate in candidates {
280 if candidate.group0 {
281 ungrouped.extend(candidate.fragments);
282 } else {
283 numbered_groups.push(candidate.fragments);
284 }
285 }
286 ungrouped.sort();
287
288 for group in &mut numbered_groups {
290 group.sort();
291 }
292 numbered_groups.sort();
293
294 let mut attributes: Vec<Attribute> = ungrouped
295 .into_iter()
296 .map(|(type_id, destination_id)| Attribute {
297 group: 0,
298 type_id,
299 destination_id,
300 })
301 .collect();
302 for (index, group) in numbered_groups.into_iter().enumerate() {
303 let group_number = (index + 1) as u32;
304 attributes.extend(
305 group
306 .into_iter()
307 .map(|(type_id, destination_id)| Attribute {
308 group: group_number,
309 type_id,
310 destination_id,
311 }),
312 );
313 }
314
315 NecessaryNormalForm { is_a, attributes }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use snomed_core::sctid::ComponentType;
322
323 fn id(item: u64) -> SctId {
326 SctId::compose(item, ComponentType::Concept, None).unwrap()
327 }
328
329 fn ax(s: &str) -> Axiom {
330 snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
331 }
332
333 fn axioms(strs: &[String]) -> Vec<Axiom> {
334 strs.iter().map(|s| ax(s)).collect()
335 }
336
337 #[test]
338 fn proximal_parents_exclude_transitively_redundant_ancestors() {
339 let a = id(2001);
340 let b = id(2002);
341 let c = id(2003);
342 let input = axioms(&[
343 format!("SubClassOf(:{a} :{b})"),
344 format!("SubClassOf(:{b} :{c})"),
345 ]);
346 let report = necessary_normal_form(&input);
347
348 let nnf_a = &report.forms[&a];
351 assert_eq!(nnf_a.is_a, vec![b]);
352 let nnf_b = &report.forms[&b];
353 assert_eq!(nnf_b.is_a, vec![c]);
354 }
355
356 #[test]
357 fn own_specific_attribute_makes_inherited_general_one_redundant() {
358 let parent = id(2010);
363 let child = id(2011);
364 let site = id(2012);
365 let general = id(2013);
366 let specific = id(2014);
367 let input = axioms(&[
368 format!("SubClassOf(:{specific} :{general})"),
369 format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{site} :{general}))"),
370 format!("SubClassOf(:{child} :{parent})"),
371 format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{site} :{specific}))"),
372 ]);
373 let report = necessary_normal_form(&input);
374
375 let nnf_child = &report.forms[&child];
376 assert_eq!(
377 nnf_child.attributes,
378 vec![Attribute {
379 group: 0,
380 type_id: site,
381 destination_id: specific
382 }]
383 );
384 }
385
386 #[test]
387 fn role_hierarchy_makes_general_attribute_type_redundant() {
388 let parent = id(2020);
393 let child = id(2021);
394 let generic_attr = id(2022);
395 let specific_attr = id(2023);
396 let value = id(2024);
397 let input = axioms(&[
398 format!("SubObjectPropertyOf(:{specific_attr} :{generic_attr})"),
399 format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{generic_attr} :{value}))"),
400 format!("SubClassOf(:{child} :{parent})"),
401 format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{specific_attr} :{value}))"),
402 ]);
403 let report = necessary_normal_form(&input);
404
405 let nnf_child = &report.forms[&child];
406 assert_eq!(
407 nnf_child.attributes,
408 vec![Attribute {
409 group: 0,
410 type_id: specific_attr,
411 destination_id: value
412 }]
413 );
414 }
415
416 #[test]
417 fn role_group_is_reconstructed_from_the_owl_encoding() {
418 let concept = id(2030);
421 let parent = id(2031);
422 let finding_site = id(2032);
423 let site_value = id(2033);
424 let morphology = id(2034);
425 let morphology_value = id(2035);
426 let input = axioms(&[format!(
427 "EquivalentClasses(:{concept} ObjectIntersectionOf(:{parent} \
428 ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
429 ObjectSomeValuesFrom(:{finding_site} :{site_value}) \
430 ObjectSomeValuesFrom(:{morphology} :{morphology_value})))))"
431 )]);
432 let report = necessary_normal_form(&input);
433
434 let nnf = &report.forms[&concept];
435 assert_eq!(nnf.is_a, vec![parent]);
436 let mut attrs = nnf.attributes.clone();
437 attrs.sort_by_key(|a| a.type_id);
438 assert_eq!(
439 attrs,
440 vec![
441 Attribute {
442 group: 1,
443 type_id: finding_site,
444 destination_id: site_value
445 },
446 Attribute {
447 group: 1,
448 type_id: morphology,
449 destination_id: morphology_value
450 },
451 ]
452 );
453 }
454
455 #[test]
456 fn ungrouped_attribute_stays_group_zero() {
457 let concept = id(2040);
460 let attr = id(2041);
461 let value = id(2042);
462 let input = axioms(&[format!(
463 "SubClassOf(:{concept} ObjectSomeValuesFrom(:{attr} :{value}))"
464 )]);
465 let report = necessary_normal_form(&input);
466
467 let nnf = &report.forms[&concept];
468 assert_eq!(
469 nnf.attributes,
470 vec![Attribute {
471 group: 0,
472 type_id: attr,
473 destination_id: value
474 }]
475 );
476 }
477
478 #[test]
479 fn group_redundant_across_two_whole_groups_is_eliminated() {
480 let parent = id(2050);
485 let child = id(2051);
486 let site_attr = id(2052);
487 let general_site = id(2053);
488 let specific_site = id(2054);
489 let extra_attr = id(2055);
490 let extra_value = id(2056);
491 let input = axioms(&[
492 format!("SubClassOf(:{specific_site} :{general_site})"),
493 format!(
494 "SubClassOf(:{parent} ObjectSomeValuesFrom(:609096000 \
495 ObjectSomeValuesFrom(:{site_attr} :{general_site})))"
496 ),
497 format!("SubClassOf(:{child} :{parent})"),
498 format!(
499 "SubClassOf(:{child} ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
500 ObjectSomeValuesFrom(:{site_attr} :{specific_site}) \
501 ObjectSomeValuesFrom(:{extra_attr} :{extra_value}))))"
502 ),
503 ]);
504 let report = necessary_normal_form(&input);
505
506 let nnf_child = &report.forms[&child];
507 let mut attrs = nnf_child.attributes.clone();
508 attrs.sort_by_key(|a| a.type_id);
509 assert_eq!(
510 attrs,
511 vec![
512 Attribute {
513 group: 1,
514 type_id: site_attr,
515 destination_id: specific_site
516 },
517 Attribute {
518 group: 1,
519 type_id: extra_attr,
520 destination_id: extra_value
521 },
522 ]
523 );
524 }
525
526 #[test]
527 fn unmodeled_attribute_shape_is_reported_not_silently_dropped() {
528 let concept = id(2060);
530 let attr = id(2061);
531 let value_attr = id(2062);
532 let input = axioms(&[format!(
533 "SubClassOf(:{concept} ObjectSomeValuesFrom(:609096000 \
534 DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
535 )]);
536 let report = necessary_normal_form(&input);
537
538 let nnf = &report.forms[&concept];
539 assert!(nnf.attributes.is_empty());
540 assert!(report
541 .skipped
542 .contains(&SkippedConstruct::UnmodeledAttributeShape { concept }));
543 let _ = attr; }
545
546 #[test]
547 fn gci_contributes_only_via_subsumption_never_a_direct_profile() {
548 let a = id(2070);
552 let b = id(2071);
553 let c = id(2072);
554 let x = id(2073);
555 let attr = id(2074);
556 let value = id(2075);
557 let input = axioms(&[
558 format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})"),
559 format!("SubClassOf(:{c} ObjectSomeValuesFrom(:{attr} :{value}))"),
560 format!("EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"),
561 ]);
562 let report = necessary_normal_form(&input);
563
564 let nnf_x = &report.forms[&x];
565 assert!(nnf_x.is_a.contains(&c));
566 assert_eq!(
567 nnf_x.attributes,
568 vec![Attribute {
569 group: 0,
570 type_id: attr,
571 destination_id: value
572 }]
573 );
574 }
575}