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).
101fn 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
112/// Transitive closure of `SubObjectPropertyOf`'s plain (non-chain) role
113/// hierarchy edges, reflexive (`r` is its own ancestor) so callers never
114/// need a separate `==` check alongside a closure lookup.
115fn 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    /// `true` if this candidate originated as (or was inherited from) an
152    /// ungrouped (`relationshipGroup 0`) stated attribute.
153    group0: bool,
154    fragments: Vec<RawAttribute>,
155}
156
157/// Computes (and caches) `c`'s redundancy-reduced attribute groups:
158/// its own stated groups plus each proximal parent's already-reduced
159/// groups, combined via [`insert_candidate`]. Cycle-safe: a concept
160/// reachable from itself via proximal-parent edges (only possible via a
161/// degenerate `EquivalentClasses` cycle — spec/14 rule 3) contributes
162/// nothing rather than recursing forever.
163fn 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
215/// `GroupSet.add`, ported: `new` is dropped if some existing candidate is
216/// already same-or-stronger; otherwise `new` replaces every existing
217/// candidate `new` makes redundant.
218fn 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
234/// `candidate` is same-or-stronger than `other` when every fragment in
235/// `other` is made redundant by some fragment in `candidate` — `candidate`
236/// may have extra fragments `other` doesn't need to cover.
237fn 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
250/// `(s, D)` makes `(r, C)` redundant when `r` is `s` or an ancestor of `s`
251/// in the role hierarchy, and `C` is `D` or an ancestor of `D` in concept
252/// subsumption (spec/14).
253fn 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    // Stable, deterministic numbering: sort groups by their fragments.
289    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    /// A synthetic, check-digit-valid SCTID for test fixture concepts
324    /// that aren't genuine SNOMED CT concepts (root `CLAUDE.md` convention).
325    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        // A is entailed to be a subtype of both B and C, but C is
349        // redundant (implied transitively via B) — only B survives.
350        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        // Parent has `site = General`; Child (a subtype of Parent) states
359        // its own `site = Specific`, where Specific is a subtype of
360        // General. Child's NNF must keep only the specific attribute —
361        // the inherited general one is implied by it.
362        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        // Parent has `genericAttr = V`; Child states its own
389        // `specificAttr = V`, where specificAttr is a subtype (in the
390        // role hierarchy) of genericAttr. Child's NNF keeps only the
391        // specific-typed attribute.
392        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        // The real SNOMED OWL pattern (spec/12, spec/14): a role group is
419        // ObjectSomeValuesFrom(RoleGroup, ObjectIntersectionOf(attrs...)).
420        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        // A top-level ObjectSomeValuesFrom, not wrapped in RoleGroup, is
458        // an MRCM-never-grouped-style attribute — relationshipGroup 0.
459        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        // Group 1 (inherited): { siteAttr = GeneralSite }.
481        // Group 2 (own, more specific): { siteAttr = SpecificSite, extra = X }.
482        // Since SpecificSite ⊑ GeneralSite, group 2 alone entails
483        // everything group 1 requires — group 1 is fully redundant.
484        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        // A DataHasValue filler inside a role group isn't modeled.
529        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; // referenced for readability of the fixture only
544    }
545
546    #[test]
547    fn gci_contributes_only_via_subsumption_never_a_direct_profile() {
548        // SubClassOf(ObjectIntersectionOf(A, B), C) — a GCI. X, defined as
549        // A AND B, is thereby entailed a subtype of C and inherits C's
550        // stated attribute, purely through classification.
551        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}