3434from itertools import product
3535from typing import (
3636 TYPE_CHECKING ,
37- Any ,
3837 Dict ,
3938 List ,
4039 Optional ,
@@ -128,6 +127,26 @@ def statement(self) -> int:
128127 """pyshifty's identifier for the failed algebra statement."""
129128 ...
130129
130+ @property
131+ def statement_id (self ) -> int :
132+ """Stable statement id shared with algebraic violations."""
133+ ...
134+
135+ @property
136+ def constraint_id (self ) -> int :
137+ """Stable top-level constraint id shared with algebraic violations."""
138+ ...
139+
140+ @property
141+ def constraint_kind (self ) -> object :
142+ """The top-level algebraic constraint discriminant."""
143+ ...
144+
145+ @property
146+ def constraint (self ) -> object :
147+ """The complete top-level algebraic constraint."""
148+ ...
149+
131150 @property
132151 def selector (self ) -> object :
133152 """How the focus was selected for the failed statement."""
@@ -156,6 +175,16 @@ def shape_name(self) -> object:
156175 """The named shape/statement, when pyshifty has one."""
157176 ...
158177
178+ @property
179+ def statement_id (self ) -> int :
180+ """Stable statement id shared with the repair witness."""
181+ ...
182+
183+ @property
184+ def constraint_id (self ) -> int :
185+ """Stable top-level constraint id shared with the repair witness."""
186+ ...
187+
159188 @property
160189 def reasons (self ) -> Sequence [object ]:
161190 """Structured validation reasons for this violation."""
@@ -326,13 +355,33 @@ def __getattr__(self, name):
326355
327356 @property
328357 def source_constraint (self ) -> Optional [object ]:
329- """Native algebraic source constraint, when pyshifty provides one .
358+ """The native algebraic constraint that produced this reason .
330359
331- pyshifty 0.2.7 does not expose this field. Returning ``None`` is
332- intentional: BuildingMOTIF does not reconstruct a source constraint
333- from the serialized shapes graph or from repair atoms.
360+ This is algebraic provenance, not a W3C SHACL source component.
334361 """
335- return getattr (self .raw , "source_constraint" , None )
362+ return getattr (self .raw , "constraint" , None )
363+
364+ @property
365+ def constraint (self ) -> Optional [object ]:
366+ """The complete native algebra node that produced this reason."""
367+ return self .source_constraint
368+
369+ @property
370+ def statement_id (self ) -> Optional [int ]:
371+ """The enclosing top-level statement's stable id."""
372+ value = getattr (self .raw , "statement_id" , None )
373+ return value if isinstance (value , int ) else None
374+
375+ @property
376+ def constraint_id (self ) -> Optional [int ]:
377+ """The specific, potentially nested algebra node's stable id."""
378+ value = getattr (self .raw , "constraint_id" , None )
379+ return value if isinstance (value , int ) else None
380+
381+ @property
382+ def constraint_kind (self ) -> Optional [object ]:
383+ """The specific algebra node's enumerated semantic kind."""
384+ return getattr (self .raw , "constraint_kind" , None )
336385
337386 def reason (self ) -> str :
338387 diagnostic = getattr (self .raw , "sparql_diagnostic" , None )
@@ -546,6 +595,8 @@ class RepairWitness:
546595 # pass. It is the source of validation reasons; witness.summary() remains
547596 # repair information and is deliberately kept separate.
548597 violation : Optional ["AlgebraicViolation" ] = None
598+ # "stable-id" for the native (focus, statement_id, constraint_id) join.
599+ alignment : str = "unavailable"
549600
550601 @cached_property
551602 def repair_summary (self ) -> Tuple :
@@ -600,10 +651,26 @@ def target_shape(self) -> Optional[Node]:
600651
601652 @property
602653 def statement_id (self ) -> Optional [int ]:
603- """pyshifty 's identifier for the failed algebra statement ."""
604- statement = getattr (self .witness , "statement " , None )
654+ """The failed statement 's stable native identifier ."""
655+ statement = getattr (self .witness , "statement_id " , None )
605656 return statement if isinstance (statement , int ) else None
606657
658+ @property
659+ def constraint_id (self ) -> Optional [int ]:
660+ """The top-level algebraic constraint id shared with the violation."""
661+ value = getattr (self .witness , "constraint_id" , None )
662+ return value if isinstance (value , int ) else None
663+
664+ @property
665+ def constraint_kind (self ) -> Optional [object ]:
666+ """The top-level algebraic constraint's enumerated semantic kind."""
667+ return getattr (self .witness , "constraint_kind" , None )
668+
669+ @property
670+ def constraint (self ) -> Optional [object ]:
671+ """The complete top-level algebraic constraint for this witness."""
672+ return getattr (self .witness , "constraint" , None )
673+
607674 @property
608675 def selector (self ) -> Optional [object ]:
609676 """The native algebra selector describing how the focus was chosen."""
@@ -623,9 +690,10 @@ def target(self) -> Optional[object]:
623690 return None
624691
625692 @property
626- def statement (self ) -> Optional [object ]:
627- """Alias for :attr:`target`, retained as the human-facing statement."""
628- return self .target
693+ def statement (self ) -> Optional [int ]:
694+ """The failed statement index, matching pyshifty's native surface."""
695+ value = getattr (self .witness , "statement" , self .statement_id )
696+ return value if isinstance (value , int ) else self .statement_id
629697
630698 @property
631699 def graph (self ) -> Graph :
@@ -664,12 +732,12 @@ def failed_component(self) -> Optional[URIRef]:
664732 def violation_alignment (self ) -> str :
665733 """How the repair witness was correlated with its validation result.
666734
667- pyshifty currently computes ``RepairSession.witnesses()`` and
668- ``validate_algebra().violations `` independently and exposes no shared
669- statement key. BuildingMOTIF correlates them by focus-local order only
670- when both APIs return the same number of failures for that focus .
735+ pyshifty 0.2.8+ exposes a stable
736+ ``(focus, statement_id, constraint_id) `` key on both independently
737+ computed results. ``unavailable`` means that key was absent,
738+ non-unique, or did not match .
671739 """
672- return "focus-order" if self .violation is not None else "unavailable"
740+ return self .alignment
673741
674742 @property
675743 def failed_shape (self ) -> Optional [Node ]:
@@ -1535,60 +1603,59 @@ def report(self) -> Graph:
15351603 )
15361604 return report_graph
15371605
1606+ @staticmethod
1607+ def _correlation_key (item : object , focus_attr : str ) -> Optional [Tuple ]:
1608+ """Return pyshifty's stable validation/repair join key, if available."""
1609+ focus = _focus_to_node (getattr (item , focus_attr , None ))
1610+ statement_id = getattr (item , "statement_id" , None )
1611+ constraint_id = getattr (item , "constraint_id" , None )
1612+ if statement_id is None or constraint_id is None :
1613+ return None
1614+ try :
1615+ hash ((focus , statement_id , constraint_id ))
1616+ except TypeError :
1617+ return None
1618+ return (focus , statement_id , constraint_id )
1619+
15381620 @cached_property
1539- def _violations_by_focus (self ) -> Dict [Optional [URIRef ], List [Any ]]:
1540- """``self._algebra.violations``, grouped by focus and kept in the
1541- engine's own per-focus order -- used by :meth:`_reasons_for` to align
1542- with :meth:`_session.witnesses`, which pyshifty computes independently
1543- (a second pass over the same shapes/data)."""
1544- grouped : Dict [Optional [URIRef ], List [Any ]] = defaultdict (list )
1545- for v in self ._algebra .violations :
1546- grouped [_focus_to_node (v .focus_node )].append (v )
1621+ def _violations_by_key (self ) -> Dict [Tuple , List [AlgebraicViolation ]]:
1622+ """Native violations indexed by their stable algebraic identity."""
1623+ grouped : Dict [Tuple , List [AlgebraicViolation ]] = defaultdict (list )
1624+ for violation in self .violations :
1625+ key = self ._correlation_key (violation , "focus_node" )
1626+ if key is not None :
1627+ grouped [key ].append (violation )
15471628 return dict (grouped )
15481629
15491630 def _violation_for (
1550- self , focus : Optional [URIRef ], index : int
1551- ) -> Optional [AlgebraicViolation ]:
1552- """Best-effort pyshifty ``Violation`` for the ``index``-th
1553- ``FocusWitness`` pyshifty returned for ``focus`` (in
1554- ``RepairSession.witnesses()`` order).
1555-
1556- Neither pyshifty API documents an explicit key to join a
1557- ``FocusWitness`` to its ``Violation`` -- both are independent
1558- evaluations of the same compiled shapes over the same data, in the
1559- engine's own constraint-declaration order. Their repair-summary atoms
1560- and validation reasons are *not* expected to align 1:1.
1631+ self ,
1632+ witness : FocusWitness ,
1633+ ) -> Tuple [Optional [AlgebraicViolation ], str ]:
1634+ """Pair a repair witness with its validation violation.
1635+
1636+ The independently computed APIs are joined only by their shared native
1637+ algebraic identity. A missing, unmatched, or non-unique key is never
1638+ silently downgraded to positional correlation.
15611639 """
1562- violations = self ._violations_by_focus .get (focus , [])
1563- if index >= len (violations ):
1564- return None
1565- return violations [index ] # type: ignore[no-any-return]
1640+ key = self ._correlation_key (witness , "focus" )
1641+ if key is not None :
1642+ matches = self ._violations_by_key .get (key , [])
1643+ if len (matches ) == 1 :
1644+ return matches [0 ], "stable-id"
1645+ return None , "unavailable"
15661646
15671647 @cached_property
15681648 def witnesses (self ) -> List [RepairWitness ]:
15691649 """The violation horizon: one :class:`RepairWitness` per failing
15701650 ``(focus, statement)``. Empty iff the graph conforms."""
15711651 out : List [RepairWitness ] = []
15721652 raw_witnesses = self ._session .witnesses ()
1573- witness_counts : Dict [Optional [URIRef ], int ] = defaultdict (int )
1574- for witness in raw_witnesses :
1575- witness_counts [_focus_to_node (witness .focus )] += 1
1576- seen_at_focus : Dict [Optional [URIRef ], int ] = defaultdict (int )
15771653 for w in raw_witnesses :
15781654 focus = _focus_to_node (w .focus )
1579- index = seen_at_focus [focus ]
1580- seen_at_focus [focus ] += 1
1581- violations = self ._violations_by_focus .get (focus , [])
1582- # Positional correlation is only defensible when the two
1583- # independently-computed APIs agree on the number of statements at
1584- # this focus. If they disagree, preserve the repair witness but
1585- # leave its validation provenance unknown.
1586- violation = (
1587- self ._violation_for (focus , index )
1588- if len (violations ) == witness_counts [focus ]
1589- else None
1655+ violation , alignment = self ._violation_for (w )
1656+ out .append (
1657+ RepairWitness (focus , w , self , violation , alignment ) # type: ignore
15901658 )
1591- out .append (RepairWitness (focus , w , self , violation )) # type: ignore
15921659 return out
15931660
15941661 def witnesses_by_focus (self ) -> Dict [Optional [URIRef ], List [RepairWitness ]]:
0 commit comments