Add directed dependency graph with cycle detection, topological sort, and query service for finding dependents/dependencies transitively. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
10 KiB
Python
309 lines
10 KiB
Python
"""
|
|
Dependency tracking models for prompt artifact graphs.
|
|
|
|
Implements FR-6: Dependency Tracking
|
|
- FR-6.1: Directed dependency edges between artifacts
|
|
- FR-6.2: Cross-space dependency graph
|
|
- FR-6.3: Circular dependency detection
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Dict, Any, List, Optional, Set, Tuple
|
|
import uuid
|
|
|
|
|
|
class EdgeType(Enum):
|
|
"""Type of dependency relationship between artifacts."""
|
|
|
|
REQUIRES = "requires"
|
|
GENERATES = "generates"
|
|
INCLUDES = "includes"
|
|
|
|
|
|
class CircularDependencyError(Exception):
|
|
"""
|
|
Raised when a circular dependency is detected in the graph.
|
|
|
|
Attributes:
|
|
cycle: List of node IDs forming the cycle
|
|
"""
|
|
|
|
def __init__(self, message: str, cycle: Optional[List[str]] = None):
|
|
super().__init__(message)
|
|
self.cycle = cycle or []
|
|
|
|
|
|
@dataclass
|
|
class DependencyEdge:
|
|
"""
|
|
Persistent dependency edge between artifacts.
|
|
|
|
Extends the ephemeral DependencyEdge from execution/manifest.py
|
|
with persistence fields (id, run_id, created_at).
|
|
|
|
Attributes:
|
|
id: Unique edge identifier
|
|
source_artifact_id: Source artifact ID
|
|
target_artifact_id: Target artifact ID
|
|
run_id: ID of the run that created this edge
|
|
edge_type: Type of dependency relationship
|
|
created_at: When this edge was created
|
|
"""
|
|
|
|
id: str
|
|
source_artifact_id: str
|
|
target_artifact_id: str
|
|
run_id: str
|
|
edge_type: EdgeType
|
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
source_artifact_id: str,
|
|
target_artifact_id: str,
|
|
run_id: str,
|
|
edge_type: EdgeType,
|
|
) -> "DependencyEdge":
|
|
"""
|
|
Create a new dependency edge.
|
|
|
|
Args:
|
|
source_artifact_id: Source artifact ID
|
|
target_artifact_id: Target artifact ID
|
|
run_id: Run that established this dependency
|
|
edge_type: Type of dependency
|
|
|
|
Returns:
|
|
New DependencyEdge instance
|
|
"""
|
|
return cls(
|
|
id=str(uuid.uuid4()),
|
|
source_artifact_id=source_artifact_id,
|
|
target_artifact_id=target_artifact_id,
|
|
run_id=run_id,
|
|
edge_type=edge_type,
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
return {
|
|
"id": self.id,
|
|
"source_artifact_id": self.source_artifact_id,
|
|
"target_artifact_id": self.target_artifact_id,
|
|
"run_id": self.run_id,
|
|
"edge_type": self.edge_type.value,
|
|
"created_at": self.created_at.isoformat(),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "DependencyEdge":
|
|
"""Create from dictionary."""
|
|
return cls(
|
|
id=data["id"],
|
|
source_artifact_id=data["source_artifact_id"],
|
|
target_artifact_id=data["target_artifact_id"],
|
|
run_id=data["run_id"],
|
|
edge_type=EdgeType(data["edge_type"]),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
|
|
# DFS node coloring for cycle detection
|
|
_WHITE = 0 # Unvisited
|
|
_GRAY = 1 # In current DFS path
|
|
_BLACK = 2 # Fully processed
|
|
|
|
|
|
class DependencyGraph:
|
|
"""
|
|
In-memory directed graph for dependency analysis.
|
|
|
|
Maintains forward and reverse adjacency lists for efficient
|
|
traversal in both directions. Supports cycle detection via
|
|
3-color DFS and topological sorting.
|
|
|
|
Implements FR-6.2: Cross-space dependency graph
|
|
Implements FR-6.3: Circular dependency detection
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._forward: Dict[str, Set[str]] = {}
|
|
self._reverse: Dict[str, Set[str]] = {}
|
|
self._edge_types: Dict[Tuple[str, str], EdgeType] = {}
|
|
|
|
@property
|
|
def nodes(self) -> Set[str]:
|
|
"""All nodes in the graph."""
|
|
return set(self._forward.keys()) | set(self._reverse.keys())
|
|
|
|
@property
|
|
def edge_count(self) -> int:
|
|
"""Total number of edges."""
|
|
return sum(len(targets) for targets in self._forward.values())
|
|
|
|
def add_edge(
|
|
self,
|
|
source: str,
|
|
target: str,
|
|
edge_type: EdgeType = EdgeType.REQUIRES,
|
|
) -> None:
|
|
"""
|
|
Add a directed edge from source to target.
|
|
|
|
Args:
|
|
source: Source node ID
|
|
target: Target node ID
|
|
edge_type: Type of dependency
|
|
"""
|
|
if source not in self._forward:
|
|
self._forward[source] = set()
|
|
if target not in self._forward:
|
|
self._forward[target] = set()
|
|
if source not in self._reverse:
|
|
self._reverse[source] = set()
|
|
if target not in self._reverse:
|
|
self._reverse[target] = set()
|
|
|
|
self._forward[source].add(target)
|
|
self._reverse[target].add(source)
|
|
self._edge_types[(source, target)] = edge_type
|
|
|
|
def get_successors(self, node: str) -> Set[str]:
|
|
"""Get direct successors (dependencies) of a node."""
|
|
return set(self._forward.get(node, set()))
|
|
|
|
def get_predecessors(self, node: str) -> Set[str]:
|
|
"""Get direct predecessors (dependents) of a node."""
|
|
return set(self._reverse.get(node, set()))
|
|
|
|
def get_edge_type(self, source: str, target: str) -> Optional[EdgeType]:
|
|
"""Get the edge type between two nodes."""
|
|
return self._edge_types.get((source, target))
|
|
|
|
def has_edge(self, source: str, target: str) -> bool:
|
|
"""Check if an edge exists between source and target."""
|
|
return target in self._forward.get(source, set())
|
|
|
|
def detect_cycles(self) -> List[List[str]]:
|
|
"""
|
|
Detect all cycles in the graph using 3-color DFS.
|
|
|
|
Returns:
|
|
List of cycles, where each cycle is a list of node IDs
|
|
"""
|
|
color: Dict[str, int] = {node: _WHITE for node in self.nodes}
|
|
parent: Dict[str, Optional[str]] = {node: None for node in self.nodes}
|
|
cycles: List[List[str]] = []
|
|
|
|
def _dfs(node: str) -> None:
|
|
color[node] = _GRAY
|
|
for neighbor in self._forward.get(node, set()):
|
|
if color.get(neighbor, _WHITE) == _GRAY:
|
|
# Back edge found - extract cycle
|
|
cycle = [neighbor]
|
|
current = node
|
|
while current != neighbor:
|
|
cycle.append(current)
|
|
current = parent[current]
|
|
cycle.append(neighbor)
|
|
cycle.reverse()
|
|
cycles.append(cycle)
|
|
elif color.get(neighbor, _WHITE) == _WHITE:
|
|
parent[neighbor] = node
|
|
_dfs(neighbor)
|
|
color[node] = _BLACK
|
|
|
|
for node in self.nodes:
|
|
if color.get(node, _WHITE) == _WHITE:
|
|
_dfs(node)
|
|
|
|
return cycles
|
|
|
|
def has_cycle(self) -> bool:
|
|
"""Check if the graph contains any cycles."""
|
|
color: Dict[str, int] = {node: _WHITE for node in self.nodes}
|
|
|
|
def _dfs(node: str) -> bool:
|
|
color[node] = _GRAY
|
|
for neighbor in self._forward.get(node, set()):
|
|
if color.get(neighbor, _WHITE) == _GRAY:
|
|
return True
|
|
if color.get(neighbor, _WHITE) == _WHITE and _dfs(neighbor):
|
|
return True
|
|
color[node] = _BLACK
|
|
return False
|
|
|
|
for node in self.nodes:
|
|
if color.get(node, _WHITE) == _WHITE:
|
|
if _dfs(node):
|
|
return True
|
|
return False
|
|
|
|
def topological_sort(self) -> List[str]:
|
|
"""
|
|
Topologically sort the graph nodes.
|
|
|
|
Returns:
|
|
Nodes in topological order (dependencies before dependents)
|
|
|
|
Raises:
|
|
CircularDependencyError: If graph contains cycles
|
|
"""
|
|
color: Dict[str, int] = {node: _WHITE for node in self.nodes}
|
|
result: List[str] = []
|
|
cycle_path: List[str] = []
|
|
|
|
def _dfs(node: str) -> bool:
|
|
color[node] = _GRAY
|
|
cycle_path.append(node)
|
|
for neighbor in sorted(self._forward.get(node, set())):
|
|
if color.get(neighbor, _WHITE) == _GRAY:
|
|
# Extract cycle for error reporting
|
|
idx = cycle_path.index(neighbor)
|
|
cycle = cycle_path[idx:] + [neighbor]
|
|
raise CircularDependencyError(
|
|
f"Circular dependency detected: {' -> '.join(cycle)}",
|
|
cycle=cycle,
|
|
)
|
|
if color.get(neighbor, _WHITE) == _WHITE:
|
|
_dfs(neighbor)
|
|
cycle_path.pop()
|
|
color[node] = _BLACK
|
|
result.append(node)
|
|
|
|
for node in sorted(self.nodes):
|
|
if color.get(node, _WHITE) == _WHITE:
|
|
_dfs(node)
|
|
|
|
result.reverse()
|
|
return result
|
|
|
|
def get_subgraph(self, node_ids: Set[str]) -> "DependencyGraph":
|
|
"""
|
|
Extract a subgraph containing only the specified nodes.
|
|
|
|
Args:
|
|
node_ids: Set of node IDs to include
|
|
|
|
Returns:
|
|
New DependencyGraph with only the specified nodes and their edges
|
|
"""
|
|
subgraph = DependencyGraph()
|
|
for source in node_ids:
|
|
for target in self._forward.get(source, set()):
|
|
if target in node_ids:
|
|
edge_type = self._edge_types.get(
|
|
(source, target), EdgeType.REQUIRES
|
|
)
|
|
subgraph.add_edge(source, target, edge_type)
|
|
# Ensure isolated nodes are present
|
|
for node_id in node_ids:
|
|
if node_id not in subgraph._forward:
|
|
subgraph._forward[node_id] = set()
|
|
if node_id not in subgraph._reverse:
|
|
subgraph._reverse[node_id] = set()
|
|
return subgraph
|