Source code for veupath_chatbot.services.strategies.engine.graph_integrity
"""Strategy graph integrity helpers (service layer).
These utilities validate the *working graph* state (mutable session graph), not the
compiled DSL AST (which is single-root by construction).
Design goals:
- DRY: centralize root detection / validation logic.
- Separation of concerns: keep logic out of AI prompt text and tool mixins.
"""
from dataclasses import dataclass
from typing import cast
from veupath_chatbot.domain.strategy.ast import PlanStepNode
from veupath_chatbot.domain.strategy.session import StrategyGraph
from veupath_chatbot.platform.types import JSONObject
[docs]
@dataclass(frozen=True)
class GraphIntegrityError:
code: str
message: str
step_id: str | None = None
input_step_id: str | None = None
kind: str | None = None
[docs]
def to_dict(self) -> JSONObject:
payload: dict[str, str | None] = {"code": self.code, "message": self.message}
if self.step_id:
payload["stepId"] = self.step_id
if self.input_step_id:
payload["inputStepId"] = self.input_step_id
if self.kind:
payload["kind"] = self.kind
return cast(JSONObject, payload)
[docs]
def find_root_step_ids(graph: StrategyGraph) -> list[str]:
"""Return root step IDs in sorted order.
Uses the incrementally-maintained ``graph.roots`` set (O(1)) instead of
recomputing from scratch.
:param graph: Strategy graph.
:returns: Sorted list of root step IDs.
"""
return sorted(graph.roots)
[docs]
def validate_graph_integrity(graph: StrategyGraph) -> list[GraphIntegrityError]:
"""Validate graph structure and single-output invariant.
Uses ``graph.roots`` for root detection and additionally checks for
dangling input references.
:param graph: Strategy graph to validate.
:returns: List of integrity errors (empty if valid).
"""
all_ids = set(graph.steps.keys())
if not all_ids:
return [GraphIntegrityError("EMPTY_GRAPH", "No steps in graph.")]
errors: list[GraphIntegrityError] = []
def add_missing(ref_id: str, step_id: str, kind: str) -> None:
errors.append(
GraphIntegrityError(
code="UNKNOWN_STEP_REFERENCE",
message="Step references an unknown input step.",
step_id=step_id,
input_step_id=ref_id,
kind=kind,
)
)
# Check for dangling references (inputs pointing to non-existent steps).
for step_id, step in graph.steps.items():
if not isinstance(step, PlanStepNode):
continue
primary = getattr(step.primary_input, "id", None)
secondary = getattr(step.secondary_input, "id", None)
if isinstance(primary, str) and primary and primary not in all_ids:
add_missing(primary, step_id, "primary_input")
if isinstance(secondary, str) and secondary and secondary not in all_ids:
add_missing(secondary, step_id, "secondary_input")
# Use the incrementally-maintained root set.
root_count = len(graph.roots)
if root_count == 0:
errors.append(
GraphIntegrityError("NO_ROOTS", "Strategy graph has no root/output steps.")
)
elif root_count > 1:
errors.append(
GraphIntegrityError(
"MULTIPLE_ROOTS",
f"Strategy graph has {root_count} roots — expected 1. "
f"Roots: {sorted(graph.roots)}",
)
)
return errors