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:
213
tests/integration/prompts/test_circular_suppression.py
Normal file
213
tests/integration/prompts/test_circular_suppression.py
Normal 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
|
||||
289
tests/integration/prompts/test_impact_debt.py
Normal file
289
tests/integration/prompts/test_impact_debt.py
Normal 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() == []
|
||||
229
tests/integration/prompts/test_incremental_recompute.py
Normal file
229
tests/integration/prompts/test_incremental_recompute.py
Normal 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
|
||||
Reference in New Issue
Block a user