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>
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""
|
|
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"]),
|
|
)
|