fix(evidence): validate active corpus artifacts

This commit is contained in:
2026-07-12 06:35:27 +02:00
parent 5cc023f390
commit 03ee3ffda8
3 changed files with 153 additions and 11 deletions
+71 -3
View File
@@ -5,9 +5,9 @@ import pytest
from tht.corpus.chunk import ChunkPolicy
from tht.corpus.pipeline import CorpusPipeline, PipelineError, PipelineResult
from tht.corpus.store import CorpusStore
from tht.corpus.models import CorpusManifest
from tht.corpus.models import CanonicalChunk, CorpusManifest
from tht.ports.evidence import AcquiredDocument, SourceObject
from tht.ports.vector import VectorCapabilities
from tht.ports.vector import VectorCapabilities, VectorHealth
class Source:
@@ -23,7 +23,9 @@ class Source:
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())
return AcquiredDocument(
source=item, content=payload.encode(), media_type=item.metadata.get("media_type")
)
class Embedder:
@@ -45,6 +47,7 @@ class Vectors:
def __init__(self, fail=False):
self.fail = fail
self.records = []
self.dimension = 3
def upsert(self, collection, records):
self.records.extend(records[:1] if self.fail else records)
@@ -57,6 +60,12 @@ class Vectors:
value.record.id: value.content_hash for value in self.records
}
def health(self):
return VectorHealth(
ok=True, expected_dimension=3, observed_dimensions=(self.dimension,),
dimension_compatible=self.dimension == 3,
)
def delete_generation(self, collection, generation, workspace_id):
self.records = [
value for value in self.records
@@ -538,6 +547,65 @@ def test_job_incomplete_active_contract_never_noops(tmp_path, damage):
assert source.acquire_calls == ["fs:one"]
@pytest.mark.parametrize(
"damage", ["modified_at", "source_metadata", "media_type", "missing_chunk",
"altered_chunk", "extra_chunk", "vector_dimension"]
)
def test_job_corrupt_canonical_document_or_chunk_never_noops(tmp_path, damage):
import hashlib
import json
vectors = Vectors()
source_object = SourceObject(
source_id="fs:one", uri="file:///safe/one.md", fingerprint="sha256:a",
modified_at=datetime(2026, 1, 1, tzinfo=UTC),
metadata={"media_type": "text/markdown", "size": 11, "owner": "docs"},
)
args = dict(workspace_id="demo", workspace_root=tmp_path,
config_fingerprint="sha256:" + "1" * 64,
input_fingerprint="sha256:" + "2" * 64)
candidate = pipeline(
tmp_path, Source([(source_object, "hello world")]), vectors=vectors,
policy=ChunkPolicy(version="chunk-v1", max_chars=6),
)
first = candidate.run_as_job(**args)
manifest_path = candidate.store.generation_path(first.generation) / "manifest.json"
payload = json.loads(manifest_path.read_text())
document = payload["documents"][0]
chunks = payload["chunks"]
if damage == "modified_at":
document["modified_at"] = "2026-01-01T00:00:01Z"
elif damage == "source_metadata":
document["metadata"]["source"]["owner"] = "attacker"
elif damage == "media_type":
document["media_type"] = "text/plain"
elif damage == "missing_chunk":
payload["chunks"] = chunks[:-1]
elif damage == "altered_chunk":
chunks[0]["content"] = "HELLO "
chunks[0]["content_hash"] = "sha256:" + hashlib.sha256(b"HELLO ").hexdigest()
chunks[0]["chunk_id"] = "chunk:" + "a" * 64
elif damage == "extra_chunk":
extra = CanonicalChunk(
chunk_id="chunk:" + "b" * 64, document_id=document["document_id"],
ordinal=len(chunks), content="", content_hash="sha256:" + hashlib.sha256(b"").hexdigest(),
source_uri=document["source_uri"], pipeline_version=document["pipeline_version"],
)
chunks.append(extra.model_dump(mode="json"))
else:
vectors.dimension = 4
manifest_path.write_text(json.dumps(payload))
source = Source([(source_object, "hello world")])
result = pipeline(
tmp_path, source, vectors=vectors,
policy=ChunkPolicy(version="chunk-v1", max_chars=6),
).run_as_job(**args)
assert result.published is True
assert result.generation != first.generation
assert source.acquire_calls == ["fs:one"]
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()