Files
ai-resume/corpus/tests/test_pipeline.py
Sam Rolfe f9dc574172 Phase 4: corpus and retrieval — chunks, Qdrant, FTS5, freshness audit
Satisfies docs/phase-4-corpus.md. content/ stays the single source of truth:
one pass over it writes both indexes, so the static pages and the retrieval
corpus cannot drift.

- corpus/: stdlib-only pipeline. validate -> chunk -> enrich -> embed -> upsert.
  Chunking is block-based, so a boundary cannot fall inside a fenced block, a
  table or front-matter (verified structurally, not by inspection).
- Deterministic ids: sha256(source_path + chunk_index + content_hash), rendered
  as a UUID. Re-running updates points; re-chunking deletes the old ones by
  source filter, so duplicates and orphans are both impossible.
- Incremental by content hash against a SQLite manifest. A provider, dimension
  or chunker change is refused instead of mixed: it needs a rebuild.
- Payloads per section 4.2 plus provenance, validated before any vector is
  written; a missing field fails the run and names the document and chunk.
- SQLite FTS5 over chunks (the keyword half of hybrid retrieval, decision D10)
  with area/confidence filters and a `terms` column so front-matter tech and
  tags are findable.
- Freshness audit: stale documents, due soon, index drift, contract drift;
  optional Apprise alert; exit code for Prefect.
- Embeddings behind one interface: omniroute (OpenAI-compatible, retries, key
  from the environment only) and local-hashing (offline placeholder). The
  provider decision is still Sam's; the pipeline does not depend on it.
- deploy/qdrant.yml: mem_limit = memswap_limit = 400m, loopback only.
- docs/phase-4-notes.md: operator manual, DoD evidence, measured memory.
- 76 unit tests, no container and no network.

Measured, Qdrant 1.20.0 under a 400 m cap at 1024 dimensions: 500 / 5,000 /
20,000 chunks settle at 128 / 138 / 151 MiB, peaking at 149 / 305 / 292 MiB
during ingest. Qdrant's default settings are OOM-killed between 5,000 and
20,000, so the collection is created with HNSW and payloads on disk; recorded in
reference/stack.md with the ceiling and the escalation order.

Note: the `make corpus*` targets were swept into the Phase 3 deploy commit
24590cc by concurrent work in this tree; the Makefile content is correct, and
this commit contains the pipeline they call.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 21:04:52 +11:00

180 lines
7.5 KiB
Python

"""The pipeline: deterministic ids, no duplicate vectors on re-run, incremental mode,
incomplete payloads never reach the store (docs/phase-4-corpus.md DoD 1-6)."""
from __future__ import annotations
import os
import unittest
from unittest import mock
from corpus import chunker, loader, pipeline, store as store_module
from corpus.tests import support
class RebuildTest(unittest.TestCase):
def setUp(self):
self.root = support.temp_repo()
self.cfg = support.make_config(self.root)
self.addCleanup(_cleanup, self.root)
# One fake for the whole test: a second run must see the first run's points.
self.fake = support.FakeQdrant(self.cfg.dim)
def run_pipeline(self, mode="sync", cfg=None):
cfg = cfg or self.cfg
with support.fake_qdrant(cfg, self.fake):
return pipeline.run(cfg, mode=mode)
def test_rebuild_from_empty_volume(self):
stats = self.run_pipeline("rebuild")
self.assertEqual(stats.documents, 3)
self.assertEqual(stats.added, 3)
self.assertGreater(stats.store_chunks, 0)
self.assertEqual(stats.store_chunks, stats.collection_points)
self.assertEqual(stats.collection_points, len(self.fake.collections[self.cfg.collection]))
self.assertGreater(stats.embed_calls, 0)
def test_running_twice_does_not_duplicate_vectors(self):
first = self.run_pipeline("rebuild")
points_after_first = len(self.fake.collections[self.cfg.collection])
second = self.run_pipeline("sync")
points_after_second = len(self.fake.collections[self.cfg.collection])
self.assertEqual(points_after_first, points_after_second)
self.assertEqual(first.store_chunks, second.store_chunks)
self.assertEqual(second.added, 0)
self.assertEqual(second.updated, 0)
self.assertEqual(second.chunks_written, 0)
def test_incremental_reembeds_only_changed_documents(self):
self.run_pipeline("rebuild")
# Nothing changed: no embedding calls at all.
unchanged = self.run_pipeline("sync")
self.assertEqual(unchanged.embed_calls, 0)
self.assertEqual(unchanged.texts_embedded, 0)
# One document changes: exactly one embedding call, for that document.
support.write_doc(
self.root,
"edge/l3/edge-zigbee.md",
id="edge-zigbee", area="edge", level=3, title="Low Power Mesh Sensing",
claim="A constrained mesh that survives power loss.", confidence="delivered",
last_verified="2026-09-15", tags="zigbee, mesh",
body="The mesh now also reports battery voltage on every node.",
)
changed = self.run_pipeline("sync")
self.assertEqual(changed.added + changed.updated, 1)
self.assertEqual(changed.embed_calls, 1)
expected_chunks = len(
chunker.chunk_document(
loader.load_documents(support.make_config(self.root))[1].body
)
)
self.assertEqual(changed.texts_embedded, expected_chunks)
self.assertEqual(changed.unchanged, 2)
def test_removed_document_is_removed_from_both_indexes(self):
self.run_pipeline("rebuild")
before = self.run_pipeline("sync").store_chunks
os.remove(os.path.join(self.root, "content", "platform", "l3", "platform-containers.md"))
after = self.run_pipeline("sync")
self.assertEqual(after.removed, 1)
self.assertLess(after.store_chunks, before)
self.assertEqual(after.store_chunks, after.collection_points)
with store_module.Store(self.cfg.db_path) as store:
self.assertEqual(store.keyword_search("endpoint latency loss"), [])
def test_ids_are_deterministic(self):
first = pipeline.chunk_id("content/ai/ai-overview.md", 0, "a" * 64)
second = pipeline.chunk_id("content/ai/ai-overview.md", 0, "a" * 64)
other = pipeline.chunk_id("content/ai/ai-overview.md", 1, "a" * 64)
self.assertEqual(first, second)
self.assertNotEqual(first, other)
self.assertEqual(len(first), 36)
def test_invalid_payload_aborts_before_any_write(self):
real_build = pipeline.payloads.build_payload
def broken(doc, chunk, cfg):
payload = real_build(doc, chunk, cfg)
payload.pop("confidence")
return payload
with mock.patch("corpus.payloads.build_payload", side_effect=broken):
with self.assertRaises(pipeline.PipelineError):
self.run_pipeline("rebuild")
self.assertEqual(self.fake.collections.get(self.cfg.collection, {}), {})
def test_provider_change_is_refused_without_rebuild(self):
self.run_pipeline("rebuild")
other = support.make_config(self.root, model="local-hashing-v2")
with self.assertRaises(pipeline.PipelineError) as caught:
self.run_pipeline("sync", cfg=other)
self.assertIn("governed migration", str(caught.exception))
def test_contract_is_recorded(self):
self.run_pipeline("rebuild")
with store_module.Store(self.cfg.db_path) as store:
contract = store.get_meta("collection")
self.assertEqual(contract["model"], self.cfg.model)
self.assertEqual(contract["dim"], self.cfg.dim)
self.assertEqual(contract["chunker_version"], self.cfg.chunker_version)
self.assertTrue(os.path.exists(self.cfg.meta_path))
class VerifyTest(unittest.TestCase):
def setUp(self):
self.root = support.temp_repo()
self.cfg = support.make_config(self.root)
self.addCleanup(_cleanup, self.root)
def test_clean_after_rebuild(self):
with support.fake_qdrant(self.cfg):
pipeline.run(self.cfg, mode="rebuild")
self.assertEqual(pipeline.verify(self.cfg), [])
def test_detects_source_changed_without_sync(self):
with support.fake_qdrant(self.cfg):
pipeline.run(self.cfg, mode="rebuild")
support.write_doc(
self.root,
"ai/ai-overview.md",
id="ai-overview", area="ai", level=1, title="Agent Systems",
claim="Changed.", confidence="running", last_verified="2026-10-02",
tags="agents", body="A different body entirely.",
)
problems = pipeline.verify(self.cfg)
self.assertTrue(any("changed since it was embedded" in problem for problem in problems), problems)
def test_detects_missing_document(self):
with support.fake_qdrant(self.cfg):
pipeline.run(self.cfg, mode="rebuild")
os.remove(os.path.join(self.root, "content", "ai", "ai-overview.md"))
problems = pipeline.verify(self.cfg)
self.assertTrue(any("no longer in content/" in problem for problem in problems), problems)
class AreaFilterTest(unittest.TestCase):
def setUp(self):
self.root = support.temp_repo()
self.cfg = support.make_config(self.root)
self.addCleanup(_cleanup, self.root)
def test_filter_narrows_the_result_set(self):
with support.fake_qdrant(self.cfg):
pipeline.run(self.cfg, mode="rebuild")
result = pipeline.demo_area_filter(self.cfg, "container estate limits", area="edge")
self.assertTrue(result["unfiltered"])
self.assertTrue(result["filtered"])
self.assertEqual({row["area"] for row in result["filtered"]}, {"edge"})
self.assertGreater(len({row["area"] for row in result["unfiltered"]}), 1)
def _cleanup(root):
import shutil
shutil.rmtree(root, ignore_errors=True)
if __name__ == "__main__":
unittest.main()