430 lines
16 KiB
Python
430 lines
16 KiB
Python
import pytest
|
|
|
|
from tht.corpus.chunk import ChunkPolicy
|
|
from tht.corpus.pipeline import CorpusPipeline, PipelineError
|
|
from tht.corpus.store import CorpusStore
|
|
from tht.ports.evidence import AcquiredDocument, SourceObject
|
|
from tht.ports.vector import VectorCapabilities
|
|
|
|
|
|
class Source:
|
|
def __init__(self, documents):
|
|
self.documents = documents
|
|
self.acquire_calls = []
|
|
|
|
def discover(self):
|
|
return [item[0] for item in self.documents]
|
|
|
|
def acquire(self, item):
|
|
self.acquire_calls.append(item.source_id)
|
|
payload = next(payload for source, payload in self.documents if source.source_id == item.source_id)
|
|
if isinstance(payload, Exception):
|
|
raise payload
|
|
return AcquiredDocument(source=item, content=payload.encode())
|
|
|
|
|
|
class Embedder:
|
|
def __init__(self, dim=3, fail=False):
|
|
self.dim = dim
|
|
self.fail = fail
|
|
self.calls = []
|
|
|
|
def embed_documents(self, texts):
|
|
self.calls.extend(texts)
|
|
if self.fail:
|
|
raise RuntimeError("embed failed")
|
|
return [[float(i) for i in range(self.dim)] for _ in texts]
|
|
|
|
|
|
class Vectors:
|
|
capabilities = VectorCapabilities(search=True, existing_hashes=True, upsert=True)
|
|
|
|
def __init__(self, fail=False):
|
|
self.fail = fail
|
|
self.records = []
|
|
|
|
def upsert(self, collection, records):
|
|
self.records.extend(records[:1] if self.fail else records)
|
|
if self.fail:
|
|
raise RuntimeError("partial write")
|
|
return len(records)
|
|
|
|
def existing_hashes(self, collection, kinds):
|
|
return {
|
|
value.record.id: value.content_hash for value in self.records
|
|
}
|
|
|
|
def delete_generation(self, collection, generation):
|
|
self.records = [
|
|
value for value in self.records
|
|
if value.record.metadata["vector_generation"] != generation
|
|
]
|
|
return 0
|
|
|
|
def list_evidence_generations(self, collection):
|
|
return sorted({
|
|
value.record.metadata["vector_generation"] for value in self.records
|
|
if value.record.kind == "evidence"
|
|
})
|
|
|
|
|
|
class InterruptingVectors(Vectors):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.batches = []
|
|
self.interrupt = True
|
|
|
|
def upsert(self, collection, records):
|
|
self.batches.append([value.record.id for value in records])
|
|
if self.interrupt:
|
|
self.interrupt = False
|
|
self.records.append(records[0])
|
|
raise KeyboardInterrupt("process interruption after partial write")
|
|
self.records.extend(records)
|
|
return len(records)
|
|
|
|
|
|
def item(name, fingerprint):
|
|
return SourceObject(
|
|
source_id=f"fs:{name}", uri=f"file:///safe/{name}.md", fingerprint=f"sha256:{fingerprint}"
|
|
)
|
|
|
|
|
|
def pipeline(tmp_path, source, *, embedder=None, vectors=None, model="model-a", policy=None,
|
|
retain=3):
|
|
return CorpusPipeline(
|
|
store=CorpusStore(tmp_path / "corpus"), sources=[source],
|
|
embedder=embedder or Embedder(), vector_store=vectors or Vectors(),
|
|
embedding_model=model, embedding_dimensions=3,
|
|
chunk_policy=policy or ChunkPolicy(version="chunk-v1", max_chars=100),
|
|
pipeline_version="evidence-v1",
|
|
retain_published_generations=retain,
|
|
)
|
|
|
|
|
|
def test_retention_bounds_generations_and_purges_vectors_after_publish(tmp_path):
|
|
vectors = Vectors()
|
|
generations = []
|
|
for index in range(4):
|
|
result = pipeline(
|
|
tmp_path, Source([(item("one", str(index)), f"version {index}")]),
|
|
vectors=vectors, retain=2,
|
|
).run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + str(index) * 64,
|
|
)
|
|
generations.append(result.generation)
|
|
store = CorpusStore(tmp_path / "corpus")
|
|
assert store.list_generations() == generations[-2:]
|
|
assert {r.record.metadata["vector_generation"] for r in vectors.records} == set(generations[-2:])
|
|
assert store.active_generation() == generations[-1]
|
|
|
|
|
|
def test_retention_keeps_filesystem_when_vector_purge_fails_then_retries(tmp_path):
|
|
class FailingDelete(Vectors):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.fail_delete = True
|
|
|
|
def delete_generation(self, collection, generation):
|
|
if self.fail_delete:
|
|
raise RuntimeError("credential secret")
|
|
return super().delete_generation(collection, generation)
|
|
|
|
vectors = FailingDelete()
|
|
for index in range(2):
|
|
pipeline(tmp_path, Source([(item("one", str(index)), str(index))]), vectors=vectors,
|
|
retain=1).run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + str(index) * 64,
|
|
)
|
|
assert len(CorpusStore(tmp_path / "corpus").list_generations()) == 2
|
|
vectors.fail_delete = False
|
|
report = pipeline(tmp_path, Source([(item("one", "1"), "1")]), vectors=vectors,
|
|
retain=1).gc(workspace_root=tmp_path)
|
|
assert report["status"] == "succeeded"
|
|
assert len(CorpusStore(tmp_path / "corpus").list_generations()) == 1
|
|
|
|
|
|
def test_gc_reconciles_vector_only_generation(tmp_path):
|
|
vectors = Vectors()
|
|
orphan = "gen:" + "f" * 32
|
|
from tht.ports.vector import VectorWriteRecord
|
|
from tht.vectorstore.records import VectorRecord
|
|
vectors.records.append(VectorWriteRecord(
|
|
record=VectorRecord(id="orphan", kind="evidence", ref="doc:x", title="", content="x",
|
|
metadata={"vector_generation": orphan}),
|
|
embedding=[0.0, 0.0, 0.0], content_hash="sha256:" + "0" * 64,
|
|
))
|
|
candidate = pipeline(tmp_path, Source([]), vectors=vectors, retain=1)
|
|
report = candidate.gc(workspace_root=tmp_path)
|
|
assert report["evicted"] == [orphan]
|
|
assert vectors.list_evidence_generations("evidence") == []
|
|
|
|
|
|
def test_unchanged_documents_skip_acquire_normalize_chunk_and_embed(tmp_path):
|
|
one = item("one", "a")
|
|
first_source = Source([(one, "hello")])
|
|
first = pipeline(tmp_path, first_source)
|
|
first.run()
|
|
second_source = Source([(one, "ignored")])
|
|
second_embedder = Embedder()
|
|
result = pipeline(tmp_path, second_source, embedder=second_embedder).run()
|
|
assert result.unchanged == ("fs:one",)
|
|
assert second_source.acquire_calls == []
|
|
assert second_embedder.calls == []
|
|
|
|
|
|
def test_removed_documents_are_marked_and_absent_from_new_manifest(tmp_path):
|
|
one, two = item("one", "a"), item("two", "b")
|
|
pipeline(tmp_path, Source([(one, "one"), (two, "two")])).run()
|
|
result = pipeline(tmp_path, Source([(one, "one")])).run()
|
|
assert result.removed == ("fs:two",)
|
|
assert {doc.source_id for doc in result.manifest.documents} == {"fs:one"}
|
|
|
|
|
|
def test_model_or_chunk_policy_change_forces_full_rebuild(tmp_path):
|
|
one = item("one", "a")
|
|
pipeline(tmp_path, Source([(one, "hello")])).run()
|
|
source = Source([(one, "hello")])
|
|
changed = pipeline(tmp_path, source, model="model-b").run()
|
|
assert changed.changed == ("fs:one",)
|
|
assert source.acquire_calls == ["fs:one"]
|
|
|
|
|
|
def test_partial_vector_failure_never_changes_active_or_exposes_generation(tmp_path):
|
|
one = item("one", "a")
|
|
good = pipeline(tmp_path, Source([(one, "old")]))
|
|
old = good.run().generation
|
|
changed = item("one", "b")
|
|
vectors = Vectors(fail=True)
|
|
broken = pipeline(tmp_path, Source([(changed, "new")]), vectors=vectors)
|
|
with pytest.raises(PipelineError):
|
|
broken.run()
|
|
assert broken.store.active_generation() == old
|
|
assert vectors.records[0].record.metadata["vector_generation"] != old
|
|
|
|
|
|
def test_dimension_mismatch_fails_before_vector_write_and_publish(tmp_path):
|
|
one = item("one", "a")
|
|
vectors = Vectors()
|
|
candidate = pipeline(tmp_path, Source([(one, "hello")]), embedder=Embedder(dim=2), vectors=vectors)
|
|
with pytest.raises(PipelineError, match="dimension"):
|
|
candidate.run()
|
|
assert vectors.records == []
|
|
assert candidate.store.active_generation() is None
|
|
|
|
|
|
def test_dry_run_and_failed_acquire_never_change_active(tmp_path):
|
|
one = item("one", "a")
|
|
active = pipeline(tmp_path, Source([(one, "old")])).run().generation
|
|
changed = item("one", "b")
|
|
dry = pipeline(tmp_path, Source([(changed, "new")])).run(dry_run=True)
|
|
assert dry.published is False
|
|
assert dry.generation is None
|
|
assert dry.manifest.documents[0].content == "old"
|
|
with pytest.raises(PipelineError):
|
|
pipeline(tmp_path, Source([(changed, RuntimeError("boom"))])).run()
|
|
assert CorpusStore(tmp_path / "corpus").active_generation() == active
|
|
|
|
|
|
def test_job_pipeline_uses_ordered_plan_and_returns_run_id(tmp_path):
|
|
one = item("one", "a")
|
|
candidate = pipeline(tmp_path, Source([(one, "hello")]))
|
|
result = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
)
|
|
assert result.status == "succeeded"
|
|
assert result.run_id and len(result.run_id) == 32
|
|
checkpoint = tmp_path / ".tht-jobs" / "evidence" / "runs" / result.run_id / "checkpoint.json"
|
|
payload = __import__("json").loads(checkpoint.read_text())
|
|
assert [stage["name"] for stage in payload["stages"]] == [
|
|
"discover", "acquire_normalize_chunk", "embed", "vector_upsert",
|
|
"stage_validate", "publish", "retention_cleanup",
|
|
]
|
|
|
|
|
|
def test_job_pipeline_dry_run_only_discovers_and_reports_changes(tmp_path):
|
|
one = item("one", "a")
|
|
source = Source([(one, "hello")])
|
|
embedder = Embedder()
|
|
vectors = Vectors()
|
|
result = pipeline(tmp_path, source, embedder=embedder, vectors=vectors).run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
dry_run=True,
|
|
)
|
|
assert result.changed == ("fs:one",)
|
|
assert source.acquire_calls == []
|
|
assert embedder.calls == []
|
|
assert vectors.records == []
|
|
assert result.generation is None and result.published is False
|
|
|
|
|
|
@pytest.mark.parametrize("crash_stage", [
|
|
"discover", "acquire_normalize_chunk", "embed", "vector_upsert",
|
|
"stage_validate", "publish", "retention_cleanup",
|
|
])
|
|
def test_job_pipeline_crash_after_each_stage_resumes_without_duplicate_effects(tmp_path, crash_stage):
|
|
one = item("one", "a")
|
|
source = Source([(one, "hello")])
|
|
embedder = Embedder()
|
|
vectors = Vectors()
|
|
candidate = pipeline(tmp_path, source, embedder=embedder, vectors=vectors)
|
|
|
|
class Crash(BaseException):
|
|
pass
|
|
|
|
def fault(_context, stage):
|
|
if stage == crash_stage:
|
|
raise Crash()
|
|
|
|
with pytest.raises(Crash):
|
|
candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
after_stage_return=fault,
|
|
)
|
|
runs = tmp_path / ".tht-jobs" / "evidence" / "runs"
|
|
crashed = next(runs.iterdir()).name
|
|
result = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
resume_run_id=crashed,
|
|
)
|
|
assert result.status == "succeeded"
|
|
assert source.acquire_calls == ["fs:one"]
|
|
assert len(embedder.calls) == 1
|
|
assert len(vectors.records) == 1
|
|
|
|
|
|
def test_job_pipeline_raw_upsert_failure_compensates_and_resumes_with_new_generation(tmp_path):
|
|
one = item("one", "a")
|
|
vectors = Vectors(fail=True)
|
|
candidate = pipeline(tmp_path, Source([(one, "hello")]), vectors=vectors)
|
|
first = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
)
|
|
assert first.status == "failed"
|
|
assert vectors.records == []
|
|
old_generation = first.generation
|
|
vectors.fail = False
|
|
resumed = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
resume_run_id=first.run_id,
|
|
)
|
|
assert resumed.status == "succeeded", resumed
|
|
assert resumed.generation != old_generation
|
|
assert candidate.store.active_generation() == resumed.generation
|
|
|
|
|
|
def test_job_pipeline_raw_stage_failure_compensates_vectors_and_resumes(tmp_path, monkeypatch):
|
|
one = item("one", "a")
|
|
vectors = Vectors()
|
|
candidate = pipeline(tmp_path, Source([(one, "hello")]), vectors=vectors)
|
|
real_stage = candidate.store.stage
|
|
calls = 0
|
|
|
|
def fail_once(*args, **kwargs):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise OSError("raw stage failure")
|
|
return real_stage(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(candidate.store, "stage", fail_once)
|
|
first = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
)
|
|
assert first.status == "failed" and vectors.records == []
|
|
resumed = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
resume_run_id=first.run_id,
|
|
)
|
|
assert resumed.status == "succeeded", resumed
|
|
|
|
|
|
@pytest.mark.parametrize("stage,filename", [
|
|
("discover", "plan.json"),
|
|
("acquire_normalize_chunk", "manifest.json"),
|
|
("embed", "embeddings.json"),
|
|
])
|
|
@pytest.mark.parametrize("mutation", ["missing", "tampered"])
|
|
def test_job_pipeline_rejects_corrupt_required_artifacts_before_resume(
|
|
tmp_path, stage, filename, mutation,
|
|
):
|
|
one = item("one", "a")
|
|
candidate = pipeline(tmp_path, Source([(one, "hello")]))
|
|
|
|
class Crash(BaseException):
|
|
pass
|
|
|
|
with pytest.raises(Crash):
|
|
candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
after_stage_return=lambda _context, name: (
|
|
(_ for _ in ()).throw(Crash()) if name == stage else None
|
|
),
|
|
)
|
|
runs = tmp_path / ".tht-jobs" / "evidence" / "runs"
|
|
crashed = next(runs.iterdir())
|
|
target = crashed / "artifacts" / filename
|
|
target.unlink() if mutation == "missing" else target.write_text("tampered")
|
|
from tht.jobs.runner import CorruptCheckpointError
|
|
with pytest.raises(CorruptCheckpointError, match="artifact"):
|
|
candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
resume_run_id=crashed.name,
|
|
)
|
|
|
|
|
|
def test_vector_intent_is_reconciled_after_process_interruption_without_duplicate_upsert(tmp_path):
|
|
one = item("one", "a")
|
|
vectors = InterruptingVectors()
|
|
candidate = pipeline(
|
|
tmp_path, Source([(one, "a" * 250)]), vectors=vectors,
|
|
policy=ChunkPolicy(version="chunk-v1", max_chars=100),
|
|
)
|
|
with pytest.raises(KeyboardInterrupt):
|
|
candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
)
|
|
runs = tmp_path / ".tht-jobs" / "evidence" / "runs"
|
|
interrupted = next(runs.iterdir())
|
|
checkpoint = __import__("json").loads((interrupted / "checkpoint.json").read_text())
|
|
vector_stage = checkpoint["stages"][3]
|
|
assert vector_stage["status"] == "running"
|
|
assert vector_stage["effect_state"] == "intent"
|
|
first_written = vectors.batches[0][0]
|
|
|
|
result = candidate.run_as_job(
|
|
workspace_id="demo", workspace_root=tmp_path,
|
|
config_fingerprint="sha256:" + "1" * 64,
|
|
input_fingerprint="sha256:" + "2" * 64,
|
|
resume_run_id=interrupted.name,
|
|
)
|
|
assert result.status == "succeeded" and result.published is True
|
|
assert first_written not in vectors.batches[1]
|
|
assert len(vectors.records) == 3
|