| """ |
| In-memory branch-graph data structure. |
| |
| Domain types (separate from pydantic models in ``models.py`` β these |
| are mutation-friendly; pydantic models are serialization-friendly). |
| |
| GraphNode β one scene / decision / merge / ending |
| GraphEdge β directed transition |
| BranchGraph β container + convenience accessors |
| |
| Validation rules (enforced by ``validate_graph``): |
| V1 Exactly one entry node (kind='scene', marked via ``is_entry``). |
| V2 Every node except entry has at least one inbound edge. |
| V3 Every non-ending node has at least one outbound edge. |
| V4 No cycles (branching graphs are DAGs by design β merge points |
| are fine, loops aren't). |
| V5 No dangling edge references (from/to must exist in the graph). |
| V6 Max depth and node count within configured caps. |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
| from typing import Dict, Iterable, Iterator, List, Optional, Set |
|
|
|
|
| class GraphValidationError(Exception): |
| """Raised when ``validate_graph`` finds a structural issue. |
| |
| ``issues`` is a list of dicts so callers can surface multiple |
| issues to the user at once (one per node/edge). |
| """ |
|
|
| def __init__(self, message: str, issues: Optional[List[dict]] = None) -> None: |
| super().__init__(message) |
| self.issues = list(issues or []) |
|
|
|
|
| @dataclass |
| class GraphNode: |
| """One node in the branch graph.""" |
|
|
| id: str |
| kind: str = "scene" |
| title: str = "" |
| narration: str = "" |
| is_entry: bool = False |
| metadata: dict = field(default_factory=dict) |
|
|
|
|
| @dataclass |
| class GraphEdge: |
| """One directed transition between nodes.""" |
|
|
| from_id: str |
| to_id: str |
| trigger_kind: str = "auto" |
| label: str = "" |
| payload: dict = field(default_factory=dict) |
| ordinal: int = 0 |
|
|
|
|
| @dataclass |
| class BranchGraph: |
| """Container for nodes + edges + lookup helpers.""" |
|
|
| nodes: List[GraphNode] = field(default_factory=list) |
| edges: List[GraphEdge] = field(default_factory=list) |
|
|
| |
|
|
| def node(self, node_id: str) -> Optional[GraphNode]: |
| for n in self.nodes: |
| if n.id == node_id: |
| return n |
| return None |
|
|
| def entry(self) -> Optional[GraphNode]: |
| for n in self.nodes: |
| if n.is_entry: |
| return n |
| return None |
|
|
| def outbound(self, node_id: str) -> List[GraphEdge]: |
| return [e for e in self.edges if e.from_id == node_id] |
|
|
| def inbound(self, node_id: str) -> List[GraphEdge]: |
| return [e for e in self.edges if e.to_id == node_id] |
|
|
| def add_node(self, node: GraphNode) -> GraphNode: |
| self.nodes.append(node) |
| return node |
|
|
| def add_edge(self, edge: GraphEdge) -> GraphEdge: |
| self.edges.append(edge) |
| return edge |
|
|
| def count_depth(self) -> int: |
| """Longest path from entry to any reachable node. |
| |
| Uses plain DFS with a visited set β correct for acyclic |
| graphs, and on a cyclic graph returns a finite answer |
| without infinite looping (cycles still get flagged by |
| ``_detect_cycles`` in validate_graph). |
| |
| Complexity O(V+E); bounded by the node count because each |
| id is only inserted into ``visited`` once. |
| """ |
| entry = self.entry() |
| if not entry: |
| return 0 |
| visited: Set[str] = set() |
| best: Dict[str, int] = {} |
|
|
| def dfs(nid: str, depth: int) -> int: |
| |
| if nid in visited: |
| return best.get(nid, depth) |
| visited.add(nid) |
| best[nid] = depth |
| max_d = depth |
| for e in self.outbound(nid): |
| child = dfs(e.to_id, depth + 1) |
| if child > max_d: |
| max_d = child |
| return max_d |
|
|
| return dfs(entry.id, 0) |
|
|
| def __iter__(self) -> Iterator[GraphNode]: |
| return iter(self.nodes) |
|
|
|
|
| |
| |
| |
|
|
| def _detect_cycles(graph: BranchGraph) -> List[List[str]]: |
| """DFS-based cycle detection. Returns the list of cycles as |
| lists of node ids; empty list = acyclic.""" |
| cycles: List[List[str]] = [] |
| color: Dict[str, str] = {n.id: "white" for n in graph.nodes} |
| stack_path: List[str] = [] |
|
|
| def dfs(nid: str) -> None: |
| color[nid] = "gray" |
| stack_path.append(nid) |
| for e in graph.outbound(nid): |
| next_id = e.to_id |
| if color.get(next_id, "white") == "white": |
| dfs(next_id) |
| elif color.get(next_id) == "gray": |
| idx = stack_path.index(next_id) |
| cycles.append(list(stack_path[idx:]) + [next_id]) |
| stack_path.pop() |
| color[nid] = "black" |
|
|
| for n in graph.nodes: |
| if color.get(n.id) == "white": |
| dfs(n.id) |
| return cycles |
|
|
|
|
| def validate_graph( |
| graph: BranchGraph, |
| *, |
| max_depth: int = 6, |
| max_nodes: int = 200, |
| ) -> None: |
| """Apply V1-V6 rules. Raises ``GraphValidationError`` with all |
| issues collected β callers get the full list, not just the |
| first failure.""" |
| issues: List[dict] = [] |
|
|
| |
| entries = [n for n in graph.nodes if n.is_entry] |
| if len(entries) == 0: |
| issues.append({"rule": "V1", "detail": "no entry node marked is_entry=True"}) |
| elif len(entries) > 1: |
| issues.append({ |
| "rule": "V1", |
| "detail": f"multiple entry nodes: {[n.id for n in entries]}", |
| }) |
|
|
| |
| if len(graph.nodes) > max_nodes: |
| issues.append({ |
| "rule": "V6", |
| "detail": f"{len(graph.nodes)} nodes exceeds max {max_nodes}", |
| }) |
|
|
| depth = graph.count_depth() |
| if depth > max_depth: |
| issues.append({ |
| "rule": "V6", |
| "detail": f"depth {depth} exceeds max {max_depth}", |
| }) |
|
|
| |
| node_ids: Set[str] = {n.id for n in graph.nodes} |
| for e in graph.edges: |
| if e.from_id not in node_ids: |
| issues.append({ |
| "rule": "V5", "edge": (e.from_id, e.to_id), |
| "detail": f"edge from_id '{e.from_id}' has no matching node", |
| }) |
| if e.to_id not in node_ids: |
| issues.append({ |
| "rule": "V5", "edge": (e.from_id, e.to_id), |
| "detail": f"edge to_id '{e.to_id}' has no matching node", |
| }) |
|
|
| |
| entry_id = entries[0].id if len(entries) == 1 else None |
| for n in graph.nodes: |
| if n.is_entry: |
| continue |
| if not graph.inbound(n.id): |
| issues.append({ |
| "rule": "V2", "node": n.id, |
| "detail": f"node '{n.id}' has no inbound edges", |
| }) |
| for n in graph.nodes: |
| if n.kind == "ending": |
| continue |
| if not graph.outbound(n.id): |
| issues.append({ |
| "rule": "V3", "node": n.id, |
| "detail": f"non-ending node '{n.id}' has no outbound edges", |
| }) |
|
|
| |
| cycles = _detect_cycles(graph) |
| for cy in cycles: |
| issues.append({ |
| "rule": "V4", |
| "cycle": cy, |
| "detail": f"cycle detected: {' β '.join(cy)}", |
| }) |
|
|
| if issues: |
| raise GraphValidationError( |
| f"Graph invalid ({len(issues)} issue(s))", |
| issues=issues, |
| ) |
|
|