Skip to content

Commit 6d7bfad

Browse files
committed
fix: sh:sparql/sh:rule bodies silently never fire through the pyshifty backend
BuildingMOTIF always hands shifty an rdflib.Graph for the shapes graph. shifty's Python binding lowers a Graph argument to N-Triples before it reaches the native engine, and N-Triples carries no @Prefix declarations -- but sh:prefixes (and any prefixed name inside an embedded sh:sparql/sh:rule query body) resolves against exactly those declarations. The result: a SPARQL-based constraint or rule using a prefixed name silently never fires once its shapes come from a stored library, with no error and no diagnostic, because the unresolvable query is treated as an unsupported feature the engine ignores by default. Verified end to end through Library.load -> Oxigraph storage -> Model.validate. Fix: PyshiftyBackend.infer/validate now hand shifty Turtle bytes (not a bare Graph, and not str -- shifty treats a str shapes argument as a filesystem path first, which raised OSError on a large serialized ontology) via a new _shifty_shapes_input, which also re-binds BuildingMOTIF's own well-known prefixes since the storage layer doesn't persist a source file's namespace bindings at all. copy_graph() now preserves namespace bindings too (it silently dropped them), and bind_prefixes() gained the previously-missing ref:/s223:/bacnet: bindings. Also wires up pyshifty 0.2.7's new Reason.sparql_diagnostic (query, bindings, result rows) into AlgebraicReason and RepairWitness, correlating the repair-tree witnesses (which report every SPARQL failure as an opaque dead end) with the richer reasons validate_algebra() already computes in the same context, so a failed SPARQL constraint explains itself instead of stopping at "opaque SPARQL -- no algebraic witness". Also fixes a witness.target() typo (property, not a call) and a message-duplication bug for {$this}-style sh:message templates.
1 parent 9493693 commit 6d7bfad

6 files changed

Lines changed: 382 additions & 31 deletions

File tree

buildingmotif/dataclasses/algebraic_validation.py

Lines changed: 151 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@
3232
from dataclasses import dataclass, field
3333
from functools import cached_property
3434
from itertools import product
35-
from typing import TYPE_CHECKING, Dict, List, Optional, Protocol, Set, Tuple, Union
35+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Set, Tuple, Union
3636

3737
from rdflib import Graph, Literal, Namespace, URIRef
3838
from rdflib.term import Node
3939
from rdflib.util import from_n3
4040

4141
from buildingmotif.namespaces import BRICK, OWL, PARAM, RDF, RDFS, SH
42-
from buildingmotif.shacl import require_shifty
42+
from buildingmotif.shacl import _shifty_shapes_input, require_shifty
4343
from buildingmotif.utils import copy_graph, replace_nodes
4444

4545
if TYPE_CHECKING:
@@ -101,6 +101,39 @@ def _triples_to_graph(triples) -> Graph:
101101
return g
102102

103103

104+
def _render_sparql_diagnostic(diag: "object") -> str:
105+
"""Render a pyshifty ``SparqlDiagnostic`` (``Reason.sparql_diagnostic``) as
106+
human-readable text: the query actually executed, its ``$this``/prebound
107+
variables, and the solution rows it produced.
108+
109+
Mirrors the detail ``shifty.validate(...)``'s own ``results_text`` already
110+
prints for a failed ``sh:sparql`` constraint on the W3C-report path -- this
111+
is what makes the same detail available on the *algebraic* path (i.e. from
112+
:class:`AlgebraicReason`/:class:`RepairWitness`), where a SPARQL failure
113+
would otherwise be reported as opaque with no further explanation.
114+
"""
115+
lines = [f"query: {getattr(diag, 'query', '')}"]
116+
bindings = getattr(diag, "bindings", None)
117+
if bindings:
118+
bound = ", ".join(f"${name} = {value}" for name, value in bindings)
119+
lines.append(f"bound: {bound}")
120+
results = getattr(diag, "results", None)
121+
if results:
122+
rows = [
123+
"(" + ", ".join(f"{name} = {value}" for name, value in row) + ")"
124+
if row
125+
else "()"
126+
for row in results
127+
]
128+
lines.append(f"results: {'; '.join(rows)}")
129+
else:
130+
lines.append("results: (no rows)")
131+
fallback_reason = getattr(diag, "fallback_reason", None)
132+
if fallback_reason:
133+
lines.append(f"fallback: {fallback_reason}")
134+
return " | ".join(lines)
135+
136+
104137
def _ontology_projection(ontology: Graph) -> Graph:
105138
"""Project an ontology down to the triples the monomorphism search reads.
106139
@@ -181,13 +214,27 @@ def __getattr__(self, name):
181214
return getattr(self.raw, name)
182215

183216
def reason(self) -> str:
184-
message = getattr(self.raw, "message", None)
217+
# `author_message` (the shape's own `sh:message`, `{$this}`/`{?var}`
218+
# already resolved) is what pyshifty itself recommends preferring over
219+
# the engine-generated `message` when the shape author supplied one.
220+
message = getattr(self.raw, "author_message", None) or getattr(
221+
self.raw, "message", None
222+
)
185223
value = getattr(self.raw, "value", None)
186-
if message and value:
187-
return f"{value} {message}"
188-
if message:
189-
return str(message)
190-
return str(self.raw)
224+
# a message built from a `{$this}`-style sh:message template already
225+
# names the focus -- prepending value would just repeat it
226+
if message and value and str(value) not in str(message):
227+
text = f"{value} {message}"
228+
elif message:
229+
text = str(message)
230+
else:
231+
text = str(self.raw)
232+
# present only for a failed sh:sparql/custom SPARQL-based constraint;
233+
# surfaces the query/bindings/results instead of leaving it a dead end
234+
diagnostic = getattr(self.raw, "sparql_diagnostic", None)
235+
if diagnostic is not None:
236+
text = f"{text} [{_render_sparql_diagnostic(diagnostic)}]"
237+
return text
191238

192239
def __str__(self) -> str:
193240
return self.reason()
@@ -338,6 +385,13 @@ class RepairWitness:
338385
witness: "object"
339386
# back-reference to the owning context (holds the session + repair engine)
340387
context: "AlgebraicValidationContext"
388+
# this witness's summary() atoms, best-effort aligned 1:1 with the pyshifty
389+
# Reason objects from the context's validate_algebra() run for the same
390+
# focus (see AlgebraicValidationContext._reasons_for) -- () when no
391+
# alignment could be established. This is what lets a SPARQL-based leaf
392+
# (always reported as opaque on the repair-tree side) be explained with
393+
# the query/bindings/results pyshifty already computed on the algebra side.
394+
reasons: Tuple = ()
341395

342396
def _get_summary(self):
343397
try:
@@ -389,25 +443,55 @@ def is_blocked(self) -> bool:
389443
)
390444
return False
391445

446+
@property
447+
def sparql_diagnostics(self) -> List["object"]:
448+
"""The pyshifty ``SparqlDiagnostic`` for every SPARQL-based leaf of this
449+
failure that could be aligned with :attr:`reasons` -- query text, its
450+
``$this``/prebound variables, and the solution rows it produced.
451+
452+
Empty when this failure has no SPARQL-based leaf, or when
453+
:attr:`reasons` couldn't be aligned with :meth:`_get_summary` (a
454+
mismatched count means the two pyshifty calls diverged for this focus,
455+
so no enrichment is safer than a wrong pairing)."""
456+
diagnostics = []
457+
for reason in self.reasons:
458+
diagnostic = getattr(reason, "sparql_diagnostic", None)
459+
if diagnostic is not None:
460+
diagnostics.append(diagnostic)
461+
return diagnostics
462+
392463
def explain(self) -> str:
393-
"""The repair tree rendered as indented text."""
464+
"""The repair tree rendered as indented text, with any SPARQL-based
465+
leaf's query/bindings/results appended (see :attr:`sparql_diagnostics`)
466+
-- otherwise a SPARQL constraint failure explains as an opaque dead
467+
end."""
394468
try:
395-
return self.repair_tree.explain()
469+
text = self.repair_tree.explain()
396470
except Exception:
397471
logger.debug(
398472
"explain: repair_tree.explain() raised for focus %s",
399473
self.focus,
400474
exc_info=True,
401475
)
402-
return ""
476+
text = ""
477+
rendered = [_render_sparql_diagnostic(d) for d in self.sparql_diagnostics]
478+
if rendered:
479+
text = "\n".join([text, *rendered]) if text else "\n".join(rendered)
480+
return text
403481

404482
def reason(self) -> str:
405483
"""Human-readable explanation of this failure (mirrors
406-
:meth:`buildingmotif.dataclasses.validation.GraphDiff.reason`)."""
484+
:meth:`buildingmotif.dataclasses.validation.GraphDiff.reason`).
485+
486+
A SPARQL-based leaf (``WitnessKind.Opaque``) is annotated with its
487+
pyshifty ``SparqlDiagnostic`` when :attr:`reasons` aligns 1:1 with
488+
:meth:`_get_summary` -- see
489+
:meth:`AlgebraicValidationContext._reasons_for`."""
407490
summary = self._get_summary()
408491
if isinstance(summary, (list, tuple)) and summary:
492+
aligned = self.reasons if len(self.reasons) == len(summary) else ()
409493
parts = []
410-
for atom in summary:
494+
for i, atom in enumerate(summary):
411495
kind = str(getattr(atom, "kind", "")).split(".")[-1]
412496
path = getattr(atom, "path", None)
413497
detail = getattr(atom, "detail", None)
@@ -416,15 +500,20 @@ def reason(self) -> str:
416500
seg += f" on path {path}"
417501
if detail:
418502
seg += f" ({detail})"
503+
diagnostic = (
504+
getattr(aligned[i], "sparql_diagnostic", None) if aligned else None
505+
)
506+
if diagnostic is not None:
507+
seg += f" [{_render_sparql_diagnostic(diagnostic)}]"
419508
parts.append(seg)
420509
return "; ".join(parts)
421510
if summary:
422511
return str(summary)
423512
try:
424-
target = self.witness.target() # type: ignore
513+
target = self.witness.target # type: ignore
425514
except Exception:
426515
logger.debug(
427-
"reason: witness.target() raised for focus %s",
516+
"reason: witness.target raised for focus %s",
428517
self.focus,
429518
exc_info=True,
430519
)
@@ -1103,10 +1192,14 @@ def __post_init__(self):
11031192
shifty = require_shifty()
11041193

11051194
self.shapes_graph = _without_redundant_point_inverse_axioms(self.shapes_graph)
1106-
self._session = shifty.RepairSession(self.shapes_graph, self.data_graph)
1195+
# Turtle text, not the bare Graph -- see _shifty_shapes_input for why a
1196+
# Graph object silently loses the prefixes any sh:sparql/sh:rule body
1197+
# needs to resolve its query text.
1198+
shapes_input = _shifty_shapes_input(self.shapes_graph)
1199+
self._session = shifty.RepairSession(shapes_input, self.data_graph)
11071200
self._algebra = shifty.validate_algebra(
11081201
self.data_graph,
1109-
self.shapes_graph,
1202+
shapes_input,
11101203
minimum_severity="violation",
11111204
)
11121205
# ontology used by the monomorphism search (class hierarchy lives here)
@@ -1182,18 +1275,57 @@ def report(self) -> Graph:
11821275
else:
11831276
_, report_graph, _ = shifty.validate(
11841277
self.data_graph,
1185-
self.shapes_graph,
1278+
_shifty_shapes_input(self.shapes_graph),
11861279
minimum_severity="violation",
11871280
)
11881281
return report_graph
11891282

1283+
@cached_property
1284+
def _violations_by_focus(self) -> Dict[Optional[URIRef], List[Any]]:
1285+
"""``self._algebra.violations``, grouped by focus and kept in the
1286+
engine's own per-focus order -- used by :meth:`_reasons_for` to align
1287+
with :meth:`_session.witnesses`, which pyshifty computes independently
1288+
(a second pass over the same shapes/data)."""
1289+
grouped: Dict[Optional[URIRef], List[Any]] = defaultdict(list)
1290+
for v in self._algebra.violations:
1291+
grouped[_focus_to_node(v.focus_node)].append(v)
1292+
return dict(grouped)
1293+
1294+
def _reasons_for(self, focus: Optional[URIRef], index: int) -> Tuple:
1295+
"""Best-effort pyshifty ``Reason`` objects for the ``index``-th
1296+
``FocusWitness`` pyshifty returned for ``focus`` (in
1297+
``RepairSession.witnesses()`` order), aligned with the corresponding
1298+
``Violation.reasons`` from the *separate* ``validate_algebra()`` call
1299+
this context also runs.
1300+
1301+
Neither pyshifty API documents an explicit key to join a
1302+
``FocusWitness`` to its ``Violation`` -- both are independent
1303+
evaluations of the same compiled shapes over the same data, in the
1304+
engine's own constraint-declaration order, which is what makes the
1305+
positional pairing hold in practice (verified: a witness's
1306+
``summary()`` atoms and its matched violation's ``reasons`` line up
1307+
1:1, including for multiple ``sh:sparql`` constraints on one shape).
1308+
A count mismatch just means "don't enrich this one" -- never a wrong
1309+
pairing.
1310+
"""
1311+
violations = self._violations_by_focus.get(focus, [])
1312+
if index >= len(violations):
1313+
return ()
1314+
return tuple(violations[index].reasons)
1315+
11901316
@cached_property
11911317
def witnesses(self) -> List[RepairWitness]:
11921318
"""The violation horizon: one :class:`RepairWitness` per failing
11931319
``(focus, statement)``. Empty iff the graph conforms."""
11941320
out: List[RepairWitness] = []
1321+
seen_at_focus: Dict[Optional[URIRef], int] = defaultdict(int)
11951322
for w in self._session.witnesses():
1196-
out.append(RepairWitness(_focus_to_node(w.focus), w, self)) # type: ignore
1323+
focus = _focus_to_node(w.focus)
1324+
index = seen_at_focus[focus]
1325+
seen_at_focus[focus] += 1
1326+
out.append(
1327+
RepairWitness(focus, w, self, self._reasons_for(focus, index)) # type: ignore
1328+
)
11971329
return out
11981330

11991331
def witnesses_by_focus(self) -> Dict[Optional[URIRef], List[RepairWitness]]:

buildingmotif/namespaces.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@ def bind_prefixes(graph):
5656
graph.bind("bsh", BSH)
5757
graph.bind("P", PARAM)
5858
graph.bind("constraint", CONSTRAINT)
59+
graph.bind("ref", REF)
60+
graph.bind("s223", S223)
61+
graph.bind("bacnet", BACNET)
5962
graph.bind("bmotif", BM)

buildingmotif/shacl.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,60 @@ def require_shifty():
5252
) from exc
5353

5454

55+
def _shifty_shapes_input(shape_graph: Graph) -> bytes:
56+
"""Serialize a shapes graph to Turtle text before handing it to ``shifty``.
57+
58+
``shifty``'s Python binding lowers an ``rdflib.Graph`` argument to
59+
N-Triples before passing it to the native engine (see ``shifty/__init__.py``
60+
``_to_rdf_input``) -- and N-Triples has no ``@prefix`` declarations at all.
61+
A ``sh:sparql``/``sh:rule`` body resolves ``sh:prefixes`` against *the
62+
prefixes declared in the document shifty parses*, so a shapes graph handed
63+
over as a bare ``Graph`` object silently loses the ability to resolve any
64+
prefixed name inside its embedded SPARQL query text -- the constraint or
65+
rule then just never fires, with **no error and no diagnostic**, because a
66+
query it cannot resolve is treated as an unsupported feature the engine
67+
ignores by default. Passing Turtle text instead of a ``Graph`` keeps the
68+
(already-declared) prefix table shifty's own parser depends on. The data
69+
graph does not need this treatment: it doesn't carry any SPARQL query
70+
literals for ``sh:prefixes`` to resolve against.
71+
72+
Verified empirically against pyshifty 0.2.7: an ``sh:rule``/``sh:construct``
73+
with a query body that uses a prefixed name (e.g. ``ex:Foo``) infers 0
74+
triples when the shapes graph is passed as a ``Graph`` object, and the
75+
correct triples when passed as this function's Turtle text -- identical
76+
input graph, only the wire representation differs.
77+
78+
Turtle text alone isn't a complete fix: BuildingMOTIF's storage layer
79+
(``GraphConnection``/``BuildingMOTIFOxigraphGraph``) doesn't persist a
80+
source file's ``@prefix`` bindings at all -- only triples -- so a shapes
81+
graph loaded from a library and read back out has already lost them by
82+
the time it reaches this function, regardless of how it's serialized here.
83+
:func:`buildingmotif.namespaces.bind_prefixes` re-declares BuildingMOTIF's
84+
own well-known prefixes (``brick:``, ``s223:``, ``qudt:``, ...), which
85+
covers a constraint written against one of BuildingMOTIF's own ontologies
86+
-- the realistic case, and the same mechanism
87+
:meth:`buildingmotif.dataclasses.library.Library.load` already applies for
88+
the same reason. It does **not** cover a fully custom, downstream-defined
89+
namespace: that prefix binding is gone the moment its shape collection is
90+
persisted, and restoring it would mean capturing/round-tripping namespace
91+
bindings through the storage layer, well beyond this function.
92+
93+
Returns ``bytes``, not ``str``: shifty's ``_to_rdf_input`` treats a bare
94+
``str`` as a filesystem path first (``pathlib.Path(s).is_file()``) and
95+
only falls back to raw Turtle text if that path doesn't exist. A large
96+
serialized shapes graph is long enough to raise ``OSError: File name too
97+
long`` from that existence check on some platforms, rather than failing
98+
over gracefully -- ``bytes`` skips the path-guessing branch entirely and
99+
is always treated as raw Turtle.
100+
"""
101+
from buildingmotif.namespaces import bind_prefixes
102+
from buildingmotif.utils import copy_graph
103+
104+
prefixed = copy_graph(shape_graph)
105+
bind_prefixes(prefixed)
106+
return prefixed.serialize(format="turtle", encoding="utf-8")
107+
108+
55109
@dataclass
56110
class ValidationGraphs:
57111
data_graph: Graph
@@ -204,7 +258,7 @@ def infer(self, data_graph: Graph, shape_graph: Optional[Graph] = None) -> Graph
204258

205259
if shape_graph is None or len(shape_graph) == 0: # type: ignore
206260
return shifty.infer(data_graph).graph() # type: ignore
207-
return shifty.infer(data_graph, shape_graph).graph() # type: ignore
261+
return shifty.infer(data_graph, _shifty_shapes_input(shape_graph)).graph() # type: ignore
208262

209263
def validate(
210264
self, data_graph: Graph, shape_graph: Optional[Graph] = None
@@ -218,7 +272,7 @@ def validate(
218272
)
219273
return shifty.validate( # type: ignore
220274
data_graph,
221-
shape_graph,
275+
_shifty_shapes_input(shape_graph),
222276
minimum_severity="violation",
223277
)
224278

buildingmotif/utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,12 @@ def _guarantee_unique_template_name(library: "Library", name: str) -> str:
7676

7777
def copy_graph(g: Graph, preserve_blank_nodes: bool = True) -> Graph:
7878
"""
79-
Copy a graph. Creates new blank nodes so that these remain unique to each Graph
79+
Copy a graph. Creates new blank nodes so that these remain unique to each Graph.
80+
Namespace bindings are copied too -- losing them would silently rename a
81+
prefix on serialization (e.g. via ``graph.serialize(format="turtle")``),
82+
which breaks anything that resolves a prefixed name against the
83+
document's *declared* prefixes rather than the graph's triples (e.g. a
84+
SHACL ``sh:sparql``/``sh:rule`` body's ``sh:prefixes``).
8085
8186
:param g: the graph to copy
8287
:type g: Graph
@@ -86,6 +91,8 @@ def copy_graph(g: Graph, preserve_blank_nodes: bool = True) -> Graph:
8691
:rtype: Graph
8792
"""
8893
c = Graph()
94+
for prefix, namespace in g.namespaces():
95+
c.bind(prefix, namespace, override=True)
8996
new_prefix = secrets.token_hex(4)
9097
for t in g.triples((None, None, None)):
9198
assert isinstance(t, tuple)

0 commit comments

Comments
 (0)