Skip to content

Commit da33f5a

Browse files
refactor: extract GraphDiff detectors and simplify _report_to_diffset
Co-authored-by: aider (openai/gpt-5) <aider@aider.chat>
1 parent dc98bff commit da33f5a

1 file changed

Lines changed: 156 additions & 124 deletions

File tree

‎buildingmotif/dataclasses/validation.py‎

Lines changed: 156 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,127 @@ def _detect_qvs_class_min_pattern(
540540
return None
541541

542542

543+
# -------------------------------
544+
# Helper detectors for GraphDiffs
545+
# -------------------------------
546+
547+
def _detect_graph_class_cardinality(g: Graph, result: Node) -> Optional["GraphClassCardinality"]:
548+
"""Detect a graph-level class cardinality violation produced by custom CONSTRAINT component."""
549+
if g.value(result, SH.sourceConstraintComponent) == CONSTRAINT.countConstraintComponent:
550+
expected_count = g.value(result, SH.sourceShape / CONSTRAINT.exactCount) # type: ignore
551+
of_class = g.value(result, SH.sourceShape / CONSTRAINT["class"]) # type: ignore
552+
if expected_count is not None and of_class is not None:
553+
validation_report = g.cbd(result)
554+
return GraphClassCardinality(
555+
None, validation_report, g, of_class, int(expected_count) # type: ignore[arg-type]
556+
)
557+
return None
558+
559+
560+
def _detect_required_class(
561+
g: Graph, result: Node, focus: Optional[URIRef]
562+
) -> Optional["RequiredClass"]:
563+
"""Detect a RequiredClass violation (focus must be an instance of a class)."""
564+
if g.value(result, SH.sourceConstraintComponent) == SH.ClassConstraintComponent and focus:
565+
requiring_shape = g.value(result, SH.sourceShape)
566+
expected_class = g.value(requiring_shape, SH["class"]) if requiring_shape else None
567+
if isinstance(expected_class, URIRef):
568+
validation_report = g.cbd(result)
569+
return RequiredClass(focus, validation_report, g, expected_class)
570+
return None
571+
572+
573+
def _detect_path_class_count(
574+
g: Graph, result: Node, focus: Optional[URIRef]
575+
) -> Optional["PathClassCount"]:
576+
"""Detect missing related entities with a given class along a path (supports qualifiedValueShape)."""
577+
if not focus:
578+
return None
579+
580+
# First, handle the qualifiedValueShape + qualifiedMinCount pattern even if the engine
581+
# surfaces a ClassConstraintComponent on a value node.
582+
qvs_match = _detect_qvs_class_min_pattern(g, result)
583+
if qvs_match:
584+
path, minc_i, maxc_i, classname = qvs_match
585+
validation_report = g.cbd(result)
586+
return PathClassCount(focus, validation_report, g, path, minc_i, maxc_i, classname)
587+
588+
# Fall back to standard class/minCount detection on the source shape
589+
classpath = SH["class"] | (SH.qualifiedValueShape / SH["class"]) # type: ignore
590+
path = g.value(result, SH.resultPath)
591+
if not path:
592+
return None
593+
min_count_lit = g.value(result, SH.sourceShape / (SH.minCount | SH.qualifiedMinCount)) # type: ignore
594+
max_count_lit = g.value(result, SH.sourceShape / (SH.maxCount | SH.qualifiedMaxCount)) # type: ignore
595+
classname = g.value(result, SH.sourceShape / classpath)
596+
if classname is None:
597+
return None
598+
minc = _to_int_maybe(min_count_lit)
599+
maxc = _to_int_maybe(max_count_lit)
600+
if minc is None and maxc is None:
601+
return None
602+
validation_report = g.cbd(result)
603+
return PathClassCount(focus, validation_report, g, path, minc, maxc, classname) # type: ignore[arg-type]
604+
605+
606+
def _detect_path_shape_count(
607+
g: Graph, result: Node, focus: Optional[URIRef]
608+
) -> Optional["PathShapeCount"]:
609+
"""Detect missing related entities that must conform to a node shape along a path."""
610+
if not focus:
611+
return None
612+
shapepath = SH["node"] | (SH.qualifiedValueShape / SH["node"]) # type: ignore
613+
path = g.value(result, SH.resultPath)
614+
shapename = g.value(result, SH.sourceShape / shapepath) # type: ignore
615+
if not (path and shapename):
616+
return None
617+
min_count_lit = g.value(result, SH.sourceShape / (SH.minCount | SH.qualifiedMinCount)) # type: ignore
618+
max_count_lit = g.value(result, SH.sourceShape / (SH.maxCount | SH.qualifiedMaxCount)) # type: ignore
619+
minc = _to_int_maybe(min_count_lit)
620+
maxc = _to_int_maybe(max_count_lit)
621+
if minc is None and maxc is None:
622+
return None
623+
extra_body, deps = get_template_parts_from_shape(shapename, g) # type: ignore[arg-type]
624+
validation_report = g.cbd(result)
625+
return PathShapeCount(
626+
focus,
627+
validation_report,
628+
g,
629+
path, # type: ignore[arg-type]
630+
minc,
631+
maxc,
632+
shapename, # type: ignore[arg-type]
633+
extra_body,
634+
tuple(deps) if deps else None,
635+
)
636+
637+
638+
def _detect_required_path(
639+
g: Graph, result: Node, focus: Optional[URIRef]
640+
) -> Optional["RequiredPath"]:
641+
"""Detect a missing path with min/max constraints and no specific class/shape requirement."""
642+
if not focus:
643+
return None
644+
path = g.value(result, SH.resultPath)
645+
if not path:
646+
return None
647+
min_count_lit = g.value(result, SH.sourceShape / (SH.minCount | SH.qualifiedMinCount)) # type: ignore
648+
max_count_lit = g.value(result, SH.sourceShape / (SH.maxCount | SH.qualifiedMaxCount)) # type: ignore
649+
minc = _to_int_maybe(min_count_lit)
650+
maxc = _to_int_maybe(max_count_lit)
651+
if minc is None and maxc is None:
652+
return None
653+
validation_report = g.cbd(result)
654+
return RequiredPath(
655+
focus,
656+
validation_report,
657+
g,
658+
path, # type: ignore[arg-type]
659+
minc,
660+
maxc,
661+
)
662+
663+
543664
@dataclass
544665
class ValidationContext:
545666
"""Holds the necessary information for processing the results of SHACL
@@ -629,139 +750,50 @@ def get_reasons_with_severity(
629750
def _report_to_diffset(self) -> Dict[Optional[URIRef], Set[GraphDiff]]:
630751
"""Interpret a SHACL validation report and say what is missing.
631752
632-
:return: a set of GraphDiffs that each abstract a SHACL shape violation
633-
:rtype: Set[GraphDiff]
753+
This implementation is organized as a sequence of small, focused detectors
754+
that each attempt to interpret a single ValidationResult node as a specific
755+
GraphDiff. The first matching detector wins for a given result.
634756
"""
635-
classpath = SH["class"] | (SH.qualifiedValueShape / SH["class"]) # type: ignore
636-
shapepath = SH["node"] | (SH.qualifiedValueShape / SH["node"]) # type: ignore
637-
# TODO: for future use
638-
# proppath = SH["property"] | (SH.qualifiedValueShape / SH["property"]) # type: ignore
639-
640757
g = self.report + self.shapes_graph
641758
diffs: Dict[Optional[URIRef], Set[GraphDiff]] = defaultdict(set)
642759

643760
for result in g.objects(predicate=SH.result):
644-
# check if the failure is due to our count constraint component
645761
focus = g.value(result, SH.focusNode)
646-
# get the subgraph corresponding to this ValidationReport -- see
647-
# https://www.w3.org/TR/shacl/#results-validation-result for details
648-
# on the structure and expected properties
649-
validation_report = g.cbd(result)
650762

651-
# First, detect the qualifiedValueShape + qualifiedMinCount pattern.
652-
# Even when the engine reports a ClassConstraintComponent on a value node,
653-
# the actionable fix is typically to add a new related entity of the required class.
654-
qvs_match = _detect_qvs_class_min_pattern(g, result)
655-
if qvs_match and focus:
656-
path, minc_i, maxc_i, classname = qvs_match
657-
diffs[focus].add(
658-
PathClassCount(
659-
focus,
660-
validation_report,
661-
g,
662-
path,
663-
minc_i,
664-
maxc_i,
665-
classname,
666-
)
667-
)
668-
# We handled this result explicitly; move to the next one.
763+
# 1) Graph-level class cardinality (custom CONSTRAINT component)
764+
graph_card = _detect_graph_class_cardinality(g, result)
765+
if graph_card is not None:
766+
diffs[None].add(graph_card)
767+
continue
768+
769+
# 2) Path to class (handles qualifiedValueShape + qualifiedMinCount pattern)
770+
pcc = _detect_path_class_count(g, result, focus)
771+
if pcc is not None:
772+
diffs[focus].add(pcc)
773+
continue
774+
775+
# 3) Path to node shape
776+
psc = _detect_path_shape_count(g, result, focus)
777+
if psc is not None:
778+
diffs[focus].add(psc)
669779
continue
670-
if (
671-
g.value(result, SH.sourceConstraintComponent)
672-
== CONSTRAINT.countConstraintComponent
673-
):
674-
expected_count = g.value(
675-
result, SH.sourceShape / CONSTRAINT.exactCount # type: ignore
676-
)
677-
of_class = g.value(result, SH.sourceShape / CONSTRAINT["class"]) # type: ignore
678-
# here, our 'self.focus' is the graph itself, which we don't want to have bound
679-
# to the templates during evaluation (for this specific kind of diff).
680-
# For this reason we override focus to be None
681-
diffs[None].add(
682-
GraphClassCardinality(
683-
None, validation_report, g, of_class, int(expected_count)
684-
)
685-
)
686-
elif (
687-
g.value(result, SH.sourceConstraintComponent)
688-
== SH.ClassConstraintComponent
689-
):
690-
requiring_shape = g.value(result, SH.sourceShape)
691-
expected_class = g.value(requiring_shape, SH["class"])
692-
if expected_class is None or isinstance(expected_class, BNode):
693-
continue
694-
diffs[focus].add(
695-
RequiredClass(focus, validation_report, g, expected_class)
696-
)
697-
elif (
698-
g.value(result, SH.sourceConstraintComponent)
699-
== SH.NodeConstraintComponent
700-
):
701-
# TODO: handle node constraint components
702-
pass
703-
# check if property shape
704-
elif g.value(result, SH.resultPath):
705-
path = g.value(result, SH.resultPath)
706-
min_count = g.value(
707-
result, SH.sourceShape / (SH.minCount | SH.qualifiedMinCount) # type: ignore
708-
)
709-
max_count = g.value(
710-
result, SH.sourceShape / (SH.maxCount | SH.qualifiedMaxCount) # type: ignore
711-
)
712-
classname = g.value(
713-
result,
714-
SH.sourceShape / classpath,
715-
)
716780

717-
# TODO: finish this for some shapes
718-
# shapes_of_object = g.value(result, SH.sourceShape / SH.qualifiedValueShape)
719-
# for soo in shapes_of_object:
720-
# soo_graph = g.cbd(soo)
721-
# handle properties (on qualifiedValueShapes?)
722-
# extra = g.value(result, SH.sourceShape / proppath) # type: ignore
723-
724-
if focus and (min_count or max_count) and classname:
725-
diffs[focus].add(
726-
PathClassCount(
727-
focus,
728-
validation_report,
729-
g,
730-
path,
731-
int(min_count) if min_count else None,
732-
int(max_count) if max_count else None,
733-
classname,
734-
)
735-
)
736-
continue
737-
shapename = g.value(result, SH.sourceShape / shapepath) # type: ignore
738-
if focus and (min_count or max_count) and shapename:
739-
extra_body, deps = get_template_parts_from_shape(shapename, g)
740-
diffs[focus].add(
741-
PathShapeCount(
742-
focus,
743-
validation_report,
744-
g,
745-
path,
746-
int(min_count) if min_count else None,
747-
int(max_count) if max_count else None,
748-
shapename,
749-
extra_body,
750-
tuple(deps),
751-
)
752-
)
753-
continue
754-
if focus and (min_count or max_count):
755-
diffs[focus].add(
756-
RequiredPath(
757-
focus,
758-
validation_report,
759-
g,
760-
path,
761-
int(min_count) if min_count else None,
762-
int(max_count) if max_count else None,
763-
)
764-
)
781+
# 4) Required path only (min/max count without class/shape requirement)
782+
rp = _detect_required_path(g, result, focus)
783+
if rp is not None:
784+
diffs[focus].add(rp)
785+
continue
786+
787+
# 5) Required class on the (focus or value) node
788+
rc = _detect_required_class(g, result, focus)
789+
if rc is not None:
790+
diffs[focus].add(rc)
791+
continue
792+
793+
# 6) NodeConstraintComponent handling (TODO)
794+
if g.value(result, SH.sourceConstraintComponent) == SH.NodeConstraintComponent:
795+
# Currently unhandled; reserved for future expansion
796+
continue
765797

766798
# TODO: this is still kind of broken...ideally we would actually interpret the shapes
767799
# inside the or clause

0 commit comments

Comments
 (0)