feat(prompts): implement Phase 5 - Dependency Tracking (FR-6)
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>
This commit is contained in:
308
markitect/prompts/dependencies/models.py
Normal file
308
markitect/prompts/dependencies/models.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user