Skip to content

Support owl:AllDisjointClasses in the RDF reader and writer - #289

Open
OnodOfTheNorth wants to merge 1 commit into
phillord:develfrom
OnodOfTheNorth:alldisjointclasses
Open

Support owl:AllDisjointClasses in the RDF reader and writer#289
OnodOfTheNorth wants to merge 1 commit into
phillord:develfrom
OnodOfTheNorth:alldisjointclasses

Conversation

@OnodOfTheNorth

Copy link
Copy Markdown

Body

Summary

owl:AllDisjointClasses is missing from vocab.rs, so n-ary class disjointness is unsupported in the RDF codec in both directions. The read side silently drops the axioms. The write side silently weakens them.

Both are reproducible against the Pizza ontology, which uses the construct nine times.

The read side: axioms silently dropped

owl:AllDisjointClasses never interns as an OWL vocabulary term, so it arrives at the blank-node dispatch in io/rdf/reader.rs as a plain Term::Iri, matches nothing, and lands in the returned IncompleteParse. The parse reports success.

Reproduction, complete file:

<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
     xmlns:owl="http://www.w3.org/2002/07/owl#" xml:base="http://t/">
    <owl:Ontology rdf:about="http://t/"/>
    <owl:Class rdf:about="http://t/#A"/>
    <owl:Class rdf:about="http://t/#B"/>
    <owl:Class rdf:about="http://t/#C"/>
    <rdf:Description>
        <rdf:type rdf:resource="http://www.w3.org/2002/07/owl#AllDisjointClasses"/>
        <owl:members rdf:parseType="Collection">
            <rdf:Description rdf:about="http://t/#A"/>
            <rdf:Description rdf:about="http://t/#B"/>
            <rdf:Description rdf:about="http://t/#C"/>
        </owl:members>
    </rdf:Description>
</rdf:RDF>

Expected: one DisjointClasses(A B C).
Actual before this change: three DeclareClass and nothing else. incomplete.bnode and incomplete.bnode_seq each hold one entry.

Against real data, https://protege.stanford.edu/ontologies/pizza/pizza.owl contains nine owl:AllDisjointClasses axioms (member counts 3, 3, 4, 4, 4, 5, 8, 13, 23). Protégé 5.6.7 reports 14 DisjointClasses for that file. horned-owl reports 5, the pairwise owl:disjointWith ones.

The write side: axioms silently weakened

This is the more serious half. DisjointClasses renders via nary(), which emits a star from the first member:

// io/rdf/writer.rs
render_to_vec! {
    DisjointClasses, self, f, ng,
    {
        let pred = ng.nn(OWL::DisjointWith);
        nary(f, ng, &self.0, pred)
    }
}

nary emits first pred each_other, so DisjointClasses(A B C) becomes:

<#A> owl:disjointWith <#B> , <#C> .

B disjointWith C is never written. Disjointness is not transitive, so the serialized output asserts strictly less than the input axiom. Round-tripping DisjointClasses(A B C) through RDF/XML or Turtle yields two axioms, DisjointClasses(A B) and DisjointClasses(A C), and the third constraint is gone.

The read-side gap masked this: the reader could never construct an n-ary DisjointClasses, so the writer was never handed one in a round-trip test.

Note that nary's star is fine for the transitive n-ary axioms it also serves (EquivalentClasses, EquivalentObjectProperties, EquivalentDataProperties, SameIndividual), since transitivity recovers the missing pairs. This PR does not touch those.

The fix

Per the OWL 2 Mapping to RDF Graphs, DisjointClasses maps to owl:disjointWith for two operands and to a blank node typed owl:AllDisjointClasses with owl:members for three or more. That is exactly what the existing members() helper in io/rdf/writer.rs already does for DifferentIndividuals, and the comment above that call already anticipates wider use:

// Need to support also DisjointData/ObjectProperties which
// have the same pattern

Three changes:

  1. src/vocab.rs add (OWL, AllDisjointClasses, false) to the OWL vocabulary table, plus its assertion in test_meta_owl.

  2. src/io/rdf/reader.rs read the construct, reusing the existing retrieve_to_ce_seq.

  3. src/io/rdf/writer.rs route DisjointClasses through members() instead of nary():

render_to_vec! {
    DisjointClasses, self, f, ng,
    {
        members(f, ng, OWL::DisjointWith, OWL::AllDisjointClasses, &self.0)
    }
}

One thing worth a reviewer's attention

The obvious place for the reader change is a new arm in the match in axioms(), mirroring the two existing owl:AllDifferent arms. That does not build on Windows.

Adding one arm there tips rustc over its default stack: exit code 0xc0000005 (STATUS_ACCESS_VIOLATION) with no diagnostic emitted at all. Clean devel builds in about 14s; the same tree with one extra arm in that match does not build unless RUST_MIN_STACK is raised, at which point it builds in about the same time. So it is a compile-time stack limit rather than anything about the added logic.

This PR therefore consumes the matching blank nodes in a small standalone pass, all_disjoint_classes(), which runs immediately before axioms() and removes what it handles. The match in axioms() is left byte-identical to what it is today, so it costs that match nothing and the build stays clean at the default stack: 11.5s on this machine, no environment variables.

Flagging it because the placement looks arbitrary and is not, and because it means that match is currently one arm away from being unbuildable on Windows for whoever adds the next construct. That is worth knowing independently of this PR.

Verification

Against horned-owl's own test data. src/ont/owl-rdf/manual/family.owl contains an owl:AllDisjointClasses axiom (line 768), and src/ont/owl-ttl/manual/family.ttl contains the same (line 485). The construct appears nowhere in src/ outside those fixtures. Loading family.owl through the RDF reader:

DisjointClasses arities unconsumed blank-node groups
before [2] 1
after [2, 3] 0

The three-member axiom is the owl:AllDisjointClasses one. The unconsumed group going to zero is the same fact from the other side: that blank node was the axiom, it was being reported in IncompleteParse, and family.owl now parses completely where it did not before.

Against the Pizza ontology (https://protege.stanford.edu/ontologies/pizza/pizza.owl, which contains nine of these): the reader now yields 14 DisjointClasses with member counts [2,2,2,2,2,3,3,4,4,4,5,8,13,23], matching what Protégé 5.6.7 reports for the same file. Every other metric Protégé reports for it also matches: 100 classes, 8 object properties, 0 data properties, 5 individuals, 12 annotation properties, 120 declaration axioms, 259 SubClassOf, 15 EquivalentClasses.

Round-trip. DisjointClasses(A B C) now survives as a single three-member axiom through functional syntax, OWL/XML, RDF/XML and Turtle. The two-member form still serialises as a plain owl:disjointWith with no blank node.

No regression in the shared code path. DifferentIndividuals uses the same members() writer helper and the same blank-node shape, and still round-trips at member counts 2, 3, 5 and 41.

test_meta_owl covers the new vocabulary entry.

Note: cargo test does not currently compile on Windows on clean devel, for a reason unrelated to this change. src/io/mod.rs:328 imports std::os::unix::fs::PermissionsExt unconditionally and line 437 calls Permissions::from_mode. A two-line cfg gate fixes it; happy to send that separately as it is independent of everything here. The verification above was therefore done through an example binary rather than the test harness.

Related, not in this PR

Three further RDF-codec issues found while investigating. Happy to file separately or fold in, whichever suits.

  1. owl:AllDisjointProperties is in the vocabulary but the reader has no case for it. The writer emits it, so DisjointObjectProperties(A B C) and DisjointDataProperties(A B C) are written correctly and then read back as zero axioms. The fix wants an n-ary version of distinguish_retrieve_property_term_pair_kind; retrieve_to_seq's fn-pointer parameter cannot carry the ic argument the single-term distinguish_retrieve_property_kind needs, so it needs a hand-rolled loop plus a homogeneity check.

  2. owl:intersectionOf / owl:unionOf / owl:oneOf on a named class subject is not read. Per the mapping, X owl:intersectionOf SEQ with named X means EquivalentClasses(X, ObjectIntersectionOf(...)). Only the anonymous-subject form is handled. Reproduction:

<owl:Class rdf:about="#X">
  <owl:intersectionOf rdf:parseType="Collection">
    <owl:Class rdf:about="#A"/><owl:Class rdf:about="#B"/>
  </owl:intersectionOf>
</owl:Class>

loads as declarations only. The W3C wine ontology is built almost entirely on this idiom and loses 62 statements, 63 class expressions and 61 sequences.

  1. The OWL/XML reader does not read xml:base, and when a relative IRI="..." reference fails CURIE expansion it is passed through as though already absolute, producing a malformed IRI rather than an error. Protégé's OWL/XML writer emits base-relative references by default (IRI="/pizza.owl#American" against xml:base="http://www.co-ode.org/ontologies/pizza"), so this affects any Protégé-authored .owx. Separate draft in scratch/.

Filing notes for him

  • Items 1 and 2 in "Related" become PR 2, together with the xml:base draft. Item 3 is that draft.
  • If a maintainer asks for the reproductions as tests rather than prose, they are already written as ours: core/tests/roundtrip.rs::pizza_matches_protege_oracle and core/tests/known_lossy_expectations.rs. They would need reshaping to horned-owl's own test conventions before offering them.
  • Do not describe the star-write bug as theoretical. It changes the meaning of a saved file, and that is the strongest argument for merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant