feat(prompts): implement Phase 6 - Incremental Execution (FR-7, FR-8)
Add change detection, structural diff-based impact analysis, configurable-depth incremental recomputation with circular suppression, and impact debt tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
327
markitect/prompts/incremental/engine.py
Normal file
327
markitect/prompts/incremental/engine.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Incremental execution engine.
|
||||
|
||||
Implements FR-7: Incremental Recomputation
|
||||
Implements FR-8: Impact Analysis
|
||||
|
||||
Orchestrates: find dependents → check cycles → assess impact →
|
||||
execute or suppress → record debt.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional, Set
|
||||
|
||||
from markitect.prompts.dependencies.queries import DependencyQueryService
|
||||
from markitect.prompts.execution.models import PromptRun
|
||||
from markitect.prompts.incremental.impact import ImpactAnalyzer
|
||||
from markitect.prompts.incremental.models import (
|
||||
ArtifactChange,
|
||||
ImpactDebt,
|
||||
RecomputeConfig,
|
||||
RecomputeResult,
|
||||
)
|
||||
|
||||
|
||||
# SQL schema for impact_debt table
|
||||
DEBT_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS impact_debt (
|
||||
id TEXT PRIMARY KEY,
|
||||
artifact_id TEXT NOT NULL,
|
||||
dependent_run_id TEXT NOT NULL,
|
||||
change_magnitude REAL NOT NULL,
|
||||
suppression_reason TEXT NOT NULL,
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_debt_artifact ON impact_debt(artifact_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_debt_run ON impact_debt(dependent_run_id);
|
||||
"""
|
||||
|
||||
|
||||
class IncrementalExecutionEngine:
|
||||
"""
|
||||
Engine for incremental recomputation of dependent artifacts.
|
||||
|
||||
Orchestrates the full recompute flow:
|
||||
1. Find dependents at configured depth (BFS)
|
||||
2. Check for circular dependencies
|
||||
3. Assess impact magnitude
|
||||
4. Execute or suppress recomputation
|
||||
5. Record impact debt for suppressed items
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
query_service: DependencyQueryService,
|
||||
impact_analyzer: Optional[ImpactAnalyzer] = None,
|
||||
):
|
||||
"""
|
||||
Initialize engine with dependencies.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database for debt persistence
|
||||
query_service: Service for dependency graph queries
|
||||
impact_analyzer: Impact analyzer (created if not provided)
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.query_service = query_service
|
||||
self.impact_analyzer = impact_analyzer or ImpactAnalyzer()
|
||||
self._initialize_tables()
|
||||
|
||||
def _initialize_tables(self) -> None:
|
||||
"""Initialize impact_debt table if not exists."""
|
||||
db_dir = Path(self.db_path).parent
|
||||
if db_dir and not db_dir.exists():
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.executescript(DEBT_TABLE_SQL)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a database connection."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def find_dependents_at_depth(
|
||||
self,
|
||||
artifact_id: str,
|
||||
max_depth: int = 1,
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Find dependents up to a configured BFS depth.
|
||||
|
||||
Implements FR-7.2: Configurable depth control.
|
||||
|
||||
Args:
|
||||
artifact_id: Artifact to find dependents of
|
||||
max_depth: Maximum BFS layers to traverse
|
||||
|
||||
Returns:
|
||||
Set of dependent artifact IDs within depth limit
|
||||
"""
|
||||
visited: Set[str] = set()
|
||||
queue: deque[tuple] = deque([(artifact_id, 0)])
|
||||
|
||||
while queue:
|
||||
current, depth = queue.popleft()
|
||||
if depth >= max_depth:
|
||||
continue
|
||||
|
||||
direct_dependents = self.query_service.find_dependents(current)
|
||||
for dep in direct_dependents:
|
||||
if dep not in visited:
|
||||
visited.add(dep)
|
||||
queue.append((dep, depth + 1))
|
||||
|
||||
return visited
|
||||
|
||||
def recompute(
|
||||
self,
|
||||
change: ArtifactChange,
|
||||
config: Optional[RecomputeConfig] = None,
|
||||
execution_callback: Optional[Callable[[str], Optional[PromptRun]]] = None,
|
||||
old_content: Optional[str] = None,
|
||||
new_content: Optional[str] = None,
|
||||
) -> RecomputeResult:
|
||||
"""
|
||||
Perform incremental recomputation for a detected change.
|
||||
|
||||
Orchestrates the full recompute flow:
|
||||
1. Find dependents at configured depth
|
||||
2. For each dependent, check cycles, assess impact
|
||||
3. Execute or suppress and record debt
|
||||
|
||||
Args:
|
||||
change: Detected artifact change
|
||||
config: Recompute configuration
|
||||
execution_callback: Callable that takes a run_id and re-executes.
|
||||
If None, records what would be recomputed without executing.
|
||||
old_content: Old content for magnitude calculation
|
||||
new_content: New content for magnitude calculation
|
||||
|
||||
Returns:
|
||||
RecomputeResult summarizing the recomputation
|
||||
"""
|
||||
config = config or RecomputeConfig()
|
||||
|
||||
# Calculate change magnitude
|
||||
magnitude = self.impact_analyzer.calculate_magnitude(
|
||||
old_content, new_content
|
||||
)
|
||||
|
||||
# Find dependents within configured depth
|
||||
dependents = self.find_dependents_at_depth(
|
||||
change.artifact_id, config.max_depth
|
||||
)
|
||||
|
||||
result = RecomputeResult(
|
||||
changed_artifact_id=change.artifact_id,
|
||||
change=change,
|
||||
total_dependents=len(dependents),
|
||||
)
|
||||
|
||||
recompute_count = 0
|
||||
|
||||
for dependent_id in dependents:
|
||||
# Budget check
|
||||
if recompute_count >= config.max_recomputes:
|
||||
debt = ImpactDebt.create(
|
||||
artifact_id=change.artifact_id,
|
||||
dependent_run_id=dependent_id,
|
||||
change_magnitude=magnitude,
|
||||
suppression_reason="budget_exhausted",
|
||||
)
|
||||
self._record_debt(debt)
|
||||
result.suppressed.append(debt)
|
||||
result.suppressed_count += 1
|
||||
continue
|
||||
|
||||
# Circular dependency check
|
||||
if config.suppress_circular:
|
||||
if self.query_service.would_create_cycle(
|
||||
dependent_id, change.artifact_id
|
||||
):
|
||||
debt = ImpactDebt.create(
|
||||
artifact_id=change.artifact_id,
|
||||
dependent_run_id=dependent_id,
|
||||
change_magnitude=magnitude,
|
||||
suppression_reason="circular_dependency",
|
||||
)
|
||||
self._record_debt(debt)
|
||||
result.suppressed.append(debt)
|
||||
result.suppressed_count += 1
|
||||
continue
|
||||
|
||||
# Impact threshold check
|
||||
if not self.impact_analyzer.should_recompute(magnitude, config):
|
||||
debt = ImpactDebt.create(
|
||||
artifact_id=change.artifact_id,
|
||||
dependent_run_id=dependent_id,
|
||||
change_magnitude=magnitude,
|
||||
suppression_reason="below_threshold",
|
||||
)
|
||||
self._record_debt(debt)
|
||||
result.suppressed.append(debt)
|
||||
result.suppressed_count += 1
|
||||
continue
|
||||
|
||||
# Execute recomputation
|
||||
if execution_callback is not None:
|
||||
run = execution_callback(dependent_id)
|
||||
if run is not None:
|
||||
result.executed_run_ids.append(run.id)
|
||||
else:
|
||||
# Dry-run mode: just record the ID
|
||||
result.executed_run_ids.append(dependent_id)
|
||||
|
||||
result.recomputed_count += 1
|
||||
recompute_count += 1
|
||||
|
||||
return result
|
||||
|
||||
def _record_debt(self, debt: ImpactDebt) -> ImpactDebt:
|
||||
"""
|
||||
Persist an ImpactDebt record to the database.
|
||||
|
||||
Args:
|
||||
debt: Debt record to persist
|
||||
|
||||
Returns:
|
||||
Persisted debt record
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO impact_debt (
|
||||
id, artifact_id, dependent_run_id,
|
||||
change_magnitude, suppression_reason, recorded_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
debt.id,
|
||||
debt.artifact_id,
|
||||
debt.dependent_run_id,
|
||||
debt.change_magnitude,
|
||||
debt.suppression_reason,
|
||||
debt.recorded_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return debt
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_debt_for_artifact(self, artifact_id: str) -> List[ImpactDebt]:
|
||||
"""
|
||||
Get all impact debt records for a given artifact.
|
||||
|
||||
Args:
|
||||
artifact_id: Artifact identifier
|
||||
|
||||
Returns:
|
||||
List of ImpactDebt records
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM impact_debt WHERE artifact_id = ?",
|
||||
(artifact_id,),
|
||||
)
|
||||
return [self._row_to_debt(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_debt_for_run(self, run_id: str) -> List[ImpactDebt]:
|
||||
"""
|
||||
Get all impact debt records for a given run.
|
||||
|
||||
Args:
|
||||
run_id: Run identifier
|
||||
|
||||
Returns:
|
||||
List of ImpactDebt records
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM impact_debt WHERE dependent_run_id = ?",
|
||||
(run_id,),
|
||||
)
|
||||
return [self._row_to_debt(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_all_debt(self) -> List[ImpactDebt]:
|
||||
"""
|
||||
Get all impact debt records.
|
||||
|
||||
Returns:
|
||||
List of all ImpactDebt records
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.execute("SELECT * FROM impact_debt")
|
||||
return [self._row_to_debt(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _row_to_debt(self, row: sqlite3.Row) -> ImpactDebt:
|
||||
"""Convert database row to ImpactDebt instance."""
|
||||
from datetime import datetime
|
||||
|
||||
return ImpactDebt(
|
||||
id=row["id"],
|
||||
artifact_id=row["artifact_id"],
|
||||
dependent_run_id=row["dependent_run_id"],
|
||||
change_magnitude=row["change_magnitude"],
|
||||
suppression_reason=row["suppression_reason"],
|
||||
recorded_at=datetime.fromisoformat(row["recorded_at"]),
|
||||
)
|
||||
Reference in New Issue
Block a user