Files
markitect-main/markitect/prompts/incremental/engine.py
tegwick bd1d05ba79 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>
2026-02-09 13:18:27 +01:00

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"]),
)