From 4b54011271efef92d763a9b7320b9721525c3149 Mon Sep 17 00:00:00 2001 From: Caglar Demir Date: Fri, 23 Oct 2026 08:46:23 +0200 Subject: [PATCH] Expand subclasses for all type triples in indirect TripleStore retrieval (#571) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++ ontolearn/triple_store.py | 39 +++++++----- ...t_triplestore_indirect_instances_sparql.py | 62 ++++++++++++++++++- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d846ff..8ceb35a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ date) and use that section as the basis for the GitHub release notes. ## [Unreleased] ### Fixed +- `TripleStoreReasoner.instances(ce, direct=False)` only expanded subclasses for the root + variable's type triple, so class expressions with fillers (e.g. + `Grandfather ⊓ ∃hasChild.Grandparent`) missed individuals typed only by a subclass of the + filler. Every type triple is now expanded, each with its own variable, and `instances_count` and + `is_subset_of_individuals` share the same helper (`with_subclass_closure`), which also removes + the shared-variable scoping bug of #575 from those two. (#571) - `ontolearn.semantic_caching.retrieve` called `NeuralOntology(path_of_kb=...)`, an argument removed from owlapy, so the neural-reasoner path raised `TypeError` whenever no KGE model path was given. It now trains/loads via diff --git a/ontolearn/triple_store.py b/ontolearn/triple_store.py index 115a9af3..9f785a41 100644 --- a/ontolearn/triple_store.py +++ b/ontolearn/triple_store.py @@ -176,6 +176,25 @@ def suf(direct: bool): return " " if direct else "* " +# A type triple `?var a ` as emitted by the Owl2SparqlConverter, for the root variable as well as +# for filler variables (`?s_1 a `). owl:NamedIndividual has no subclasses, so it is left alone. +_TYPE_TRIPLE = re.compile(r"(\?\w+) a (?=<(?!http://www\.w3\.org/2002/07/owl#NamedIndividual>))") + + +def with_subclass_closure(sparql: str) -> str: + """Make every type triple `?v a ` of a converted class expression match instances of subclasses of + `` too, i.e. `?v a ?some_cls_i. ?some_cls_i rdfs:subClassOf* `, so that indirect instances are found + without materialized type assertions. Each occurrence gets its own variable so that uses inside/outside + constructs like FILTER NOT EXISTS don't accidentally share a binding.""" + counter = count() + + def _expand(match: re.Match) -> str: + var = f"?some_cls_{next(counter)}" + return f"{match.group(1)} a {var}. \n {var} * " + + return _TYPE_TRIPLE.sub(_expand, sparql) + + class TripleStoreOntology(AbstractOWLOntology): def __init__(self, triplestore_address: str, use_cache: bool = False): @@ -484,15 +503,7 @@ def instances(self, ce: OWLClassExpression, direct: bool = False, seen_set: Set seen_set.add(ce) ce_to_sparql = self._owl2sparql_converter.as_query("?x", ce) if not direct: - counter = count() - - def _replace_with_subclass_filter(_match: re.Match) -> str: - # Each occurrence gets its own variable so that uses inside/outside - # constructs like FILTER NOT EXISTS don't accidentally share a binding. - var = f"?some_cls_{next(counter)}" - return f"?x a {var}. \n {var} * " - - ce_to_sparql = re.sub(r"\?x a ", _replace_with_subclass_filter, ce_to_sparql) + ce_to_sparql = with_subclass_closure(ce_to_sparql) yield from send_http_request_to_ts_and_fetch_results(self.url, ce_to_sparql, OWLNamedIndividual) if not direct: for cls in self.equivalent_classes(ce): @@ -506,10 +517,7 @@ def instances_count(self, ce: OWLClassExpression, direct: bool = False) -> int: through an owl:equivalentClass axiom on `ce`.""" ce_to_sparql = self._owl2sparql_converter.as_query("?x", ce, count=True) if not direct: - ce_to_sparql = ce_to_sparql.replace( - "?x a ", - "?x a ?some_cls. \n ?some_cls * ", - ) + ce_to_sparql = with_subclass_closure(ce_to_sparql) bindings = self.query(ce_to_sparql).json()["results"]["bindings"] return int(bindings[0]["cnt"]["value"]) if bindings else 0 @@ -522,10 +530,7 @@ def is_subset_of_individuals(self, individuals: Iterable[OWLNamedIndividual], ce return True ce_pattern = "\n".join(self._owl2sparql_converter.convert("?x", ce)) if not direct: - ce_pattern = ce_pattern.replace( - "?x a ", - "?x a ?some_cls. \n ?some_cls * ", - ) + ce_pattern = with_subclass_closure(ce_pattern) values = " ".join(f"<{i.str}>" for i in individuals) ask_query = f"ASK {{ VALUES ?x {{ {values} }} FILTER NOT EXISTS {{ {ce_pattern} }} }}" return not self.query(ask_query).json()["boolean"] diff --git a/tests/test_triplestore_indirect_instances_sparql.py b/tests/test_triplestore_indirect_instances_sparql.py index 2f3d61b6..1fe13d2e 100644 --- a/tests/test_triplestore_indirect_instances_sparql.py +++ b/tests/test_triplestore_indirect_instances_sparql.py @@ -15,9 +15,12 @@ import unittest from unittest.mock import MagicMock, patch -from ontolearn.triple_store import TripleStoreOntology, TripleStoreReasoner -from owlapy.class_expression import OWLClass, OWLObjectComplementOf, OWLObjectIntersectionOf +from ontolearn.triple_store import TripleStoreOntology, TripleStoreReasoner, with_subclass_closure +from owlapy.class_expression import (OWLClass, OWLObjectComplementOf, OWLObjectIntersectionOf, + OWLObjectSomeValuesFrom) from owlapy.iri import IRI +from owlapy.owl_individual import OWLNamedIndividual +from owlapy.owl_property import OWLObjectProperty NS = "http://example.org/family#" URL = "http://localhost:3030/fake/sparql" @@ -81,3 +84,58 @@ def test_direct_true_does_not_rewrite_the_query(self): if __name__ == "__main__": unittest.main() + + +class TestSubclassClosureOfFillers(unittest.TestCase): + """https://github.com/dice-group/Ontolearn/issues/571: the subclass closure must cover every type + triple of the converted query, not only the one on the root variable.""" + + def setUp(self): + self.grandfather = OWLClass(IRI.create(NS, "Grandfather")) + self.grandparent = OWLClass(IRI.create(NS, "Grandparent")) + self.has_child = OWLObjectProperty(IRI.create(NS, "hasChild")) + self.ce = OWLObjectIntersectionOf( + [self.grandfather, OWLObjectSomeValuesFrom(self.has_child, self.grandparent)]) + self.reasoner = TripleStoreReasoner(TripleStoreOntology(URL)) + + def test_filler_type_triple_is_expanded(self): + query = with_subclass_closure(self.reasoner._owl2sparql_converter.as_query("?x", self.ce)) + bindings = SUBCLASS_BINDING_RE.findall(query) + self.assertEqual({iri for _, iri in bindings}, {self.grandfather.str, self.grandparent.str}) + self.assertEqual(len({var for var, _ in bindings}), 2) + self.assertIn("?s_1 a ?some_cls_", query) + + def test_owl_named_individual_is_not_expanded(self): + sparql = "SELECT ?x WHERE { ?x a . ?x a . }" + self.assertEqual(SUBCLASS_BINDING_RE.findall(with_subclass_closure(sparql)), [("?some_cls_0", "http://e.org/A")]) + + def test_count_and_subset_queries_scope_each_occurrence(self): + ce = OWLObjectIntersectionOf([OWLObjectComplementOf(OWLClass(IRI.create(NS, "Daughter"))), + OWLClass(IRI.create(NS, "Female"))]) + resp = MagicMock() + resp.json.return_value = {"head": {"vars": ["cnt"]}, "boolean": False, + "results": {"bindings": [{"cnt": {"value": "0"}}]}} + with patch.object(TripleStoreReasoner, "query", return_value=resp) as mocked_query: + self.reasoner.instances_count(ce) + self.reasoner.is_subset_of_individuals([OWLNamedIndividual(IRI.create(NS, "a"))], ce) + self.assertEqual(mocked_query.call_count, 2) + for call in mocked_query.call_args_list: + variables = [var for var, _ in SUBCLASS_BINDING_RE.findall(call.args[0])] + self.assertEqual(len(variables), 2) + self.assertEqual(len(variables), len(set(variables))) + + def test_indirect_instances_found_through_subclass_fillers(self): + """Run the rewritten query on a tiny graph where the only type assertions are for subclasses.""" + from rdflib import Graph + g = Graph() + g.parse(format="turtle", data=f""" + @prefix : <{NS}> . @prefix rdfs: . + :Grandfather rdfs:subClassOf :Father . :Grandparent rdfs:subClassOf :Parent . + :GrandfatherOfTwo rdfs:subClassOf :Grandfather . :GreatGrandparent rdfs:subClassOf :Grandparent . + :a a :GrandfatherOfTwo ; :hasChild :b . :b a :GreatGrandparent . + :c a :GrandfatherOfTwo ; :hasChild :d . :d a :Parent . + """) + base = self.reasoner._owl2sparql_converter.as_query("?x", self.ce) + names = lambda q: {str(r[0]).split("#")[1] for r in g.query(q)} + self.assertEqual(names(base), set()) # no asserted Grandfather/Grandparent types + self.assertEqual(names(with_subclass_closure(base)), {"a"}) # c's child is only a Parent, not a Grandparent