3232from dataclasses import dataclass , field
3333from functools import cached_property
3434from 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
3737from rdflib import Graph , Literal , Namespace , URIRef
3838from rdflib .term import Node
3939from rdflib .util import from_n3
4040
4141from 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
4343from buildingmotif .utils import copy_graph , replace_nodes
4444
4545if 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+
104137def _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 ]]:
0 commit comments