HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
8.08 kB
"""
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" # scene | decision | merge | ending | assessment | remediation
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" # auto | choice | hotspot | timer | fallback | intent
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)
# ── Lookup helpers ────────────────────────────────────────────
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:
# On a cycle we'd revisit β€” bail out to keep O(V+E).
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)
# ─────────────────────────────────────────────────────────────────
# Validation
# ─────────────────────────────────────────────────────────────────
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] = []
# V1 β€” exactly one entry node
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]}",
})
# V6 β€” size caps
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}",
})
# V5 β€” dangling edges
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",
})
# V2 / V3 β€” inbound/outbound expectations
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",
})
# V4 β€” cycles
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,
)