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:
2026-02-09 13:18:27 +01:00
parent 9ce157400e
commit bd1d05ba79
13 changed files with 2446 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
"""
Integration tests for circular dependency suppression.
Tests circular dependency handling with real DB, debt recording,
and various cycle topologies.
"""
import pytest
import tempfile
from pathlib import Path
from markitect.prompts.models import Artifact, ArtifactType
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
from markitect.prompts.dependencies.models import DependencyEdge, EdgeType
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.dependencies.queries import DependencyQueryService
from markitect.prompts.incremental.detector import ChangeDetector
from markitect.prompts.incremental.engine import IncrementalExecutionEngine
from markitect.prompts.incremental.models import RecomputeConfig, ChangeType
@pytest.fixture
def temp_db():
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
yield db_path
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def artifact_repo(temp_db):
"""Create artifact repository."""
return SQLiteArtifactRepository(temp_db)
@pytest.fixture
def dep_repo(temp_db):
"""Create dependency repository."""
return SQLiteDependencyRepository(temp_db)
@pytest.fixture
def query_service(dep_repo):
"""Create DependencyQueryService."""
return DependencyQueryService(dep_repo)
@pytest.fixture
def detector(temp_db):
"""Create ChangeDetector."""
return ChangeDetector(temp_db)
@pytest.fixture
def engine(temp_db, query_service):
"""Create IncrementalExecutionEngine."""
return IncrementalExecutionEngine(temp_db, query_service)
def _create_artifact(repo, space_id, name, content="content"):
"""Helper to create and persist an artifact."""
artifact = Artifact.create(
space_id=space_id,
name=name,
content=content,
artifact_type=ArtifactType.CONTENT,
)
return repo.create(artifact)
def _create_edge(repo, src, tgt, run_id="run-1"):
"""Helper to create and persist a dependency edge."""
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id=run_id,
edge_type=EdgeType.REQUIRES,
)
return repo.create(edge)
class TestDirectCircularSuppression:
"""Tests for direct circular dependency (A <-> B) suppression."""
def test_mutual_dependency_suppressed(
self, artifact_repo, dep_repo, detector, engine
):
"""Test mutual dependency suppresses recompute and records debt."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "content-a")
art_b = _create_artifact(artifact_repo, "space-1", "b", "content-b")
# A -> B and B -> A (circular)
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_b.id, art_a.id)
# Detect change in B
change = detector.detect_change(art_b, "content-b-modified")
assert change is not None
detector.record_change(change)
# Recompute: A depends on B, but A -> B creates cycle
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="content-b",
new_content="content-b-modified",
)
assert result.total_dependents == 1
assert result.suppressed_count == 1
assert result.recomputed_count == 0
assert result.suppressed[0].suppression_reason == "circular_dependency"
def test_debt_persisted_for_circular(
self, artifact_repo, dep_repo, detector, engine
):
"""Test that circular suppression debt is persisted in DB."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "a-v1")
art_b = _create_artifact(artifact_repo, "space-1", "b", "b-v1")
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_b.id, art_a.id)
change = detector.detect_change(art_b, "b-v2")
detector.record_change(change)
engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="b-v1",
new_content="b-v2",
)
# Verify debt persisted
debt = engine.get_debt_for_artifact(art_b.id)
assert len(debt) == 1
assert debt[0].suppression_reason == "circular_dependency"
assert debt[0].dependent_run_id == art_a.id
class TestThreeNodeCycleSuppression:
"""Tests for three-node circular dependency suppression."""
def test_three_node_cycle(
self, artifact_repo, dep_repo, detector, engine
):
"""Test 3-node cycle: A -> B -> C -> A."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "a")
art_b = _create_artifact(artifact_repo, "space-1", "b", "b")
art_c = _create_artifact(artifact_repo, "space-1", "c", "c")
# A -> B -> C -> A
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_b.id, art_c.id)
_create_edge(dep_repo, art_c.id, art_a.id)
# Change C, dependent at depth 1 is B
change = detector.detect_change(art_c, "c-modified")
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="c",
new_content="c-modified",
)
# B depends on C. would_create_cycle(B, C) checks if C can reach B.
# C -> A -> B: yes, C can reach B. So B is suppressed.
assert result.total_dependents == 1
assert result.suppressed_count == 1
assert result.suppressed[0].suppression_reason == "circular_dependency"
class TestMixedCircularAndNormal:
"""Tests with a mix of circular and normal dependencies."""
def test_some_suppressed_some_recomputed(
self, artifact_repo, dep_repo, detector, engine
):
"""Test graph with both circular and normal dependents."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "a")
art_b = _create_artifact(artifact_repo, "space-1", "b", "b")
art_c = _create_artifact(artifact_repo, "space-1", "c", "c")
# B -> A (normal), A -> B (creates cycle with B -> A)
# C -> A (normal, no cycle)
_create_edge(dep_repo, art_b.id, art_a.id)
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_c.id, art_a.id)
# Change A: dependents are B and C
change = detector.detect_change(art_a, "a-modified")
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="a",
new_content="a-modified",
)
assert result.total_dependents == 2
# B has circular dep with A → suppressed
# C has no circular dep with A → recomputed
circular_debts = [d for d in result.suppressed if d.suppression_reason == "circular_dependency"]
assert len(circular_debts) == 1
assert circular_debts[0].dependent_run_id == art_b.id
assert result.recomputed_count == 1
assert art_c.id in result.executed_run_ids

View File

@@ -0,0 +1,289 @@
"""
Integration tests for impact debt tracking.
Tests below-threshold suppression, budget exhaustion, and debt querying
with a real SQLite database.
"""
import pytest
import tempfile
from pathlib import Path
from markitect.prompts.models import Artifact, ArtifactType
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
from markitect.prompts.dependencies.models import DependencyEdge, EdgeType
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.dependencies.queries import DependencyQueryService
from markitect.prompts.incremental.detector import ChangeDetector
from markitect.prompts.incremental.engine import IncrementalExecutionEngine
from markitect.prompts.incremental.models import RecomputeConfig
@pytest.fixture
def temp_db():
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
yield db_path
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def artifact_repo(temp_db):
"""Create artifact repository."""
return SQLiteArtifactRepository(temp_db)
@pytest.fixture
def dep_repo(temp_db):
"""Create dependency repository."""
return SQLiteDependencyRepository(temp_db)
@pytest.fixture
def query_service(dep_repo):
"""Create DependencyQueryService."""
return DependencyQueryService(dep_repo)
@pytest.fixture
def detector(temp_db):
"""Create ChangeDetector."""
return ChangeDetector(temp_db)
@pytest.fixture
def engine(temp_db, query_service):
"""Create IncrementalExecutionEngine."""
return IncrementalExecutionEngine(temp_db, query_service)
def _create_artifact(repo, space_id, name, content="content"):
"""Helper to create and persist an artifact."""
artifact = Artifact.create(
space_id=space_id,
name=name,
content=content,
artifact_type=ArtifactType.CONTENT,
)
return repo.create(artifact)
def _create_edge(repo, src, tgt, run_id="run-1"):
"""Helper to create and persist a dependency edge."""
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id=run_id,
edge_type=EdgeType.REQUIRES,
)
return repo.create(edge)
class TestBelowThresholdSuppression:
"""Tests for impact debt from below-threshold suppression."""
def test_small_change_creates_debt(
self, artifact_repo, dep_repo, detector, engine
):
"""Test small change below threshold creates impact debt."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "hello world")
app = _create_artifact(artifact_repo, "space-1", "app", "uses lib")
_create_edge(dep_repo, app.id, lib.id)
change = detector.detect_change(lib, "hello World") # tiny change
assert change is not None
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, impact_threshold=0.5),
old_content="hello world",
new_content="hello World",
)
assert result.suppressed_count == 1
assert result.recomputed_count == 0
assert result.suppressed[0].suppression_reason == "below_threshold"
def test_debt_records_magnitude(
self, artifact_repo, dep_repo, detector, engine
):
"""Test debt records include change magnitude."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "content A")
app = _create_artifact(artifact_repo, "space-1", "app", "uses lib")
_create_edge(dep_repo, app.id, lib.id)
change = detector.detect_change(lib, "content B")
detector.record_change(change)
engine.recompute(
change,
config=RecomputeConfig(max_depth=1, impact_threshold=0.9),
old_content="content A",
new_content="content B",
)
debt = engine.get_debt_for_artifact(lib.id)
assert len(debt) == 1
assert debt[0].change_magnitude > 0.0
assert debt[0].change_magnitude < 1.0
def test_large_change_no_debt(
self, artifact_repo, dep_repo, detector, engine
):
"""Test large change above threshold creates no debt."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "old content here")
app = _create_artifact(artifact_repo, "space-1", "app", "uses lib")
_create_edge(dep_repo, app.id, lib.id)
change = detector.detect_change(lib, "completely new different content xyz")
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, impact_threshold=0.1),
old_content="old content here",
new_content="completely new different content xyz",
)
assert result.recomputed_count == 1
assert result.suppressed_count == 0
debt = engine.get_debt_for_artifact(lib.id)
assert len(debt) == 0
class TestBudgetExhaustion:
"""Tests for impact debt from budget exhaustion."""
def test_budget_creates_debt_for_excess(
self, artifact_repo, dep_repo, detector, engine
):
"""Test budget exhaustion creates debt for overflow dependents."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "lib v1")
# Create 5 apps depending on lib
apps = []
for i in range(5):
app = _create_artifact(artifact_repo, "space-1", f"app-{i}", f"app-{i}")
_create_edge(dep_repo, app.id, lib.id, run_id=f"run-{i}")
apps.append(app)
change = detector.detect_change(lib, "lib v2")
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, max_recomputes=2),
old_content="lib v1",
new_content="lib v2",
)
assert result.total_dependents == 5
assert result.recomputed_count == 2
assert result.suppressed_count == 3
budget_debt = [d for d in result.suppressed if d.suppression_reason == "budget_exhausted"]
assert len(budget_debt) == 3
def test_budget_debt_queryable(
self, artifact_repo, dep_repo, detector, engine
):
"""Test budget-exhaustion debt is queryable from DB."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "lib v1")
for i in range(3):
app = _create_artifact(artifact_repo, "space-1", f"app-{i}", f"app-{i}")
_create_edge(dep_repo, app.id, lib.id, run_id=f"run-{i}")
change = detector.detect_change(lib, "lib v2")
detector.record_change(change)
engine.recompute(
change,
config=RecomputeConfig(max_depth=1, max_recomputes=1),
old_content="lib v1",
new_content="lib v2",
)
all_debt = engine.get_all_debt()
budget_debt = [d for d in all_debt if d.suppression_reason == "budget_exhausted"]
assert len(budget_debt) == 2
class TestDebtQuerying:
"""Tests for querying impact debt records."""
def test_query_by_artifact(
self, artifact_repo, dep_repo, detector, engine
):
"""Test querying debt by artifact ID."""
lib_a = _create_artifact(artifact_repo, "space-1", "lib-a", "a-v1")
lib_b = _create_artifact(artifact_repo, "space-1", "lib-b", "b-v1")
app = _create_artifact(artifact_repo, "space-1", "app", "app")
_create_edge(dep_repo, app.id, lib_a.id)
_create_edge(dep_repo, app.id, lib_b.id)
# Suppress change to lib_a
change_a = detector.detect_change(lib_a, "a-v2")
detector.record_change(change_a)
engine.recompute(
change_a,
config=RecomputeConfig(max_depth=1, impact_threshold=0.99),
old_content="a-v1",
new_content="a-v2",
)
# Suppress change to lib_b
change_b = detector.detect_change(lib_b, "b-v2")
detector.record_change(change_b)
engine.recompute(
change_b,
config=RecomputeConfig(max_depth=1, impact_threshold=0.99),
old_content="b-v1",
new_content="b-v2",
)
# Query by artifact
debt_a = engine.get_debt_for_artifact(lib_a.id)
assert len(debt_a) == 1
debt_b = engine.get_debt_for_artifact(lib_b.id)
assert len(debt_b) == 1
# Total debt
all_debt = engine.get_all_debt()
assert len(all_debt) == 2
def test_query_by_run(
self, artifact_repo, dep_repo, detector, engine
):
"""Test querying debt by dependent run ID."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "lib-v1")
app = _create_artifact(artifact_repo, "space-1", "app", "app")
_create_edge(dep_repo, app.id, lib.id)
change = detector.detect_change(lib, "lib-v2")
detector.record_change(change)
engine.recompute(
change,
config=RecomputeConfig(max_depth=1, impact_threshold=0.99),
old_content="lib-v1",
new_content="lib-v2",
)
debt = engine.get_debt_for_run(app.id)
assert len(debt) == 1
assert debt[0].dependent_run_id == app.id
def test_no_debt_returns_empty(self, engine):
"""Test querying debt when none exists returns empty list."""
assert engine.get_debt_for_artifact("nonexistent") == []
assert engine.get_debt_for_run("nonexistent") == []
assert engine.get_all_debt() == []

View File

@@ -0,0 +1,229 @@
"""
Integration test for full incremental recompute workflow.
Tests: change artifact → detect → find dependents → recompute
with a real SQLite database.
"""
import pytest
import tempfile
from pathlib import Path
from markitect.prompts.models import Artifact, ArtifactType, calculate_content_digest
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
from markitect.prompts.dependencies.models import DependencyEdge, EdgeType
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.dependencies.queries import DependencyQueryService
from markitect.prompts.incremental.detector import ChangeDetector
from markitect.prompts.incremental.engine import IncrementalExecutionEngine
from markitect.prompts.incremental.models import RecomputeConfig
@pytest.fixture
def temp_db():
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
yield db_path
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def artifact_repo(temp_db):
"""Create artifact repository."""
return SQLiteArtifactRepository(temp_db)
@pytest.fixture
def dep_repo(temp_db):
"""Create dependency repository (shares same DB)."""
return SQLiteDependencyRepository(temp_db)
@pytest.fixture
def query_service(dep_repo):
"""Create DependencyQueryService."""
return DependencyQueryService(dep_repo)
@pytest.fixture
def detector(temp_db):
"""Create ChangeDetector."""
return ChangeDetector(temp_db)
@pytest.fixture
def engine(temp_db, query_service):
"""Create IncrementalExecutionEngine."""
return IncrementalExecutionEngine(temp_db, query_service)
def _create_artifact(repo, space_id, name, content):
"""Helper to create and persist an artifact."""
artifact = Artifact.create(
space_id=space_id,
name=name,
content=content,
artifact_type=ArtifactType.CONTENT,
)
return repo.create(artifact)
def _create_edge(repo, src, tgt, run_id="run-1"):
"""Helper to create and persist a dependency edge."""
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id=run_id,
edge_type=EdgeType.REQUIRES,
)
return repo.create(edge)
class TestFullRecomputeWorkflow:
"""Full end-to-end incremental recompute workflow."""
def test_change_detect_and_recompute(
self, artifact_repo, dep_repo, query_service, detector, engine
):
"""Test complete flow: create artifacts, detect change, recompute dependents."""
# Step 1: Create artifacts
lib = _create_artifact(artifact_repo, "space-1", "lib", "library v1")
app = _create_artifact(artifact_repo, "space-1", "app", "app using lib")
# Step 2: Establish dependency (app depends on lib)
_create_edge(dep_repo, app.id, lib.id)
# Step 3: Detect a change in lib
change = detector.detect_change(lib, "library v2")
assert change is not None
detector.record_change(change)
# Step 4: Recompute dependents
executed_ids = []
def callback(dep_id):
from markitect.prompts.execution.models import PromptRun
run = PromptRun.create(
template_id=dep_id,
input_bundle_hash="recompute-hash",
)
run.mark_complete()
executed_ids.append(dep_id)
return run
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1),
execution_callback=callback,
old_content="library v1",
new_content="library v2",
)
# Verify
assert result.total_dependents == 1
assert result.recomputed_count == 1
assert result.suppressed_count == 0
assert app.id in executed_ids
def test_multi_level_recompute(
self, artifact_repo, dep_repo, query_service, detector, engine
):
"""Test recompute propagates through multiple dependency levels."""
# core -> utils -> app
core = _create_artifact(artifact_repo, "space-1", "core", "core v1")
utils = _create_artifact(artifact_repo, "space-1", "utils", "utils v1")
app = _create_artifact(artifact_repo, "space-1", "app", "app v1")
_create_edge(dep_repo, utils.id, core.id)
_create_edge(dep_repo, app.id, utils.id)
# Change core
change = detector.detect_change(core, "core v2")
assert change is not None
detector.record_change(change)
# Recompute with depth 2
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=2),
old_content="core v1",
new_content="core v2",
)
assert result.total_dependents == 2
assert result.recomputed_count == 2
assert set(result.executed_run_ids) == {utils.id, app.id}
def test_no_change_no_recompute(
self, artifact_repo, dep_repo, detector, engine
):
"""Test that no change means no recompute."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "unchanged")
app = _create_artifact(artifact_repo, "space-1", "app", "app")
_create_edge(dep_repo, app.id, lib.id)
# Same content → no change
change = detector.detect_change(lib, "unchanged")
assert change is None
def test_change_record_persisted(
self, artifact_repo, detector
):
"""Test change records are persisted across detector instances."""
lib = _create_artifact(artifact_repo, "space-1", "lib", "v1")
change = detector.detect_change(lib, "v2")
assert change is not None
detector.record_change(change)
# Verify persisted
changes = detector.get_changes_for_artifact(lib.id)
assert len(changes) == 1
assert changes[0].id == change.id
class TestMultipleArtifactChanges:
"""Tests for handling changes to multiple artifacts."""
def test_independent_changes(
self, artifact_repo, dep_repo, detector, engine
):
"""Test independent artifact changes trigger separate recomputes."""
lib_a = _create_artifact(artifact_repo, "space-1", "lib-a", "lib-a v1")
lib_b = _create_artifact(artifact_repo, "space-1", "lib-b", "lib-b v1")
app = _create_artifact(artifact_repo, "space-1", "app", "app v1")
_create_edge(dep_repo, app.id, lib_a.id)
_create_edge(dep_repo, app.id, lib_b.id)
# Change lib_a
change_a = detector.detect_change(lib_a, "lib-a v2")
assert change_a is not None
detector.record_change(change_a)
result_a = engine.recompute(
change_a,
config=RecomputeConfig(max_depth=1),
old_content="lib-a v1",
new_content="lib-a v2",
)
assert result_a.total_dependents == 1
assert result_a.recomputed_count == 1
# Change lib_b
change_b = detector.detect_change(lib_b, "lib-b v2")
assert change_b is not None
detector.record_change(change_b)
result_b = engine.recompute(
change_b,
config=RecomputeConfig(max_depth=1),
old_content="lib-b v1",
new_content="lib-b v2",
)
assert result_b.total_dependents == 1
assert result_b.recomputed_count == 1