Skip to content

Commit 37fe5fb

Browse files
committed
feat: expose native algebraic provenance
1 parent 8b16aee commit 37fe5fb

7 files changed

Lines changed: 252 additions & 88 deletions

File tree

.agents/skills/buildingmotif/references/repair.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,16 @@ for w in ctx.witnesses:
7878
w.validation_reasons # structured reasons, including path/value/severity
7979
w.target_shape # named algebra statement/shape, when available
8080
w.violation # full native pyshifty algebraic violation
81-
w.violation_alignment # "focus-order" or "unavailable"
81+
w.violation_alignment # "stable-id" or "unavailable"
8282
w.statement_id # native algebra statement identifier
83+
w.constraint_id # statement-level algebra id shared with the violation
84+
w.constraint_kind # enumerated statement-level algebra operator
85+
w.constraint # complete statement-level algebra constraint
8386
w.selector # how the focus was selected for that statement
8487
w.target # rendered algebra target
8588
w.graph # complete compiled graph for this violation
8689
w.shapes_graph # complete algebra/schema graph
87-
w.source_constraints # native sources, when pyshifty exposes them
90+
w.source_constraints # native reason-level algebra constraints
8891
w.repair_summary # repair atoms -- edit choices, NOT validation findings
8992
w.is_blocked # True -> no data repair possible in scope; do not fight it
9093
print(w.explain()) # the repair tree, indented
@@ -110,10 +113,22 @@ Keep the two halves distinct: `w.reason()` / `w.validation_reasons` say **what
110113
failed**, while `w.repair_summary` / `w.explain()` say **what edits could fix it**.
111114
A class failure may have both a `CountHigh` repair atom (delete the bad edge) and a
112115
`CountLow` atom (add the missing type); those atoms are alternatives, not count
113-
violations. `source_constraints`, `failed_shape`, and `failed_component` remain empty
114-
when pyshifty does not provide native provenance. BuildingMOTIF does not reconstruct
115-
them from the Turtle encoding or the repair tree; query `ctx.report` explicitly when
116-
you specifically need the W3C report view.
116+
violations.
117+
118+
There are three deliberately separate levels of algebraic provenance:
119+
120+
- `w.constraint_id` / `w.constraint` identify the top-level statement constraint
121+
shared by the violation and repair witness.
122+
- Each validation reason has its own `constraint_id`, `constraint_kind`, and
123+
`constraint`, identifying the specific nested algebra node that produced the cause.
124+
- Each repair-summary atom has leaf-level constraint metadata describing the edit
125+
alternative that it witnesses.
126+
127+
The validation/repair join uses `(focus, statement_id, constraint_id)`, not ordering.
128+
`failed_shape` and `failed_component` remain empty unless pyshifty explicitly provides
129+
those W3C source fields. BuildingMOTIF does not reconstruct them from the Turtle encoding
130+
or the repair tree; query `ctx.report` explicitly when you specifically need the W3C
131+
report view.
117132

118133
To present the whole violation horizon grouped by focus node (the notebooks' way of
119134
reading a real model's report), iterate `ctx.diffset`:

.agents/skills/buildingmotif/references/validation.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,24 +184,34 @@ for w in ctx.witnesses:
184184
print(w.violation) # full native algebraic violation
185185
print(w.violation_alignment) # how it was paired with the repair witness
186186
print(w.statement_id) # native algebra statement identifier
187+
print(w.constraint_id) # statement-level algebra id
188+
print(w.constraint_kind) # enumerated top-level algebra operator
189+
print(w.constraint) # complete top-level algebra constraint
187190
print(w.selector) # focus selector for that statement
188191
print(w.target) # rendered algebra target
189192
print(w.graph) # complete compiled data graph
190-
print(w.source_constraints) # native sources, when pyshifty exposes them
193+
print(w.source_constraints) # native reason-level algebra constraints
191194
print(w.repair_summary) # structured repair alternatives
192195
print(w.explain())
193196
print("blocked?", w.is_blocked) # True = opaque constraint, no data repair possible
194197
```
195198

196199
Use `ctx.algebra` for the complete native `validate_algebra()` result and
197-
`ctx.violations` for its unmodified violation tuple. These remain authoritative
198-
even when `w.violation_alignment == "unavailable"` prevents BuildingMOTIF from
199-
safely joining an independently-computed repair witness to one violation.
200+
`ctx.violations` for its unmodified violation tuple. BuildingMOTIF joins the
201+
independently computed validation and repair results by pyshifty's stable
202+
`(focus, statement_id, constraint_id)` identity, reported as
203+
`w.violation_alignment == "stable-id"`. `unavailable` means no safe join could
204+
be made; BuildingMOTIF never falls back to positional correlation.
200205

201206
The algebraic witness is deliberately independent of the shapes graph's Turtle/blank-node
202-
encoding. `source_constraints`, `failed_shape`, and `failed_component` remain empty when
203-
pyshifty does not expose native provenance; BuildingMOTIF will not infer them from repair
204-
atoms. Use `ctx.report` only when you explicitly want the separate W3C report view.
207+
encoding. `w.constraint` is the statement-level algebra used to synthesize repair.
208+
Each `w.validation_reasons` entry has its own `.constraint`, `.constraint_id`, and
209+
`.constraint_kind` identifying the specific, potentially nested algebra node that
210+
produced that cause. Repair atoms likewise carry leaf-level constraint provenance,
211+
but it describes the edit alternative and is not substituted for validation provenance.
212+
`failed_shape` and `failed_component` remain empty unless pyshifty explicitly preserves
213+
those W3C fields; BuildingMOTIF will not infer them from repair atoms. Use `ctx.report`
214+
only when you explicitly want the separate W3C report view.
205215

206216
`w.is_blocked` matters even for read-only validation: a blocked witness (opaque SPARQL,
207217
identity, coinductive back-edge) means the failure is real but *no data edit can discharge

buildingmotif/dataclasses/algebraic_validation.py

Lines changed: 122 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
from itertools import product
3535
from 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]]:

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ dependencies = [
3030
"pyshacl>=0.30,<0.31",
3131
# pyshifty (imported as ``shifty``) is a required dependency: the ``shifty``
3232
# SHACL engine and the algebraic validation/repair path are always available.
33-
# Pinned to the 0.2.x line -- the algebra/repair API is not yet stable across
33+
# Pinned to the 0.3.x line -- the algebra/repair API is not yet stable across
3434
# minor releases.
35-
"pyshifty>=0.2,<0.3",
35+
"pyshifty>=0.3,<0.4",
3636
"alembic>=1.8.0,<2",
3737
"Flask>=2.1.2,<3",
3838
"Flask-API>=3.0.post1,<4",

0 commit comments

Comments
 (0)