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