Skip to main content

owl_dl_core/
normalize.rs

1//! Normalization passes that prepare an ontology for reasoning.
2//!
3//! Phase 1 of the strategy. This module provides:
4//!
5//! - [`to_nnf`] — Negation Normal Form, pushing `Not` to atomic positions.
6//! - [`nnf_axioms`] — apply NNF to every concept embedded in every axiom of
7//!   an ontology, returning a fresh `Vec<Axiom>` with the original axiom
8//!   list untouched.
9//!
10//! Coming next: absorption (binary, role, nominal) and the structural
11//! (Tseitin-style) transformation.
12
13use crate::ConceptPool;
14use crate::ir::{ConceptExpr, ConceptId};
15use crate::ontology::{Axiom, InternalOntology};
16
17/// Rewrite a concept into Negation Normal Form: every `Not` is pushed down
18/// until it wraps an atomic concept (`Atomic`, `Nominal`, or
19/// `SelfRestriction`). Pure transformation — the input pool is mutated only
20/// by interning the new sub-expressions.
21///
22/// The standard SROIQ NNF rules:
23///
24/// | input            | NNF                       |
25/// |------------------|---------------------------|
26/// | `¬⊤`             | `⊥`                       |
27/// | `¬⊥`             | `⊤`                       |
28/// | `¬¬C`            | `nnf(C)`                  |
29/// | `¬(C ⊓ D)`       | `nnf(¬C) ⊔ nnf(¬D)`       |
30/// | `¬(C ⊔ D)`       | `nnf(¬C) ⊓ nnf(¬D)`       |
31/// | `¬∃R.C`          | `∀R. nnf(¬C)`             |
32/// | `¬∀R.C`          | `∃R. nnf(¬C)`             |
33/// | `¬(≥0 R.C)`      | `⊥` (≥0 is satisfied by everything) |
34/// | `¬(≥n R.C)`, n≥1 | `≤(n-1) R. nnf(C)`        |
35/// | `¬(≤n R.C)`      | `≥(n+1) R. nnf(C)`        |
36///
37/// In the cardinality cases the inner `C` is *not* negated: the restriction
38/// itself is what's flipped. `C` is re-normalized so any deeper negations
39/// surface to leaves.
40#[must_use]
41pub fn to_nnf(cid: ConceptId, pool: &mut ConceptPool) -> ConceptId {
42    let expr = pool.get(cid).clone();
43    match expr {
44        ConceptExpr::Top
45        | ConceptExpr::Bot
46        | ConceptExpr::Atomic(_)
47        | ConceptExpr::Nominal(_)
48        | ConceptExpr::SelfRestriction(_) => cid,
49        ConceptExpr::Not(inner) => push_negation_in(inner, pool),
50        ConceptExpr::And(args) => {
51            let normalized: Vec<ConceptId> = args.iter().map(|&c| to_nnf(c, pool)).collect();
52            pool.and(normalized)
53        }
54        ConceptExpr::Or(args) => {
55            let normalized: Vec<ConceptId> = args.iter().map(|&c| to_nnf(c, pool)).collect();
56            pool.or(normalized)
57        }
58        ConceptExpr::Some(role, c) => {
59            let c_nnf = to_nnf(c, pool);
60            pool.some(role, c_nnf)
61        }
62        ConceptExpr::All(role, c) => {
63            let c_nnf = to_nnf(c, pool);
64            pool.all(role, c_nnf)
65        }
66        ConceptExpr::Min(n, role, c) => {
67            let c_nnf = to_nnf(c, pool);
68            pool.min(n, role, c_nnf)
69        }
70        ConceptExpr::Max(n, role, c) => {
71            let c_nnf = to_nnf(c, pool);
72            pool.max(n, role, c_nnf)
73        }
74    }
75}
76
77/// Compute `nnf(¬C)` given `C` (already assumed NNF or convertible).
78/// Exposed for the tableau's choose rule, which needs the NNF
79/// complement of a concept when branching on `≤n R.C`.
80#[must_use]
81pub fn nnf_complement(cid: ConceptId, pool: &mut ConceptPool) -> ConceptId {
82    push_negation_in(cid, pool)
83}
84
85/// Helper: compute `nnf(¬C)` given the `C` (not its negation).
86fn push_negation_in(cid: ConceptId, pool: &mut ConceptPool) -> ConceptId {
87    let expr = pool.get(cid).clone();
88    match expr {
89        ConceptExpr::Top => pool.bot(),
90        ConceptExpr::Bot => pool.top(),
91        ConceptExpr::Atomic(_) | ConceptExpr::Nominal(_) | ConceptExpr::SelfRestriction(_) => {
92            pool.not(cid)
93        }
94        // ¬¬C = nnf(C)
95        ConceptExpr::Not(inner) => to_nnf(inner, pool),
96        // ¬(C ⊓ D) = nnf(¬C) ⊔ nnf(¬D)
97        ConceptExpr::And(args) => {
98            let negated: Vec<ConceptId> = args.iter().map(|&c| push_negation_in(c, pool)).collect();
99            pool.or(negated)
100        }
101        // ¬(C ⊔ D) = nnf(¬C) ⊓ nnf(¬D)
102        ConceptExpr::Or(args) => {
103            let negated: Vec<ConceptId> = args.iter().map(|&c| push_negation_in(c, pool)).collect();
104            pool.and(negated)
105        }
106        // ¬∃R.C = ∀R. nnf(¬C)
107        ConceptExpr::Some(role, c) => {
108            let c_neg = push_negation_in(c, pool);
109            pool.all(role, c_neg)
110        }
111        // ¬∀R.C = ∃R. nnf(¬C)
112        ConceptExpr::All(role, c) => {
113            let c_neg = push_negation_in(c, pool);
114            pool.some(role, c_neg)
115        }
116        // ¬(≥0 R.C) = ⊥  (any individual has at least 0 R-successors)
117        // ¬(≥n R.C) = ≤(n-1) R. nnf(C),  for n ≥ 1
118        ConceptExpr::Min(n, role, c) => {
119            if n == 0 {
120                pool.bot()
121            } else {
122                let c_nnf = to_nnf(c, pool);
123                pool.max(n - 1, role, c_nnf)
124            }
125        }
126        // ¬(≤n R.C) = ≥(n+1) R. nnf(C)
127        ConceptExpr::Max(n, role, c) => {
128            let c_nnf = to_nnf(c, pool);
129            pool.min(n + 1, role, c_nnf)
130        }
131    }
132}
133
134/// Check the NNF invariant: in a properly normalized concept tree, every
135/// `Not` directly wraps an `Atomic`, `Nominal`, or `SelfRestriction`.
136#[must_use]
137pub fn is_nnf(cid: ConceptId, pool: &ConceptPool) -> bool {
138    match pool.get(cid) {
139        ConceptExpr::Top
140        | ConceptExpr::Bot
141        | ConceptExpr::Atomic(_)
142        | ConceptExpr::Nominal(_)
143        | ConceptExpr::SelfRestriction(_) => true,
144        ConceptExpr::Not(inner) => matches!(
145            pool.get(*inner),
146            ConceptExpr::Atomic(_) | ConceptExpr::Nominal(_) | ConceptExpr::SelfRestriction(_)
147        ),
148        ConceptExpr::And(args) | ConceptExpr::Or(args) => args.iter().all(|&c| is_nnf(c, pool)),
149        ConceptExpr::Some(_, c)
150        | ConceptExpr::All(_, c)
151        | ConceptExpr::Min(_, _, c)
152        | ConceptExpr::Max(_, _, c) => is_nnf(*c, pool),
153    }
154}
155
156/// Apply NNF to every concept embedded in the ontology's axioms, producing
157/// a fresh `Vec<Axiom>`. The original `ontology.axioms` is left untouched;
158/// only `ontology.concepts` may grow with newly interned sub-expressions.
159///
160/// The pipeline pattern matches told/definitions: source ontology in,
161/// derived view out. Absorption and structural transformation downstream
162/// consume the result.
163pub fn nnf_axioms(ontology: &mut InternalOntology) -> Vec<Axiom> {
164    // Disjoint field access: read `axioms`, mutate `concepts`.
165    let axioms = &ontology.axioms;
166    let pool = &mut ontology.concepts;
167    let mut out = Vec::with_capacity(axioms.len());
168    for ax in axioms {
169        out.push(nnf_axiom(ax, pool));
170    }
171    out
172}
173
174/// Apply NNF to every `ConceptId` field of a single [`Axiom`]. Axioms with
175/// no concept fields (role characteristics, `ABox` assertions, declarations,
176/// etc.) are returned unchanged.
177fn nnf_axiom(ax: &Axiom, pool: &mut ConceptPool) -> Axiom {
178    match ax {
179        Axiom::SubClassOf { sub, sup } => Axiom::SubClassOf {
180            sub: to_nnf(*sub, pool),
181            sup: to_nnf(*sup, pool),
182        },
183        Axiom::EquivalentClasses(ids) => {
184            Axiom::EquivalentClasses(ids.iter().map(|&c| to_nnf(c, pool)).collect())
185        }
186        Axiom::DisjointClasses(ids) => {
187            Axiom::DisjointClasses(ids.iter().map(|&c| to_nnf(c, pool)).collect())
188        }
189        Axiom::DisjointUnion { class, members } => Axiom::DisjointUnion {
190            class: *class,
191            members: members.iter().map(|&c| to_nnf(c, pool)).collect(),
192        },
193        Axiom::ObjectPropertyDomain { role, domain } => Axiom::ObjectPropertyDomain {
194            role: *role,
195            domain: to_nnf(*domain, pool),
196        },
197        Axiom::ObjectPropertyRange { role, range } => Axiom::ObjectPropertyRange {
198            role: *role,
199            range: to_nnf(*range, pool),
200        },
201        Axiom::ClassAssertion { class, individual } => Axiom::ClassAssertion {
202            class: to_nnf(*class, pool),
203            individual: *individual,
204        },
205        // All other variants — RBox characteristics, role hierarchies, ABox
206        // assertions, declarations — have no embedded ConceptId.
207        other => other.clone(),
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::ir::{ClassId, IndividualId, Role, RoleId};
215
216    fn pool() -> ConceptPool {
217        ConceptPool::new()
218    }
219
220    #[test]
221    fn nnf_of_atomic_is_identity() {
222        let mut p = pool();
223        let a = p.atomic(ClassId::new(0));
224        assert_eq!(to_nnf(a, &mut p), a);
225    }
226
227    #[test]
228    fn nnf_of_not_top_is_bot() {
229        let mut p = pool();
230        let t = p.top();
231        let not_top = p.not(t);
232        let nnf = to_nnf(not_top, &mut p);
233        assert_eq!(nnf, p.bot());
234    }
235
236    #[test]
237    fn nnf_of_not_bot_is_top() {
238        let mut p = pool();
239        let b = p.bot();
240        let not_bot = p.not(b);
241        let nnf = to_nnf(not_bot, &mut p);
242        assert_eq!(nnf, p.top());
243    }
244
245    #[test]
246    fn nnf_of_double_negation_is_inner() {
247        let mut p = pool();
248        let a = p.atomic(ClassId::new(0));
249        let not_a = p.not(a);
250        let not_not_a = p.not(not_a);
251        assert_eq!(to_nnf(not_not_a, &mut p), a);
252    }
253
254    #[test]
255    fn nnf_of_not_atomic_keeps_one_not() {
256        let mut p = pool();
257        let a = p.atomic(ClassId::new(0));
258        let not_a = p.not(a);
259        assert_eq!(to_nnf(not_a, &mut p), not_a);
260    }
261
262    #[test]
263    fn nnf_pushes_through_and_via_de_morgan() {
264        // ¬(A ⊓ B) ≡ ¬A ⊔ ¬B
265        let mut p = pool();
266        let a = p.atomic(ClassId::new(0));
267        let b = p.atomic(ClassId::new(1));
268        let and_ab = p.and([a, b]);
269        let not_and = p.not(and_ab);
270        let result = to_nnf(not_and, &mut p);
271        let na = p.not(a);
272        let nb = p.not(b);
273        let expected = p.or([na, nb]);
274        assert_eq!(result, expected);
275    }
276
277    #[test]
278    fn nnf_pushes_through_or_via_de_morgan() {
279        // ¬(A ⊔ B) ≡ ¬A ⊓ ¬B
280        let mut p = pool();
281        let a = p.atomic(ClassId::new(0));
282        let b = p.atomic(ClassId::new(1));
283        let or_ab = p.or([a, b]);
284        let not_or = p.not(or_ab);
285        let result = to_nnf(not_or, &mut p);
286        let na = p.not(a);
287        let nb = p.not(b);
288        let expected = p.and([na, nb]);
289        assert_eq!(result, expected);
290    }
291
292    #[test]
293    fn nnf_pushes_through_some_to_all_with_negated_inner() {
294        // ¬∃R.A ≡ ∀R. ¬A
295        let mut p = pool();
296        let r = Role::named(RoleId::new(0));
297        let a = p.atomic(ClassId::new(0));
298        let some_a = p.some(r, a);
299        let not_some = p.not(some_a);
300        let result = to_nnf(not_some, &mut p);
301        let na = p.not(a);
302        let expected = p.all(r, na);
303        assert_eq!(result, expected);
304    }
305
306    #[test]
307    fn nnf_pushes_through_all_to_some_with_negated_inner() {
308        // ¬∀R.A ≡ ∃R. ¬A
309        let mut p = pool();
310        let r = Role::named(RoleId::new(0));
311        let a = p.atomic(ClassId::new(0));
312        let all_a = p.all(r, a);
313        let not_all = p.not(all_a);
314        let result = to_nnf(not_all, &mut p);
315        let na = p.not(a);
316        let expected = p.some(r, na);
317        assert_eq!(result, expected);
318    }
319
320    #[test]
321    fn nnf_of_not_min_positive_n_becomes_max_n_minus_one() {
322        // ¬(≥3 R.A) ≡ ≤2 R.A     (inner A stays positive)
323        let mut p = pool();
324        let r = Role::named(RoleId::new(0));
325        let a = p.atomic(ClassId::new(0));
326        let min3 = p.min(3, r, a);
327        let not_min3 = p.not(min3);
328        let result = to_nnf(not_min3, &mut p);
329        let expected = p.max(2, r, a);
330        assert_eq!(result, expected);
331    }
332
333    #[test]
334    fn nnf_of_not_min_zero_is_bot() {
335        // ¬(≥0 R.A) ≡ ⊥
336        let mut p = pool();
337        let r = Role::named(RoleId::new(0));
338        let a = p.atomic(ClassId::new(0));
339        let min0 = p.min(0, r, a);
340        let not_min0 = p.not(min0);
341        let result = to_nnf(not_min0, &mut p);
342        assert_eq!(result, p.bot());
343    }
344
345    #[test]
346    fn nnf_of_not_max_becomes_min_n_plus_one() {
347        // ¬(≤2 R.A) ≡ ≥3 R.A
348        let mut p = pool();
349        let r = Role::named(RoleId::new(0));
350        let a = p.atomic(ClassId::new(0));
351        let max2 = p.max(2, r, a);
352        let not_max2 = p.not(max2);
353        let result = to_nnf(not_max2, &mut p);
354        let expected = p.min(3, r, a);
355        assert_eq!(result, expected);
356    }
357
358    #[test]
359    fn nnf_of_not_nominal_keeps_one_not() {
360        // ¬{a} stays as Not(Nominal) — there's no equivalent positive form.
361        let mut p = pool();
362        let n = p.nominal(IndividualId::new(0));
363        let not_n = p.not(n);
364        assert_eq!(to_nnf(not_n, &mut p), not_n);
365    }
366
367    #[test]
368    fn nnf_of_not_self_restriction_keeps_one_not() {
369        let mut p = pool();
370        let r = Role::named(RoleId::new(0));
371        let s = p.self_restriction(r);
372        let not_s = p.not(s);
373        assert_eq!(to_nnf(not_s, &mut p), not_s);
374    }
375
376    #[test]
377    fn nested_negation_through_nested_structure() {
378        // ¬(A ⊓ ∃R.B) ≡ ¬A ⊔ ∀R.¬B
379        let mut p = pool();
380        let r = Role::named(RoleId::new(0));
381        let a = p.atomic(ClassId::new(0));
382        let b = p.atomic(ClassId::new(1));
383        let some_b = p.some(r, b);
384        let conj = p.and([a, some_b]);
385        let neg = p.not(conj);
386        let result = to_nnf(neg, &mut p);
387        let na = p.not(a);
388        let nb = p.not(b);
389        let all_nb = p.all(r, nb);
390        let expected = p.or([na, all_nb]);
391        assert_eq!(result, expected);
392    }
393
394    #[test]
395    fn is_nnf_recognizes_nnf_forms() {
396        let mut p = pool();
397        let r = Role::named(RoleId::new(0));
398        let a = p.atomic(ClassId::new(0));
399        let na = p.not(a);
400        let conj = p.and([a, na]);
401        assert!(is_nnf(conj, &p));
402        let all_na = p.all(r, na);
403        assert!(is_nnf(all_na, &p));
404    }
405
406    #[test]
407    fn is_nnf_rejects_non_atomic_negation() {
408        // Not(And(...)) is NOT in NNF.
409        let mut p = pool();
410        let a = p.atomic(ClassId::new(0));
411        let b = p.atomic(ClassId::new(1));
412        let conj = p.and([a, b]);
413        let neg = p.not(conj);
414        assert!(!is_nnf(neg, &p));
415    }
416
417    // ── nnf_axioms tests ───────────────────────────────────────────────
418
419    use crate::ontology::{Axiom, InternalOntology};
420
421    fn fresh_ontology() -> InternalOntology {
422        let mut o = InternalOntology::new();
423        o.vocabulary.intern_class("A");
424        o.vocabulary.intern_class("B");
425        o.vocabulary.intern_class("C");
426        o
427    }
428
429    #[test]
430    fn nnf_axioms_rewrites_sub_class_of() {
431        // SubClassOf(A, Not(And(B, C)))  →  SubClassOf(A, Or(Not(B), Not(C)))
432        let mut o = fresh_ontology();
433        let a = o.concepts.atomic(ClassId::new(0));
434        let b = o.concepts.atomic(ClassId::new(1));
435        let c = o.concepts.atomic(ClassId::new(2));
436        let and_bc = o.concepts.and([b, c]);
437        let not_and = o.concepts.not(and_bc);
438        o.axioms.push(Axiom::SubClassOf {
439            sub: a,
440            sup: not_and,
441        });
442        let normalized = nnf_axioms(&mut o);
443        let Axiom::SubClassOf { sub, sup } = &normalized[0] else {
444            panic!()
445        };
446        assert_eq!(*sub, a);
447        let nb = o.concepts.not(b);
448        let nc = o.concepts.not(c);
449        let expected_sup = o.concepts.or([nb, nc]);
450        assert_eq!(*sup, expected_sup);
451        assert!(is_nnf(*sup, &o.concepts));
452    }
453
454    #[test]
455    fn nnf_axioms_leaves_original_axioms_unchanged() {
456        // Verify the source list survives unaltered.
457        let mut o = fresh_ontology();
458        let a = o.concepts.atomic(ClassId::new(0));
459        let b = o.concepts.atomic(ClassId::new(1));
460        let and_ab = o.concepts.and([a, b]);
461        let not_and = o.concepts.not(and_ab);
462        o.axioms.push(Axiom::SubClassOf {
463            sub: a,
464            sup: not_and,
465        });
466        let original_count = o.axioms.len();
467        let _ = nnf_axioms(&mut o);
468        assert_eq!(o.axioms.len(), original_count);
469        // The original axiom still holds the un-NNF'd super-concept.
470        let Axiom::SubClassOf { sup, .. } = &o.axioms[0] else {
471            panic!()
472        };
473        assert_eq!(*sup, not_and);
474    }
475
476    #[test]
477    fn nnf_axioms_handles_multi_concept_axioms() {
478        // EquivalentClasses normalizes every member.
479        let mut o = fresh_ontology();
480        let a = o.concepts.atomic(ClassId::new(0));
481        let b = o.concepts.atomic(ClassId::new(1));
482        let not_a = o.concepts.not(a);
483        let not_not_a = o.concepts.not(not_a);
484        o.axioms.push(Axiom::EquivalentClasses(vec![not_not_a, b]));
485        let normalized = nnf_axioms(&mut o);
486        let Axiom::EquivalentClasses(ids) = &normalized[0] else {
487            panic!()
488        };
489        // Double-negated A collapses to A in NNF.
490        assert_eq!(ids[0], a);
491        assert_eq!(ids[1], b);
492    }
493
494    #[test]
495    fn nnf_axioms_passes_through_role_axioms_unchanged() {
496        let mut o = fresh_ontology();
497        let r = Role::named(RoleId::new(0));
498        o.axioms.push(Axiom::TransitiveRole(r));
499        let normalized = nnf_axioms(&mut o);
500        assert!(matches!(normalized[0], Axiom::TransitiveRole(_)));
501    }
502
503    #[test]
504    fn nnf_axioms_normalizes_class_assertion_concept() {
505        let mut o = fresh_ontology();
506        let i = o.vocabulary.intern_individual("a");
507        let a = o.concepts.atomic(ClassId::new(0));
508        let not_a = o.concepts.not(a);
509        let not_not_a = o.concepts.not(not_a);
510        o.axioms.push(Axiom::ClassAssertion {
511            class: not_not_a,
512            individual: i,
513        });
514        let normalized = nnf_axioms(&mut o);
515        let Axiom::ClassAssertion { class, .. } = normalized[0] else {
516            panic!()
517        };
518        assert_eq!(class, a);
519    }
520}