Skip to main content

owl_dl_core/
absorb.rs

1//! Absorption: turn `TBox` axioms into focused triggers the tableau can
2//! apply lazily.
3//!
4//! Three flavors are produced in one pipeline:
5//!
6//! - **Binary absorption (class trigger)** — `⊤ ⊑ ¬A ⊔ ψ` becomes
7//!   `ConceptRule { trigger: A, conclusion: ψ }`. Fires when `A` shows
8//!   up in a node label.
9//! - **Nominal absorption** — `⊤ ⊑ ¬{a} ⊔ ψ` becomes
10//!   `NominalRule { individual: a, conclusion: ψ }`. Applies directly
11//!   to the named individual `a`.
12//! - **Role absorption** — a `ConceptRule`/residual GCI whose conclusion
13//!   is `∀R.D` is rewritten as `RoleRule { role: R, guard, target_label: D }`.
14//!   Fires when an R-edge from a node carrying the guard (if any) is
15//!   added. This is the second pass — it consumes the output of binary
16//!   absorption.
17//!
18//! ## Why
19//!
20//! A naive tableau must apply every GCI `⊤ ⊑ φ` universally — to every
21//! node, adding `φ` to its label. Disjunctive `φ` then causes branching
22//! everywhere. Absorption finds patterns that let triggers fire only
23//! when needed.
24//!
25//! ## Algorithm (single-trigger v0)
26//!
27//! For each input axiom, encode as `⊤ ⊑ φ`:
28//!
29//! - `SubClassOf { sub, sup }` → `φ = nnf(¬sub) ⊔ sup`.
30//! - `EquivalentClasses(ids)` → decompose into pairwise `SubClassOf`.
31//! - `DisjointClasses(ids)` → decompose into pairwise `SubClassOf(Ci, ¬Cj)`.
32//! - `DisjointUnion { class, members }` → emit the equivalence half and
33//!   pairwise-disjoint half.
34//! - `ObjectPropertyDomain { role, domain }` → `∃role.⊤ ⊑ domain`.
35//! - `ObjectPropertyRange { role, range }`  → `⊤ ⊑ ∀role.range`.
36//!
37//! Then walk the top-level disjuncts of `φ` looking for the first that
38//! has shape `Not(Atomic(A))` or `Not(Nominal(a))`. If found, emit a
39//! `ConceptRule` or `NominalRule` accordingly; the conclusion is the
40//! `Or` of the remaining disjuncts. Otherwise `φ` joins `residual_gcis`.
41//!
42//! After the binary/nominal pass, [`absorb_roles`] rewrites every rule
43//! or residual GCI whose conclusion is exactly `∀R.D` into a `RoleRule`.
44//!
45//! Multi-trigger absorption (`A ⊓ B ⊑ C`) is a Phase 4 refinement.
46
47use std::collections::HashMap;
48
49use crate::ConceptPool;
50use crate::ir::{ClassId, ConceptExpr, ConceptId, IndividualId, Role};
51use crate::normalize::to_nnf;
52use crate::ontology::Axiom;
53
54/// The output of absorption. Always a derived view of an `InternalOntology`'s
55/// axiom list — never a replacement.
56///
57/// In addition to the four `Vec`-based axiom families, holds two
58/// dispatch indices ([`Self::concept_rules_by_trigger`],
59/// [`Self::nominal_rules_by_individual`]). They map a trigger to the
60/// list of conclusions to apply, so [`crate::AbsorbedTBox`]-driven
61/// tableau rules do `O(triggers × hits_per_trigger)` work per node
62/// instead of `O(triggers × |rules|)`. [`absorb`] and [`absorb_roles`]
63/// keep the indices in sync; callers who build an [`AbsorbedTBox`] by
64/// hand should call [`Self::finalize`] before handing it to the
65/// tableau (the tableau falls back to a linear scan when the indices
66/// are empty, so this is "for performance, not correctness").
67#[derive(Debug, Default, Clone, Eq, PartialEq)]
68pub struct AbsorbedTBox {
69    /// `A ⊑ ψ` — when the trigger class appears in a node label,
70    /// add the conclusion concept.
71    pub concept_rules: Vec<ConceptRule>,
72    /// `{a} ⊑ ψ` — apply the conclusion directly to the named
73    /// individual.
74    pub nominal_rules: Vec<NominalRule>,
75    /// `[guard ⊑] ∀R.D` — when an R-edge to `y` is added (from a node
76    /// carrying the guard if `Some`, or any node if `None`), add
77    /// `target_label` to `y`'s label.
78    pub role_rules: Vec<RoleRule>,
79    /// `⊤ ⊑ φ` — applied universally by the tableau, after every other
80    /// pattern was tried.
81    pub residual_gcis: Vec<ConceptId>,
82    /// Subset of [`Self::residual_gcis`] whose body is `Or(_)` — the
83    /// lazy-unfolding deferral candidates (see
84    /// `docs/lazy-unfolding-plan.md`). `apply_residual_gcis` skips
85    /// these (they're not materialised on every node eagerly);
86    /// `apply_deferred_or_residuals` materialises them at saturate
87    /// stable-state, but only on nodes where no disjunct is already
88    /// present. Populated by [`Self::finalize`].
89    pub deferred_or_residuals: Vec<ConceptId>,
90    /// Index: every conclusion `ConceptId` that should fire for a
91    /// given trigger class. Derived from `concept_rules` by
92    /// [`Self::finalize`]; consulted by `apply_concept_rules` to skip
93    /// the linear scan.
94    pub concept_rules_by_trigger: HashMap<ClassId, Vec<ConceptId>>,
95    /// Same idea for nominal rules — index by individual id.
96    pub nominal_rules_by_individual: HashMap<IndividualId, Vec<ConceptId>>,
97    /// `RoleRule`s with no class guard — they fire on any node that
98    /// has an outgoing edge matching their `role`. Partition of
99    /// `role_rules` produced by [`Self::finalize`].
100    pub unguarded_role_rules: Vec<RoleRule>,
101    /// Guarded `RoleRule`s indexed by guard class. Partition of
102    /// `role_rules` produced by [`Self::finalize`].
103    pub guarded_role_rules_by_guard: HashMap<ClassId, Vec<RoleRule>>,
104}
105
106impl AbsorbedTBox {
107    /// Rebuild the dispatch indices from the canonical `Vec` fields.
108    /// Idempotent — safe to call after any mutation of the rule lists.
109    /// Linear in the total rule count; cheap.
110    pub fn finalize(&mut self) {
111        self.concept_rules_by_trigger.clear();
112        self.concept_rules_by_trigger
113            .reserve(self.concept_rules.len());
114        for rule in &self.concept_rules {
115            self.concept_rules_by_trigger
116                .entry(rule.trigger)
117                .or_default()
118                .push(rule.conclusion);
119        }
120        self.nominal_rules_by_individual.clear();
121        self.nominal_rules_by_individual
122            .reserve(self.nominal_rules.len());
123        for rule in &self.nominal_rules {
124            self.nominal_rules_by_individual
125                .entry(rule.individual)
126                .or_default()
127                .push(rule.conclusion);
128        }
129        self.unguarded_role_rules.clear();
130        self.guarded_role_rules_by_guard.clear();
131        for rule in &self.role_rules {
132            match rule.guard {
133                None => self.unguarded_role_rules.push(*rule),
134                Some(g) => self
135                    .guarded_role_rules_by_guard
136                    .entry(g)
137                    .or_default()
138                    .push(*rule),
139            }
140        }
141    }
142}
143
144#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
145pub struct ConceptRule {
146    pub trigger: ClassId,
147    pub conclusion: ConceptId,
148}
149
150#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
151pub struct NominalRule {
152    pub individual: IndividualId,
153    pub conclusion: ConceptId,
154}
155
156#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
157pub struct RoleRule {
158    /// The role expression to match against an edge incident on the
159    /// labelled node. `Role::Named(r)` fires on outgoing r-edges;
160    /// `Role::Inverse(r)` fires on incoming r-edges. Sub-role
161    /// propagation is consulted by the tableau, not by absorption.
162    pub role: Role,
163    pub guard: Option<ClassId>,
164    pub target_label: ConceptId,
165}
166
167/// Run absorption over the NNF axiom list. The full pipeline:
168///
169/// 1. Binary/nominal absorption: walk every axiom, encode as `⊤ ⊑ φ`,
170///    extract a class or individual trigger when possible.
171/// 2. Role absorption: rewrite rules whose conclusion is `∀R.D` as
172///    `RoleRule`s.
173#[must_use]
174pub fn absorb(axioms_nnf: &[Axiom], pool: &mut ConceptPool) -> AbsorbedTBox {
175    let mut tbox = AbsorbedTBox::default();
176    for ax in axioms_nnf {
177        absorb_one(ax, pool, &mut tbox);
178    }
179    absorb_roles(&mut tbox, pool);
180    tbox
181}
182
183// `absorb_roles` is the last mutator on `concept_rules` /
184// `nominal_rules`, so it owns the responsibility for refreshing the
185// dispatch indices — see the `finalize()` call at its tail.
186
187/// Second pass over an [`AbsorbedTBox`]: rewrite rules / residual GCIs of
188/// shape `∀R.D` as [`RoleRule`]s. Conceptually a separate stage from
189/// binary/nominal absorption, exposed publicly so consumers can run it
190/// against an externally-built tbox.
191pub fn absorb_roles(tbox: &mut AbsorbedTBox, pool: &ConceptPool) {
192    // Concept rules with conclusion All(R, D) become guarded role rules.
193    let mut kept = Vec::with_capacity(tbox.concept_rules.len());
194    for rule in std::mem::take(&mut tbox.concept_rules) {
195        if let ConceptExpr::All(role, target) = pool.get(rule.conclusion) {
196            tbox.role_rules.push(RoleRule {
197                role: *role,
198                guard: Some(rule.trigger),
199                target_label: *target,
200            });
201        } else {
202            kept.push(rule);
203        }
204    }
205    tbox.concept_rules = kept;
206
207    // Residual GCIs of shape ⊤ ⊑ All(R, D) become unguarded role rules.
208    let mut kept = Vec::with_capacity(tbox.residual_gcis.len());
209    for gci in std::mem::take(&mut tbox.residual_gcis) {
210        if let ConceptExpr::All(role, target) = pool.get(gci) {
211            tbox.role_rules.push(RoleRule {
212                role: *role,
213                guard: None,
214                target_label: *target,
215            });
216        } else {
217            kept.push(gci);
218        }
219    }
220    tbox.residual_gcis = kept;
221
222    // Nominal rules with conclusion All(R, D): less common (the tableau
223    // can handle these as nominal-plus-All) but follow the same pattern
224    // for consistency.
225    let mut kept = Vec::with_capacity(tbox.nominal_rules.len());
226    for rule in std::mem::take(&mut tbox.nominal_rules) {
227        if let ConceptExpr::All(role, target) = pool.get(rule.conclusion) {
228            // Express as an *unguarded* role rule: the nominal-level
229            // application is handled at ABox time by the tableau, which
230            // walks edges from the specific individual. Phase 1 keeps it
231            // as a NominalRule so the original individual stays attached.
232            // No conversion here; leave the nominal rule unchanged.
233            let _ = (role, target);
234            kept.push(rule);
235        } else {
236            kept.push(rule);
237        }
238    }
239    tbox.nominal_rules = kept;
240
241    // Lazy-unfolding split: precompute the `Or(_)`-shaped residual
242    // GCIs so the tableau can defer their materialisation to
243    // saturate stable-state instead of asserting them on every
244    // node. See `docs/lazy-unfolding-plan.md`. The eager residuals
245    // stay in `residual_gcis` and `apply_residual_gcis` skips the
246    // Or-shaped ones, which `apply_deferred_or_residuals` handles.
247    tbox.deferred_or_residuals = tbox
248        .residual_gcis
249        .iter()
250        .copied()
251        .filter(|&g| matches!(pool.get(g), ConceptExpr::Or(_)))
252        .collect();
253    // Sorted so `apply_residual_gcis` can binary_search to skip the
254    // deferred entries. The set defines *exactly* which residuals
255    // are deferred — `apply_residual_gcis` skips members,
256    // `apply_deferred_or_residuals` materialises them, so the two
257    // stay consistent even for hand-built TBoxes that never run this
258    // split (empty set ⇒ everything eager ⇒ sound, just unoptimised).
259    tbox.deferred_or_residuals
260        .sort_unstable_by_key(|c| c.index());
261
262    // Rebuild the dispatch indices now that every mutator has run.
263    tbox.finalize();
264}
265
266fn absorb_one(ax: &Axiom, pool: &mut ConceptPool, tbox: &mut AbsorbedTBox) {
267    match ax {
268        Axiom::SubClassOf { sub, sup } => absorb_sub_sup(*sub, *sup, pool, tbox),
269        Axiom::EquivalentClasses(ids) => {
270            for i in 0..ids.len() {
271                for j in 0..ids.len() {
272                    if i != j {
273                        absorb_sub_sup(ids[i], ids[j], pool, tbox);
274                    }
275                }
276            }
277        }
278        Axiom::DisjointClasses(ids) => {
279            emit_pairwise_disjoint(ids, pool, tbox);
280        }
281        Axiom::DisjointUnion { class, members } => {
282            let class_concept = pool.atomic(*class);
283            let union_concept = pool.or(members.iter().copied());
284            // The equivalence half.
285            absorb_sub_sup(class_concept, union_concept, pool, tbox);
286            absorb_sub_sup(union_concept, class_concept, pool, tbox);
287            // Pairwise-disjoint half.
288            emit_pairwise_disjoint(members, pool, tbox);
289        }
290        Axiom::ObjectPropertyDomain { role, domain } => {
291            // ∃role.⊤ ⊑ domain
292            let top = pool.top();
293            let some_r_top = pool.some(*role, top);
294            absorb_sub_sup(some_r_top, *domain, pool, tbox);
295        }
296        Axiom::ObjectPropertyRange { role, range } => {
297            // ⊤ ⊑ ∀role.range — a clean residual GCI.
298            let all_r = pool.all(*role, *range);
299            tbox.residual_gcis.push(all_r);
300        }
301        _ => {
302            // Role characteristics, ABox, declarations — not TBox content.
303            // They flow through to the reasoner via separate paths.
304        }
305    }
306}
307
308fn emit_pairwise_disjoint(ids: &[ConceptId], pool: &mut ConceptPool, tbox: &mut AbsorbedTBox) {
309    for i in 0..ids.len() {
310        for j in (i + 1)..ids.len() {
311            let not_cj = pool.not(ids[j]);
312            let not_cj_nnf = to_nnf(not_cj, pool);
313            absorb_sub_sup(ids[i], not_cj_nnf, pool, tbox);
314        }
315    }
316}
317
318/// Encode `sub ⊑ sup` as `⊤ ⊑ nnf(¬sub) ⊔ sup` and try to extract a trigger.
319fn absorb_sub_sup(sub: ConceptId, sup: ConceptId, pool: &mut ConceptPool, tbox: &mut AbsorbedTBox) {
320    let neg_sub = pool.not(sub);
321    let neg_sub_nnf = to_nnf(neg_sub, pool);
322    let disjunction = pool.or([neg_sub_nnf, sup]);
323    absorb_gci(disjunction, pool, tbox);
324}
325
326/// Process a `⊤ ⊑ φ` GCI: extract a `Not(Atomic)` or `Not(Nominal)`
327/// disjunct as trigger if any, otherwise add `φ` to the residual list.
328fn absorb_gci(phi: ConceptId, pool: &mut ConceptPool, tbox: &mut AbsorbedTBox) {
329    let disjuncts: Vec<ConceptId> = match pool.get(phi) {
330        ConceptExpr::Or(args) => args.to_vec(),
331        _ => vec![phi],
332    };
333
334    // Find first disjunct of the form Not(Atomic) or Not(Nominal).
335    let mut chosen: Option<(usize, Trigger)> = None;
336    for (i, &d) in disjuncts.iter().enumerate() {
337        if let Some(t) = as_trigger(d, pool) {
338            chosen = Some((i, t));
339            break;
340        }
341    }
342
343    if let Some((pos, trigger)) = chosen {
344        let rest: Vec<ConceptId> = disjuncts
345            .iter()
346            .enumerate()
347            .filter_map(|(i, &c)| (i != pos).then_some(c))
348            .collect();
349        // Or normalizations handle empty (→ Bot), single (→ operand), or
350        // multi-operand cases.
351        let conclusion = pool.or(rest);
352        match trigger {
353            Trigger::Class(trigger) => tbox.concept_rules.push(ConceptRule {
354                trigger,
355                conclusion,
356            }),
357            Trigger::Individual(individual) => tbox.nominal_rules.push(NominalRule {
358                individual,
359                conclusion,
360            }),
361        }
362    } else {
363        tbox.residual_gcis.push(phi);
364    }
365}
366
367/// What kind of "trigger" a `Not(...)` disjunct can produce.
368enum Trigger {
369    Class(ClassId),
370    Individual(IndividualId),
371}
372
373/// Recognize `Not(Atomic(A))` or `Not(Nominal(a))` shapes; otherwise None.
374fn as_trigger(cid: ConceptId, pool: &ConceptPool) -> Option<Trigger> {
375    if let ConceptExpr::Not(inner) = pool.get(cid) {
376        match pool.get(*inner) {
377            ConceptExpr::Atomic(c) => Some(Trigger::Class(*c)),
378            ConceptExpr::Nominal(i) => Some(Trigger::Individual(*i)),
379            _ => None,
380        }
381    } else {
382        None
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    #![allow(clippy::many_single_char_names)]
389
390    use super::*;
391    use crate::Vocabulary;
392    use crate::ir::{Role, RoleId};
393    use crate::nnf_axioms;
394    use crate::ontology::InternalOntology;
395
396    fn fresh(class_names: &[&str]) -> InternalOntology {
397        let mut o = InternalOntology::new();
398        for n in class_names {
399            o.vocabulary.intern_class(n);
400        }
401        o
402    }
403
404    fn cid(o: &InternalOntology, name: &str) -> ClassId {
405        o.vocabulary.class_id(name).expect("class missing")
406    }
407
408    fn atom(o: &mut InternalOntology, name: &str) -> ConceptId {
409        let c = cid(o, name);
410        o.concepts.atomic(c)
411    }
412
413    /// NNF the ontology's axioms and run absorption. Returns the absorbed
414    /// tbox and the NNF'd axioms (for inspection in tests).
415    fn run(o: &mut InternalOntology) -> AbsorbedTBox {
416        let nnf = nnf_axioms(o);
417        absorb(&nnf, &mut o.concepts)
418    }
419
420    #[test]
421    fn atomic_sub_class_of_yields_one_rule() {
422        // A ⊑ B  →  rule (A, B).
423        let mut o = fresh(&["A", "B"]);
424        let a = atom(&mut o, "A");
425        let b = atom(&mut o, "B");
426        o.axioms.push(Axiom::SubClassOf { sub: a, sup: b });
427        let t = run(&mut o);
428        assert_eq!(t.concept_rules.len(), 1);
429        assert!(t.residual_gcis.is_empty());
430        assert_eq!(t.concept_rules[0].trigger, cid(&o, "A"));
431        assert_eq!(t.concept_rules[0].conclusion, b);
432    }
433
434    #[test]
435    fn sub_class_of_with_conjunctive_conclusion() {
436        // A ⊑ B ⊓ C  →  rule (A, And([B, C])).
437        let mut o = fresh(&["A", "B", "C"]);
438        let a = atom(&mut o, "A");
439        let b = atom(&mut o, "B");
440        let cc = atom(&mut o, "C");
441        let and_bc = o.concepts.and([b, cc]);
442        o.axioms.push(Axiom::SubClassOf {
443            sub: a,
444            sup: and_bc,
445        });
446        let t = run(&mut o);
447        assert_eq!(t.concept_rules.len(), 1);
448        assert_eq!(t.concept_rules[0].trigger, cid(&o, "A"));
449        assert_eq!(t.concept_rules[0].conclusion, and_bc);
450    }
451
452    #[test]
453    fn complex_lhs_with_atomic_rhs_absorbs_via_double_negation() {
454        // (B ⊓ C) ⊑ A  →  ⊤ ⊑ ¬(B⊓C) ⊔ A  →  ⊤ ⊑ ¬B ⊔ ¬C ⊔ A.
455        // Top-level disjuncts include Not(B) and Not(C); pick one (first
456        // by id) as trigger, conclusion = Or of the rest.
457        let mut o = fresh(&["A", "B", "C"]);
458        let a = atom(&mut o, "A");
459        let b = atom(&mut o, "B");
460        let cc = atom(&mut o, "C");
461        let and_bc = o.concepts.and([b, cc]);
462        o.axioms.push(Axiom::SubClassOf {
463            sub: and_bc,
464            sup: a,
465        });
466        let t = run(&mut o);
467        assert_eq!(t.concept_rules.len(), 1);
468        assert!(t.residual_gcis.is_empty());
469        // The trigger must be one of B or C (whichever Not is sorted first).
470        let trigger = t.concept_rules[0].trigger;
471        assert!(trigger == cid(&o, "B") || trigger == cid(&o, "C"));
472    }
473
474    #[test]
475    fn pure_existential_gci_is_residual() {
476        // ⊤ ⊑ ∃R.A  has no Not(Atomic) top-level disjunct → residual.
477        let mut o = fresh(&["A"]);
478        let a = atom(&mut o, "A");
479        let r = Role::named(RoleId::new(0));
480        let some_a = o.concepts.some(r, a);
481        let top = o.concepts.top();
482        o.axioms.push(Axiom::SubClassOf {
483            sub: top,
484            sup: some_a,
485        });
486        let t = run(&mut o);
487        assert!(t.concept_rules.is_empty());
488        assert_eq!(t.residual_gcis.len(), 1);
489        assert_eq!(t.residual_gcis[0], some_a);
490    }
491
492    #[test]
493    fn equivalent_classes_creates_pairwise_rules() {
494        // A ≡ B  →  rules (A, B) and (B, A).
495        let mut o = fresh(&["A", "B"]);
496        let a = atom(&mut o, "A");
497        let b = atom(&mut o, "B");
498        o.axioms.push(Axiom::EquivalentClasses(vec![a, b]));
499        let t = run(&mut o);
500        assert_eq!(t.concept_rules.len(), 2);
501        // One rule for each direction.
502        let triggers: Vec<ClassId> = t.concept_rules.iter().map(|r| r.trigger).collect();
503        assert!(triggers.contains(&cid(&o, "A")));
504        assert!(triggers.contains(&cid(&o, "B")));
505    }
506
507    #[test]
508    fn disjoint_classes_yields_not_atom_conclusion() {
509        // DisjointClasses(A, B)  →  rule (A, Not(B)).
510        let mut o = fresh(&["A", "B"]);
511        let a = atom(&mut o, "A");
512        let b = atom(&mut o, "B");
513        o.axioms.push(Axiom::DisjointClasses(vec![a, b]));
514        let t = run(&mut o);
515        assert_eq!(t.concept_rules.len(), 1);
516        let rule = &t.concept_rules[0];
517        // The trigger is whichever Not gets matched first — but both
518        // operands are atoms, so the trigger and conclusion partition them.
519        let trigger = rule.trigger;
520        assert!(trigger == cid(&o, "A") || trigger == cid(&o, "B"));
521        // Conclusion must be Not(Atomic(other)).
522        let other = if trigger == cid(&o, "A") {
523            cid(&o, "B")
524        } else {
525            cid(&o, "A")
526        };
527        let expected_other_atom = o.concepts.atomic(other);
528        let expected_conclusion = o.concepts.not(expected_other_atom);
529        assert_eq!(rule.conclusion, expected_conclusion);
530    }
531
532    #[test]
533    fn disjoint_classes_three_way_yields_three_pairwise_rules() {
534        // DisjointClasses(A, B, C) — pairs are (A,B), (A,C), (B,C).
535        let mut o = fresh(&["A", "B", "C"]);
536        let a = atom(&mut o, "A");
537        let b = atom(&mut o, "B");
538        let cc = atom(&mut o, "C");
539        o.axioms.push(Axiom::DisjointClasses(vec![a, b, cc]));
540        let t = run(&mut o);
541        assert_eq!(t.concept_rules.len(), 3);
542    }
543
544    #[test]
545    fn disjoint_union_emits_subsumption_and_pairwise_disjoint_rules() {
546        // DisjointUnion(P, [C1, C2]):
547        //   1. P ⊑ C1 ⊔ C2     →  one rule (P, Or([C1, C2]))
548        //   2. C1 ⊔ C2 ⊑ P    →  residual (multi-trigger required)
549        //   3. C1 ⌖ C2         →  one rule (C1, Not(C2))
550        let mut o = fresh(&["P", "C1", "C2"]);
551        let c1 = atom(&mut o, "C1");
552        let c2 = atom(&mut o, "C2");
553        o.axioms.push(Axiom::DisjointUnion {
554            class: cid(&o, "P"),
555            members: vec![c1, c2],
556        });
557        let t = run(&mut o);
558        // One concept rule from P ⊑ C1 ⊔ C2.
559        // One concept rule from pairwise disjoint.
560        // C1 ⊔ C2 ⊑ P drops to residual.
561        assert_eq!(t.concept_rules.len(), 2);
562        assert_eq!(t.residual_gcis.len(), 1);
563    }
564
565    #[test]
566    fn object_property_range_becomes_unguarded_role_rule() {
567        // ObjectPropertyRange(r, A)  ≡  ⊤ ⊑ ∀r.A
568        // After binary absorption: residual GCI ∀r.A.
569        // After role absorption: RoleRule { role: r, guard: None, target_label: A }.
570        let mut o = fresh(&["A"]);
571        let a = atom(&mut o, "A");
572        let r = Role::named(RoleId::new(0));
573        o.axioms
574            .push(Axiom::ObjectPropertyRange { role: r, range: a });
575        let t = run(&mut o);
576        assert!(t.concept_rules.is_empty());
577        assert!(t.residual_gcis.is_empty());
578        assert_eq!(t.role_rules.len(), 1);
579        let rr = t.role_rules[0];
580        assert_eq!(rr.role, crate::Role::Named(r.role_id()));
581        assert_eq!(rr.guard, None);
582        assert_eq!(rr.target_label, a);
583    }
584
585    #[test]
586    fn sub_class_of_all_becomes_guarded_role_rule() {
587        let mut o = fresh(&["A", "B"]);
588        let a = atom(&mut o, "A");
589        let b = atom(&mut o, "B");
590        let r = Role::named(RoleId::new(0));
591        let all_r_b = o.concepts.all(r, b);
592        o.axioms.push(Axiom::SubClassOf {
593            sub: a,
594            sup: all_r_b,
595        });
596        let t = run(&mut o);
597        assert!(t.concept_rules.is_empty());
598        assert_eq!(t.role_rules.len(), 1);
599        let rr = t.role_rules[0];
600        assert_eq!(rr.role, crate::Role::Named(r.role_id()));
601        assert_eq!(rr.guard, Some(cid(&o, "A")));
602        assert_eq!(rr.target_label, b);
603    }
604
605    #[test]
606    fn nominal_sub_class_yields_nominal_rule() {
607        // {a} ⊑ B  → NominalRule(individual=a, conclusion=B).
608        let mut o = fresh(&["B"]);
609        let b = atom(&mut o, "B");
610        let ind = o.vocabulary.intern_individual("a");
611        let nom = o.concepts.nominal(ind);
612        o.axioms.push(Axiom::SubClassOf { sub: nom, sup: b });
613        let t = run(&mut o);
614        assert!(t.concept_rules.is_empty());
615        assert_eq!(t.nominal_rules.len(), 1);
616        assert_eq!(t.nominal_rules[0].individual, ind);
617        assert_eq!(t.nominal_rules[0].conclusion, b);
618    }
619
620    #[test]
621    fn unrelated_axioms_pass_through_without_contribution() {
622        // Role characteristics and ABox don't show up in AbsorbedTBox.
623        let mut o = fresh(&["A"]);
624        let _ = atom(&mut o, "A");
625        let r = Role::named(RoleId::new(0));
626        let _ = Vocabulary::new(); // placate clippy::dead_code
627        o.axioms.push(Axiom::TransitiveRole(r));
628        let i = o.vocabulary.intern_individual("a");
629        let a = o.concepts.atomic(cid(&o, "A"));
630        o.axioms.push(Axiom::ClassAssertion {
631            class: a,
632            individual: i,
633        });
634        let t = run(&mut o);
635        assert!(t.concept_rules.is_empty());
636        assert!(t.residual_gcis.is_empty());
637    }
638
639    #[test]
640    fn sub_class_of_top_to_atom_is_residual() {
641        // ⊤ ⊑ A — no Not(Atomic) anywhere; just `A` is the residual GCI.
642        let mut o = fresh(&["A"]);
643        let a = atom(&mut o, "A");
644        let top = o.concepts.top();
645        o.axioms.push(Axiom::SubClassOf { sub: top, sup: a });
646        let t = run(&mut o);
647        assert!(t.concept_rules.is_empty());
648        assert_eq!(t.residual_gcis.len(), 1);
649        assert_eq!(t.residual_gcis[0], a);
650    }
651
652    #[test]
653    fn finalize_indexes_concept_rules_by_trigger() {
654        // A ⊑ B, A ⊑ C, D ⊑ E — two rules trigger on A, one on D.
655        let mut o = fresh(&["A", "B", "C", "D", "E"]);
656        let a = atom(&mut o, "A");
657        let b = atom(&mut o, "B");
658        let cc = atom(&mut o, "C");
659        let d = atom(&mut o, "D");
660        let e = atom(&mut o, "E");
661        o.axioms.push(Axiom::SubClassOf { sub: a, sup: b });
662        o.axioms.push(Axiom::SubClassOf { sub: a, sup: cc });
663        o.axioms.push(Axiom::SubClassOf { sub: d, sup: e });
664        let t = run(&mut o);
665        assert_eq!(t.concept_rules.len(), 3);
666        // Index reachable from each trigger.
667        let a_id = cid(&o, "A");
668        let d_id = cid(&o, "D");
669        let from_a = t.concept_rules_by_trigger.get(&a_id).expect("A indexed");
670        assert_eq!(from_a.len(), 2);
671        assert!(from_a.contains(&b));
672        assert!(from_a.contains(&cc));
673        let from_d = t.concept_rules_by_trigger.get(&d_id).expect("D indexed");
674        assert_eq!(from_d, &vec![e]);
675        // Triggers not present in the index ⇒ not in any rule.
676        assert!(!t.concept_rules_by_trigger.contains_key(&cid(&o, "B")));
677    }
678
679    #[test]
680    fn finalize_indexes_nominal_rules_by_individual() {
681        // {a} ⊑ B, {a} ⊑ C, {b} ⊑ D — two rules trigger on a, one on b.
682        let mut o = fresh(&["B", "C", "D"]);
683        let b = atom(&mut o, "B");
684        let cc = atom(&mut o, "C");
685        let d = atom(&mut o, "D");
686        let ind_a = o.vocabulary.intern_individual("a");
687        let ind_b = o.vocabulary.intern_individual("b");
688        let nom_a = o.concepts.nominal(ind_a);
689        let nom_b = o.concepts.nominal(ind_b);
690        o.axioms.push(Axiom::SubClassOf { sub: nom_a, sup: b });
691        o.axioms.push(Axiom::SubClassOf {
692            sub: nom_a,
693            sup: cc,
694        });
695        o.axioms.push(Axiom::SubClassOf { sub: nom_b, sup: d });
696        let t = run(&mut o);
697        assert_eq!(t.nominal_rules.len(), 3);
698        let from_a = t
699            .nominal_rules_by_individual
700            .get(&ind_a)
701            .expect("a indexed");
702        assert_eq!(from_a.len(), 2);
703        assert!(from_a.contains(&b));
704        assert!(from_a.contains(&cc));
705        assert_eq!(t.nominal_rules_by_individual.get(&ind_b), Some(&vec![d]));
706    }
707
708    #[test]
709    fn finalize_partitions_role_rules_by_guard() {
710        // A ⊑ ∀r.B (guarded) plus Range(r, C) which lowers to ⊤ ⊑ ∀r.C
711        // (unguarded). Partition must split them correctly.
712        let mut o = fresh(&["A", "B", "C"]);
713        let a = atom(&mut o, "A");
714        let b = atom(&mut o, "B");
715        let cc = atom(&mut o, "C");
716        let r = Role::named(RoleId::new(0));
717        let all_r_b = o.concepts.all(r, b);
718        o.axioms.push(Axiom::SubClassOf {
719            sub: a,
720            sup: all_r_b,
721        });
722        o.axioms
723            .push(Axiom::ObjectPropertyRange { role: r, range: cc });
724        let t = run(&mut o);
725        assert_eq!(t.role_rules.len(), 2);
726        assert_eq!(t.unguarded_role_rules.len(), 1);
727        assert_eq!(t.unguarded_role_rules[0].target_label, cc);
728        let a_id = cid(&o, "A");
729        let guarded = t
730            .guarded_role_rules_by_guard
731            .get(&a_id)
732            .expect("guarded on A");
733        assert_eq!(guarded.len(), 1);
734        assert_eq!(guarded[0].target_label, b);
735    }
736
737    #[test]
738    fn finalize_is_idempotent() {
739        let mut o = fresh(&["A", "B"]);
740        let a = atom(&mut o, "A");
741        let b = atom(&mut o, "B");
742        o.axioms.push(Axiom::SubClassOf { sub: a, sup: b });
743        let mut t = run(&mut o);
744        let before = t.clone();
745        t.finalize();
746        t.finalize();
747        assert_eq!(t, before);
748    }
749}