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, ObjectPropertyExpression};
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        chains: None,
78    };
79
80    // First pass: Rule 1 only (class and role inclusions). Rule 2 can't run
81    // yet — it asks whether one attribute's filler reaches another's by
82    // following a property, and that graph is made of the very forms this
83    // pass produces (spec/14).
84    let mut forms = HashMap::new();
85    for &c in &concepts {
86        let candidates = groups_for(c, &mut ctx);
87        let is_a = proximal.get(&c).cloned().unwrap_or_default();
88        forms.insert(c, finalize(is_a, candidates));
89    }
90
91    // Second pass: with the node graphs built, re-normalize the concepts
92    // Rule 2 could possibly affect — those holding an attribute whose type
93    // is (or is a subtype of) some chain's source type. Everything else
94    // would recompute to the identical answer, so the reference
95    // implementation skips it too.
96    let chains = property_chains(axioms);
97    if !chains.is_empty() {
98        let graphs = NodeGraphs::build(&chains, &forms);
99        let affected: Vec<SctId> = forms
100            .iter()
101            .filter(|(_, form)| {
102                form.attributes.iter().any(|attribute| {
103                    chains.iter().any(|chain| {
104                        chain.source == attribute.type_id
105                            || role_ancestors
106                                .get(&attribute.type_id)
107                                .is_some_and(|a| a.contains(&chain.source))
108                    })
109                })
110            })
111            .map(|(&c, _)| c)
112            .collect();
113
114        ctx.chains = Some((&chains, &graphs));
115        for c in affected {
116            // Drop only this concept's cached first-pass groups: its
117            // ancestors' entries stay, so inherited fragments arrive
118            // already reduced, exactly as in the reference.
119            ctx.cache.remove(&c);
120            let candidates = groups_for(c, &mut ctx);
121            let is_a = proximal.get(&c).cloned().unwrap_or_default();
122            forms.insert(c, finalize(is_a, candidates));
123        }
124    }
125
126    NecessaryNormalFormReport { forms, skipped }
127}
128
129fn dedup_unordered<T: PartialEq + Copy>(items: &mut Vec<T>) {
130    let mut unique = Vec::with_capacity(items.len());
131    for item in items.drain(..) {
132        if !unique.contains(&item) {
133            unique.push(item);
134        }
135    }
136    *items = unique;
137}
138
139/// `p` survives as a proximal parent of `c` unless some other entailed
140/// supertype `q` of `c` already implies `p` (spec/14 rule 1).
141///
142/// Two *equivalent* supertypes imply each other, so a naive "drop `p` when
143/// some `q` implies it" eliminates both and leaves `c` with no parent at
144/// all (spec/14 rule 5). Equivalent supertypes are therefore an
145/// equivalence class from which exactly one representative survives — the
146/// lowest SCTID, so the choice is deterministic rather than dependent on
147/// iteration order.
148fn proximal_parents(c: SctId, classification: &Classification) -> Vec<SctId> {
149    let all: Vec<SctId> = classification.subsumers(c).collect();
150    all.iter()
151        .filter(|&&p| {
152            !all.iter().any(|&q| {
153                if q == p || !classification.is_subsumed_by(q, p) {
154                    return false;
155                }
156                // `q` implies `p`. If `p` implies `q` back they are
157                // equivalent, and only the lower id survives; otherwise
158                // `q` is strictly more specific and `p` is redundant.
159                !classification.is_subsumed_by(p, q) || q < p
160            })
161        })
162        .copied()
163        .collect()
164}
165
166/// Transitive closure of `SubObjectPropertyOf`'s plain (non-chain) role
167/// hierarchy edges, reflexive (`r` is its own ancestor) so callers never
168/// need a separate `==` check alongside a closure lookup.
169fn role_ancestor_closure(axioms: &[Axiom]) -> HashMap<SctId, HashSet<SctId>> {
170    let tbox = normalize::normalize(axioms);
171    let mut direct: HashMap<SctId, Vec<SctId>> = HashMap::new();
172    for (sub, sup) in &tbox.role_hierarchy {
173        if let (crate::types::RoleId::Named(s), crate::types::RoleId::Named(t)) = (sub, sup) {
174            direct.entry(*s).or_default().push(*t);
175        }
176    }
177
178    let mut closure: HashMap<SctId, HashSet<SctId>> = HashMap::new();
179    for &role in direct.keys() {
180        let mut seen = HashSet::from([role]);
181        let mut queue = vec![role];
182        while let Some(r) = queue.pop() {
183            for &next in direct.get(&r).map(Vec::as_slice).unwrap_or(&[]) {
184                if seen.insert(next) {
185                    queue.push(next);
186                }
187            }
188        }
189        closure.insert(role, seen);
190    }
191    closure
192}
193
194/// One `SubObjectPropertyOf(ObjectPropertyChain(t s) r)` axiom, in the
195/// reference implementation's names: `t ∘ s ⊑ r` (spec/14 Rule 2).
196///
197/// A `TransitiveObjectProperty(r)` axiom is the chain `r ∘ r ⊑ r`, and is
198/// collected here as one — which is why transitive-property redundancy
199/// needs no separate rule.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201struct PropertyChain {
202    source: SctId,
203    destination: SctId,
204    inferred: SctId,
205}
206
207/// Every property chain in `axioms`. Chains longer than two operands are
208/// skipped: `snomed-classify` normalizes them for *classification* with
209/// fresh intermediate roles (spec/13), but those fresh roles name nothing
210/// a relationship can refer to, so they can't participate in the
211/// relationship-level redundancy Rule 2 describes. SNOMED CT uses only
212/// two-operand chains (spec/12).
213fn property_chains(axioms: &[Axiom]) -> Vec<PropertyChain> {
214    let mut chains = Vec::new();
215    for axiom in axioms {
216        match axiom {
217            Axiom::SubObjectPropertyOf {
218                sub: ObjectPropertyExpression::Chain(ids),
219                sup,
220            } if ids.len() == 2 => chains.push(PropertyChain {
221                source: ids[0],
222                destination: ids[1],
223                inferred: *sup,
224            }),
225            Axiom::TransitiveObjectProperty(r) => chains.push(PropertyChain {
226                source: *r,
227                destination: *r,
228                inferred: *r,
229            }),
230            _ => {}
231        }
232    }
233    chains.sort_by_key(|c| (c.source, c.destination, c.inferred));
234    chains.dedup();
235    chains
236}
237
238/// Direct `concept --property--> filler` edges for every property that
239/// appears as a chain's *destination*, taken from the first pass's normal
240/// forms — the reference implementation's `NodeGraph` per traversable
241/// property.
242///
243/// Only chain destinations need a graph: Rule 2 asks "does `D` reach `C`
244/// via `s`", and `s` is always a chain's destination type.
245#[derive(Debug, Default)]
246struct NodeGraphs {
247    edges: HashMap<(SctId, SctId), Vec<SctId>>,
248}
249
250impl NodeGraphs {
251    fn build(chains: &[PropertyChain], forms: &HashMap<SctId, NecessaryNormalForm>) -> Self {
252        let traversable: HashSet<SctId> = chains.iter().map(|c| c.destination).collect();
253        let mut edges: HashMap<(SctId, SctId), Vec<SctId>> = HashMap::new();
254        for (&concept, form) in forms {
255            for attribute in &form.attributes {
256                if traversable.contains(&attribute.type_id) {
257                    edges
258                        .entry((concept, attribute.type_id))
259                        .or_default()
260                        .push(attribute.destination_id);
261                }
262            }
263        }
264        for targets in edges.values_mut() {
265            targets.sort_unstable();
266            targets.dedup();
267        }
268        NodeGraphs { edges }
269    }
270
271    /// The reference's `getPropertyChainTransitiveClosure`: everything
272    /// reachable from `from` by following `property` any number of times,
273    /// including `from` itself, plus the concept-subsumption ancestors of
274    /// every concept so reached.
275    fn chain_closure(
276        &self,
277        from: SctId,
278        property: SctId,
279        classification: &Classification,
280    ) -> HashSet<SctId> {
281        let mut reached = HashSet::from([from]);
282        let mut queue = vec![from];
283        while let Some(node) = queue.pop() {
284            for &next in self
285                .edges
286                .get(&(node, property))
287                .map(Vec::as_slice)
288                .unwrap_or(&[])
289            {
290                if reached.insert(next) {
291                    queue.push(next);
292                }
293            }
294        }
295        let ancestors: Vec<SctId> = reached
296            .iter()
297            .flat_map(|&node| classification.subsumers(node))
298            .collect();
299        reached.extend(ancestors);
300        reached
301    }
302}
303
304struct Context<'a> {
305    profiles: &'a HashMap<SctId, StatedProfile>,
306    classification: &'a Classification,
307    role_ancestors: &'a HashMap<SctId, HashSet<SctId>>,
308    proximal: &'a HashMap<SctId, Vec<SctId>>,
309    cache: HashMap<SctId, Vec<GroupCandidate>>,
310    in_progress: HashSet<SctId>,
311    /// `None` during the first pass, `Some` during the second: Rule 2
312    /// needs the node graphs, and those can only be built once every
313    /// concept's first-pass form is known (spec/14).
314    chains: Option<(&'a [PropertyChain], &'a NodeGraphs)>,
315}
316
317#[derive(Debug, Clone)]
318struct GroupCandidate {
319    /// `true` if this candidate originated as (or was inherited from) an
320    /// ungrouped (`relationshipGroup 0`) stated attribute.
321    group0: bool,
322    fragments: Vec<RawAttribute>,
323}
324
325/// Computes (and caches) `c`'s redundancy-reduced attribute groups:
326/// its own stated groups plus each proximal parent's already-reduced
327/// groups, combined via [`insert_candidate`]. Cycle-safe: a concept
328/// reachable from itself via proximal-parent edges (only possible via a
329/// degenerate `EquivalentClasses` cycle — spec/14 rule 3) contributes
330/// nothing rather than recursing forever.
331fn groups_for(c: SctId, ctx: &mut Context<'_>) -> Vec<GroupCandidate> {
332    if let Some(cached) = ctx.cache.get(&c) {
333        return cached.clone();
334    }
335    if !ctx.in_progress.insert(c) {
336        return Vec::new();
337    }
338
339    let mut candidates: Vec<GroupCandidate> = Vec::new();
340
341    if let Some(profile) = ctx.profiles.get(&c) {
342        for &attr in &profile.ungrouped {
343            insert_candidate(
344                &mut candidates,
345                GroupCandidate {
346                    group0: true,
347                    fragments: vec![attr],
348                },
349                ctx.role_ancestors,
350                ctx.classification,
351                ctx.chains,
352            );
353        }
354        for group in profile.groups.clone() {
355            insert_candidate(
356                &mut candidates,
357                GroupCandidate {
358                    group0: false,
359                    fragments: group,
360                },
361                ctx.role_ancestors,
362                ctx.classification,
363                ctx.chains,
364            );
365        }
366    }
367
368    let parents = ctx.proximal.get(&c).cloned().unwrap_or_default();
369    for parent in parents {
370        for inherited in groups_for(parent, ctx) {
371            insert_candidate(
372                &mut candidates,
373                inherited,
374                ctx.role_ancestors,
375                ctx.classification,
376                ctx.chains,
377            );
378        }
379    }
380
381    ctx.in_progress.remove(&c);
382    ctx.cache.insert(c, candidates.clone());
383    candidates
384}
385
386/// `GroupSet.add`, ported: `new` is dropped if some existing candidate is
387/// already same-or-stronger; otherwise `new` replaces every existing
388/// candidate `new` makes redundant.
389fn insert_candidate(
390    candidates: &mut Vec<GroupCandidate>,
391    new: GroupCandidate,
392    role_ancestors: &HashMap<SctId, HashSet<SctId>>,
393    classification: &Classification,
394    chains: Option<(&[PropertyChain], &NodeGraphs)>,
395) {
396    if candidates
397        .iter()
398        .any(|g| group_is_same_or_stronger(g, &new, role_ancestors, classification, chains))
399    {
400        return;
401    }
402    candidates
403        .retain(|g| !group_is_same_or_stronger(&new, g, role_ancestors, classification, chains));
404    candidates.push(new);
405}
406
407/// `candidate` is same-or-stronger than `other` when every fragment in
408/// `other` is made redundant by some fragment in `candidate` — `candidate`
409/// may have extra fragments `other` doesn't need to cover.
410fn group_is_same_or_stronger(
411    candidate: &GroupCandidate,
412    other: &GroupCandidate,
413    role_ancestors: &HashMap<SctId, HashSet<SctId>>,
414    classification: &Classification,
415    chains: Option<(&[PropertyChain], &NodeGraphs)>,
416) -> bool {
417    other.fragments.iter().all(|&weaker| {
418        candidate.fragments.iter().any(|&stronger| {
419            fragment_is_same_or_stronger(stronger, weaker, role_ancestors, classification, chains)
420        })
421    })
422}
423
424/// Whether `stronger` makes `weaker` redundant, by either of spec/14's two
425/// rules. Named after the reference implementation's
426/// `RelationshipFragment.isSameOrStrongerThan`, and following its
427/// vocabulary: `stronger` is `B = (u, D)`, `weaker` is `A = (r, C)`.
428///
429/// **Rule 1 — class and role inclusions.** `A` is redundant when `u` is
430/// `r` or a subtype of it, and `D` is `C` or a subtype of it.
431///
432/// **Rule 2 — property chains.** Given a chain `t ∘ s ⊑ r`, `A` is
433/// redundant when `u` is `t` or a subtype of it, and `D` reaches `C` via
434/// `s`. Concretely: if a concept has `findingSite = Hand` and
435/// `findingSite = UpperLimb`, `Hand partOf UpperLimb` holds, and
436/// `findingSite ∘ partOf ⊑ findingSite`, then the second attribute adds
437/// nothing the first doesn't already imply. Only available on the second
438/// pass, when `chains` is `Some` — the node graphs it needs are built from
439/// the first pass's forms.
440fn fragment_is_same_or_stronger(
441    stronger: RawAttribute,
442    weaker: RawAttribute,
443    role_ancestors: &HashMap<SctId, HashSet<SctId>>,
444    classification: &Classification,
445    chains: Option<(&[PropertyChain], &NodeGraphs)>,
446) -> bool {
447    let (u, d) = stronger;
448    let (r, c) = weaker;
449    let role_closure = |role: SctId, ancestor: SctId| {
450        role == ancestor
451            || role_ancestors
452                .get(&role)
453                .is_some_and(|ancestors| ancestors.contains(&ancestor))
454    };
455
456    // Rule 1.
457    if role_closure(u, r) && (d == c || classification.is_subsumed_by(d, c)) {
458        return true;
459    }
460
461    // Rule 2.
462    let Some((chains, graphs)) = chains else {
463        return false;
464    };
465    chains
466        .iter()
467        .filter(|chain| chain.inferred == r && role_closure(u, chain.source))
468        .any(|chain| {
469            graphs
470                .chain_closure(d, chain.destination, classification)
471                .contains(&c)
472        })
473}
474
475fn finalize(mut is_a: Vec<SctId>, candidates: Vec<GroupCandidate>) -> NecessaryNormalForm {
476    is_a.sort();
477    is_a.dedup();
478
479    let mut ungrouped: Vec<RawAttribute> = Vec::new();
480    let mut numbered_groups: Vec<Vec<RawAttribute>> = Vec::new();
481    for candidate in candidates {
482        if candidate.group0 {
483            ungrouped.extend(candidate.fragments);
484        } else {
485            numbered_groups.push(candidate.fragments);
486        }
487    }
488    ungrouped.sort();
489
490    // Stable, deterministic numbering: sort groups by their fragments.
491    for group in &mut numbered_groups {
492        group.sort();
493    }
494    numbered_groups.sort();
495
496    let mut attributes: Vec<Attribute> = ungrouped
497        .into_iter()
498        .map(|(type_id, destination_id)| Attribute {
499            group: 0,
500            type_id,
501            destination_id,
502        })
503        .collect();
504    for (index, group) in numbered_groups.into_iter().enumerate() {
505        let group_number = (index + 1) as u32;
506        attributes.extend(
507            group
508                .into_iter()
509                .map(|(type_id, destination_id)| Attribute {
510                    group: group_number,
511                    type_id,
512                    destination_id,
513                }),
514        );
515    }
516
517    NecessaryNormalForm { is_a, attributes }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use snomed_core::sctid::ComponentType;
524
525    /// A synthetic, check-digit-valid SCTID for test fixture concepts
526    /// that aren't genuine SNOMED CT concepts (root `CLAUDE.md` convention).
527    fn id(item: u64) -> SctId {
528        SctId::compose(item, ComponentType::Concept, None).unwrap()
529    }
530
531    fn ax(s: &str) -> Axiom {
532        snomed_owl::parse(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
533    }
534
535    fn axioms(strs: &[String]) -> Vec<Axiom> {
536        strs.iter().map(|s| ax(s)).collect()
537    }
538
539    #[test]
540    fn proximal_parents_exclude_transitively_redundant_ancestors() {
541        let a = id(2001);
542        let b = id(2002);
543        let c = id(2003);
544        let input = axioms(&[
545            format!("SubClassOf(:{a} :{b})"),
546            format!("SubClassOf(:{b} :{c})"),
547        ]);
548        let report = necessary_normal_form(&input);
549
550        // A is entailed to be a subtype of both B and C, but C is
551        // redundant (implied transitively via B) — only B survives.
552        let nnf_a = &report.forms[&a];
553        assert_eq!(nnf_a.is_a, vec![b]);
554        let nnf_b = &report.forms[&b];
555        assert_eq!(nnf_b.is_a, vec![c]);
556    }
557
558    #[test]
559    fn own_specific_attribute_makes_inherited_general_one_redundant() {
560        // Parent has `site = General`; Child (a subtype of Parent) states
561        // its own `site = Specific`, where Specific is a subtype of
562        // General. Child's NNF must keep only the specific attribute —
563        // the inherited general one is implied by it.
564        let parent = id(2010);
565        let child = id(2011);
566        let site = id(2012);
567        let general = id(2013);
568        let specific = id(2014);
569        let input = axioms(&[
570            format!("SubClassOf(:{specific} :{general})"),
571            format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{site} :{general}))"),
572            format!("SubClassOf(:{child} :{parent})"),
573            format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{site} :{specific}))"),
574        ]);
575        let report = necessary_normal_form(&input);
576
577        let nnf_child = &report.forms[&child];
578        assert_eq!(
579            nnf_child.attributes,
580            vec![Attribute {
581                group: 0,
582                type_id: site,
583                destination_id: specific
584            }]
585        );
586    }
587
588    #[test]
589    fn role_hierarchy_makes_general_attribute_type_redundant() {
590        // Parent has `genericAttr = V`; Child states its own
591        // `specificAttr = V`, where specificAttr is a subtype (in the
592        // role hierarchy) of genericAttr. Child's NNF keeps only the
593        // specific-typed attribute.
594        let parent = id(2020);
595        let child = id(2021);
596        let generic_attr = id(2022);
597        let specific_attr = id(2023);
598        let value = id(2024);
599        let input = axioms(&[
600            format!("SubObjectPropertyOf(:{specific_attr} :{generic_attr})"),
601            format!("SubClassOf(:{parent} ObjectSomeValuesFrom(:{generic_attr} :{value}))"),
602            format!("SubClassOf(:{child} :{parent})"),
603            format!("SubClassOf(:{child} ObjectSomeValuesFrom(:{specific_attr} :{value}))"),
604        ]);
605        let report = necessary_normal_form(&input);
606
607        let nnf_child = &report.forms[&child];
608        assert_eq!(
609            nnf_child.attributes,
610            vec![Attribute {
611                group: 0,
612                type_id: specific_attr,
613                destination_id: value
614            }]
615        );
616    }
617
618    #[test]
619    fn role_group_is_reconstructed_from_the_owl_encoding() {
620        // The real SNOMED OWL pattern (spec/12, spec/14): a role group is
621        // ObjectSomeValuesFrom(RoleGroup, ObjectIntersectionOf(attrs...)).
622        let concept = id(2030);
623        let parent = id(2031);
624        let finding_site = id(2032);
625        let site_value = id(2033);
626        let morphology = id(2034);
627        let morphology_value = id(2035);
628        let input = axioms(&[format!(
629            "EquivalentClasses(:{concept} ObjectIntersectionOf(:{parent} \
630             ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
631             ObjectSomeValuesFrom(:{finding_site} :{site_value}) \
632             ObjectSomeValuesFrom(:{morphology} :{morphology_value})))))"
633        )]);
634        let report = necessary_normal_form(&input);
635
636        let nnf = &report.forms[&concept];
637        assert_eq!(nnf.is_a, vec![parent]);
638        let mut attrs = nnf.attributes.clone();
639        attrs.sort_by_key(|a| a.type_id);
640        assert_eq!(
641            attrs,
642            vec![
643                Attribute {
644                    group: 1,
645                    type_id: finding_site,
646                    destination_id: site_value
647                },
648                Attribute {
649                    group: 1,
650                    type_id: morphology,
651                    destination_id: morphology_value
652                },
653            ]
654        );
655    }
656
657    #[test]
658    fn ungrouped_attribute_stays_group_zero() {
659        // A top-level ObjectSomeValuesFrom, not wrapped in RoleGroup, is
660        // an MRCM-never-grouped-style attribute — relationshipGroup 0.
661        let concept = id(2040);
662        let attr = id(2041);
663        let value = id(2042);
664        let input = axioms(&[format!(
665            "SubClassOf(:{concept} ObjectSomeValuesFrom(:{attr} :{value}))"
666        )]);
667        let report = necessary_normal_form(&input);
668
669        let nnf = &report.forms[&concept];
670        assert_eq!(
671            nnf.attributes,
672            vec![Attribute {
673                group: 0,
674                type_id: attr,
675                destination_id: value
676            }]
677        );
678    }
679
680    #[test]
681    fn group_redundant_across_two_whole_groups_is_eliminated() {
682        // Group 1 (inherited): { siteAttr = GeneralSite }.
683        // Group 2 (own, more specific): { siteAttr = SpecificSite, extra = X }.
684        // Since SpecificSite ⊑ GeneralSite, group 2 alone entails
685        // everything group 1 requires — group 1 is fully redundant.
686        let parent = id(2050);
687        let child = id(2051);
688        let site_attr = id(2052);
689        let general_site = id(2053);
690        let specific_site = id(2054);
691        let extra_attr = id(2055);
692        let extra_value = id(2056);
693        let input = axioms(&[
694            format!("SubClassOf(:{specific_site} :{general_site})"),
695            format!(
696                "SubClassOf(:{parent} ObjectSomeValuesFrom(:609096000 \
697                 ObjectSomeValuesFrom(:{site_attr} :{general_site})))"
698            ),
699            format!("SubClassOf(:{child} :{parent})"),
700            format!(
701                "SubClassOf(:{child} ObjectSomeValuesFrom(:609096000 ObjectIntersectionOf(\
702                 ObjectSomeValuesFrom(:{site_attr} :{specific_site}) \
703                 ObjectSomeValuesFrom(:{extra_attr} :{extra_value}))))"
704            ),
705        ]);
706        let report = necessary_normal_form(&input);
707
708        let nnf_child = &report.forms[&child];
709        let mut attrs = nnf_child.attributes.clone();
710        attrs.sort_by_key(|a| a.type_id);
711        assert_eq!(
712            attrs,
713            vec![
714                Attribute {
715                    group: 1,
716                    type_id: site_attr,
717                    destination_id: specific_site
718                },
719                Attribute {
720                    group: 1,
721                    type_id: extra_attr,
722                    destination_id: extra_value
723                },
724            ]
725        );
726    }
727
728    #[test]
729    fn unmodeled_attribute_shape_is_reported_not_silently_dropped() {
730        // A DataHasValue filler inside a role group isn't modeled.
731        let concept = id(2060);
732        let attr = id(2061);
733        let value_attr = id(2062);
734        let input = axioms(&[format!(
735            "SubClassOf(:{concept} ObjectSomeValuesFrom(:609096000 \
736             DataHasValue(:{value_attr} \"1\"^^xsd:integer)))"
737        )]);
738        let report = necessary_normal_form(&input);
739
740        let nnf = &report.forms[&concept];
741        assert!(nnf.attributes.is_empty());
742        assert!(report
743            .skipped
744            .contains(&SkippedConstruct::UnmodeledAttributeShape { concept }));
745        let _ = attr; // referenced for readability of the fixture only
746    }
747
748    #[test]
749    fn gci_contributes_only_via_subsumption_never_a_direct_profile() {
750        // SubClassOf(ObjectIntersectionOf(A, B), C) — a GCI. X, defined as
751        // A AND B, is thereby entailed a subtype of C and inherits C's
752        // stated attribute, purely through classification.
753        let a = id(2070);
754        let b = id(2071);
755        let c = id(2072);
756        let x = id(2073);
757        let attr = id(2074);
758        let value = id(2075);
759        let input = axioms(&[
760            format!("SubClassOf(ObjectIntersectionOf(:{a} :{b}) :{c})"),
761            format!("SubClassOf(:{c} ObjectSomeValuesFrom(:{attr} :{value}))"),
762            format!("EquivalentClasses(:{x} ObjectIntersectionOf(:{a} :{b}))"),
763        ]);
764        let report = necessary_normal_form(&input);
765
766        let nnf_x = &report.forms[&x];
767        assert!(nnf_x.is_a.contains(&c));
768        assert_eq!(
769            nnf_x.attributes,
770            vec![Attribute {
771                group: 0,
772                type_id: attr,
773                destination_id: value
774            }]
775        );
776    }
777
778    #[test]
779    fn equivalent_parents_do_not_eliminate_each_other() {
780        // spec/14 rule 5: `B` and `C` are equivalent, so each implies the
781        // other. Dropping every implied parent would leave `A` with no
782        // parent at all; exactly one representative (the lower SCTID)
783        // must survive.
784        let a = id(1090);
785        let b = id(1091);
786        let c = id(1092);
787        assert!(b < c, "the test relies on b sorting before c");
788        let report = necessary_normal_form(&[
789            ax(&format!("EquivalentClasses(:{b} :{c})")),
790            ax(&format!("SubClassOf(:{a} :{b})")),
791        ]);
792        assert_eq!(report.forms[&a].is_a, vec![b]);
793    }
794
795    #[test]
796    fn a_property_chain_makes_a_more_general_attribute_redundant() {
797        // spec/14 Rule 2, the reference implementation's second pass.
798        // Given `findingSite ∘ partOf ⊑ findingSite`, a concept stating
799        // both `findingSite = Hand` and `findingSite = UpperLimb` needs
800        // only the first: Hand is part of UpperLimb, so the chain already
801        // entails the second. Rule 1 cannot see this — neither attribute
802        // subsumes the other by role hierarchy and concept subsumption.
803        let finding_site = id(1200);
804        let part_of = id(1201);
805        let hand = id(1202);
806        let upper_limb = id(1203);
807        let disorder = id(1204);
808        let hand_disorder = id(1205);
809
810        let axioms = vec![
811            ax(&format!(
812                "SubObjectPropertyOf(ObjectPropertyChain(:{finding_site} :{part_of}) :{finding_site})"
813            )),
814            // Hand is part of Upper limb — the edge the node graph needs,
815            // and it must be in Hand's own normal form to be seen.
816            ax(&format!(
817                "SubClassOf(:{hand} ObjectSomeValuesFrom(:{part_of} :{upper_limb}))"
818            )),
819            ax(&format!(
820                "EquivalentClasses(:{hand_disorder} ObjectIntersectionOf(:{disorder} \
821                 ObjectSomeValuesFrom(:{finding_site} :{hand}) \
822                 ObjectSomeValuesFrom(:{finding_site} :{upper_limb})))"
823            )),
824        ];
825
826        let report = necessary_normal_form(&axioms);
827        let form = &report.forms[&hand_disorder];
828        let sites: Vec<SctId> = form
829            .attributes
830            .iter()
831            .filter(|a| a.type_id == finding_site)
832            .map(|a| a.destination_id)
833            .collect();
834        assert_eq!(
835            sites,
836            vec![hand],
837            "the Upper limb site is implied by the Hand site through the chain"
838        );
839    }
840
841    #[test]
842    fn a_transitive_property_makes_a_reachable_attribute_redundant() {
843        // A `TransitiveObjectProperty` is the chain `r ∘ r ⊑ r`, so it
844        // needs no separate rule: stating both `partOf = Hand` and
845        // `partOf = UpperLimb` when Hand is part of Upper limb keeps only
846        // the more specific one.
847        let part_of = id(1210);
848        let hand = id(1211);
849        let upper_limb = id(1212);
850        let structure = id(1213);
851        let thing = id(1214);
852
853        let axioms = vec![
854            ax(&format!("TransitiveObjectProperty(:{part_of})")),
855            ax(&format!(
856                "SubClassOf(:{hand} ObjectSomeValuesFrom(:{part_of} :{upper_limb}))"
857            )),
858            ax(&format!(
859                "EquivalentClasses(:{thing} ObjectIntersectionOf(:{structure} \
860                 ObjectSomeValuesFrom(:{part_of} :{hand}) \
861                 ObjectSomeValuesFrom(:{part_of} :{upper_limb})))"
862            )),
863        ];
864
865        let report = necessary_normal_form(&axioms);
866        let parts: Vec<SctId> = report.forms[&thing]
867            .attributes
868            .iter()
869            .filter(|a| a.type_id == part_of)
870            .map(|a| a.destination_id)
871            .collect();
872        assert_eq!(parts, vec![hand]);
873    }
874
875    #[test]
876    fn a_chain_does_not_eliminate_an_unreachable_attribute() {
877        // The guard against over-elimination: same chain, but Hand is not
878        // part of Foot, so both sites survive. Rule 2 must fire on
879        // reachability, not on the chain's mere existence.
880        let finding_site = id(1220);
881        let part_of = id(1221);
882        let hand = id(1222);
883        let foot = id(1223);
884        let disorder = id(1224);
885        let odd_disorder = id(1225);
886
887        let axioms = vec![
888            ax(&format!(
889                "SubObjectPropertyOf(ObjectPropertyChain(:{finding_site} :{part_of}) :{finding_site})"
890            )),
891            ax(&format!(
892                "EquivalentClasses(:{odd_disorder} ObjectIntersectionOf(:{disorder} \
893                 ObjectSomeValuesFrom(:{finding_site} :{hand}) \
894                 ObjectSomeValuesFrom(:{finding_site} :{foot})))"
895            )),
896        ];
897
898        let mut sites: Vec<SctId> = necessary_normal_form(&axioms).forms[&odd_disorder]
899            .attributes
900            .iter()
901            .filter(|a| a.type_id == finding_site)
902            .map(|a| a.destination_id)
903            .collect();
904        sites.sort();
905        let mut expected = vec![hand, foot];
906        expected.sort();
907        assert_eq!(sites, expected, "neither site implies the other");
908    }
909}