Skip to main content

snomed_classify/
normal_form.rs

1//! Necessary normal form generation, per `spec/14-necessary-normal-form.md`:
2//! reduces a classified ontology's entailed subsumption plus each
3//! concept's stated attributes down to the minimal set that, together
4//! with subsumption reasoning, implies everything else — the shape real
5//! RF2 `Relationship` rows take.
6
7use 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/// One necessary-normal-form relationship: `group` is `0` for ungrouped
17/// (spec/07's `relationshipGroup` convention), otherwise a group number
18/// assigned `1..N` in a stable (sorted) order — this crate generates from
19/// a single axiom set with no prior release to number against, so there's
20/// no attempt to preserve any particular numbering scheme beyond that.
21#[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/// A concept's necessary normal form: its proximal (most specific
29/// entailed, non-redundant) parents, and its redundancy-reduced
30/// attributes.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct NecessaryNormalForm {
33    pub is_a: Vec<SctId>,
34    pub attributes: Vec<Attribute>,
35}
36
37/// [`necessary_normal_form`]'s result: one [`NecessaryNormalForm`] per
38/// named concept the input axioms said anything about, plus every
39/// construct recognized but not modeled (spec/14's scope), reported never
40/// silently dropped.
41#[derive(Debug, Clone)]
42pub struct NecessaryNormalFormReport {
43    pub forms: HashMap<SctId, NecessaryNormalForm>,
44    pub skipped: Vec<SkippedConstruct>,
45}
46
47/// Computes the necessary normal form of every concept `axioms` mention,
48/// via classification (spec/13) plus stated-profile redundancy
49/// elimination (spec/14). `axioms` is read multiple times (classification,
50/// stated-profile extraction, role hierarchy) — a slice, not a
51/// single-pass iterator, unlike [`classify`].
52pub 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
99/// `p` survives as a proximal parent of `c` unless some other entailed
100/// supertype `q` of `c` already implies `p` (spec/14 rule 1).
101///
102/// Two *equivalent* supertypes imply each other, so a naive "drop `p` when
103/// some `q` implies it" eliminates both and leaves `c` with no parent at
104/// all (spec/14 rule 5). Equivalent supertypes are therefore an
105/// equivalence class from which exactly one representative survives — the
106/// lowest SCTID, so the choice is deterministic rather than dependent on
107/// iteration order.
108fn 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                // `q` implies `p`. If `p` implies `q` back they are
117                // equivalent, and only the lower id survives; otherwise
118                // `q` is strictly more specific and `p` is redundant.
119                !classification.is_subsumed_by(p, q) || q < p
120            })
121        })
122        .copied()
123        .collect()
124}
125
126/// Transitive closure of `SubObjectPropertyOf`'s plain (non-chain) role
127/// hierarchy edges, reflexive (`r` is its own ancestor) so callers never
128/// need a separate `==` check alongside a closure lookup.
129fn 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    /// `true` if this candidate originated as (or was inherited from) an
166    /// ungrouped (`relationshipGroup 0`) stated attribute.
167    group0: bool,
168    fragments: Vec<RawAttribute>,
169}
170
171/// Computes (and caches) `c`'s redundancy-reduced attribute groups:
172/// its own stated groups plus each proximal parent's already-reduced
173/// groups, combined via [`insert_candidate`]. Cycle-safe: a concept
174/// reachable from itself via proximal-parent edges (only possible via a
175/// degenerate `EquivalentClasses` cycle — spec/14 rule 3) contributes
176/// nothing rather than recursing forever.
177fn 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
229/// `GroupSet.add`, ported: `new` is dropped if some existing candidate is
230/// already same-or-stronger; otherwise `new` replaces every existing
231/// candidate `new` makes redundant.
232fn 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
248/// `candidate` is same-or-stronger than `other` when every fragment in
249/// `other` is made redundant by some fragment in `candidate` — `candidate`
250/// may have extra fragments `other` doesn't need to cover.
251fn 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
264/// `(s, D)` makes `(r, C)` redundant when `r` is `s` or an ancestor of `s`
265/// in the role hierarchy, and `C` is `D` or an ancestor of `D` in concept
266/// subsumption (spec/14).
267fn 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    // Stable, deterministic numbering: sort groups by their fragments.
303    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    /// A synthetic, check-digit-valid SCTID for test fixture concepts
338    /// that aren't genuine SNOMED CT concepts (root `CLAUDE.md` convention).
339    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        // A is entailed to be a subtype of both B and C, but C is
363        // redundant (implied transitively via B) — only B survives.
364        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        // Parent has `site = General`; Child (a subtype of Parent) states
373        // its own `site = Specific`, where Specific is a subtype of
374        // General. Child's NNF must keep only the specific attribute —
375        // the inherited general one is implied by it.
376        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        // Parent has `genericAttr = V`; Child states its own
403        // `specificAttr = V`, where specificAttr is a subtype (in the
404        // role hierarchy) of genericAttr. Child's NNF keeps only the
405        // specific-typed attribute.
406        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        // The real SNOMED OWL pattern (spec/12, spec/14): a role group is
433        // ObjectSomeValuesFrom(RoleGroup, ObjectIntersectionOf(attrs...)).
434        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        // A top-level ObjectSomeValuesFrom, not wrapped in RoleGroup, is
472        // an MRCM-never-grouped-style attribute — relationshipGroup 0.
473        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        // Group 1 (inherited): { siteAttr = GeneralSite }.
495        // Group 2 (own, more specific): { siteAttr = SpecificSite, extra = X }.
496        // Since SpecificSite ⊑ GeneralSite, group 2 alone entails
497        // everything group 1 requires — group 1 is fully redundant.
498        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        // A DataHasValue filler inside a role group isn't modeled.
543        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; // referenced for readability of the fixture only
558    }
559
560    #[test]
561    fn gci_contributes_only_via_subsumption_never_a_direct_profile() {
562        // SubClassOf(ObjectIntersectionOf(A, B), C) — a GCI. X, defined as
563        // A AND B, is thereby entailed a subtype of C and inherits C's
564        // stated attribute, purely through classification.
565        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        // spec/14 rule 5: `B` and `C` are equivalent, so each implies the
593        // other. Dropping every implied parent would leave `A` with no
594        // parent at all; exactly one representative (the lower SCTID)
595        // must survive.
596        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}