From 1dcedbe228bfe9980fb15219f237034aa805e760 Mon Sep 17 00:00:00 2001 From: Matthew Horridge Date: Fri, 31 Jul 2026 10:32:01 -0700 Subject: [PATCH 1/2] Extract class relationships for all properties and axiom shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateGroundTriplesForAxioms only recognised the flat shape SubClassOf(A, someValuesFrom(p, B)) and only for a hard-coded set of OBO properties (part_of, contains, develops_from and their IRIs), gated on the property IRI containing "obo". Relationships on any other property — e.g. EDAM's has_topic/has_output/has_input — were silently dropped, which is why some relationships appear in the class Details view and others do not. Add generateRelationshipTriples, which visits the class expression of every SubClassOf and EquivalentClasses axiom of a named class (RelationshipVisitor, an OWLClassExpressionVisitorAdapter) and emits a relationship triple (subject --p--> filler, under the property's own IRI) for every someValuesFrom, hasValue, and min/exact cardinality (n >= 1). Intersection operands are traversed, both in the superclass expression and in a restriction's filler, so SubClassOf(A, p some (B and C)) yields A p B and A p C, and relationships stated via equivalences are extracted too. Unions, complements and allValuesFrom are not traversed/emitted (not entailed for every instance). This is additive: the existing OBO treeView/hierarchy emission is left unchanged, so the class tree is unaffected; the Details view now shows the full set of relationships. Verified on EDAM and UBERON, and with a fixture covering each shape. --- .../ncbo/owlapi/wrapper/OntologyParser.java | 128 ++++++++++++++++++ .../wrapper/RelationshipExtractionTest.java | 82 +++++++++++ .../repo/input/relations/relations.ttl | 79 +++++++++++ 3 files changed, 289 insertions(+) create mode 100644 src/test/java/org/stanford/ncbo/owlapi/wrapper/RelationshipExtractionTest.java create mode 100644 src/test/resources/repo/input/relations/relations.ttl diff --git a/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java b/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java index 57a990c..3795ef3 100644 --- a/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java +++ b/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java @@ -16,6 +16,7 @@ import org.semanticweb.owlapi.search.EntitySearcher; import org.semanticweb.owlapi.util.AutoIRIMapper; import org.semanticweb.owlapi.util.InferredSubClassAxiomGenerator; +import org.semanticweb.owlapi.util.OWLClassExpressionVisitorAdapter; import org.semanticweb.owlapi.util.SimpleIRIMapper; import org.semanticweb.owlapi.vocab.OWLRDFVocabulary; import org.slf4j.Logger; @@ -253,6 +254,7 @@ private boolean buildOWLOntology(OWLOntology masterOntology, boolean isOBO) { addGroundMetadata(documentIRI, fact, sourceOnt); generateGroundTriplesForAxioms(allAxioms, fact, sourceOnt); + generateRelationshipTriples(allAxioms, fact, sourceOnt); if (isOBO) { if (!documentIRI.toString().startsWith("owlapi:ontology")) { @@ -567,6 +569,132 @@ private void generateGroundTriplesForAxioms(Set allAxioms, OWLDataFact } } + /* + * Emits a metadata triple for every class-to-class (or class-to-individual) + * relationship a named class participates in, for ANY object property, derived + * from SubClassOf and EquivalentClasses axioms. + * + * Unlike generateGroundTriplesForAxioms (which only recognises the flat shape + * SubClassOf(A, someValuesFrom(p, B)) for a hard-coded set of OBO properties), + * the right-hand side of each axiom is visited (see RelationshipVisitor) so that + * relationships nested inside intersections, and those stated via equivalences, + * are also extracted. Each relationship A --p--> filler is emitted under the + * property's own IRI. + */ + private void generateRelationshipTriples(Set allAxioms, OWLDataFactory fact, OWLOntology sourceOnt) { + for (OWLSubClassOfAxiom sc : sourceOnt.getAxioms(AxiomType.SUBCLASS_OF)) { + if (!sc.getSubClass().isAnonymous()) { + sc.getSuperClass().accept(new RelationshipVisitor(sc.getSubClass().asOWLClass(), allAxioms, fact)); + } + } + + for (OWLEquivalentClassesAxiom eq : sourceOnt.getAxioms(AxiomType.EQUIVALENT_CLASSES)) { + for (OWLClassExpression named : eq.getClassExpressions()) { + if (!named.isAnonymous()) { + RelationshipVisitor visitor = new RelationshipVisitor(named.asOWLClass(), allAxioms, fact); + for (OWLClassExpression other : eq.getClassExpressions()) { + if (!other.equals(named)) { + other.accept(visitor); + } + } + } + } + } + } + + /* + * Collects the relationships a fixed subject class participates in, by visiting + * the class expression on the right-hand side of a SubClassOf/EquivalentClasses + * axiom. Only intersections are traversed further; the existential shapes + * (someValuesFrom, hasValue, min/exact cardinality >= 1) emit a relationship. The + * filler of those shapes is expanded the same way -- a named filler, or the named + * classes of an intersection filler (see emitRelationshipToClass). Every other + * class-expression type is a no-op (inherited from the adapter), so unions, + * complements and allValuesFrom are, by design, neither traversed nor emitted. + */ + private class RelationshipVisitor extends OWLClassExpressionVisitorAdapter { + private final OWLClass subject; + private final Set allAxioms; + private final OWLDataFactory fact; + + RelationshipVisitor(OWLClass subject, Set allAxioms, OWLDataFactory fact) { + this.subject = subject; + this.allAxioms = allAxioms; + this.fact = fact; + } + + @Override + public void visit(OWLObjectIntersectionOf intersection) { + for (OWLClassExpression operand : intersection.getOperands()) { + operand.accept(this); + } + } + + @Override + public void visit(OWLObjectSomeValuesFrom some) { + emitRelationshipToClass(some.getProperty(), some.getFiller()); + } + + @Override + public void visit(OWLObjectHasValue hasValue) { + emitRelationshipToIndividual(hasValue.getProperty(), hasValue.getFiller()); + } + + @Override + public void visit(OWLObjectMinCardinality card) { + if (card.getCardinality() >= 1) { + emitRelationshipToClass(card.getProperty(), card.getFiller()); + } + } + + @Override + public void visit(OWLObjectExactCardinality card) { + if (card.getCardinality() >= 1) { + emitRelationshipToClass(card.getProperty(), card.getFiller()); + } + } + + /* + * Emits subject --p--> C for every named class C in the filler. A named + * filler contributes itself; an intersection filler contributes the named + * classes of its operands (recursively), since `p some (B and C1 ... Cn)` + * entails a relationship to each of B, C1, ... Cn. Anonymous fillers with no + * named class (e.g. a union, or a nested restriction) contribute nothing. + */ + private void emitRelationshipToClass(OWLObjectPropertyExpression property, OWLClassExpression filler) { + if (property.isAnonymous()) { + return; + } + OWLAnnotationProperty prop = fact.getOWLAnnotationProperty(property.asOWLObjectProperty().getIRI()); + for (OWLClass namedFiller : namedClasses(filler)) { + allAxioms.add(fact.getOWLAnnotationAssertionAxiom(prop, subject.getIRI(), namedFiller.getIRI())); + } + } + + // The named classes reachable through a (possibly nested) intersection. + private Set namedClasses(OWLClassExpression filler) { + if (!filler.isAnonymous()) { + return Collections.singleton(filler.asOWLClass()); + } + if (filler instanceof OWLObjectIntersectionOf) { + Set result = new HashSet<>(); + for (OWLClassExpression operand : ((OWLObjectIntersectionOf) filler).getOperands()) { + result.addAll(namedClasses(operand)); + } + return result; + } + return Collections.emptySet(); + } + + private void emitRelationshipToIndividual(OWLObjectPropertyExpression property, OWLIndividual filler) { + if (property.isAnonymous() || filler.isAnonymous()) { + return; + } + OWLAnnotationProperty prop = fact.getOWLAnnotationProperty(property.asOWLObjectProperty().getIRI()); + allAxioms.add(fact.getOWLAnnotationAssertionAxiom(prop, subject.getIRI(), filler.asOWLNamedIndividual().getIRI())); + } + } + /* * Parses one or more ontology files. */ diff --git a/src/test/java/org/stanford/ncbo/owlapi/wrapper/RelationshipExtractionTest.java b/src/test/java/org/stanford/ncbo/owlapi/wrapper/RelationshipExtractionTest.java new file mode 100644 index 0000000..f631993 --- /dev/null +++ b/src/test/java/org/stanford/ncbo/owlapi/wrapper/RelationshipExtractionTest.java @@ -0,0 +1,82 @@ +package org.stanford.ncbo.owlapi.wrapper; + +import org.junit.Test; +import org.semanticweb.owlapi.apibinding.OWLManager; +import org.semanticweb.owlapi.model.*; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.*; + +/** + * Verifies that class-to-class (and class-to-individual) relationships are + * extracted from a range of axiom shapes -- not just SubClassOf(A, some(p, B)) -- + * and for ANY object property (no OBO privilege). The parser emits each + * relationship as a ground triple subject --p--> filler using the property's own + * IRI. Because the property is declared an object property in the export, on + * reload these triples come back as ObjectPropertyAssertion axioms, which the test + * reads directly. + */ +public class RelationshipExtractionTest { + + private static final String NS = "http://example.org/relations#"; + + private Set extractedRelationships() throws Exception { + ParserInvocation pi = new ParserInvocation( + "./src/test/resources/repo/input/relations", + "./src/test/resources/repo/output/relations", + "relations.ttl", true); + assertTrue("parse failed", new OntologyParser(pi).parse()); + + OWLOntology out = OWLManager.createOWLOntologyManager() + .loadOntologyFromOntologyDocument( + new File("./src/test/resources/repo/output/relations/owlapi.xrdf")); + + Set rels = new HashSet<>(); + for (OWLObjectPropertyAssertionAxiom ax : out.getAxioms(AxiomType.OBJECT_PROPERTY_ASSERTION)) { + if (!ax.getProperty().isAnonymous() + && ax.getSubject().isNamed() && ax.getObject().isNamed()) { + rels.add(individualOrClassIri(ax.getSubject()) + " " + + ax.getProperty().asOWLObjectProperty().getIRI() + " " + + individualOrClassIri(ax.getObject())); + } + } + return rels; + } + + private String individualOrClassIri(OWLIndividual ind) { + return ind.asOWLNamedIndividual().getIRI().toString(); + } + + private boolean rel(Set rels, String s, String p, String o) { + return rels.contains(NS + s + " " + NS + p + " " + NS + o); + } + + @Test + public void extractsAllShapes() throws Exception { + Set rels = extractedRelationships(); + + // Extracted: + assertTrue("bare some: A hasPart B", rel(rels, "A", "hasPart", "B")); + assertTrue("nested intersection: A connectedTo D", rel(rels, "A", "connectedTo", "D")); + assertTrue("equivalent + intersection: E connectedTo F", rel(rels, "E", "connectedTo", "F")); + assertTrue("hasValue: A hasColour red", rel(rels, "A", "hasColour", "red")); + assertTrue("min-cardinality >=1: A adjacentTo D", rel(rels, "A", "adjacentTo", "D")); + // someValuesFrom with an intersection filler -> a relationship to each named conjunct. + assertTrue("intersection filler: G hasPart B", rel(rels, "G", "hasPart", "B")); + assertTrue("intersection filler: G hasPart C1", rel(rels, "G", "hasPart", "C1")); + // nested intersection filler -> to each named class at any depth. + assertTrue("nested intersection filler: H hasPart B", rel(rels, "H", "hasPart", "B")); + assertTrue("nested intersection filler: H hasPart C1", rel(rels, "H", "hasPart", "C1")); + assertTrue("nested intersection filler: H hasPart C2", rel(rels, "H", "hasPart", "C2")); + + // Not extracted: + assertFalse("allValuesFrom must not extract", rel(rels, "A", "hasPart", "E")); + assertFalse("union operand C1 must not extract", rel(rels, "A", "hasPart", "C1")); + assertFalse("union operand C2 must not extract", rel(rels, "A", "hasPart", "C2")); + assertFalse("union filler operand B must not extract", rel(rels, "I", "hasPart", "B")); + assertFalse("union filler operand C1 must not extract", rel(rels, "I", "hasPart", "C1")); + } +} diff --git a/src/test/resources/repo/input/relations/relations.ttl b/src/test/resources/repo/input/relations/relations.ttl new file mode 100644 index 0000000..032e683 --- /dev/null +++ b/src/test/resources/repo/input/relations/relations.ttl @@ -0,0 +1,79 @@ +@prefix : . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + + a owl:Ontology . + +# Non-OBO object properties (would be dropped by the old contains("obo") guard) +:hasPart a owl:ObjectProperty . +:connectedTo a owl:ObjectProperty . +:hasColour a owl:ObjectProperty . +:adjacentTo a owl:ObjectProperty . + +# Named classes +:A a owl:Class . +:B a owl:Class . +:C1 a owl:Class . +:C2 a owl:Class . +:D a owl:Class . +:E a owl:Class . +:F a owl:Class . + +# Named individual (filler for hasValue) +:red a owl:NamedIndividual . + +# 1) Bare someValuesFrom under SubClassOf -> A hasPart some B +:A rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :hasPart ; owl:someValuesFrom :B ] . + +# 2) someValuesFrom NESTED in an intersection under SubClassOf +# A subClassOf ( C1 and (connectedTo some D) and C2 ) +:A rdfs:subClassOf [ + a owl:Class ; + owl:intersectionOf ( :C1 + [ a owl:Restriction ; owl:onProperty :connectedTo ; owl:someValuesFrom :D ] + :C2 ) +] . + +# 3) EquivalentClasses with intersection + someValuesFrom -> E connectedTo some F +:E owl:equivalentClass [ + a owl:Class ; + owl:intersectionOf ( :C1 + [ a owl:Restriction ; owl:onProperty :connectedTo ; owl:someValuesFrom :F ] ) +] . + +# 4) hasValue to an individual -> A hasColour red +:A rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :hasColour ; owl:hasValue :red ] . + +# 5) min-cardinality (n>=1) with a named filler -> A adjacentTo some D +:A rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :adjacentTo ; owl:minQualifiedCardinality "1"^^xsd:nonNegativeInteger ; owl:onClass :D ] . + +# 6) allValuesFrom -> MUST NOT be extracted (constraint, not existential) +:A rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :hasPart ; owl:allValuesFrom :E ] . + +# 7) union -> MUST NOT be descended into +:A rdfs:subClassOf [ + a owl:Class ; + owl:unionOf ( [ a owl:Restriction ; owl:onProperty :hasPart ; owl:someValuesFrom :C1 ] + [ a owl:Restriction ; owl:onProperty :hasPart ; owl:someValuesFrom :C2 ] ) +] . + +# 8) someValuesFrom whose filler is an INTERSECTION containing named classes +# G subClassOf hasPart some (B and C1) -> G hasPart B, G hasPart C1 +:G a owl:Class . +:G rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :hasPart ; + owl:someValuesFrom [ a owl:Class ; owl:intersectionOf ( :B :C1 ) ] ] . + +# 9) NESTED intersection filler +# H subClassOf hasPart some (B and (C1 and C2)) -> H hasPart B, C1, C2 +:H a owl:Class . +:H rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :hasPart ; + owl:someValuesFrom [ a owl:Class ; owl:intersectionOf + ( :B [ a owl:Class ; owl:intersectionOf ( :C1 :C2 ) ] ) ] ] . + +# 10) UNION filler -> MUST NOT extract +# I subClassOf hasPart some (B or C1) -> nothing +:I a owl:Class . +:I rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :hasPart ; + owl:someValuesFrom [ a owl:Class ; owl:unionOf ( :B :C1 ) ] ] . From 2bd55c8394b9e29b6aa196278254cc7f2ad401ae Mon Sep 17 00:00:00 2001 From: Matthew Horridge Date: Fri, 31 Jul 2026 11:28:22 -0700 Subject: [PATCH 2/2] Stop emitting the duplicate OBO relationship annotations generateGroundTriplesForAxioms emitted, for the whitelisted OBO properties, a metadata/obo/part_of|contains|develops_from annotation and a treeView edge, plus a generic annotation (under the property's own IRI) for any other obo-namespace property. Now that generateRelationshipTriples emits every relationship under the property's own IRI, the metadata/obo/* annotations and the generic branch are redundant: a part_of relationship appeared twice in a class's property listing (once as obo/part_of, once as its own IRI). Keep only the treeView edges, which drive the OBO class hierarchy (OntologyFormat#tree_property), with their existing per-property direction (part_of/develops_from: subclass -> filler; contains: filler -> subclass). The relationships themselves are now emitted once, uniformly, by generateRelationshipTriples. Since the method now only produces treeView edges, rename it generateGroundTriplesForAxioms -> generateTreeViewEdges. The metadata/obo/* relationship predicates have no production consumer in ontologies_linked_data, ontologies_api or bioportal_web_ui; the only reference is the ontologies_linked_data test test_obo_part_of, which asserts obo/part_of and must be updated to match (assert the relationship under the property's own IRI; the treeView assertion is unchanged). --- .../ncbo/owlapi/wrapper/OntologyParser.java | 68 ++++++------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java b/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java index 3795ef3..b01a626 100644 --- a/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java +++ b/src/main/java/org/stanford/ncbo/owlapi/wrapper/OntologyParser.java @@ -253,7 +253,7 @@ private boolean buildOWLOntology(OWLOntology masterOntology, boolean isOBO) { IRI documentIRI = sourceOwlManager.getOntologyDocumentIRI(sourceOnt); addGroundMetadata(documentIRI, fact, sourceOnt); - generateGroundTriplesForAxioms(allAxioms, fact, sourceOnt); + generateTreeViewEdges(allAxioms, fact, sourceOnt); generateRelationshipTriples(allAxioms, fact, sourceOnt); if (isOBO) { @@ -503,7 +503,9 @@ private void escapeXMLLiterals(OWLOntology target) { } } - private void generateGroundTriplesForAxioms(Set allAxioms, OWLDataFactory fact, OWLOntology sourceOnt) { + private void generateTreeViewEdges(Set allAxioms, OWLDataFactory fact, OWLOntology sourceOnt) { + + OWLAnnotationProperty treeView = fact.getOWLAnnotationProperty(IRI.create("http://data.bioontology.org/metadata/treeView")); for (OWLAxiom axiom : sourceOnt.getAxioms()) { allAxioms.add(axiom); @@ -522,46 +524,20 @@ private void generateGroundTriplesForAxioms(Set allAxioms, OWLDataFact if (!some.getProperty().isAnonymous() && !some.getFiller().isAnonymous()) { String propSome = some.getProperty().asOWLObjectProperty().getIRI().toString().toLowerCase(); - - if (propSome.contains("obo")) { - - if (propSome.endsWith("part_of") - || propSome.endsWith("bfo_0000050") - || propSome.endsWith("contains") - || propSome.endsWith("ro_0001019") - || propSome.endsWith("develops_from") - || propSome.endsWith("ro_0002202")) { - - OWLAnnotationProperty prop = null; - if (propSome.endsWith("contains") || propSome.endsWith("ro_0001019")) { - prop = fact.getOWLAnnotationProperty(IRI.create("http://data.bioontology.org/metadata/obo/contains")); - OWLAxiom annAsse = fact.getOWLAnnotationAssertionAxiom(prop, some.getFiller().asOWLClass().getIRI(), sc.getSubClass().asOWLClass().getIRI()); - allAxioms.add(annAsse); - - prop = fact.getOWLAnnotationProperty(IRI.create("http://data.bioontology.org/metadata/treeView")); - annAsse = fact.getOWLAnnotationAssertionAxiom(prop, some.getFiller().asOWLClass().getIRI(), sc.getSubClass().asOWLClass().getIRI()); - allAxioms.add(annAsse); - } else { - if (propSome.endsWith("part_of") || propSome.endsWith("bfo_0000050")) - prop = fact.getOWLAnnotationProperty(IRI.create("http://data.bioontology.org/metadata/obo/part_of")); - else { - prop = fact.getOWLAnnotationProperty(IRI.create("http://data.bioontology.org/metadata/obo/develops_from")); - } - - OWLAxiom annAsse = fact.getOWLAnnotationAssertionAxiom(prop, sc.getSubClass().asOWLClass().getIRI(), some.getFiller().asOWLClass().getIRI()); - allAxioms.add(annAsse); - - prop = fact.getOWLAnnotationProperty(IRI.create("http://data.bioontology.org/metadata/treeView")); - annAsse = fact.getOWLAnnotationAssertionAxiom(prop, sc.getSubClass().asOWLClass().getIRI(), some.getFiller().asOWLClass().getIRI()); - allAxioms.add(annAsse); - } - } else { - if (!some.getFiller().isAnonymous() && !sc.getSubClass().isAnonymous()) { - OWLAnnotationProperty prop = fact.getOWLAnnotationProperty(some.getProperty().asOWLObjectProperty().getIRI()); - OWLAxiom annAsse = fact.getOWLAnnotationAssertionAxiom(prop, sc.getSubClass().asOWLClass().getIRI(), some.getFiller().asOWLClass().getIRI()); - allAxioms.add(annAsse); - } - } + IRI subClass = sc.getSubClass().asOWLClass().getIRI(); + IRI filler = some.getFiller().asOWLClass().getIRI(); + + // The OBO hierarchy is built from treeView edges. part_of/develops_from + // (and their IRIs) contribute an edge subclass -> filler; contains points + // the other way, filler -> subclass. The relationships themselves are + // emitted, for every property, by generateRelationshipTriples. + if (propSome.endsWith("part_of") + || propSome.endsWith("bfo_0000050") + || propSome.endsWith("develops_from") + || propSome.endsWith("ro_0002202")) { + allAxioms.add(fact.getOWLAnnotationAssertionAxiom(treeView, subClass, filler)); + } else if (propSome.endsWith("contains") || propSome.endsWith("ro_0001019")) { + allAxioms.add(fact.getOWLAnnotationAssertionAxiom(treeView, filler, subClass)); } } } @@ -574,12 +550,10 @@ private void generateGroundTriplesForAxioms(Set allAxioms, OWLDataFact * relationship a named class participates in, for ANY object property, derived * from SubClassOf and EquivalentClasses axioms. * - * Unlike generateGroundTriplesForAxioms (which only recognises the flat shape - * SubClassOf(A, someValuesFrom(p, B)) for a hard-coded set of OBO properties), - * the right-hand side of each axiom is visited (see RelationshipVisitor) so that + * The right-hand side of each axiom is visited (see RelationshipVisitor) so that * relationships nested inside intersections, and those stated via equivalences, - * are also extracted. Each relationship A --p--> filler is emitted under the - * property's own IRI. + * are also extracted -- not just the flat shape SubClassOf(A, someValuesFrom(p, + * B)). Each relationship A --p--> filler is emitted under the property's own IRI. */ private void generateRelationshipTriples(Set allAxioms, OWLDataFactory fact, OWLOntology sourceOnt) { for (OWLSubClassOfAxiom sc : sourceOnt.getAxioms(AxiomType.SUBCLASS_OF)) {