Skip to main content

owl_dl_core/
convert.rs

1//! Conversion from `horned-owl`'s model into our [`InternalOntology`].
2//!
3//! - Day 10: concept-level conversion ([`convert_class_expression`],
4//!   [`convert_object_property`], [`convert_individual`]).
5//! - Day 11: axiom-level conversion ([`convert_component`],
6//!   [`convert_ontology`]) — this file.
7//! - Day 12: reverse conversion + round-trip proptest (still to come).
8
9use horned_owl::model::{AnnotatedComponent, Class, SubObjectPropertyExpression};
10use horned_owl::model::{ClassExpression, Component, ForIRI, Individual, ObjectPropertyExpression};
11use horned_owl::ontology::set::SetOntology;
12use thiserror::Error;
13
14use crate::ConceptPool;
15use crate::Vocabulary;
16use crate::ir::{ClassId, ConceptId, IndividualId, Role};
17use crate::ontology::{Axiom, InternalOntology, SubRolePath};
18
19/// Errors produced by conversion from `horned-owl` to our IR.
20#[derive(Debug, Error, Clone, Eq, PartialEq)]
21pub enum ConversionError {
22    /// A class expression variant our IR cannot represent in this phase.
23    /// The `kind` field names the offending constructor.
24    #[error("unsupported class expression variant: {kind}")]
25    UnsupportedConcept { kind: &'static str },
26
27    /// An axiom variant our IR cannot represent in this phase.
28    #[error("unsupported axiom kind: {kind}")]
29    UnsupportedAxiom { kind: &'static str },
30
31    /// Anonymous individuals are not part of our IR in Phase 0; they are
32    /// scheduled for the `ABox` work in Phase 7.
33    #[error("anonymous individuals are not supported (planned for Phase 7)")]
34    AnonymousIndividual,
35
36    /// Data ranges (everything `xsd:*`-like) wait until Phase 3 minimal
37    /// datatype support and Phase 7 full concrete domains.
38    #[error("data ranges and data properties are not supported until Phase 3")]
39    UnsupportedDataRange,
40}
41
42/// Convert a horned-owl [`ClassExpression`] to a [`ConceptId`] in `pool`,
43/// interning any encountered class IRIs into `vocab`.
44///
45/// Concept-level rewriting is performed here because our IR has no direct
46/// counterpart for some horned-owl constructors:
47///
48/// | horned-owl                  | IR encoding              |
49/// |-----------------------------|--------------------------|
50/// | `ObjectHasValue { r, i }`   | `Some(r, Nominal(i))`    |
51/// | `ObjectExactCardinality`    | `And(Min(n,r,c), Max(n,r,c))` |
52/// | `ObjectOneOf([a, b, ...])`  | `Or(Nominal(a), Nominal(b), ...)` |
53/// | `ObjectIntersectionOf([])`  | `Top`                    |
54/// | `ObjectUnionOf([])`         | `Bot`                    |
55///
56/// These rewrites are logically lossless — our IR's `And/Or/Some/Max/Min`
57/// already canonicalize internally.
58pub fn convert_class_expression<A: ForIRI>(
59    ce: &ClassExpression<A>,
60    vocab: &mut Vocabulary,
61    pool: &mut ConceptPool,
62) -> Result<ConceptId, ConversionError> {
63    match ce {
64        ClassExpression::Class(c) => {
65            let iri: &str = c.0.as_ref();
66            // OWL 2 built-in vocabulary: owl:Thing ≡ ⊤, owl:Nothing ≡ ⊥.
67            // The IRI form is the only legal way to refer to them in
68            // ClassExpression (horned-owl has no dedicated Top/Bottom
69            // variants), so we intercept here and lower to the IR's
70            // structural Top/Bot rather than interning the IRI as if
71            // it were an arbitrary user class.
72            match iri {
73                "http://www.w3.org/2002/07/owl#Thing" => Ok(pool.top()),
74                "http://www.w3.org/2002/07/owl#Nothing" => Ok(pool.bot()),
75                _ => {
76                    let class_id = vocab.intern_class(iri);
77                    Ok(pool.atomic(class_id))
78                }
79            }
80        }
81        ClassExpression::ObjectIntersectionOf(xs) => {
82            let ids = convert_many(xs, vocab, pool)?;
83            if ids.is_empty() {
84                Ok(pool.top())
85            } else {
86                Ok(pool.and(ids))
87            }
88        }
89        ClassExpression::ObjectUnionOf(xs) => {
90            let ids = convert_many(xs, vocab, pool)?;
91            if ids.is_empty() {
92                Ok(pool.bot())
93            } else {
94                Ok(pool.or(ids))
95            }
96        }
97        ClassExpression::ObjectComplementOf(inner) => {
98            let inner_id = convert_class_expression(inner, vocab, pool)?;
99            Ok(pool.not(inner_id))
100        }
101        ClassExpression::ObjectOneOf(xs) => {
102            let mut ids = Vec::with_capacity(xs.len());
103            for ind in xs {
104                let id = convert_individual(ind, vocab)?;
105                ids.push(pool.nominal(id));
106            }
107            if ids.is_empty() {
108                Ok(pool.bot())
109            } else {
110                Ok(pool.or(ids))
111            }
112        }
113        ClassExpression::ObjectSomeValuesFrom { ope, bce } => {
114            let role = convert_object_property(ope, vocab)?;
115            let inner = convert_class_expression(bce, vocab, pool)?;
116            Ok(pool.some(role, inner))
117        }
118        ClassExpression::ObjectAllValuesFrom { ope, bce } => {
119            let role = convert_object_property(ope, vocab)?;
120            let inner = convert_class_expression(bce, vocab, pool)?;
121            Ok(pool.all(role, inner))
122        }
123        ClassExpression::ObjectHasValue { ope, i } => {
124            let role = convert_object_property(ope, vocab)?;
125            let ind = convert_individual(i, vocab)?;
126            let nom = pool.nominal(ind);
127            Ok(pool.some(role, nom))
128        }
129        ClassExpression::ObjectHasSelf(ope) => {
130            let role = convert_object_property(ope, vocab)?;
131            Ok(pool.self_restriction(role))
132        }
133        ClassExpression::ObjectMinCardinality { n, ope, bce } => {
134            let role = convert_object_property(ope, vocab)?;
135            let inner = convert_class_expression(bce, vocab, pool)?;
136            Ok(pool.min(*n, role, inner))
137        }
138        ClassExpression::ObjectMaxCardinality { n, ope, bce } => {
139            let role = convert_object_property(ope, vocab)?;
140            let inner = convert_class_expression(bce, vocab, pool)?;
141            Ok(pool.max(*n, role, inner))
142        }
143        ClassExpression::ObjectExactCardinality { n, ope, bce } => {
144            let role = convert_object_property(ope, vocab)?;
145            let inner = convert_class_expression(bce, vocab, pool)?;
146            let lo = pool.min(*n, role, inner);
147            let hi = pool.max(*n, role, inner);
148            Ok(pool.and([lo, hi]))
149        }
150        ClassExpression::DataSomeValuesFrom { .. }
151        | ClassExpression::DataAllValuesFrom { .. }
152        | ClassExpression::DataHasValue { .. }
153        | ClassExpression::DataMinCardinality { .. }
154        | ClassExpression::DataMaxCardinality { .. }
155        | ClassExpression::DataExactCardinality { .. } => {
156            Err(ConversionError::UnsupportedDataRange)
157        }
158    }
159}
160
161fn convert_many<A: ForIRI>(
162    xs: &[ClassExpression<A>],
163    vocab: &mut Vocabulary,
164    pool: &mut ConceptPool,
165) -> Result<Vec<ConceptId>, ConversionError> {
166    let mut out = Vec::with_capacity(xs.len());
167    for ce in xs {
168        out.push(convert_class_expression(ce, vocab, pool)?);
169    }
170    Ok(out)
171}
172
173/// Convert a horned-owl [`ObjectPropertyExpression`] to a [`Role`].
174///
175/// `InverseObjectProperty` lowers to [`Role::Inverse`] as of Phase 3
176/// commit 2. The named property inside the inversion is interned in
177/// the role vocabulary just like a forward use; downstream rules
178/// decide direction by inspecting [`Role::is_inverse`].
179pub fn convert_object_property<A: ForIRI>(
180    ope: &ObjectPropertyExpression<A>,
181    vocab: &mut Vocabulary,
182) -> Result<Role, ConversionError> {
183    match ope {
184        ObjectPropertyExpression::ObjectProperty(op) => {
185            let iri: &str = op.0.as_ref();
186            Ok(Role::named(vocab.intern_role(iri)))
187        }
188        ObjectPropertyExpression::InverseObjectProperty(op) => {
189            let iri: &str = op.0.as_ref();
190            Ok(Role::inverse(vocab.intern_role(iri)))
191        }
192    }
193}
194
195/// Convert a horned-owl [`Individual`] (named only — anonymous is rejected).
196pub fn convert_individual<A: ForIRI>(
197    i: &Individual<A>,
198    vocab: &mut Vocabulary,
199) -> Result<IndividualId, ConversionError> {
200    match i {
201        Individual::Named(ni) => {
202            let iri: &str = ni.0.as_ref();
203            Ok(vocab.intern_individual(iri))
204        }
205        Individual::Anonymous(_) => Err(ConversionError::AnonymousIndividual),
206    }
207}
208
209fn intern_class_decl<A: ForIRI>(c: &Class<A>, vocab: &mut Vocabulary) -> ClassId {
210    let iri: &str = c.0.as_ref();
211    vocab.intern_class(iri)
212}
213
214fn convert_sub_role_path<A: ForIRI>(
215    sub: &SubObjectPropertyExpression<A>,
216    vocab: &mut Vocabulary,
217) -> Result<SubRolePath, ConversionError> {
218    match sub {
219        SubObjectPropertyExpression::ObjectPropertyExpression(ope) => {
220            Ok(SubRolePath::Role(convert_object_property(ope, vocab)?))
221        }
222        SubObjectPropertyExpression::ObjectPropertyChain(chain) => {
223            let mut roles = Vec::with_capacity(chain.len());
224            for link in chain {
225                roles.push(convert_object_property(link, vocab)?);
226            }
227            Ok(SubRolePath::Chain(roles))
228        }
229    }
230}
231
232fn convert_individuals<A: ForIRI>(
233    inds: &[Individual<A>],
234    vocab: &mut Vocabulary,
235) -> Result<Vec<IndividualId>, ConversionError> {
236    let mut out = Vec::with_capacity(inds.len());
237    for i in inds {
238        out.push(convert_individual(i, vocab)?);
239    }
240    Ok(out)
241}
242
243fn convert_roles<A: ForIRI>(
244    opes: &[ObjectPropertyExpression<A>],
245    vocab: &mut Vocabulary,
246) -> Result<Vec<Role>, ConversionError> {
247    let mut out = Vec::with_capacity(opes.len());
248    for o in opes {
249        out.push(convert_object_property(o, vocab)?);
250    }
251    Ok(out)
252}
253
254/// Axiom-site helper: convert a `ClassExpression`, but if the
255/// expression contains a data-range constructor
256/// ([`ConversionError::UnsupportedDataRange`]), return `Ok(None)` for
257/// the enclosing axiom (drops it silently — sound under-approximation,
258/// see Phase D1 notes in the data-property arms). Other errors
259/// propagate via `?`.
260macro_rules! ce_or_skip {
261    ($expr:expr) => {
262        match $expr {
263            Ok(c) => c,
264            Err(ConversionError::UnsupportedDataRange) => return Ok(None),
265            Err(e) => return Err(e),
266        }
267    };
268}
269
270/// Convert a single horned-owl [`Component`] to one of our axioms.
271///
272/// Returns:
273/// - `Ok(Some(axiom))` when the component maps to an axiom in our IR.
274/// - `Ok(None)` when the component is metadata or annotation-related and
275///   has no representation in our IR (silently dropped — see the module
276///   docs for the rationale). Also returned for axioms dropped under
277///   Phase D1 data-axiom sound-under-approximation (see the data property
278///   arms below + the `ce_or_skip!` macro above).
279/// - `Err(_)` when the component is semantically meaningful but
280///   unsupported in this phase (data ranges, datatypes, SWRL rules,
281///   inverse-property expressions, anonymous individuals, etc.).
282#[allow(clippy::too_many_lines)] // intrinsic to the breadth of horned-owl's Component enum
283pub fn convert_component<A: ForIRI>(
284    c: &Component<A>,
285    vocab: &mut Vocabulary,
286    pool: &mut ConceptPool,
287) -> Result<Option<Axiom>, ConversionError> {
288    use Component as C;
289    match c {
290        // ── Silently dropped: metadata + annotation axioms ──────────────
291        // None of these carry reasoning-load-bearing content.
292        #[allow(clippy::match_same_arms)]
293        C::OntologyID(_)
294        | C::DocIRI(_)
295        | C::OntologyAnnotation(_)
296        | C::Import(_)
297        | C::DeclareAnnotationProperty(_)
298        | C::AnnotationAssertion(_)
299        | C::SubAnnotationPropertyOf(_)
300        | C::AnnotationPropertyDomain(_)
301        | C::AnnotationPropertyRange(_) => Ok(None),
302
303        // ── Declarations ────────────────────────────────────────────────
304        C::DeclareClass(d) => Ok(Some(Axiom::DeclareClass(intern_class_decl(&d.0, vocab)))),
305        C::DeclareObjectProperty(d) => {
306            let iri: &str = d.0.0.as_ref();
307            Ok(Some(Axiom::DeclareObjectProperty(vocab.intern_role(iri))))
308        }
309        C::DeclareNamedIndividual(d) => {
310            let iri: &str = d.0.0.as_ref();
311            Ok(Some(Axiom::DeclareNamedIndividual(
312                vocab.intern_individual(iri),
313            )))
314        }
315        // ── Data properties + datatypes: sound under-approximation ──────
316        // Phase D1 (2026-06-03): silently drop data-related declarations
317        // and axioms. Class subsumption inferences that DEPEND on data
318        // axioms (e.g., disjointness derivable from
319        // DataMaxCardinality(1, dp) + DataMinCardinality(2, dp)) are
320        // missed; no false positives are introduced. Class expressions
321        // containing data-range constructors cause the enclosing axiom
322        // to be dropped via the `ce_or_skip!` macro at axiom sites
323        // (see `convert_class_expression`'s UnsupportedDataRange returns).
324        // Phase D2 measurement decides whether real data-cardinality
325        // reasoning (Tier B) is needed; Phase D3+ would add datatype
326        // ranges (Tier C).
327        C::DeclareDataProperty(_) | C::DeclareDatatype(_) => Ok(None),
328
329        // ── TBox ────────────────────────────────────────────────────────
330        C::SubClassOf(ax) => {
331            let sub = ce_or_skip!(convert_class_expression(&ax.sub, vocab, pool));
332            let sup = ce_or_skip!(convert_class_expression(&ax.sup, vocab, pool));
333            Ok(Some(Axiom::SubClassOf { sub, sup }))
334        }
335        C::EquivalentClasses(ax) => {
336            let mut ids = Vec::with_capacity(ax.0.len());
337            for ce in &ax.0 {
338                ids.push(ce_or_skip!(convert_class_expression(ce, vocab, pool)));
339            }
340            Ok(Some(Axiom::EquivalentClasses(ids)))
341        }
342        C::DisjointClasses(ax) => {
343            let mut ids = Vec::with_capacity(ax.0.len());
344            for ce in &ax.0 {
345                ids.push(ce_or_skip!(convert_class_expression(ce, vocab, pool)));
346            }
347            Ok(Some(Axiom::DisjointClasses(ids)))
348        }
349        C::DisjointUnion(ax) => {
350            let class = intern_class_decl(&ax.0, vocab);
351            let mut members = Vec::with_capacity(ax.1.len());
352            for ce in &ax.1 {
353                members.push(ce_or_skip!(convert_class_expression(ce, vocab, pool)));
354            }
355            Ok(Some(Axiom::DisjointUnion { class, members }))
356        }
357
358        // ── RBox ────────────────────────────────────────────────────────
359        C::SubObjectPropertyOf(ax) => {
360            let sub = convert_sub_role_path(&ax.sub, vocab)?;
361            let sup = convert_object_property(&ax.sup, vocab)?;
362            Ok(Some(Axiom::SubObjectPropertyOf { sub, sup }))
363        }
364        C::EquivalentObjectProperties(ax) => {
365            let roles = convert_roles(&ax.0, vocab)?;
366            Ok(Some(Axiom::EquivalentObjectProperties(roles)))
367        }
368        C::DisjointObjectProperties(ax) => {
369            let roles = convert_roles(&ax.0, vocab)?;
370            Ok(Some(Axiom::DisjointObjectProperties(roles)))
371        }
372        C::InverseObjectProperties(ax) => {
373            let a = Role::named(vocab.intern_role(ax.0.0.as_ref()));
374            let b = Role::named(vocab.intern_role(ax.1.0.as_ref()));
375            Ok(Some(Axiom::InverseObjectProperties(a, b)))
376        }
377        C::ObjectPropertyDomain(ax) => {
378            let role = convert_object_property(&ax.ope, vocab)?;
379            let domain = ce_or_skip!(convert_class_expression(&ax.ce, vocab, pool));
380            Ok(Some(Axiom::ObjectPropertyDomain { role, domain }))
381        }
382        C::ObjectPropertyRange(ax) => {
383            let role = convert_object_property(&ax.ope, vocab)?;
384            let range = ce_or_skip!(convert_class_expression(&ax.ce, vocab, pool));
385            Ok(Some(Axiom::ObjectPropertyRange { role, range }))
386        }
387        C::FunctionalObjectProperty(ax) => Ok(Some(Axiom::FunctionalRole(
388            convert_object_property(&ax.0, vocab)?,
389        ))),
390        C::InverseFunctionalObjectProperty(ax) => Ok(Some(Axiom::InverseFunctionalRole(
391            convert_object_property(&ax.0, vocab)?,
392        ))),
393        C::ReflexiveObjectProperty(ax) => Ok(Some(Axiom::ReflexiveRole(convert_object_property(
394            &ax.0, vocab,
395        )?))),
396        C::IrreflexiveObjectProperty(ax) => Ok(Some(Axiom::IrreflexiveRole(
397            convert_object_property(&ax.0, vocab)?,
398        ))),
399        C::SymmetricObjectProperty(ax) => Ok(Some(Axiom::SymmetricRole(convert_object_property(
400            &ax.0, vocab,
401        )?))),
402        C::AsymmetricObjectProperty(ax) => Ok(Some(Axiom::AsymmetricRole(
403            convert_object_property(&ax.0, vocab)?,
404        ))),
405        C::TransitiveObjectProperty(ax) => Ok(Some(Axiom::TransitiveRole(
406            convert_object_property(&ax.0, vocab)?,
407        ))),
408
409        // ── ABox ────────────────────────────────────────────────────────
410        C::ClassAssertion(ax) => {
411            let class = ce_or_skip!(convert_class_expression(&ax.ce, vocab, pool));
412            let individual = convert_individual(&ax.i, vocab)?;
413            Ok(Some(Axiom::ClassAssertion { class, individual }))
414        }
415        C::ObjectPropertyAssertion(ax) => {
416            let role = convert_object_property(&ax.ope, vocab)?;
417            let subject = convert_individual(&ax.from, vocab)?;
418            let object = convert_individual(&ax.to, vocab)?;
419            Ok(Some(Axiom::ObjectPropertyAssertion {
420                role,
421                subject,
422                object,
423            }))
424        }
425        C::NegativeObjectPropertyAssertion(ax) => {
426            let role = convert_object_property(&ax.ope, vocab)?;
427            let subject = convert_individual(&ax.from, vocab)?;
428            let object = convert_individual(&ax.to, vocab)?;
429            Ok(Some(Axiom::NegativeObjectPropertyAssertion {
430                role,
431                subject,
432                object,
433            }))
434        }
435        C::SameIndividual(ax) => Ok(Some(Axiom::SameIndividual(convert_individuals(
436            &ax.0, vocab,
437        )?))),
438        C::DifferentIndividuals(ax) => Ok(Some(Axiom::DifferentIndividuals(convert_individuals(
439            &ax.0, vocab,
440        )?))),
441
442        // ── Data property / datatype: silently dropped per Phase D1 ─────
443        // See the DeclareDataProperty / DeclareDatatype block above for
444        // the sound-under-approximation rationale.
445        #[allow(clippy::match_same_arms)]
446        C::SubDataPropertyOf(_)
447        | C::EquivalentDataProperties(_)
448        | C::DisjointDataProperties(_)
449        | C::DataPropertyDomain(_)
450        | C::DataPropertyRange(_)
451        | C::FunctionalDataProperty(_)
452        | C::DatatypeDefinition(_)
453        | C::DataPropertyAssertion(_)
454        | C::NegativeDataPropertyAssertion(_) => Ok(None),
455
456        // ── HasKey: advanced feature, deferred ──────────────────────────
457        C::HasKey(_) => Err(ConversionError::UnsupportedAxiom { kind: "HasKey" }),
458
459        // ── SWRL rules: silently skipped ────────────────────────────────
460        // DL-safe `Rule` axioms are FOL-style entailment rules over
461        // individuals; on real workloads (e.g. RO with 25 such rules)
462        // they encode ABox-level inferences (`if x has property P
463        // and y holds, then ...`). They don't enter class-side
464        // classification — no class definition references their head
465        // predicates — so silently dropping them is sound for the
466        // `classify` use case. A future `swrl` feature gate could
467        // materialise them via tableau extensions if needed.
468        #[allow(clippy::match_same_arms)]
469        C::Rule(_) => Ok(None),
470    }
471}
472
473/// Convert an entire horned-owl [`SetOntology`] into an [`InternalOntology`].
474///
475/// Returns the first error encountered. horned-owl iterates a `HashSet`, so
476/// the components arrive in HashMap-iteration order (different between
477/// processes). Two stabilizations make every downstream pass — vocabulary
478/// interning, absorption, saturation, the tableau search — deterministic
479/// across runs:
480///
481/// 1. Sort components by their derived `Ord` *before* lowering, so the
482///    sequence of `intern_class` / `intern_role` / `intern_individual`
483///    calls is reproducible. This pins `ClassId` / `RoleId` /
484///    `IndividualId` assignment (and therefore every `ConceptId` derived
485///    from them) to a single canonical order across runs.
486/// 2. Sort the lowered axiom list afterwards. Step 1 already guarantees
487///    a deterministic sequence given a stable component order, but
488///    sorting the output too keeps the contract explicit and survives
489///    any future change to lowering that might shuffle ordering.
490///
491/// Same input → same axiom vector → reproducible reasoning behaviour and
492/// timings.
493pub fn convert_ontology<A: ForIRI>(
494    src: &SetOntology<A>,
495) -> Result<InternalOntology, ConversionError> {
496    let mut components: Vec<&AnnotatedComponent<A>> = src.iter().collect();
497    components.sort();
498    let mut out = InternalOntology::new();
499    for ac in components {
500        if let Some(axiom) =
501            convert_component(&ac.component, &mut out.vocabulary, &mut out.concepts)?
502        {
503            out.axioms.push(axiom);
504        }
505    }
506    // Phase D4 (2026-06-03): scan for data-axiom patterns the main
507    // conversion dropped (DeclareDataProperty, DataMin/Max, Functional,
508    // DataPropertyDomain, SubDataPropertyOf, DataSome) and emit derived
509    // class-subsumption / unsat axioms. The vocabulary is now fully
510    // populated so class IRIs resolve. Sound under-approximation:
511    // patterns we don't recognize stay dropped; recognized patterns
512    // contribute additional axioms that close specific completeness
513    // gaps without changing any other behavior. See
514    // crates/owl-dl-core/src/data_axioms.rs for the pattern docs +
515    // crates/owl-dl-reasoner/tests/datatype_completeness.rs for the
516    // TDD harness.
517    let bot_id = out.concepts.bot();
518    // We intern atomic concept lookups inside the closure so the pool
519    // gets all referenced atomic classes (some may not have been
520    // referenced by any axiom that survived ce_or_skip!).
521    // RefCell scoped tightly so its borrow on out.concepts ends before
522    // out.axioms.extend (which doesn't need it but reads cleaner).
523    let derived = {
524        let concepts_cell = std::cell::RefCell::new(&mut out.concepts);
525        crate::data_axioms::derive_data_axioms(
526            src,
527            &out.vocabulary,
528            bot_id,
529            |cid| concepts_cell.borrow_mut().atomic(cid),
530        )
531    };
532    out.axioms.extend(derived);
533    out.axioms.sort();
534    Ok(out)
535}
536
537impl<A: ForIRI> TryFrom<&SetOntology<A>> for InternalOntology {
538    type Error = ConversionError;
539
540    fn try_from(src: &SetOntology<A>) -> Result<Self, Self::Error> {
541        convert_ontology(src)
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    #![allow(clippy::unwrap_used)]
548
549    use super::*;
550    use crate::ir::ConceptExpr;
551    use horned_owl::model::{Build, RcStr};
552
553    fn b() -> Build<RcStr> {
554        Build::new_rc()
555    }
556
557    fn ctx() -> (Vocabulary, ConceptPool) {
558        (Vocabulary::new(), ConceptPool::new())
559    }
560
561    #[test]
562    fn class() {
563        let (mut v, mut p) = ctx();
564        let ce = ClassExpression::Class(b().class("http://example.org/A"));
565        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
566        let ConceptExpr::Atomic(c) = p.get(id) else {
567            panic!("expected Atomic")
568        };
569        assert_eq!(v.class_iri(*c), "http://example.org/A");
570    }
571
572    #[test]
573    fn intersection() {
574        let (mut v, mut p) = ctx();
575        let ce = ClassExpression::ObjectIntersectionOf(vec![
576            ClassExpression::Class(b().class("A")),
577            ClassExpression::Class(b().class("B")),
578        ]);
579        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
580        let ConceptExpr::And(args) = p.get(id) else {
581            panic!("expected And")
582        };
583        assert_eq!(args.len(), 2);
584    }
585
586    #[test]
587    fn empty_intersection_is_top() {
588        let (mut v, mut p) = ctx();
589        let ce: ClassExpression<RcStr> = ClassExpression::ObjectIntersectionOf(vec![]);
590        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
591        assert!(matches!(p.get(id), ConceptExpr::Top));
592    }
593
594    #[test]
595    fn union() {
596        let (mut v, mut p) = ctx();
597        let ce = ClassExpression::ObjectUnionOf(vec![
598            ClassExpression::Class(b().class("A")),
599            ClassExpression::Class(b().class("B")),
600        ]);
601        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
602        assert!(matches!(p.get(id), ConceptExpr::Or(_)));
603    }
604
605    #[test]
606    fn empty_union_is_bot() {
607        let (mut v, mut p) = ctx();
608        let ce: ClassExpression<RcStr> = ClassExpression::ObjectUnionOf(vec![]);
609        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
610        assert!(matches!(p.get(id), ConceptExpr::Bot));
611    }
612
613    #[test]
614    fn complement() {
615        let (mut v, mut p) = ctx();
616        let ce =
617            ClassExpression::ObjectComplementOf(Box::new(ClassExpression::Class(b().class("A"))));
618        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
619        assert!(matches!(p.get(id), ConceptExpr::Not(_)));
620    }
621
622    #[test]
623    fn some_values_from() {
624        let (mut v, mut p) = ctx();
625        let ce = ClassExpression::ObjectSomeValuesFrom {
626            ope: ObjectPropertyExpression::ObjectProperty(b().object_property("r")),
627            bce: Box::new(ClassExpression::Class(b().class("A"))),
628        };
629        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
630        assert!(matches!(p.get(id), ConceptExpr::Some(_, _)));
631    }
632
633    #[test]
634    fn all_values_from() {
635        let (mut v, mut p) = ctx();
636        let ce = ClassExpression::ObjectAllValuesFrom {
637            ope: ObjectPropertyExpression::ObjectProperty(b().object_property("r")),
638            bce: Box::new(ClassExpression::Class(b().class("A"))),
639        };
640        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
641        assert!(matches!(p.get(id), ConceptExpr::All(_, _)));
642    }
643
644    #[test]
645    fn has_value_encodes_as_some_of_nominal() {
646        let (mut v, mut p) = ctx();
647        let ce = ClassExpression::ObjectHasValue {
648            ope: ObjectPropertyExpression::ObjectProperty(b().object_property("r")),
649            i: Individual::Named(b().named_individual("a")),
650        };
651        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
652        let ConceptExpr::Some(_, inner) = p.get(id) else {
653            panic!("expected Some(_, _)")
654        };
655        assert!(matches!(p.get(*inner), ConceptExpr::Nominal(_)));
656    }
657
658    #[test]
659    fn has_self() {
660        let (mut v, mut p) = ctx();
661        let ce = ClassExpression::ObjectHasSelf(ObjectPropertyExpression::ObjectProperty(
662            b().object_property("r"),
663        ));
664        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
665        assert!(matches!(p.get(id), ConceptExpr::SelfRestriction(_)));
666    }
667
668    #[test]
669    fn min_cardinality() {
670        let (mut v, mut p) = ctx();
671        let ce = ClassExpression::ObjectMinCardinality {
672            n: 3,
673            ope: ObjectPropertyExpression::ObjectProperty(b().object_property("r")),
674            bce: Box::new(ClassExpression::Class(b().class("A"))),
675        };
676        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
677        let ConceptExpr::Min(n, _, _) = p.get(id) else {
678            panic!("expected Min")
679        };
680        assert_eq!(*n, 3);
681    }
682
683    #[test]
684    fn max_cardinality() {
685        let (mut v, mut p) = ctx();
686        let ce = ClassExpression::ObjectMaxCardinality {
687            n: 5,
688            ope: ObjectPropertyExpression::ObjectProperty(b().object_property("r")),
689            bce: Box::new(ClassExpression::Class(b().class("A"))),
690        };
691        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
692        let ConceptExpr::Max(n, _, _) = p.get(id) else {
693            panic!("expected Max")
694        };
695        assert_eq!(*n, 5);
696    }
697
698    #[test]
699    fn exact_cardinality_encodes_as_and_of_min_max() {
700        let (mut v, mut p) = ctx();
701        let ce = ClassExpression::ObjectExactCardinality {
702            n: 2,
703            ope: ObjectPropertyExpression::ObjectProperty(b().object_property("r")),
704            bce: Box::new(ClassExpression::Class(b().class("A"))),
705        };
706        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
707        let ConceptExpr::And(args) = p.get(id) else {
708            panic!("expected And(Min, Max)")
709        };
710        assert_eq!(args.len(), 2);
711        // One of the conjuncts is Min, the other Max.
712        let kinds: Vec<&'static str> = args
713            .iter()
714            .map(|a| match p.get(*a) {
715                ConceptExpr::Min(..) => "Min",
716                ConceptExpr::Max(..) => "Max",
717                _ => "other",
718            })
719            .collect();
720        assert!(kinds.contains(&"Min"));
721        assert!(kinds.contains(&"Max"));
722    }
723
724    #[test]
725    fn one_of_encodes_as_or_of_nominals() {
726        let (mut v, mut p) = ctx();
727        let ce = ClassExpression::ObjectOneOf(vec![
728            Individual::Named(b().named_individual("a")),
729            Individual::Named(b().named_individual("b")),
730        ]);
731        let id = convert_class_expression(&ce, &mut v, &mut p).unwrap();
732        let ConceptExpr::Or(args) = p.get(id) else {
733            panic!("expected Or")
734        };
735        assert_eq!(args.len(), 2);
736        for a in args {
737            assert!(matches!(p.get(*a), ConceptExpr::Nominal(_)));
738        }
739    }
740
741    #[test]
742    fn inverse_object_property_lowers_to_inverse_role() {
743        let mut v = Vocabulary::new();
744        let ope =
745            ObjectPropertyExpression::<RcStr>::InverseObjectProperty(b().object_property("r"));
746        let role = convert_object_property(&ope, &mut v).unwrap();
747        assert!(role.is_inverse());
748        // The named id should match the forward use's id.
749        let forward = b().object_property("r");
750        let forward_ope = ObjectPropertyExpression::<RcStr>::ObjectProperty(forward);
751        let forward_role = convert_object_property(&forward_ope, &mut v).unwrap();
752        assert_eq!(role.role_id(), forward_role.role_id());
753    }
754
755    #[test]
756    fn anonymous_individual_rejected() {
757        use horned_owl::model::AnonymousIndividual;
758        use std::rc::Rc;
759
760        let mut v = Vocabulary::new();
761        let i = Individual::<RcStr>::Anonymous(AnonymousIndividual(Rc::from("blank-0")));
762        let err = convert_individual(&i, &mut v).unwrap_err();
763        assert_eq!(err, ConversionError::AnonymousIndividual);
764    }
765
766    #[test]
767    fn data_some_values_rejected() {
768        let (mut v, mut p) = ctx();
769        let ce: ClassExpression<RcStr> = ClassExpression::DataSomeValuesFrom {
770            dp: b().data_property("dp"),
771            dr: horned_owl::model::DataRange::Datatype(b().datatype("dt")),
772        };
773        let err = convert_class_expression(&ce, &mut v, &mut p).unwrap_err();
774        assert_eq!(err, ConversionError::UnsupportedDataRange);
775    }
776
777    #[test]
778    fn shared_subexpressions_share_ids() {
779        let (mut v, mut p) = ctx();
780        let ce1 = ClassExpression::Class(b().class("A"));
781        let ce2 = ClassExpression::Class(b().class("A"));
782        let id1 = convert_class_expression(&ce1, &mut v, &mut p).unwrap();
783        let id2 = convert_class_expression(&ce2, &mut v, &mut p).unwrap();
784        assert_eq!(id1, id2);
785        assert_eq!(p.len(), 1);
786        assert_eq!(v.num_classes(), 1);
787    }
788
789    // ──────────────────────────────────────────────────────────────────
790    // Day 11: per-Component axiom conversion tests
791    // ──────────────────────────────────────────────────────────────────
792
793    use horned_owl::model as ho;
794    use horned_owl::model::MutableOntology;
795
796    fn ce_class(name: &str) -> ClassExpression<RcStr> {
797        ClassExpression::Class(b().class(name))
798    }
799
800    fn ope(name: &str) -> ObjectPropertyExpression<RcStr> {
801        ObjectPropertyExpression::ObjectProperty(b().object_property(name))
802    }
803
804    fn named_ind(name: &str) -> Individual<RcStr> {
805        Individual::Named(b().named_individual(name))
806    }
807
808    fn convert_one(c: &Component<RcStr>) -> (InternalOntology, Option<Axiom>) {
809        let mut o = InternalOntology::new();
810        let ax = convert_component(c, &mut o.vocabulary, &mut o.concepts).unwrap();
811        (o, ax)
812    }
813
814    #[test]
815    fn sub_class_of_axiom() {
816        let c = Component::SubClassOf(ho::SubClassOf {
817            sub: ce_class("A"),
818            sup: ce_class("B"),
819        });
820        let (_, ax) = convert_one(&c);
821        assert!(matches!(ax, Some(Axiom::SubClassOf { .. })));
822    }
823
824    #[test]
825    fn equivalent_classes_axiom_keeps_vec() {
826        let c = Component::EquivalentClasses(ho::EquivalentClasses(vec![
827            ce_class("A"),
828            ce_class("B"),
829            ce_class("C"),
830        ]));
831        let (_, ax) = convert_one(&c);
832        let Some(Axiom::EquivalentClasses(v)) = ax else {
833            panic!()
834        };
835        assert_eq!(v.len(), 3);
836    }
837
838    #[test]
839    fn disjoint_classes_axiom() {
840        let c = Component::DisjointClasses(ho::DisjointClasses(vec![ce_class("A"), ce_class("B")]));
841        let (_, ax) = convert_one(&c);
842        assert!(matches!(ax, Some(Axiom::DisjointClasses(_))));
843    }
844
845    #[test]
846    fn disjoint_union_axiom() {
847        let c = Component::DisjointUnion(ho::DisjointUnion(
848            b().class("Parent"),
849            vec![ce_class("Child1"), ce_class("Child2")],
850        ));
851        let (_, ax) = convert_one(&c);
852        let Some(Axiom::DisjointUnion { members, .. }) = ax else {
853            panic!()
854        };
855        assert_eq!(members.len(), 2);
856    }
857
858    #[test]
859    fn sub_object_property_of_single() {
860        let c = Component::SubObjectPropertyOf(ho::SubObjectPropertyOf {
861            sub: SubObjectPropertyExpression::ObjectPropertyExpression(ope("r")),
862            sup: ope("s"),
863        });
864        let (_, ax) = convert_one(&c);
865        let Some(Axiom::SubObjectPropertyOf { sub, .. }) = ax else {
866            panic!()
867        };
868        assert!(matches!(sub, SubRolePath::Role(_)));
869    }
870
871    #[test]
872    fn sub_object_property_of_chain() {
873        let c = Component::SubObjectPropertyOf(ho::SubObjectPropertyOf {
874            sub: SubObjectPropertyExpression::ObjectPropertyChain(vec![ope("r"), ope("s")]),
875            sup: ope("t"),
876        });
877        let (_, ax) = convert_one(&c);
878        let Some(Axiom::SubObjectPropertyOf { sub, .. }) = ax else {
879            panic!()
880        };
881        let SubRolePath::Chain(chain) = sub else {
882            panic!()
883        };
884        assert_eq!(chain.len(), 2);
885    }
886
887    #[test]
888    fn equivalent_object_properties() {
889        let c = Component::EquivalentObjectProperties(ho::EquivalentObjectProperties(vec![
890            ope("r"),
891            ope("s"),
892        ]));
893        let (_, ax) = convert_one(&c);
894        assert!(matches!(ax, Some(Axiom::EquivalentObjectProperties(_))));
895    }
896
897    #[test]
898    fn inverse_object_properties_axiom() {
899        let c = Component::InverseObjectProperties(ho::InverseObjectProperties(
900            b().object_property("r"),
901            b().object_property("s"),
902        ));
903        let (_, ax) = convert_one(&c);
904        assert!(matches!(ax, Some(Axiom::InverseObjectProperties(_, _))));
905    }
906
907    #[test]
908    fn object_property_domain_and_range() {
909        let domain_c = Component::ObjectPropertyDomain(ho::ObjectPropertyDomain {
910            ope: ope("r"),
911            ce: ce_class("A"),
912        });
913        let range_c = Component::ObjectPropertyRange(ho::ObjectPropertyRange {
914            ope: ope("r"),
915            ce: ce_class("B"),
916        });
917        assert!(matches!(
918            convert_one(&domain_c).1,
919            Some(Axiom::ObjectPropertyDomain { .. })
920        ));
921        assert!(matches!(
922            convert_one(&range_c).1,
923            Some(Axiom::ObjectPropertyRange { .. })
924        ));
925    }
926
927    #[test]
928    fn role_characteristics() {
929        type AxiomCheck = (Component<RcStr>, fn(&Axiom) -> bool);
930        let cases: Vec<AxiomCheck> = vec![
931            (
932                Component::TransitiveObjectProperty(ho::TransitiveObjectProperty(ope("r"))),
933                |a| matches!(a, Axiom::TransitiveRole(_)),
934            ),
935            (
936                Component::FunctionalObjectProperty(ho::FunctionalObjectProperty(ope("r"))),
937                |a| matches!(a, Axiom::FunctionalRole(_)),
938            ),
939            (
940                Component::InverseFunctionalObjectProperty(ho::InverseFunctionalObjectProperty(
941                    ope("r"),
942                )),
943                |a| matches!(a, Axiom::InverseFunctionalRole(_)),
944            ),
945            (
946                Component::ReflexiveObjectProperty(ho::ReflexiveObjectProperty(ope("r"))),
947                |a| matches!(a, Axiom::ReflexiveRole(_)),
948            ),
949            (
950                Component::IrreflexiveObjectProperty(ho::IrreflexiveObjectProperty(ope("r"))),
951                |a| matches!(a, Axiom::IrreflexiveRole(_)),
952            ),
953            (
954                Component::SymmetricObjectProperty(ho::SymmetricObjectProperty(ope("r"))),
955                |a| matches!(a, Axiom::SymmetricRole(_)),
956            ),
957            (
958                Component::AsymmetricObjectProperty(ho::AsymmetricObjectProperty(ope("r"))),
959                |a| matches!(a, Axiom::AsymmetricRole(_)),
960            ),
961        ];
962        for (c, pred) in cases {
963            let (_, ax) = convert_one(&c);
964            let ax = ax.expect("expected an axiom");
965            assert!(pred(&ax), "wrong axiom: {ax:?}");
966        }
967    }
968
969    #[test]
970    fn class_assertion() {
971        let c = Component::ClassAssertion(ho::ClassAssertion {
972            ce: ce_class("A"),
973            i: named_ind("a"),
974        });
975        let (_, ax) = convert_one(&c);
976        assert!(matches!(ax, Some(Axiom::ClassAssertion { .. })));
977    }
978
979    #[test]
980    fn object_property_assertion_positive_and_negative() {
981        let pos = Component::ObjectPropertyAssertion(ho::ObjectPropertyAssertion {
982            ope: ope("r"),
983            from: named_ind("a"),
984            to: named_ind("b"),
985        });
986        let neg = Component::NegativeObjectPropertyAssertion(ho::NegativeObjectPropertyAssertion {
987            ope: ope("r"),
988            from: named_ind("a"),
989            to: named_ind("b"),
990        });
991        assert!(matches!(
992            convert_one(&pos).1,
993            Some(Axiom::ObjectPropertyAssertion { .. })
994        ));
995        assert!(matches!(
996            convert_one(&neg).1,
997            Some(Axiom::NegativeObjectPropertyAssertion { .. })
998        ));
999    }
1000
1001    #[test]
1002    fn same_and_different_individuals() {
1003        let same =
1004            Component::SameIndividual(ho::SameIndividual(vec![named_ind("a"), named_ind("b")]));
1005        let diff = Component::DifferentIndividuals(ho::DifferentIndividuals(vec![
1006            named_ind("a"),
1007            named_ind("c"),
1008        ]));
1009        assert!(matches!(
1010            convert_one(&same).1,
1011            Some(Axiom::SameIndividual(_))
1012        ));
1013        assert!(matches!(
1014            convert_one(&diff).1,
1015            Some(Axiom::DifferentIndividuals(_))
1016        ));
1017    }
1018
1019    #[test]
1020    fn declarations() {
1021        assert!(matches!(
1022            convert_one(&Component::DeclareClass(ho::DeclareClass(b().class("A")))).1,
1023            Some(Axiom::DeclareClass(_))
1024        ));
1025        assert!(matches!(
1026            convert_one(&Component::DeclareObjectProperty(
1027                ho::DeclareObjectProperty(b().object_property("r"))
1028            ))
1029            .1,
1030            Some(Axiom::DeclareObjectProperty(_))
1031        ));
1032        assert!(matches!(
1033            convert_one(&Component::DeclareNamedIndividual(
1034                ho::DeclareNamedIndividual(b().named_individual("a"))
1035            ))
1036            .1,
1037            Some(Axiom::DeclareNamedIndividual(_))
1038        ));
1039    }
1040
1041    #[test]
1042    fn metadata_and_annotations_silently_skipped() {
1043        // OntologyID with no IRIs is the default.
1044        let id = ho::OntologyID::default();
1045        let (_, ax) = convert_one(&Component::<RcStr>::OntologyID(id));
1046        assert!(ax.is_none());
1047        // AnnotationProperty declaration is dropped (not reasoning-load-bearing).
1048        let ap = Component::<RcStr>::DeclareAnnotationProperty(ho::DeclareAnnotationProperty(
1049            b().annotation_property("p"),
1050        ));
1051        assert!(convert_one(&ap).1.is_none());
1052    }
1053
1054    /// Phase D1 (2026-06-03): data-axiom declarations no longer hard-
1055    /// error — they're silently dropped as sound under-approximation
1056    /// so the 4 erroring fixtures (family, ro, sio, shoiq-knowledge)
1057    /// parse + classify. Phase D2 measures FP/MISSED vs Konclude to
1058    /// decide if real cardinality reasoning (Tier B) is needed.
1059    #[test]
1060    fn data_axiom_declarations_silently_dropped() {
1061        let c = Component::<RcStr>::DeclareDataProperty(ho::DeclareDataProperty(
1062            b().data_property("dp"),
1063        ));
1064        let mut o = InternalOntology::new();
1065        let result = convert_component(&c, &mut o.vocabulary, &mut o.concepts).unwrap();
1066        assert!(
1067            result.is_none(),
1068            "Phase D1: data-property declarations drop silently (Ok(None))"
1069        );
1070    }
1071
1072    /// Phase D1: a SubClassOf where the SUP contains a data-range
1073    /// constructor (e.g., `DataMaxCardinality`) is silently dropped —
1074    /// the `ce_or_skip!` macro maps `UnsupportedDataRange` to `Ok(None)`
1075    /// for the enclosing axiom. Sound under-approximation: we lose the
1076    /// constraint, never invent a wrong one.
1077    #[test]
1078    fn subclass_with_data_range_silently_dropped() {
1079        use horned_owl::model::DataProperty;
1080        let dp = DataProperty::<RcStr>(b().iri("http://t/dp"));
1081        let c = Component::<RcStr>::SubClassOf(ho::SubClassOf {
1082            sub: ce_class("A"),
1083            sup: ClassExpression::DataMaxCardinality {
1084                n: 1,
1085                dp,
1086                dr: horned_owl::model::DataRange::Datatype(horned_owl::model::Datatype(
1087                    b().iri("http://www.w3.org/2001/XMLSchema#integer"),
1088                )),
1089            },
1090        });
1091        let mut o = InternalOntology::new();
1092        let result = convert_component(&c, &mut o.vocabulary, &mut o.concepts).unwrap();
1093        assert!(
1094            result.is_none(),
1095            "Phase D1: SubClassOf containing a data-range SUP drops silently"
1096        );
1097    }
1098
1099    #[test]
1100    fn convert_ontology_smoke() {
1101        let mut o = SetOntology::<RcStr>::new();
1102        o.insert(ho::AnnotatedComponent::from(Component::SubClassOf(
1103            ho::SubClassOf {
1104                sub: ce_class("A"),
1105                sup: ce_class("B"),
1106            },
1107        )));
1108        o.insert(ho::AnnotatedComponent::from(Component::DeclareClass(
1109            ho::DeclareClass(b().class("A")),
1110        )));
1111        let internal = convert_ontology(&o).unwrap();
1112        assert_eq!(internal.num_axioms(), 2);
1113        assert_eq!(internal.vocabulary.num_classes(), 2); // A, B
1114    }
1115
1116    #[test]
1117    fn try_from_set_ontology() {
1118        let mut o = SetOntology::<RcStr>::new();
1119        o.insert(ho::AnnotatedComponent::from(Component::SubClassOf(
1120            ho::SubClassOf {
1121                sub: ce_class("A"),
1122                sup: ce_class("B"),
1123            },
1124        )));
1125        let internal = InternalOntology::try_from(&o).unwrap();
1126        assert_eq!(internal.num_axioms(), 1);
1127    }
1128}