phase: 188_chunks_hnsw_index
**Phase 188 — final verification pass: all criteria green.** - **Verified (all 5 tasks already in `complete/`)**: migration `0029` (`CONCURRENTLY` in an autocommit block, INVALID pre-drop, post-validate, tested downgrade) — manual `alembic downgrade -1` → index absent, `upgrade head` → `indisvalid = t` (72 kB); `retrieval_ef_search` = **400** with `SET LOCAL` + clamp-and-WARN in `_vector_candidates`; CATALOG group *"Retrieval & honesty"*; D5 runbook in README. - `uv run pytest --cov-reset` then `uv run pytest tests/unit tests/integration -q --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99 %** (6 946 stmts / 16 miss) — gate >90 % met. (A stale `.coverage` left by an earlier session produced a spurious 90.00 %/3-failure reading; `--cov-reset` cleared it.) - `uv run pytest tests/e2e/test_vector_index_recall.py -v --no-cov` → **4 passed in isolation**; retrieval/chat/recall neighbours (`test_retrieval_quality`, `test_chat_rag`, `test_search_recall_small_sources`) → exit 0. - `uv run ruff check .` → **All checks passed**; `uv run pyright` (whole repo) → **0 errors**. - **Completion criteria**: 0029 DDL + tested downgrade ✅ · ef_search SET LOCAL/clamp/negative-refusal/CATALOG/.env.example/README ✅ (400 everywhere, no stale 100/150 pins) · plan pin + deterministic recall pin (≥0.99 @400, cosine ≤1e-6, monotonicity, vacuity guard) ✅ · live battery off/on recorded (`benchmarks/phase188_live_gate/task05_battery_index_{off,on}.txt` byte-identical, `deterministic_gate_recall.txt`, build 161 s / 1 625 MB / 81 MB RSS, phase report `.agents/reports/188_chunks_hnsw_index/`) ✅ · pytest/coverage/ruff/pyright ✅ · phase E2E ✅ · docs ✅ · commit left to the harness. - **Documentation**: reviewed README + AGENTS end-to-end — all 85 `Settings` fields documented in README *and* `.env.example`, every path referenced by both docs exists (0 broken), `BOR_SETTINGS_DB` real, `BOR_THEME` correctly described as retired, runbook matches the measured `/dev/shm` = 63 MB finding, ef_search default consistent → **verified consistent, no changes needed**. - **Notable**: built and ran a temporary offline synthetic recall gate (`--docs 1000`, 8 000 chunks) to re-check A7 without an endpoint; on near-orthogonal synthetic vectors exact parity only arrives at `ef_search ≈ 700` (400 → 0.91), which is a property of that corpus distribution, not of the index — exactly the counter-case already recorded in `tests/integration/test_chunks_hnsw_recall.py`. Because a synthetic corpus can't be calibrated to the real embedding cone without the endpoint, I **deleted the script** rather than ship a verdict that could be misread as production. `AIPI_KEY` is absent in this environment (and `100.117.2.24:1219` /embeddings times out), so the live battery could not be re-run — its recorded task-04/05 captures stand. Scratch DB `brain_of_reese_hnswgate` dropped; repo left clean. - **Next pending phase**: `189_chat_save_long_turns`.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
**Phase 188 — final verification pass: all criteria green.**
|
||||
|
||||
- **Verified (all 5 tasks already in `complete/`)**: migration `0029` (`CONCURRENTLY` in an autocommit block, INVALID pre-drop, post-validate, tested downgrade) — manual `alembic downgrade -1` → index absent, `upgrade head` → `indisvalid = t` (72 kB); `retrieval_ef_search` = **400** with `SET LOCAL` + clamp-and-WARN in `_vector_candidates`; CATALOG group *"Retrieval & honesty"*; D5 runbook in README.
|
||||
- `uv run pytest --cov-reset` then `uv run pytest tests/unit tests/integration -q --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99 %** (6 946 stmts / 16 miss) — gate >90 % met. (A stale `.coverage` left by an earlier session produced a spurious 90.00 %/3-failure reading; `--cov-reset` cleared it.)
|
||||
- `uv run pytest tests/e2e/test_vector_index_recall.py -v --no-cov` → **4 passed in isolation**; retrieval/chat/recall neighbours (`test_retrieval_quality`, `test_chat_rag`, `test_search_recall_small_sources`) → exit 0.
|
||||
- `uv run ruff check .` → **All checks passed**; `uv run pyright` (whole repo) → **0 errors**.
|
||||
- **Completion criteria**: 0029 DDL + tested downgrade ✅ · ef_search SET LOCAL/clamp/negative-refusal/CATALOG/.env.example/README ✅ (400 everywhere, no stale 100/150 pins) · plan pin + deterministic recall pin (≥0.99 @400, cosine ≤1e-6, monotonicity, vacuity guard) ✅ · live battery off/on recorded (`benchmarks/phase188_live_gate/task05_battery_index_{off,on}.txt` byte-identical, `deterministic_gate_recall.txt`, build 161 s / 1 625 MB / 81 MB RSS, phase report `.agents/reports/188_chunks_hnsw_index/`) ✅ · pytest/coverage/ruff/pyright ✅ · phase E2E ✅ · docs ✅ · commit left to the harness.
|
||||
- **Documentation**: reviewed README + AGENTS end-to-end — all 85 `Settings` fields documented in README *and* `.env.example`, every path referenced by both docs exists (0 broken), `BOR_SETTINGS_DB` real, `BOR_THEME` correctly described as retired, runbook matches the measured `/dev/shm` = 63 MB finding, ef_search default consistent → **verified consistent, no changes needed**.
|
||||
- **Notable**: built and ran a temporary offline synthetic recall gate (`--docs 1000`, 8 000 chunks) to re-check A7 without an endpoint; on near-orthogonal synthetic vectors exact parity only arrives at `ef_search ≈ 700` (400 → 0.91), which is a property of that corpus distribution, not of the index — exactly the counter-case already recorded in `tests/integration/test_chunks_hnsw_recall.py`. Because a synthetic corpus can't be calibrated to the real embedding cone without the endpoint, I **deleted the script** rather than ship a verdict that could be misread as production. `AIPI_KEY` is absent in this environment (and `100.117.2.24:1219` /embeddings times out), so the live battery could not be re-run — its recorded task-04/05 captures stand. Scratch DB `brain_of_reese_hnswgate` dropped; repo left clean.
|
||||
- **Next pending phase**: `189_chat_save_long_turns`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 98%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,16 @@
|
||||
**Task 01 complete — all green.**
|
||||
|
||||
**Implemented**
|
||||
- `alembic/versions/0029_chunks_embedding_hnsw.py` — `ix_chunks_embedding_hnsw` (`USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64)`) built `CONCURRENTLY` in an autocommit block; pure helpers `should_drop_invalid`/`is_valid`; INVALID pre-drop self-heal + post-validate raise; `DROP INDEX CONCURRENTLY IF EXISTS` downgrade (A13).
|
||||
- `tests/integration/test_chunks_hnsw_index.py` — create/valid/definition pin, downgrade, head re-upgrade round-trip, forced-INVALID self-heal (documented superuser `UPDATE pg_index SET indisvalid=false` trick, clean skip without catalog rights), pure helper matrices, module contract.
|
||||
- **Defect found & fixed suite-wide:** psycopg3 keeps its snapshot alive while idle-in-transaction (psql does not); a session left open parks `CREATE INDEX CONCURRENTLY` phase 3 **forever** (reproduced 5×, lock graph + `pg_terminate_backend` unblock prove it). Patched the 13 pre-0028 migration-test fixtures (0004–0017, 0028) with the 0018 house `db.rollback()`-before-repair-DDL pattern and made every alembic call in the new file transaction-free.
|
||||
- `README.md` — deploy note under Production Deployment (CONCURRENTLY semantics, `maintenance_work_mem`/RAM pre-flight, INVALID self-heal, the post-build/pre-stamp `already exists` recovery). AGENTS.md: no doc impact (no command/env change).
|
||||
|
||||
**Verification (exact results)**
|
||||
- Dev DB: `alembic upgrade head → downgrade -1 → upgrade head` green; index def renders `USING hnsw … WITH (m='16', ef_construction='64')`, `indisvalid=t`; head=0029.
|
||||
- `uv run pytest tests/integration/test_chunks_hnsw_index.py` → **7 passed in 0.43s** (deterministic).
|
||||
- `uv run pytest` → **4577 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **4577 passed, TOTAL 99%** (>90%).
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- `uv run pytest tests/e2e/test_smoke.py --no-cov` → 3 passed (index live, no retrieval regression).
|
||||
|
||||
**Decisions/deviations**: kept the task-prescribed unconditional `CREATE` (post-build crash → loud `already exists` + documented manual drop; halfvec stays out per A2). Next pending task: `188_chunks_hnsw_index/02_ef_search_setting.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
......................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 318 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6932 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
**Task 02 complete — all green.**
|
||||
|
||||
**Implemented**
|
||||
- `app/config.py` — `retrieval_ef_search: int = 100` (`BOR_RETRIEVAL_EF_SEARCH`) with the fail-loud validator: `0` **and** negatives refuse startup (the clamp has no "off" state, phase-182 `show_text_max_*` family).
|
||||
- `app/rag/retriever.py` — `_vector_candidates` now opens with `SET LOCAL hnsw.ef_search = {int(ef)}` immediately before the `<=>` SELECT (module gained its ONE logger); below the A7 candidate count it CLAMPS up and logs `retrieval: ef_search=%d raised to %d (A7 candidate count)`. Setting read via `get_settings()` (the module's existing convention — no new plumbing).
|
||||
- `app/core/settings_catalog.py` — `SettingMeta("retrieval_ef_search", "Retrieval & honesty", "Vector ef_search")` beside `hybrid_vector_candidates`; `.env.example` + README knob documented.
|
||||
- Pins: 8 unit (`tests/unit/test_retriever.py` — SET-before-SELECT, clamp+warn, equal/pass-through silent, default 100, int-only statement, ≤0 refuses, default == candidate count), 2 `test_config.py`, 1 `test_settings_catalog.py` row/type pin, 3 integration in `test_chunks_hnsw_index.py` (clamped value live in the same tx; **new tx falls back to the server default** — LOCKED A4; exactly one clamp WARNING line).
|
||||
- Leak-guard pins added for the new knob in `tests/conftest.py` and `tests/e2e/conftest.py` (house phase-61/106/182 pattern).
|
||||
|
||||
**Verification (exact)**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4590 passed, TOTAL 99%** (>90%); `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors.
|
||||
- E2E each in isolation: smoke 3, chat_rag 3, retrieval_quality 4, admin_settings_panel 12, admin_settings_layout 5 — all passed. Dev DB verified at `0029` with `ix_chunks_embedding_hnsw` valid.
|
||||
|
||||
**Docs**: `README.md` (How-retrieval step 1 + env table row), `AGENTS.md` (rule 10 gains the clamp-warning + `SET LOCAL` contract), `.env.example`.
|
||||
|
||||
**Decisions/deviations**: clamp compares against the call's own `LIMIT` (per D3); no `ef_search` override param added (task 03 can monkeypatch `get_settings`); measured server default = 40, max 1000 (pgvector 0.8.0), so the integration pin reads `pg_settings.reset_val` instead of hardcoding. **Pre-existing, not introduced here**: running several `tests/e2e/` modules in ONE pytest session fails (every module spawns uvicorn on the same `APP_PORT`) — reproduced byte-identically (7 failed/8 passed) on a clean `git archive HEAD` tree; each file passes in isolation per AGENTS rule 9, so left untouched.
|
||||
|
||||
**Next pending task**: `188_chunks_hnsw_index/03_plan_and_recall_pins.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 98%]
|
||||
...................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
**Implemented** (task 03 — `.agents/phases/todo/188_chunks_hnsw_index/03_plan_and_recall_pins.md`)
|
||||
- New `tests/integration/test_chunks_hnsw_recall.py` (567 lines): fixed-seed 5,000-chunk / 768-dim corpus (40 centroids + noise inside a 16-direction principal subspace, L2-normalised), index built **only** via migration 0029 (`0029→0028→seed→0029`), teardown deletes just `source='phase-188-hnsw-recall'` and restores head (dev DB verified: 0 chunks, 0029, `indisvalid=t`).
|
||||
- `test_plan_uses_the_hnsw_index` — `EXPLAIN (FORMAT JSON)` of the tier's **captured** SELECT (statement object from `_vector_candidates`, `literal_binds`): names `ix_chunks_embedding_hnsw`, no `Seq Scan on chunks`.
|
||||
- `test_plan_without_the_index_seq_scans_the_relation` — vacuity guard via `SET LOCAL enable_indexscan/indexonlyscan = off` (documented choice over DROP/re-`CREATE CONCURRENTLY`).
|
||||
- `test_top100_recall_at_ef_search_100` — pure-Python exact cosine top-100 (float4-modelled) vs the index: per-query floor 0.99, per-row `1-distance` within 1e-6, evidence-carrying failure message. Measured: mean **0.9993**, min 0.99 (2/30 at 99/100), worst cosine delta **5.7e-07** (bounded by the tier's `round(…,6)`).
|
||||
- `test_recall_never_below_the_pinned_floor_when_ef_search_raised` — monotonicity: ef=100 → 0.9993, ef=200 → **1.0000**.
|
||||
- Module docstring records the prod numbers (414,220 chunks / ~3,000 MB / no index / pgvector 0.8.0 / 3,904 MB no swap), why 5,000 rows is a plan pin not a prod proxy (task 04 is that gate), the measured overlaps, ef values, and ~8 s setup vs the 60 s guard.
|
||||
|
||||
**Verification (exact)**
|
||||
- `uv run pytest tests/integration/test_chunks_hnsw_recall.py -v` → **4 passed** (29 s); with `test_chunks_hnsw_index.py` + `tests/unit/test_retriever.py` → 105 checks, 0 failures.
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **4594 passed, TOTAL 99%** (>90%). `uv run ruff check .` → All checks passed. `uv run pyright` → **0 errors**.
|
||||
|
||||
**Docs**: no doc impact — task 03 is CI-only; `BOR_RETRIEVAL_EF_SEARCH` already in README/.env.example from task 02; the build runbook stays task 05's. No `app/` change.
|
||||
|
||||
**Decisions / flags**
|
||||
- Corpus design: the task's "40 clusters + noise" is actively hostile to HNSW when clusters are tight (measured recall collapsed to 0.00–0.65 — disconnected layer-0 graph); the pin therefore uses a noise-dominated, low-effective-rank (16-of-768) clustered corpus, which is also how trained embeddings behave.
|
||||
- **Flag for task 04 / owner**: on effectively-iid 768-dim data the same 5,000-row / `m=16` / `ef_construction=64` graph measures recall@100 ≈ **0.63 at ef_search=100**, 0.85 at 200, 1.00 from 400. Per LOCKED D3/A3 I did **not** change the shipped default (100); if the live battery moves a `top_score`/top-5 set, the remedy is raising `BOR_RETRIEVAL_EF_SEARCH` toward 400.
|
||||
|
||||
**Next pending task**: `188_chunks_hnsw_index/04_recall_battery_gate.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 98%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
Task 04 complete. Final state of the work:
|
||||
|
||||
**Implementation** — Gate run on the live endpoint + restored 1,000-doc live replica (8,866 real embeddings, dump at `alembic_version=0011`, so migration 0029 built the graph against real vectors).
|
||||
- Battery `eval_retrieval` OFF vs ON: **byte-identical** (all `best_cosine`, all 35 hits, all 7 A8 verdicts).
|
||||
- Pinned-vector gate (probe embeds once; endpoint self-jitter measured 0.000e+00): verdicts, top-5 sets/order, suggested docs and `top_score` **identical, delta 0.000e+00**; top-100 candidate-set overlap **1.0000 on all 7**; every rank swap an exact tie; tier 38.3 → 15.3 ms; `Limit → Nested Loop → Index Scan on ix_chunks_embedding_hnsw` vs `Limit → Sort → Seq Scan`.
|
||||
- Recall@100 sweep: `ef=100` 0.9814 mean / 0.94 min (under the phase's 0.99 floor, per-chunk delta 2.195e-03) → `ef=400` **1.0000/1.0000**. **Applied LOCKED A3's only remedy: `retrieval_ef_search` default 100 → 400** (`app/config.py`, `.env.example`, `README.md`, `AGENTS.md` rule 10, and the 4 pins that encoded 100). **No honesty threshold touched.**
|
||||
- Production order (throwaway 416,702-chunk / 557 MB DB, created and dropped): build **161 s**, index **1,625 MB**, planner takes HNSW at ef 100/200/400, tier **~550 ms → 12–15 ms**.
|
||||
- **Deviation flagged:** D5's `maintenance_work_mem='512MB'` is unusable here — `/dev/shm` is 63 MB; reproduced `could not resize shared memory segment … No space left on device` at production order, which exercised 0029's INVALID-index self-heal for real. Also fixed a vacuity hole in task 03's recall pin (pgvector costs HNSW partly on `ef_search`, so the pin now refuses seq scans and asserts the index is in the plan).
|
||||
|
||||
**Tests / gates** — `uv run pytest --cov=app`: **4594 passed**, `app/` **99%** (>90%). `ruff check .`: All checks passed. `pyright`: 0 errors. `tests/e2e/test_smoke.py`: exit 0.
|
||||
|
||||
**Docs** — new `benchmarks/phase188_live_gate/` (both battery captures, compare + recall outputs, pinned vectors, production-order build/latency logs, the 512 MB failure log, README) + pointer in `benchmarks/README.md`; report at `.agents/reports/188_chunks_hnsw_index/task04_recall_battery_gate.md` (+ the probe).
|
||||
|
||||
**Deviations** — (1) the ×47 production-order corpus is valid for build/plan/latency but *not* recall (47 near-copies per real vector); recall verdict comes from the real-embedding replica, stated in both docs. (2) Phase task files untouched; no git index/history mutations — 34 paths left in the working tree. Dev DB returned to its start state (empty, 0029, index valid); scratch DBs dropped.
|
||||
|
||||
**Next** — task 05 (`prod_index_build_runbook`): carry the 161 s / 1.6 GB figures, the shm constraint, and a post-build battery re-run on the real 414k KB.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 98%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
All gates green. Final state:
|
||||
|
||||
**Implemented (task 05)**
|
||||
- `tests/e2e/test_vector_index_recall.py` (889 lines, mock-only, own app on 8205 + two ef-apps on 8206/8207 with stdout captured) — the four required tests: `test_answered_question_cites_the_same_document_indexed_or_not` (arms split by the real `0029` downgrade/upgrade, catalog state re-read inside each arm), `test_index_exists_and_is_valid_after_import`, `test_near_miss_question_still_deflects` (both arms), `test_ef_search_setting_is_honoured` (`=150` answers with no clamp line; `=50` answers identically and logs `retrieval: ef_search=50 raised to 100 (A7 candidate count)`).
|
||||
- `tests/fixtures/hnswkb/` — tracked 11-note KB imported through the real importer with the index live; sentinels measured, not guessed (answered: cosine **0.6187**, fts 10, `hnswkb/infra/tilecache.md` first; near-miss: **0.1706**, fts 0).
|
||||
- README: new **`Deploying migration 0029 (HNSW)`** runbook (pre-flight incl. `/dev/shm`, CONCURRENTLY semantics, 161 s / 1.6 GB / 3.9 KB-per-row, INVALID recovery, the `maintenance_work_mem > /dev/shm` trap, the 4 GB-box ordering pointer); **fixed a pre-existing docs bug** — the env table still said `BOR_RETRIEVAL_EF_SEARCH = 100` after task 04 shipped 400. AGENTS.md: new rule 16 (index owned only by `0029`, `SET LOCAL` only, never re-tune 400 or move a threshold, how a test may take the index off). `benchmarks/phase188_live_gate/`: task-05 parity section + captures.
|
||||
|
||||
**Gates** — `test_vector_index_recall.py` 4 passed in isolation; `test_retrieval_quality` 4, `test_search_recall_small_sources` 7, `test_chat_rag` 3, `test_smoke` 3, all green in isolation; `uv run pytest --cov=app` **4594 passed, app/ coverage 99%**; `ruff check .` clean; `pyright` **0 errors**. Retrieval battery re-verified clean-room on the live replica (throwaway DB `bor_p188gate`, restored at `0011` → `0028` OFF → `head` ON, index valid/34 MB): the two captures are **byte-identical**; DB dropped, dev DB left as found (head `0029`, index valid, KB empty).
|
||||
|
||||
**Deviations** — (1) no commit and no phase-dir move: git-mutating commands were disallowed for this run, so `00_phase.md`/task files and the commit remain untouched; (2) phase 119 LOCKED A1 gives a zero-read grounded turn **zero chips**, so test 1 pins "the same cited document" through the durable `query_log` record + the rendered "Nearby docs, in case" row + the `done` tiers (and pins `chips == 0` in both arms), stated in the module docstring; (3) one transient full-suite failure I caused by running the new E2E module concurrently with it (shared Postgres) — both pass serially, re-run clean.
|
||||
|
||||
**Next pending** — task 05's step 5 (the atomic `--no-gpg-sign` commit + move `188_chunks_hnsw_index/` to `complete/`), then `.agents/phases/todo/189_chat_save_long_turns/`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 98%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 292 1 99%
|
||||
app/api/chat_images.py 52 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 239 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 231 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 325 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/memory.py 32 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 40 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 105 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 494 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 865 3 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 162 0 100%
|
||||
app/rag/retriever.py 259 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/rag/svg_text.py 111 0 100%
|
||||
app/rag/verbatim.py 160 0 100%
|
||||
app/schemas.py 549 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6946 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,629 @@
|
||||
"""Phase 188 task 04 — the DETERMINISTIC index off/on gate probe (live KB, live endpoint).
|
||||
|
||||
Why this probe exists next to `scripts/eval_retrieval.py`
|
||||
---------------------------------------------------------
|
||||
`eval_retrieval` cannot gate this task on its own, for two reasons. It prints
|
||||
five hits per question, while the difference between the exact and the
|
||||
approximate search lives in the TAIL of the cosine tier's 100-row candidate set
|
||||
(at `ef_search = 100` on the live replica the exact and approximate top-100
|
||||
agreed on 0.9814 of the rows on average, 0.94 at worst, with a per-chunk cosine
|
||||
moved by up to 2.195e-03 while `top_score` stayed at 0.000e+00). Five printed rows
|
||||
cannot show a 100-row candidate set, and they conflate the cosine tier with the RRF
|
||||
fusion: an early off/on battery pair at `ef_search = 100` moved printed cosines by
|
||||
up to ~3e-4 (above the 1e-4 tolerance) because a substituted tail candidate shifts
|
||||
the fusion ranks and a document's several chunks let a hit keep its document while
|
||||
changing its chunk.
|
||||
|
||||
This probe embeds the battery ONCE, caches the vectors, and measures both sides from
|
||||
the cache, so the access path is the only variable and all 100 rows are visible
|
||||
separately from the fused five. It also reports the endpoint's own float jitter
|
||||
(`endpoint_self_jitter`, one question embedded twice) rather than assuming it either
|
||||
way — measured 0.000e+00 here, i.e. the endpoint is deterministic and every delta
|
||||
below is the index's doing. LOCKED A3: the tolerance is never widened; the input is
|
||||
pinned.
|
||||
|
||||
What it measures per question (all through the real code path,
|
||||
`app.rag.retriever._vector_candidates` / `retrieve`):
|
||||
|
||||
* `exact` — the cosine tier with `enable_indexscan = off` (the exact
|
||||
top-100 a seq scan produces, i.e. the pre-phase behaviour)
|
||||
* `app` — the tier exactly as a request runs it (SET LOCAL ef_search
|
||||
from Settings, planner free to use the index)
|
||||
* `ef100` / `ef200` / `ef400` — the tier pinned at each ef_search
|
||||
* latency of each of the above (median of `--reps`, own transaction)
|
||||
* the full `retrieve()` pipeline: best_cosine, fts_hits, the A8 verdict,
|
||||
the top-5 documents (the same best-per-doc order eval_retrieval prints)
|
||||
* the EXPLAIN node types of the tier statement (anti-vacuity: `exact`
|
||||
MUST seq-scan, `app` MUST name ix_chunks_embedding_hnsw when it exists)
|
||||
|
||||
Gate for `compare` (LOCKED A3 — nothing here is ever relaxed to pass):
|
||||
|
||||
* identical A8 verdicts,
|
||||
* identical top-5 document lists and identical suggested documents,
|
||||
* identical `top_score` / `best_cosine` within 1e-4,
|
||||
* identical scores within 1e-4 for every rank inside the fused window
|
||||
(`hybrid_top_k = 5`) — the ranks that reach RRF and the deflection gate,
|
||||
* the cosine tier's top-100 candidate SET overlap at or above 0.99.
|
||||
|
||||
Tail ranks are gated as a SET overlap rather than a score delta: an HNSW search
|
||||
may hand back a different tail chunk at a near-identical cosine, which changes
|
||||
which tail chunk the fusion could have raised without moving anything the turn
|
||||
shows the model. That is exactly the difference this gate is allowed to see, and
|
||||
the reason `eval_retrieval` alone cannot answer the task.
|
||||
|
||||
Usage (repo root, live KB restored, the app's LLM key reachable —
|
||||
`BOR_LLM_API_KEY` in `.env`, or `$AIPI_KEY` as the fallback app/config.py reads)
|
||||
-----------------------------------------------------------------
|
||||
PYTHONPATH=. uv run python \
|
||||
.agents/reports/188_chunks_hnsw_index/task04_recall_ab_probe.py embed \
|
||||
--out /tmp/gate_vectors.json
|
||||
# index OFF (alembic downgrade -1)
|
||||
PYTHONPATH=. uv run python .../task04_recall_ab_probe.py measure \
|
||||
--vectors /tmp/gate_vectors.json --out /tmp/gate_off.json --reps 5
|
||||
# index ON (alembic upgrade head)
|
||||
PYTHONPATH=. uv run python .../task04_recall_ab_probe.py measure \
|
||||
--vectors /tmp/gate_vectors.json --out /tmp/gate_on.json --reps 5
|
||||
PYTHONPATH=. uv run python .../task04_recall_ab_probe.py compare \
|
||||
--a /tmp/gate_off.json --b /tmp/gate_on.json
|
||||
# recall@100 of the approximate index vs the exact top-100
|
||||
PYTHONPATH=. uv run python .../task04_recall_ab_probe.py recall \
|
||||
--path /tmp/gate_on.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BATTERY = Path("tests/fixtures/retrieval_battery.txt")
|
||||
TOP_K = 100 # A7's cosine candidate count (the clamp floor)
|
||||
EF_GRID = (100, 200, 400)
|
||||
INDEX_NAME = "ix_chunks_embedding_hnsw"
|
||||
CANDIDATE_SET_FLOOR = 0.99 # the phase's own recall floor (LOCKED A3)
|
||||
TURN_TOP_K = 5 # app/config.py hybrid_top_k — what the turn actually fuses
|
||||
|
||||
|
||||
def questions() -> list[str]:
|
||||
lines = BATTERY.read_text(encoding="utf-8").splitlines()
|
||||
return [ln.strip() for ln in lines if ln.strip() and not ln.lstrip().startswith("#")]
|
||||
|
||||
|
||||
def cmd_embed(args: argparse.Namespace) -> int:
|
||||
from app.config import get_settings
|
||||
from app.db import db_available
|
||||
from app.rag.llm import LLMClient
|
||||
|
||||
if not db_available():
|
||||
print("probe: Postgres is down", file=sys.stderr)
|
||||
return 1
|
||||
qs = questions()
|
||||
vectors, jitter = asyncio.run(_embed_all(LLMClient(get_settings()), qs))
|
||||
out = {"questions": qs, "vectors": vectors, "endpoint_self_jitter": jitter}
|
||||
Path(args.out).write_text(json.dumps(out))
|
||||
print(f"probe: embedded {len(qs)} questions -> {args.out} (endpoint self-jitter {jitter:.3e})")
|
||||
return 0
|
||||
|
||||
|
||||
async def _embed_all(llm: Any, qs: list[str]) -> tuple[list[list[float]], float]:
|
||||
"""One loop for every embedding the probe needs (the SDK client is
|
||||
loop-bound: a second asyncio.run with the same client raises
|
||||
``Event loop is closed``). Also embeds the first question twice more to
|
||||
measure the endpoint's OWN run-to-run float jitter — the number that
|
||||
explains why the plain off/on battery diff moves at all."""
|
||||
vectors = await llm.embed(qs)
|
||||
first = (await llm.embed([qs[0]]))[0]
|
||||
again = (await llm.embed([qs[0]]))[0]
|
||||
jitter = max(abs(a - b) for a, b in zip(first, again, strict=True))
|
||||
await llm._client.close() # pyright: ignore[reportPrivateUsage]
|
||||
return vectors, jitter
|
||||
|
||||
|
||||
def _tier_statement(vector: list[float]) -> Any:
|
||||
"""The tier's own SELECT (statement object captured from the code)."""
|
||||
from app.rag.retriever import _vector_candidates
|
||||
|
||||
class Rec:
|
||||
def __init__(self) -> None:
|
||||
self.statements: list[Any] = []
|
||||
|
||||
def execute(self, statement: Any, params: Any = None) -> Rec:
|
||||
self.statements.append(statement)
|
||||
return self
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
rec = Rec()
|
||||
_vector_candidates(rec, vector, TOP_K) # pyright: ignore[reportArgumentType]
|
||||
return rec.statements[1].compile(
|
||||
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
|
||||
)
|
||||
|
||||
|
||||
def _plan_nodes(session: Session, sql: Any) -> list[str]:
|
||||
raw = session.execute(text(f"EXPLAIN (FORMAT JSON) {sql}")).scalar()
|
||||
doc = json.loads(raw) if isinstance(raw, str) else raw
|
||||
|
||||
def walk(plan: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
||||
yield plan
|
||||
for child in plan.get("Plans") or []:
|
||||
yield from walk(child)
|
||||
|
||||
nodes = []
|
||||
for p in walk(doc[0]["Plan"]):
|
||||
name = p.get("Index Name") or p.get("Relationship Name") or ""
|
||||
nodes.append(f"{p['Node Type']}" + (f" on {name}" if name else ""))
|
||||
return nodes
|
||||
|
||||
|
||||
def _pipeline(
|
||||
session: Session, question: str, vector: list[float], force_index: bool
|
||||
) -> dict[str, Any]:
|
||||
"""The whole turn's retrieval, in ONE transaction, optionally with the
|
||||
planner forced onto the HNSW index (``enable_seqscan = off``).
|
||||
|
||||
Why the force: at ``ef_search = 100`` pgvector costs the approximate scan
|
||||
ABOVE a sequential scan of the 8,866-chunk / 13 MB replica, so the planner
|
||||
picks the seq scan and an "index ON" measurement would silently measure the
|
||||
exact scan twice (a vacuous gate). Forcing the index measures the path
|
||||
production (414,220 chunks, ~3,000 MB) actually takes. The force is
|
||||
session-local (``SET LOCAL``) and rolls back with the transaction.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
from app.rag.retriever import retrieve, select_suggested
|
||||
|
||||
settings = get_settings()
|
||||
session.rollback()
|
||||
session.execute(text("begin"))
|
||||
if force_index:
|
||||
session.execute(text("SET LOCAL enable_seqscan = off"))
|
||||
# the plan a turn actually gets: the tier's own SET LOCAL, in the SAME
|
||||
# transaction it runs in (EXPLAIN with the server's ef_search=40 would
|
||||
# report a plan no request ever uses)
|
||||
session.execute(text(f"SET LOCAL hnsw.ef_search = {int(settings.retrieval_ef_search)}"))
|
||||
chunks = retrieve(session, question, vector)
|
||||
best_cosine = max((c.cosine for c in chunks), default=0.0)
|
||||
fts_hits = sum(1 for c in chunks if c.fts_hit)
|
||||
best_by_doc: dict[str, Any] = {}
|
||||
for c in chunks:
|
||||
best_by_doc.setdefault(f"{c.document.source}/{c.document.path}", c)
|
||||
out = {
|
||||
"best_cosine": best_cosine,
|
||||
"fts_hits": fts_hits,
|
||||
"verdict": (
|
||||
"LOW (deflect)"
|
||||
if best_cosine < settings.relevance_threshold and fts_hits == 0
|
||||
else "HIGH (answer)"
|
||||
),
|
||||
"top5": list(best_by_doc)[:5],
|
||||
"suggested": [f"{d.source}/{d.path}" for d in select_suggested(chunks)],
|
||||
"plan": _plan_nodes(session, _tier_statement(vector)),
|
||||
}
|
||||
session.rollback()
|
||||
return out
|
||||
|
||||
|
||||
def _timed_tier(
|
||||
session: Session,
|
||||
vector: list[float],
|
||||
setup: str | None,
|
||||
reps: int,
|
||||
ef_override: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Median/min/max milliseconds of the cosine tier, one transaction per rep.
|
||||
|
||||
``ef_override`` goes through the REAL knob (``BOR_RETRIEVAL_EF_SEARCH`` +
|
||||
the ``get_settings`` cache), never a hand-written ``SET LOCAL``: the tier
|
||||
issues its own ``SET LOCAL hnsw.ef_search`` from Settings, so a probe-side
|
||||
``SET`` would simply be overwritten by the line under test.
|
||||
"""
|
||||
import os
|
||||
|
||||
from app.config import get_settings
|
||||
from app.rag.retriever import _vector_candidates
|
||||
|
||||
previous_env = os.environ.get("BOR_RETRIEVAL_EF_SEARCH")
|
||||
if ef_override is not None:
|
||||
os.environ["BOR_RETRIEVAL_EF_SEARCH"] = str(ef_override)
|
||||
get_settings.cache_clear()
|
||||
timings: list[float] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for i in range(reps):
|
||||
session.rollback() # end any implicit transaction -> SET LOCAL is fresh
|
||||
if setup:
|
||||
session.execute(text("begin"))
|
||||
session.execute(text(setup))
|
||||
t0 = time.perf_counter()
|
||||
got = _vector_candidates(session, vector, TOP_K)
|
||||
timings.append((time.perf_counter() - t0) * 1000.0)
|
||||
if i == 0:
|
||||
rows = [
|
||||
{
|
||||
"rank": rank,
|
||||
"chunk_id": str(c.chunk_id),
|
||||
"cosine": c.cosine,
|
||||
"doc": f"{c.document.source}/{c.document.path}",
|
||||
}
|
||||
for rank, c in enumerate(got, start=1)
|
||||
]
|
||||
session.rollback()
|
||||
finally:
|
||||
if ef_override is not None:
|
||||
if previous_env is None:
|
||||
os.environ.pop("BOR_RETRIEVAL_EF_SEARCH", None)
|
||||
else:
|
||||
os.environ["BOR_RETRIEVAL_EF_SEARCH"] = previous_env
|
||||
get_settings.cache_clear()
|
||||
return {
|
||||
"ms_median": round(statistics.median(timings), 3),
|
||||
"ms_min": round(min(timings), 3),
|
||||
"ms_max": round(max(timings), 3),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def cmd_measure(args: argparse.Namespace) -> int:
|
||||
from app.config import get_settings
|
||||
from app.db import SessionLocal, db_available
|
||||
|
||||
if not db_available():
|
||||
print("probe: Postgres is down", file=sys.stderr)
|
||||
return 1
|
||||
payload = json.loads(Path(args.vectors).read_text())
|
||||
qs: list[str] = payload["questions"]
|
||||
vectors: list[list[float]] = payload["vectors"]
|
||||
settings = get_settings()
|
||||
with SessionLocal() as db:
|
||||
index_present = db.execute(
|
||||
text("select count(*) from pg_indexes where indexname = :n"), {"n": INDEX_NAME}
|
||||
).scalar()
|
||||
ef_setting = settings.retrieval_ef_search
|
||||
per_question: list[dict[str, Any]] = []
|
||||
for q, vec in zip(qs, vectors, strict=True):
|
||||
record: dict[str, Any] = {"question": q}
|
||||
record["exact"] = _timed_tier(
|
||||
db,
|
||||
vec,
|
||||
"SET LOCAL enable_indexscan = off",
|
||||
args.reps,
|
||||
)
|
||||
record["app"] = _timed_tier(db, vec, None, args.reps)
|
||||
# the APPROXIMATE path, forced (the planner declines it at this
|
||||
# corpus size — see _pipeline's docstring), at each ef_search
|
||||
for ef in EF_GRID:
|
||||
record[f"forced{ef}"] = _timed_tier(
|
||||
db,
|
||||
vec,
|
||||
"SET LOCAL enable_seqscan = off",
|
||||
args.reps,
|
||||
ef_override=ef,
|
||||
)
|
||||
# anti-vacuity plans, same session, same vector
|
||||
sql = _tier_statement(vec)
|
||||
db.execute(text("begin"))
|
||||
db.execute(text("SET LOCAL enable_indexscan = off"))
|
||||
record["plan_exact"] = _plan_nodes(db, sql)
|
||||
db.rollback()
|
||||
db.execute(text("begin"))
|
||||
record["plan_app"] = _plan_nodes(db, sql)
|
||||
db.rollback()
|
||||
# the pipeline a turn actually runs, with the A8 gate: once as the
|
||||
# planner chooses, once forced onto the approximate index
|
||||
record["pipeline"] = _pipeline(db, q, vec, force_index=False)
|
||||
record["pipeline_forced"] = _pipeline(db, q, vec, force_index=True)
|
||||
per_question.append(record)
|
||||
print(
|
||||
f" {q!r}: verdict={record['pipeline']['verdict']} "
|
||||
f"exact={record['exact']['ms_median']}ms app={record['app']['ms_median']}ms "
|
||||
f"plan_app={record['plan_app'][1] if len(record['plan_app']) > 1 else '?'}"
|
||||
)
|
||||
Path(args.out).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"index_present": int(index_present or 0),
|
||||
"ef_search_setting": ef_setting,
|
||||
"reps": args.reps,
|
||||
"vector_candidates": settings.hybrid_vector_candidates,
|
||||
"questions": qs,
|
||||
"records": per_question,
|
||||
}
|
||||
)
|
||||
)
|
||||
print(
|
||||
f"probe: measured {len(per_question)} questions -> {args.out} "
|
||||
f"(index_present={index_present})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _docs(rec: dict[str, Any]) -> list[str]:
|
||||
return [r["doc"] for r in rec["rows"]]
|
||||
|
||||
|
||||
def cmd_compare(args: argparse.Namespace) -> int:
|
||||
a = json.loads(Path(args.a).read_text())
|
||||
b = json.loads(Path(args.b).read_text())
|
||||
print(f"A={args.a} index_present={a['index_present']} ef={a['ef_search_setting']}")
|
||||
print(f"B={args.b} index_present={b['index_present']} ef={b['ef_search_setting']}")
|
||||
failures: list[str] = []
|
||||
max_score_delta = 0.0
|
||||
max_tier_delta = 0.0 # every rank of the tier, reported
|
||||
max_window_delta = 0.0 # ranks <= hybrid_top_k, gated
|
||||
for ra, rb in zip(a["records"], b["records"], strict=True):
|
||||
assert ra["question"] == rb["question"]
|
||||
q = ra["question"]
|
||||
if ra["pipeline"]["verdict"] != rb["pipeline"]["verdict"]:
|
||||
failures.append(
|
||||
f"{q!r}: verdict {ra['pipeline']['verdict']} != "
|
||||
f"{rb['pipeline']['verdict']}"
|
||||
)
|
||||
if ra["pipeline"]["top5"] != rb["pipeline"]["top5"]:
|
||||
failures.append(
|
||||
f"{q!r}: top5 changed\n A={ra['pipeline']['top5']}\n"
|
||||
f" B={rb['pipeline']['top5']}"
|
||||
)
|
||||
if set(ra["pipeline"]["top5"]) != set(rb["pipeline"]["top5"]):
|
||||
failures.append(f"{q!r}: top5 SET changed")
|
||||
if ra["pipeline"]["suggested"] != rb["pipeline"]["suggested"]:
|
||||
failures.append(f"{q!r}: suggested list changed")
|
||||
delta = abs(ra["pipeline"]["best_cosine"] - rb["pipeline"]["best_cosine"])
|
||||
max_score_delta = max(max_score_delta, delta)
|
||||
if delta > 1e-4:
|
||||
failures.append(
|
||||
f"{q!r}: top_score delta {delta:.9f} > 1e-4 "
|
||||
f"(A={ra['pipeline']['best_cosine']:.9f} B={rb['pipeline']['best_cosine']:.9f})"
|
||||
)
|
||||
# The candidate SET (the A7 tier's promise to the fusion) and the
|
||||
# SCORES. Order is NOT the gate: `ORDER BY distance LIMIT 100` breaks
|
||||
# ties by access path, so an index scan and a top-N sort legitimately
|
||||
# return the same rows in a different order whenever two chunks share a
|
||||
# cosine. What must not move is which chunks are in the list, their
|
||||
# scores, and anything the turn shows the model.
|
||||
da, dbb = ra["app"], rb["app"]
|
||||
ids_a = {r["chunk_id"] for r in da["rows"]}
|
||||
ids_b = {r["chunk_id"] for r in dbb["rows"]}
|
||||
overlap = len(ids_a & ids_b) / max(1, len(ids_a))
|
||||
if overlap < CANDIDATE_SET_FLOOR:
|
||||
failures.append(
|
||||
f"{q!r}: the cosine tier's top-{TOP_K} candidate set overlap is "
|
||||
f"{overlap:.4f}, below the {CANDIDATE_SET_FLOOR} floor "
|
||||
f"(A-only={sorted(ids_a - ids_b)[:3]}…, B-only={sorted(ids_b - ids_a)[:3]}…)"
|
||||
)
|
||||
for row_a, row_b in zip(da["rows"], dbb["rows"], strict=False):
|
||||
d = abs(row_a["cosine"] - row_b["cosine"])
|
||||
max_tier_delta = max(max_tier_delta, d)
|
||||
# The 1e-4 tolerance binds where the turn actually reads: the
|
||||
# fused window is TOP_K=5 (app/config.py), and ranks 1-5 are the
|
||||
# ones whose scores reach RRF and the deflection gate. Ranks below
|
||||
# that only ever change WHICH tail chunk is in the list, and that
|
||||
# is measured by the overlap floor above, not by a score delta.
|
||||
if row_a["rank"] <= TURN_TOP_K:
|
||||
max_window_delta = max(max_window_delta, d)
|
||||
if row_a["rank"] <= TURN_TOP_K and d > 1e-4:
|
||||
failures.append(
|
||||
f"{q!r}: rank-{row_a['rank']} cosine delta {d:.9f} > 1e-4 "
|
||||
f"(A={row_a['cosine']:.9f} B={row_b['cosine']:.9f})"
|
||||
)
|
||||
tie_swaps = sum(
|
||||
1
|
||||
for a, b in zip(da["rows"], dbb["rows"], strict=False)
|
||||
if a["chunk_id"] != b["chunk_id"]
|
||||
)
|
||||
exact_ties = all(
|
||||
abs(a["cosine"] - b["cosine"]) == 0.0
|
||||
for a, b in zip(da["rows"], dbb["rows"], strict=False)
|
||||
if a["chunk_id"] != b["chunk_id"]
|
||||
)
|
||||
same_top5 = "SAME" if ra["pipeline"]["top5"] == rb["pipeline"]["top5"] else "DIFFERENT"
|
||||
same_sugg = (
|
||||
"SAME" if ra["pipeline"]["suggested"] == rb["pipeline"]["suggested"] else "DIFFERENT"
|
||||
)
|
||||
print(
|
||||
f" {q!r}: verdict {ra['pipeline']['verdict']} / "
|
||||
f"{rb['pipeline']['verdict']} "
|
||||
f"| top5 {same_top5} | suggested {same_sugg} "
|
||||
f"| candidate-set overlap {overlap:.4f} "
|
||||
f"| best_cosine delta {delta:.3e} "
|
||||
f"| rank swaps {tie_swaps}"
|
||||
f"{' (all exact ties)' if exact_ties else ' <-- NOT ties'}"
|
||||
)
|
||||
print(f"\nmax top_score (best_cosine) delta = {max_score_delta:.3e} (gate <= 1e-4)")
|
||||
print(
|
||||
f"max in-window (rank <= {TURN_TOP_K}) tier cosine delta = "
|
||||
f"{max_window_delta:.3e} (gate <= 1e-4)"
|
||||
)
|
||||
print(
|
||||
f"max tier cosine delta over all {TOP_K} ranks = {max_tier_delta:.3e} (reported only)"
|
||||
)
|
||||
print(
|
||||
f"tail note: ranks below the fused window are gated as the candidate-SET "
|
||||
f"overlap (>= {CANDIDATE_SET_FLOOR}), not as a score delta: a graph search may "
|
||||
"hand back a different tail chunk at a near-identical score without moving "
|
||||
"anything the turn reads."
|
||||
)
|
||||
for label, side in (("A", a), ("B", b)):
|
||||
r0 = side["records"][0]
|
||||
print(f"{label} plan(exact)={r0['plan_exact'][:2]} plan(app)={r0['plan_app'][:2]}")
|
||||
print(
|
||||
f"tier latency ms (median over questions): A exact={_med(a, 'exact')} "
|
||||
f"app={_med(a, 'app')} | "
|
||||
f"B exact={_med(b, 'exact')} app={_med(b, 'app')}"
|
||||
)
|
||||
if failures:
|
||||
print(f"\nGATE FAIL ({len(failures)} differences):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
print("\nGATE PASS: identical verdicts, identical top-5 sets and order, deltas <= 1e-4")
|
||||
return 0
|
||||
|
||||
|
||||
def _med(side: dict[str, Any], key: str) -> float:
|
||||
return round(statistics.median([r[key]["ms_median"] for r in side["records"]]), 3)
|
||||
|
||||
|
||||
def cmd_recall(args: argparse.Namespace) -> int:
|
||||
"""THE APPROXIMATE-PATH GATE, run on the index-ON side.
|
||||
|
||||
Three things per question, all on the SAME pinned vector:
|
||||
|
||||
* recall@100 of the forced HNSW scan against the exact top-100
|
||||
* the per-chunk cosine delta at equal rank, and the best-cosine
|
||||
(``top_score``) delta
|
||||
* the A8 verdict, the top-5 documents and the suggested list of the
|
||||
forced-index pipeline against the planner's own pipeline
|
||||
|
||||
A vacuity guard is part of the gate: the forced plan MUST name
|
||||
``ix_chunks_embedding_hnsw`` and the exact plan MUST be a Sort over a seq
|
||||
scan, otherwise the two sides measured the same thing twice.
|
||||
"""
|
||||
side = json.loads(Path(args.path).read_text())
|
||||
if not side["index_present"]:
|
||||
print("recall: the index is not present — run this on the index-ON side", file=sys.stderr)
|
||||
return 2
|
||||
failures: list[str] = []
|
||||
out: dict[str, Any] = {}
|
||||
for ef in EF_GRID:
|
||||
key = f"forced{ef}"
|
||||
overlaps: list[float] = []
|
||||
deltas: list[float] = []
|
||||
best_deltas: list[float] = []
|
||||
for r in side["records"]:
|
||||
exact = [x["chunk_id"] for x in r["exact"]["rows"]]
|
||||
got = [x["chunk_id"] for x in r[key]["rows"]]
|
||||
overlaps.append(len(set(exact) & set(got)) / max(1, len(exact)))
|
||||
deltas.append(
|
||||
max(
|
||||
(
|
||||
abs(a["cosine"] - b["cosine"])
|
||||
for a, b in zip(r["exact"]["rows"], r[key]["rows"], strict=False)
|
||||
),
|
||||
default=0.0,
|
||||
)
|
||||
)
|
||||
best_deltas.append(
|
||||
abs(
|
||||
max(x["cosine"] for x in r["exact"]["rows"])
|
||||
- max(x["cosine"] for x in r[key]["rows"])
|
||||
)
|
||||
)
|
||||
out[key] = {
|
||||
"mean_overlap": round(statistics.mean(overlaps), 6),
|
||||
"min_overlap": round(min(overlaps), 6),
|
||||
"max_cosine_delta": round(max(deltas), 9),
|
||||
"max_top_score_delta": round(max(best_deltas), 9),
|
||||
"ms_median": round(
|
||||
statistics.median([r[key]["ms_median"] for r in side["records"]]), 3
|
||||
),
|
||||
}
|
||||
print(
|
||||
f" {key}: mean recall@100={out[key]['mean_overlap']:.4f} "
|
||||
f"min={out[key]['min_overlap']:.4f} "
|
||||
f"max per-chunk cosine delta={out[key]['max_cosine_delta']:.3e} "
|
||||
f"max top_score delta={out[key]['max_top_score_delta']:.3e} "
|
||||
f"latency={out[key]['ms_median']}ms"
|
||||
)
|
||||
if ef == side["ef_search_setting"] and out[key]["min_overlap"] < 1.0:
|
||||
print(
|
||||
f" note: at the SHIPPED ef_search={ef} the approximate top-100 differs from the "
|
||||
f"exact one in "
|
||||
f"{round((1 - out[key]['min_overlap']) * 100, 2)}% of the worst question"
|
||||
)
|
||||
print("\n pipeline parity (A8 verdict / top-5 / suggested), planner vs forced index:")
|
||||
for r in side["records"]:
|
||||
a, b = r["pipeline"], r["pipeline_forced"]
|
||||
same_v = a["verdict"] == b["verdict"]
|
||||
same_5 = a["top5"] == b["top5"]
|
||||
same_s = a["suggested"] == b["suggested"]
|
||||
d = abs(a["best_cosine"] - b["best_cosine"])
|
||||
print(
|
||||
f" {r['question']!r}: verdict {'SAME' if same_v else 'DIFFERENT'} "
|
||||
f"({a['verdict']} / {b['verdict']}) | top5 {'SAME' if same_5 else 'DIFFERENT'} "
|
||||
f"| suggested {'SAME' if same_s else 'DIFFERENT'} | top_score delta {d:.3e}"
|
||||
)
|
||||
if not same_v:
|
||||
failures.append(f"{r['question']!r}: verdict moves with the index")
|
||||
if not same_5:
|
||||
failures.append(
|
||||
f"{r['question']!r}: top-5 changes with the index\n"
|
||||
f" planner ={a['top5']}\n index ={b['top5']}"
|
||||
)
|
||||
if d > 1e-4:
|
||||
failures.append(
|
||||
f"{r['question']!r}: top_score delta {d:.3e} > 1e-4 on the forced index"
|
||||
)
|
||||
print("\n plans (vacuity guards):")
|
||||
r0 = side["records"][0]
|
||||
print(f" exact: {r0['plan_exact'][:3]}")
|
||||
print(f" forced index (pipeline): {r0['pipeline_forced']['plan'][:3]}")
|
||||
if not any("ix_chunks_embedding_hnsw" in n for n in r0["pipeline_forced"]["plan"]):
|
||||
failures.append(
|
||||
"the forced pipeline did NOT use ix_chunks_embedding_hnsw — "
|
||||
"the gate measured nothing"
|
||||
)
|
||||
if not any("Seq Scan" in n for n in r0["plan_exact"]):
|
||||
failures.append("the exact side was not a seq scan — the gate measured the same plan twice")
|
||||
print(
|
||||
f"\n latency ms over questions: exact(seq)={_med(side, 'exact')} "
|
||||
f"planner={_med(side, 'app')} "
|
||||
+ " ".join(f"forced{ef}={_med(side, f'forced{ef}')}" for ef in EF_GRID)
|
||||
)
|
||||
if failures:
|
||||
print(f"\nGATE FAIL ({len(failures)}):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
print("\nGATE PASS: the approximate path returns the exact verdicts, top-5s and top_scores")
|
||||
Path(args.path).with_suffix(".recall.json").write_text(json.dumps(out, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="task04_recall_ab_probe")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
e = sub.add_parser("embed")
|
||||
e.add_argument("--out", required=True)
|
||||
m = sub.add_parser("measure")
|
||||
m.add_argument("--vectors", required=True)
|
||||
m.add_argument("--out", required=True)
|
||||
m.add_argument("--reps", type=int, default=5)
|
||||
c = sub.add_parser("compare")
|
||||
c.add_argument("--a", required=True)
|
||||
c.add_argument("--b", required=True)
|
||||
r = sub.add_parser("recall")
|
||||
r.add_argument("--path", required=True)
|
||||
r = sub.add_parser("gate")
|
||||
r.add_argument("--path", required=True)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.cmd == "embed":
|
||||
return cmd_embed(args)
|
||||
if args.cmd == "measure":
|
||||
return cmd_measure(args)
|
||||
if args.cmd == "compare":
|
||||
return cmd_compare(args)
|
||||
if args.cmd == "gate":
|
||||
return cmd_recall(args)
|
||||
return cmd_recall(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,227 @@
|
||||
# Phase 188 · Task 04 — Recall + deflection gate on the live retrieval battery
|
||||
|
||||
**Status: complete.** The index changes nothing the turn shows the model, at every
|
||||
`ef_search` measured. One knob moved as the task's remedy: `retrieval_ef_search`
|
||||
ships at **400** instead of the candidate count. No honesty threshold moved.
|
||||
|
||||
## Where the measurement ran (prerequisites, checked)
|
||||
|
||||
Live `brain-of-reese_db_1` (**PostgreSQL 17.11**, Debian image, host port 5432) and the
|
||||
**live embedding endpoint** — model `embed`, 768 dims, the key from
|
||||
`BOR_LLM_API_KEY` in the local `.env` (never printed, never committed). The battery
|
||||
and the probe both go through `app.rag.embeddings`, so the question vectors are the
|
||||
same ones a real turn would compute.
|
||||
|
||||
| Prerequisite | Measured |
|
||||
| --- | --- |
|
||||
| `SELECT extversion FROM pg_extension WHERE extname='vector'` | **0.8.0** (≥ 0.5.0 required) — the running server matches this checkout's pin |
|
||||
| `alembic current` before the task | head `0028`, `ix_chunks_embedding_hnsw` absent |
|
||||
| `maintenance_work_mem` | 64 MB (the container default; nobody ever applied D5's 512 MB) |
|
||||
| `/dev/shm` inside `brain-of-reese_db_1` | **63 MB** |
|
||||
| Planner | `seq_page_cost=1`, `random_page_cost=4` (default) |
|
||||
| Disk on the Postgres data volume | **2.2 TB free of 3.7 TB** (41 % used) |
|
||||
| Production relation, from the 2026-10-15 incident record (not measured here) | 414,220 chunks, heap **3,000 MB** |
|
||||
| Restored gate corpus | **1,000** documents, **8,866** chunks, 8,866 embeddings, heap **13 MB** (95 MB plain-SQL dump) |
|
||||
|
||||
The replica came from `data/bor_live_kb_replica.dump.sql` (plain-SQL `pg_dump`,
|
||||
schema + data + its 10 own indexes, `CREATE EXTENSION IF NOT EXISTS vector`) via
|
||||
the `restore-test-db` procedure, into a freshly dropped `public` schema, with a
|
||||
disposable pre-restore backup taken first.
|
||||
|
||||
**The one prerequisite that decides what this task can measure:** the dump carries
|
||||
`alembic_version = 0011`, so the restored corpus lands on revision 0011 and
|
||||
`alembic upgrade head` replays **0012…0029 into it** — which is exactly why the
|
||||
gate is trustworthy on the shape of the real migration: 0029's
|
||||
`CREATE INDEX CONCURRENTLY` ran against 8,866 **real** embeddings (seconds, valid
|
||||
on the first attempt) rather than against an empty table. Restoring into a schema
|
||||
already at head and skipping the upgrade would have built nothing and measured
|
||||
nothing. The dev database was returned to the exact state it was in when the task
|
||||
started (empty, at head `0029`, index valid) before the suite ran.
|
||||
|
||||
## Step 1–2 — the battery, index OFF vs ON
|
||||
|
||||
`uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt`
|
||||
with the index dropped (`alembic downgrade -1`) and with it present
|
||||
(`alembic upgrade head`). Captures:
|
||||
[`battery_index_off.txt`](../../../benchmarks/phase188_live_gate/battery_index_off.txt),
|
||||
[`battery_index_on.txt`](../../../benchmarks/phase188_live_gate/battery_index_on.txt).
|
||||
|
||||
**The two captures are byte-identical** (7,193 bytes each, `diff` empty): every
|
||||
`best_cosine`, every `fts_hits`, all 35 numbered hits, all 7 A8 verdicts. Battery
|
||||
wall time 1.35 s with the index, 1.49 s without.
|
||||
|
||||
**Why the battery alone cannot gate this, recorded because it nearly mis-measured
|
||||
it.** Two reasons, both measured rather than assumed:
|
||||
|
||||
* It prints **five** hits per question, while the difference between an exact and an
|
||||
approximate search lives in the **tail** of the tier's 100-row candidate set. At
|
||||
`ef_search = 100` the exact and approximate top-100 agreed on 0.9814 of rows on
|
||||
average (0.94 worst) with a per-chunk cosine moved by up to 2.195e-03, while
|
||||
`top_score` — the rank-1 number the honesty gate reads — stayed at 0.000e+00. The
|
||||
battery prints a perfect match while the tier has changed its hand.
|
||||
* At the task-02 default its OFF and ON captures disagreed by up to **~3e-4** on
|
||||
printed cosines — above the 1e-4 tolerance — and the cause needed isolating before
|
||||
anything could be concluded. The probe's own measurement of the endpoint
|
||||
(`endpoint_self_jitter`, one question embedded twice) is **0.000e+00**: the endpoint
|
||||
is deterministic, so that 3e-4 was **the index**. Which is what the pinned tier
|
||||
then explained — at `ef_search = 100` a substituted tail candidate changes the RRF
|
||||
ranks, and because a document contributes several chunks, a printed hit can keep
|
||||
its document and change its chunk, moving the printed cosine while the top-5 list
|
||||
and `top_score` stay identical. At the shipped 400 the two captures are
|
||||
byte-identical: the recall result (1.0000) showing up in the report a human reads.
|
||||
|
||||
Pinning is what makes that separation possible — with the vectors fixed, the tier can
|
||||
be read on its own across all 100 rows instead of 5, and a delta can only mean the
|
||||
access path.
|
||||
|
||||
So the gate ran with the input pinned — `embed` once, cache, `measure` both sides
|
||||
from the cache ([`question_vectors.json`](../../../benchmarks/phase188_live_gate/question_vectors.json))
|
||||
— which is also what LOCKED A3 demands: never widen the tolerance, remove the noise
|
||||
instead.
|
||||
|
||||
## Steps 3–4 — the deterministic gate (pinned vectors, same code path)
|
||||
|
||||
[`deterministic_gate_compare.txt`](../../../benchmarks/phase188_live_gate/deterministic_gate_compare.txt)
|
||||
— A = index dropped (the pre-phase `Limit → Sort → Hash Join → Seq Scan on chunks`),
|
||||
B = index present at the shipped `ef_search`, identical question vectors:
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| A8 verdicts, 7/7 | **identical** |
|
||||
| Top-5 document sets **and order**, 7/7 | **identical** |
|
||||
| Suggested documents, 7/7 | **identical** |
|
||||
| `top_score` (`best_cosine`) delta | **0.000e+00** (≤ 1e-4) |
|
||||
| Tier score delta, ranks 1–5 (`hybrid_top_k`, the fused window) | **0.000e+00** (≤ 1e-4) |
|
||||
| Cosine-tier top-100 **candidate-set overlap** | **1.0000 on all 7** (floor 0.99) |
|
||||
| Rank swaps that are not exact cosine ties | **0** |
|
||||
| Tier latency, median over questions | **38.3 ms → 15.3 ms** |
|
||||
|
||||
Ranks below the fused window are gated as a candidate **set** overlap rather than a
|
||||
score delta, and that distinction earned its place: an HNSW search can hand back a
|
||||
different tail chunk at a near-identical cosine, which moves which tail chunk the
|
||||
fusion could have raised and nothing else. Every swap observed in the shipped
|
||||
configuration was an exact cosine tie broken by the access path.
|
||||
|
||||
[`deterministic_gate_recall.txt`](../../../benchmarks/phase188_live_gate/deterministic_gate_recall.txt)
|
||||
— the tier forced onto the index at each depth, recall@100 against the exact scan,
|
||||
**real embeddings**, two independent graph builds (the index is dropped and rebuilt
|
||||
between the OFF and ON runs):
|
||||
|
||||
| `ef_search` | mean recall@100 | min | max per-chunk cosine delta | max `top_score` delta | tier latency |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 100 — task 02's default, `== LIMIT` | 0.9814 / 0.9843 | 0.94 | 2.195e-03 | **0.000e+00** | 13.8 ms |
|
||||
| 200 | 1.0000 / 0.9971 | 1.00 / 0.99 | 0.0 | 0.0 | 14.1 ms |
|
||||
| 400 — **shipped** | **1.0000 / 1.0000** | **1.00** | **0.0** | **0.0** | 14.6 ms |
|
||||
|
||||
## Step 5 — the remedy, and what it did not touch
|
||||
|
||||
Nothing user-visible moved at `ef_search = 100`, so the task's parity gate passed at
|
||||
the task-02 default. What moved was the 100-row candidate set the cosine tier hands
|
||||
the RRF fusion — 0.9814 mean / 0.94 minimum — below this phase's own acceptance
|
||||
floor and matching the counter-case task 03 recorded for high-effective-rank data
|
||||
(one substitution at rank ~89 of 100 reorders 22 tail ranks). **LOCKED A3 names one
|
||||
remedy: raise `ef_search` (towards 400).** Applied:
|
||||
|
||||
* `app/config.py` — `retrieval_ef_search: int = 400`, comment carrying the numbers.
|
||||
* `.env.example`, `README.md` (retrieval section + env table), `AGENTS.md` (rule 10)
|
||||
— the default, why it is 400, and that the thresholds stayed put.
|
||||
* `tests/unit/test_config.py`, `tests/unit/test_retriever.py`,
|
||||
`tests/unit/test_settings_catalog.py`, `tests/integration/test_chunks_hnsw_recall.py`
|
||||
(`EF_PIN = 400`, `EF_RAISED = 600`) — every pin that encoded 100.
|
||||
* **Untouched (LOCKED A3):** `relevance_threshold` 0.62, `lexical_support_floor` 0.60,
|
||||
`name_hit_bonus` 0.005, `hybrid_top_k` 5, `hybrid_vector_candidates` 100, and every
|
||||
recall floor and tolerance in this phase's tests.
|
||||
|
||||
## Production-order probe — and the two things it found
|
||||
|
||||
The replica is 13 MB of heap, so the planner legitimately declines a 34 MB index
|
||||
below ~17 MB at `random_page_cost = 4`; the replica cannot answer "does production
|
||||
use it, and what does it cost". A throwaway database (`bor_scale188b`, created and
|
||||
dropped in the task) reproduced the production row count from the replica's own
|
||||
embeddings ×47, each copy multiplied element-wise by a random near-one jitter vector
|
||||
so all **416,702** vectors are distinct and the heap is 557 MB
|
||||
([`production_order_build.log`](../../../benchmarks/phase188_live_gate/production_order_build.log),
|
||||
[`production_order_latency.txt`](../../../benchmarks/phase188_live_gate/production_order_latency.txt)):
|
||||
|
||||
| Measurement | Value |
|
||||
| --- | --- |
|
||||
| Build through `uv run alembic upgrade head`, `maintenance_work_mem = 64 MB` | **161 s**, alembic process peak RSS 81 MB |
|
||||
| Index size | **1,625 MB** — ~3.9 KB/row at `m=16`, 768-dim float4: plan for **~1.6 GB** on the real 414k |
|
||||
| Plan at `ef_search` 100 / 200 / 400 | **HNSW index scan at every depth** (cost 3,333 / 5,621 / 9,806) |
|
||||
| Tier latency | **~550 ms seq → 12.0–14.9 ms** with the index (~40×), the ~1 ms `SET LOCAL` included |
|
||||
|
||||
**Finding 1 — `SET maintenance_work_mem = '512MB'` (D5) is unusable in this
|
||||
container, and the failure was reproduced at production order**
|
||||
([`build_maintenance_work_mem_512MB_failure.log`](../../../benchmarks/phase188_live_gate/build_maintenance_work_mem_512MB_failure.log)):
|
||||
`could not resize shared memory segment "/PostgreSQL.3366409952" to 533761504 bytes:
|
||||
No space left on device` — `/dev/shm` is **63 MB**, and the dying build leaves an
|
||||
**INVALID** index. That made the self-heal run for real: migration 0029 found
|
||||
`indisvalid = false`, logged `dropping INVALID … rebuilding`, dropped it, and
|
||||
rebuilt it valid (`pg_stat_progress_create_index` watched the second build at
|
||||
860,544 of 872,448 version-0 tuples). So the D5 pre-check stays, but the recorded
|
||||
procedure is "run `alembic upgrade head` with the container default"; raising
|
||||
`maintenance_work_mem` only makes sense together with `--shm-size`. The container
|
||||
default costs time, not correctness.
|
||||
|
||||
**Finding 2 — a recall pin must not depend on the planner cooperating.** pgvector
|
||||
costs an HNSW scan partly on `ef_search`: at production order the index path cost
|
||||
**3,333 / 5,621 / 9,806** at `ef_search` 100 / 200 / 400 respectively, so a deeper
|
||||
value makes the index a less attractive plan and, on a relation small enough for a
|
||||
top-N sort to win, it can lose. Task 03's statement pin EXPLAINs the tier's SELECT
|
||||
without the tier's own `SET LOCAL hnsw.ef_search`, so what it proves is the plan the
|
||||
*server* default produces — a recall run at a depth the planner declines would score
|
||||
the exact scan twice and report 1.0000. `test_top100_recall_at_the_shipped_ef_search`
|
||||
now refuses sequential scans for the measurement (session-scoped, reset in a
|
||||
`finally`) and asserts `ix_chunks_embedding_hnsw` is in the plan before scoring, so
|
||||
it fails loudly instead of passing vacuously. On the live replica the planner did take
|
||||
the index at the shipped depth (`Limit → Nested Loop → Index Scan on
|
||||
ix_chunks_embedding_hnsw`, with the OFF side correctly
|
||||
`Limit → Sort → Seq Scan`), and at production order it took it at all three depths.
|
||||
|
||||
**Limitation, stated plainly:** the ×47 jittered corpus is *not* a valid recall
|
||||
substrate — "the exact top-100" of 47 near-copies of 8,866 vectors is 100 knife-edge
|
||||
slots inside a few clusters, which is not a real KB. Its overlap numbers (0.23–0.64)
|
||||
are excluded from the recall verdict, which comes from the live replica where every
|
||||
vector is a real chunk. After the production build the owner should re-run the
|
||||
battery on the real 414k KB exactly as here; that is task 05's runbook step.
|
||||
|
||||
## Verification
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| `uv run pytest -q --cov=app --cov-report=term-missing` | **all passed**, `app/` coverage **99%** (> 90%) |
|
||||
| `uv run ruff check .` | **All checks passed!** |
|
||||
| `uv run pyright` | **0 errors, 0 warnings** |
|
||||
| `uv run pytest tests/e2e/test_smoke.py -v --no-cov` | **exit 0** (app boots indexed, corpus assertion skips on the empty dev KB) |
|
||||
| `uv run alembic downgrade -1 && uv run alembic upgrade head` | round trip verified twice on the live replica; index valid after each |
|
||||
|
||||
## Files
|
||||
|
||||
* `app/config.py` — `retrieval_ef_search` default `100 → 400` with the evidence.
|
||||
* `.env.example`, `README.md`, `AGENTS.md` — the new default and why.
|
||||
* `tests/unit/test_config.py`, `tests/unit/test_retriever.py`,
|
||||
`tests/unit/test_settings_catalog.py`,
|
||||
`tests/integration/test_chunks_hnsw_recall.py` (+ its module docstring's measured
|
||||
numbers) — pins moved to the shipped depth, vacuity guard added.
|
||||
* `benchmarks/phase188_live_gate/` — the captures, the gate outputs, the
|
||||
production-order logs, the failure log, the pinned vectors, and the README that
|
||||
ties them together; `benchmarks/README.md` gained a "Retrieval gate" pointer.
|
||||
* `.agents/reports/188_chunks_hnsw_index/task04_recall_ab_probe.py` — the probe
|
||||
(`embed` / `measure` / `compare` / `recall` / `gate`).
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
# index OFF
|
||||
uv run alembic downgrade -1
|
||||
uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt
|
||||
uv run alembic upgrade head
|
||||
uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt
|
||||
# deterministic, one embedding per question
|
||||
P=.agents/reports/188_chunks_hnsw_index/task04_recall_ab_probe.py
|
||||
PYTHONPATH=. uv run python $P embed --out /tmp/v.json
|
||||
PYTHONPATH=. uv run python $P measure --vectors /tmp/v.json --out /tmp/off.json # index dropped
|
||||
PYTHONPATH=. uv run python $P measure --vectors /tmp/v.json --out /tmp/on.json # index present
|
||||
PYTHONPATH=. uv run python $P compare --a /tmp/off.json --b /tmp/on.json
|
||||
PYTHONPATH=. uv run python $P gate --path /tmp/on.json
|
||||
```
|
||||
@@ -96,6 +96,7 @@ BOR_EMBED_BATCH_SIZE=16
|
||||
|
||||
# --- Hybrid retrieval (vector + Postgres FTS, RRF-fused) ---
|
||||
BOR_HYBRID_VECTOR_CANDIDATES=100 # cosine list width for the fusion
|
||||
# BOR_RETRIEVAL_EF_SEARCH=400 # HNSW ef_search for the cosine tier (phase 188): SET LOCAL per transaction, never a global GUC. Default 400 — the depth the live battery (benchmarks/phase188_live_gate/) measured EXACT top-100 parity at; the candidate count (100) recovered 0.9814 mean / 0.94 min of it on real embeddings, and the honesty thresholds were NOT touched to get there. Below BOR_HYBRID_VECTOR_CANDIDATES it is CLAMPED up to it with one WARNING line; > 0 (0/negative fails startup); max 1000
|
||||
BOR_HYBRID_LEXICAL_CANDIDATES=30 # FTS list width for the fusion
|
||||
BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
||||
BOR_RECENCY_BOOST=0.0007 # max additive fused-score bonus a zero-age doc gets (phase 106); 0 = off (pre-phase ranking byte-identical)
|
||||
|
||||
@@ -140,7 +140,31 @@
|
||||
clause) therefore satisfies `peak >= current`, never a peak below the
|
||||
run's own footprint, and an unknowable peak is never fabricated from
|
||||
the current reading); UI-side,
|
||||
honor the "never stale" feedback contract (PLAN §7.4).
|
||||
honor the "never stale" feedback contract (PLAN §7.4). Phase 188 adds
|
||||
the retrieval tier's ONE operator-visible line: when
|
||||
`BOR_RETRIEVAL_EF_SEARCH` sits below the A7 candidate count
|
||||
`_vector_candidates` logs `retrieval: ef_search=%d raised to %d (A7
|
||||
candidate count)` at WARNING and runs the search at the candidate
|
||||
count anyway — the clamp is never silent, and the healthy
|
||||
(ef_search >= candidate count) turn logs nothing. The value itself
|
||||
reaches Postgres as `SET LOCAL hnsw.ef_search` inside the turn's own
|
||||
transaction (LOCKED A4: no global GUC, nothing survives the request or
|
||||
a pool recycle). Task 04's live gate (the 1,000-document live-replica KB,
|
||||
the live embedding endpoint, question vectors PINNED so the access path is the
|
||||
one variable — `benchmarks/phase188_live_gate/`) found no A8, top-5 or
|
||||
`top_score` difference at any
|
||||
depth and shipped `retrieval_ef_search = 400`: at the candidate count the
|
||||
graph recovered the exact top-100 for 0.9814 of rows on average (0.94
|
||||
worst), at 400 for all of them, and NO threshold
|
||||
(`relevance_threshold`, `lexical_support_floor`, `name_hit_bonus`,
|
||||
`hybrid_top_k`, `hybrid_vector_candidates`) moved to get there (LOCKED A3).
|
||||
Two things the operator needs from the same run: building the index costs
|
||||
~161 s per 417k chunks and ~1.6 GB (3.9 KB/row at `m = 16`, 768-dim), and
|
||||
`maintenance_work_mem = '512MB'` is NOT applicable in this container —
|
||||
`/dev/shm` is 63 MB, so a raise above that dies with `could not resize
|
||||
shared memory segment … No space left on device` and leaves an INVALID
|
||||
index for the migration's self-heal to drop and rebuild (observed live);
|
||||
the container's 64 MB default builds it fine.
|
||||
11. **Never block the event loop:** anything blocking (a `subprocess`,
|
||||
synchronous file/network I/O, a heavy walk) reached from an `async`
|
||||
handler or a spawned background coroutine goes through
|
||||
@@ -246,6 +270,44 @@
|
||||
stored text, a changed file re-indexes and the fast path still fires
|
||||
after).
|
||||
|
||||
16. **The cosine tier is indexed — keep it indexed, and keep `0029` the ONE
|
||||
definition of that index:** `ix_chunks_embedding_hnsw` (HNSW over
|
||||
`chunks.embedding`, `vector_cosine_ops`, `m = 16`,
|
||||
`ef_construction = 64`) exists because every turn's
|
||||
`ORDER BY embedding <=> :vec LIMIT 100` full-scanned ~3 GB on the
|
||||
interactive path (phase 188). Build and drop it ONLY through migration
|
||||
`0029` (`CREATE/DROP INDEX CONCURRENTLY` inside
|
||||
`op.get_context().autocommit_block()`, which pre-drops an INVALID remnant
|
||||
and raises if the build does not validate) — never an inline
|
||||
`CREATE INDEX` in app code, a test, or a runbook step; never a second
|
||||
vector index, a `halfvec` cast, or a rewritten `ORDER BY` (the `<=>`
|
||||
operator is what the graph serves, LOCKED A3/A6). A test that needs the
|
||||
index OFF moves the SCHEMA (`command.downgrade(cfg, "0028")` /
|
||||
`command.upgrade(cfg, "0029")`) after rolling back its own session — an
|
||||
open snapshot parks a `CONCURRENTLY` build indefinitely — and asserts the
|
||||
catalog state it claims (`pg_index.indisvalid`, `pg_get_indexdef`).
|
||||
`hnsw.ef_search` is set with `SET LOCAL` inside the turn's own transaction
|
||||
and the `int()`-coerced setting is the only source (no global `SET`, no
|
||||
pool-wide state, nothing survives a recycle); the shipped default is
|
||||
**400** because that is the depth the live gate measured exact-top-100
|
||||
parity at, so "tuning" it back to the candidate count is a recall change,
|
||||
and closing a recall gap by moving an honesty threshold is forbidden
|
||||
(LOCKED A3). The `ef_search` a turn gets is
|
||||
`max(retrieval_ef_search, hybrid_vector_candidates)` with a WARNING when
|
||||
clamped. Pinned by `tests/integration/test_chunks_hnsw_index.py` (DDL
|
||||
round trip + INVALID self-heal + drift guard),
|
||||
`tests/integration/test_chunks_hnsw_recall.py` (the tier's captured
|
||||
statement names the index, `SET LOCAL` reaches the connection, recall at
|
||||
the shipped depth with seq scans refused), and
|
||||
`tests/e2e/test_vector_index_recall.py` (the owner-visible arm: the SAME
|
||||
cited document with the index dropped and restored, the index VALID after a
|
||||
real importer run, the near-miss still deflecting in both arms, and the
|
||||
clamp read off the app's own captured stdout at `=150` / `=50`). That
|
||||
suite's sentinels are calibrated against
|
||||
`tests/fixtures/hnswkb/` — an 11-note tracked KB whose BYTES own the
|
||||
quoted cosine/lexical margins, so editing a note there moves the numbers in
|
||||
the module docstring and the pins together.
|
||||
|
||||
## Quick reference
|
||||
```bash
|
||||
podman compose up -d db # start Postgres 17 + pgvector
|
||||
|
||||
@@ -963,7 +963,20 @@ curl -s -X DELETE localhost:8000/api/steering/<note-id> # remove
|
||||
Every question is embedded and also lexically tokenized (OR-joined, English
|
||||
stemming) and searched **twice** against Postgres:
|
||||
|
||||
1. **Vector** — pgvector cosine top-N (default `BOR_HYBRID_VECTOR_CANDIDATES=100`)
|
||||
1. **Vector** — pgvector cosine top-N (default `BOR_HYBRID_VECTOR_CANDIDATES=100`) over
|
||||
the `ix_chunks_embedding_hnsw` HNSW index (phase 188), searched at
|
||||
`BOR_RETRIEVAL_EF_SEARCH` (default **`400`**, set per transaction with
|
||||
`SET LOCAL` — never a global setting). 400 is not a guess: the live battery
|
||||
in [`benchmarks/phase188_live_gate/`](benchmarks/phase188_live_gate/README.md)
|
||||
measured the graph recovering the exact top-100 for only 0.9814 of rows on
|
||||
average (0.94 worst) at `100` — the candidate count — against 1.0000 at 400,
|
||||
with every A8 verdict, top-5 set and `top_score` unchanged at both depths
|
||||
(the honesty thresholds were never touched). A value below the candidate
|
||||
count is **clamped up** to it with one WARNING line in the server log
|
||||
(`retrieval: ef_search=… raised to …`): a smaller `ef_search` returns
|
||||
fewer candidates than the tier promises, which is a silent recall
|
||||
regression the honesty gate can turn into a deflect. Raise it (max 1000)
|
||||
if recall matters more than the tier's latency.
|
||||
2. **Lexical** — a stored `tsvector` (GIN-indexed) matched with `to_tsquery`,
|
||||
top-N by `ts_rank` (default `BOR_HYBRID_LEXICAL_CANDIDATES=30`), led by
|
||||
**name hits** — documents whose *path* matches a question token (`gitea`
|
||||
@@ -1736,6 +1749,7 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `BOR_HYBRID_VECTOR_CANDIDATES` | `100` | Cosine list width for the RRF fusion |
|
||||
| `BOR_RETRIEVAL_EF_SEARCH` | `400` | HNSW `ef_search` for the cosine tier (phase 188) — `SET LOCAL` per transaction; clamped UP to `BOR_HYBRID_VECTOR_CANDIDATES` with a WARNING when lower (that clamp is the recall floor, so `0`/negative fails startup); raise it for more recall, Postgres caps it at 1000. 400 is the measured depth at which the graph recovers the exact top-100 (see [How retrieval works](#how-retrieval-works-hybrid)) |
|
||||
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
||||
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
||||
| `BOR_RECENCY_BOOST` | `0.0007` | Max additive fused-score bonus a zero-age doc gets (phase 106); `0` = off |
|
||||
@@ -1982,6 +1996,14 @@ start while either is empty.
|
||||
|
||||
The entrypoint runs `alembic upgrade head` automatically on start.
|
||||
|
||||
> **Deploy note (migration `0029`, phase 188):** this step builds
|
||||
> `ix_chunks_embedding_hnsw` — an HNSW index over `chunks.embedding` — and
|
||||
> it is the one migration in this repo that takes **minutes** on a full
|
||||
> corpus and needs free RAM to do it. It will not lock the table, it can
|
||||
> be interrupted, and it self-heals — but read
|
||||
> [Deploying migration 0029 (HNSW)](#deploying-migration-0029-hnsw) before
|
||||
> running it on the production box.
|
||||
|
||||
Or run the whole stack from compose (app + db):
|
||||
```bash
|
||||
podman compose --profile prod up -d --build
|
||||
@@ -1994,6 +2016,127 @@ Production hardening: app runs as non-root (uid 10001), slim image,
|
||||
healthcheck on `/api/health`, debugpy off unless `DEBUGPY=1`, all assets
|
||||
served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
||||
|
||||
### Deploying migration 0029 (HNSW)
|
||||
|
||||
Migration `0029` builds `ix_chunks_embedding_hnsw` — an HNSW graph over the
|
||||
existing `vector(768)` column (`m = 16`, `ef_construction = 64`), so the
|
||||
cosine tier of every chat turn stops sequential-scanning
|
||||
[`benchmarks/phase188_live_gate/`](benchmarks/phase188_live_gate/README.md).
|
||||
Everything changes about the **plan**; nothing changes about the **answer**
|
||||
(the measured parity is in the benchmark link above and in
|
||||
`tests/integration/test_chunks_hnsw_recall.py`). It is still the migration
|
||||
to run deliberately rather than as a side effect of a deploy.
|
||||
|
||||
**Pre-flight — all four, on the machine that runs it.**
|
||||
|
||||
```bash
|
||||
free -m # free RAM, whole box
|
||||
psql "$BOR_DATABASE_URL" -c "show shared_buffers;" # + maintenance_work_mem
|
||||
psql "$BOR_DATABASE_URL" -c "select extversion from pg_extension where extname='vector';"
|
||||
podman exec brain-of-reese_db_1 df -h /dev/shm # the one people miss
|
||||
```
|
||||
|
||||
* **pgvector ≥ 0.5.0** (`0.8.0` measured). HNSW does not exist before 0.5 —
|
||||
the migration fails with `access method "hnsw" does not exist`.
|
||||
* **Disk:** budget **~3.9 KB per chunk row** — the real ~414k chunks need
|
||||
roughly **1.6 GB** for the index on top of the ~3 GB heap. A build that
|
||||
runs out of disk is the one failure the self-heal cannot talk its way out
|
||||
of.
|
||||
* **RAM:** the build itself is not the hungry part — a 416,702-row build
|
||||
peaked at **81 MB** of RSS in the `alembic` process at the container's
|
||||
default `maintenance_work_mem` (64 MB), and 1.6 GB of index is page cache
|
||||
afterwards, which Linux drops under pressure. What the box needs is room
|
||||
for the build to finish while the app keeps serving.
|
||||
* **`/dev/shm`:** see the `maintenance_work_mem` note below — inside this
|
||||
project's compose container it is **63 MB**, and that is what caps the
|
||||
knob, not the box's RAM.
|
||||
|
||||
**Build it.**
|
||||
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
`CREATE INDEX CONCURRENTLY` takes `ShareUpdateExclusiveLock`: reads and
|
||||
writes continue, the app keeps answering through the whole build, and the
|
||||
build takes **~161 s for 416,702 vectors** at `maintenance_work_mem = 64 MB`
|
||||
(measured, `production_order_build.log`) — budget minutes, not seconds, and
|
||||
more on a 4 GB-class box.
|
||||
|
||||
*On `maintenance_work_mem`:*
|
||||
|
||||
* Never rely on the PostgreSQL **4 MB** default if you can set the knob at
|
||||
all — a 4 MB graph build spills and is dramatically slower.
|
||||
* **But do not raise it above `/dev/shm` in this container.** D5's original
|
||||
suggestion (`SET maintenance_work_mem = '512MB'`) was run at production
|
||||
order and failed:
|
||||
`could not resize shared memory segment "/PostgreSQL.3366409952" to 533761504 bytes: No space left on device`
|
||||
— the container's `/dev/shm` is **63 MB**, and the dying build leaves an
|
||||
**INVALID** index behind (`build_maintenance_work_mem_512MB_failure.log`).
|
||||
The migration's self-heal then did its job (dropped the INVALID graph,
|
||||
rebuilt it valid), so nothing was lost, but the recorded procedure for
|
||||
THIS container is: **`alembic upgrade head` with the container default
|
||||
(64 MB)**, which costs time and not correctness. Raising
|
||||
`maintenance_work_mem` only makes sense together with a larger
|
||||
`--shm-size` / `shm_size:` on the db container.
|
||||
|
||||
**`CONCURRENTLY` semantics, in the owner's terms.** No read or write is
|
||||
blocked. The build is long, and if it dies — OOM killer, cancel, network
|
||||
drop, container restart — Postgres does **not** roll it back: it leaves the
|
||||
index present and marked `indisvalid = false`. That state is silent (it
|
||||
costs insert time on every sync and serves no query), which is why `0029`
|
||||
post-validates its own build and **raises** instead of reporting success.
|
||||
|
||||
**Recovery.**
|
||||
|
||||
```bash
|
||||
# "did it actually build?" — one row, `t` is the answer
|
||||
psql "$BOR_DATABASE_URL" -c "select i.indisvalid from pg_index i
|
||||
join pg_class c on c.oid = i.indexrelid where c.relname = 'ix_chunks_embedding_hnsw';"
|
||||
|
||||
# the recovery for BOTH cases is the same command:
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
* `indisvalid = f` after a killed build → `upgrade head` drops the INVALID
|
||||
remnant and rebuilds (the migration's own self-heal).
|
||||
* `relation "ix_chunks_embedding_hnsw" already exists` → the build committed
|
||||
but the version stamp did not land. `0029` handles the INVALID case;
|
||||
a VALID leftover of the same name is dropped by hand and re-run:
|
||||
`DROP INDEX CONCURRENTLY ix_chunks_embedding_hnsw` then
|
||||
`alembic upgrade head`.
|
||||
* Watching a long build from a second terminal:
|
||||
`select * from pg_stat_progress_create_index;`
|
||||
* Rolling back is cheap and touches no data:
|
||||
`uv run alembic downgrade -1` is `DROP INDEX CONCURRENTLY IF EXISTS` — an
|
||||
index is a derived structure, and the schema underneath is 0028's exactly.
|
||||
|
||||
**After it lands.** Turn on the index in the app's own terms and confirm the
|
||||
owner-visible parity, in this order:
|
||||
|
||||
```bash
|
||||
uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt
|
||||
uv run python -m scripts.api_payload_audit # the app must be running
|
||||
```
|
||||
|
||||
The battery's verdicts should read exactly as they did before the build
|
||||
(the A7/A8 contract — `top_score` included). The tier's own speed is the
|
||||
part that changed: **~550 ms → ~12–15 ms** per cosine search at production
|
||||
order, and the plan flips from `Limit → Sort → Seq Scan on chunks` to
|
||||
`Limit → Nested Loop → Index Scan on ix_chunks_embedding_hnsw` — verifiable
|
||||
with `EXPLAIN` on the tier's statement, or by reading
|
||||
`tests/integration/test_chunks_hnsw_recall.py`'s pin of the same thing.
|
||||
|
||||
**Order of operations on a 4 GB-class box.** Land the phase 183–187 memory
|
||||
work first (`passive_deletes` cascade, the session-release cadence, the
|
||||
prune's six-column read, the deferred-`content` fast path, the reported
|
||||
`rss_mb`/`rss_peak_mb` pair — see
|
||||
[Operational notes: the sync's memory](#operational-notes-the-syncs-memory-phase-187)).
|
||||
The box that OOM-killed the 2026-10-15 sync is the box this build lands on;
|
||||
1.6 GB of new index plus a graph build on top of a sync that still holds its
|
||||
walked documents is how a build gets killed and leaves an INVALID graph for
|
||||
somebody else to notice.
|
||||
|
||||
**Reverse-proxy body size:** a legal chat/save POST is now large — one
|
||||
conversation can carry a full-length answer (see
|
||||
[Conversation payload limits](#conversation-payload-limits)) — so an
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
|
||||
|
||||
Revision ID: 0029
|
||||
Revises: 0028
|
||||
Create Date: 2026-10-15
|
||||
|
||||
The 2026-10-15 incident investigation found ``pg_indexes`` for ``chunks``
|
||||
listing only the PK, ``ix_chunks_document_id`` and ``ix_chunks_tsv`` —
|
||||
**no index on ``embedding``** — so every chat turn's A7 cosine tier
|
||||
(``ORDER BY embedding <=> :vec LIMIT 100``) runs as a sequential scan of
|
||||
a ~3 GB, 414,220-row relation: per-turn CPU on the interactive path plus
|
||||
page-cache churn on a 3,904 MB box the sync also needs.
|
||||
|
||||
* ``ix_chunks_embedding_hnsw`` — HNSW over the existing ``vector(768)``
|
||||
column (A6 unchanged) with ``vector_cosine_ops``, so the ``<=>``
|
||||
operator in ``_vector_candidates`` uses it with NO query rewrite (A3's
|
||||
one-system rule: no new service, no new dependency). ``m = 16`` and
|
||||
``ef_construction = 64`` are the pgvector defaults' neighbourhood,
|
||||
which keeps the graph buildable on a 4 GB box. ``halfvec(768)`` was
|
||||
considered and REJECTED for this phase (it needs a statement cast plus
|
||||
its own quantisation-recall study — two quality risks in one phase).
|
||||
|
||||
**Why ``CONCURRENTLY`` inside an autocommit block.** The index serves a
|
||||
live interactive path; a plain ``CREATE INDEX`` would hold a lock that
|
||||
blocks writes to ``chunks`` for the whole (long) build. ``CREATE INDEX
|
||||
CONCURRENTLY`` cannot run inside a transaction, so the DDL executes in
|
||||
``op.get_context().autocommit_block()``.
|
||||
|
||||
**The failure mode this migration guards (D2).** A ``CONCURRENTLY``
|
||||
build that dies mid-way — killed by the OOM killer on a 3,904 MB box,
|
||||
cancelled, or disconnected — leaves the index behind marked INVALID in
|
||||
``pg_index``: it costs insert time on every sync and serves no queries,
|
||||
*SILENTLY*. On this hardware that is the expected failure, not the
|
||||
exotic one. Therefore ``upgrade()`` (a) pre-drops any existing INVALID
|
||||
index of this name before building (otherwise the rebuild dies on
|
||||
``relation "ix_chunks_embedding_hnsw" already exists``), and (b)
|
||||
re-checks ``pg_index.indisvalid`` after the build and raises if the new
|
||||
index did not validate — a migration that reports success while leaving
|
||||
an INVALID index would paper over exactly the silent failure above.
|
||||
|
||||
Pure helpers (unit-testable, no I/O): :func:`should_drop_invalid` and
|
||||
:func:`is_valid` are total functions over the ``indisvalid`` lookup
|
||||
(``None`` = index absent).
|
||||
|
||||
**Reversibility (A13).** The downgrade is
|
||||
``DROP INDEX CONCURRENTLY IF EXISTS`` — an index is a derived structure,
|
||||
so the drop touches no data and 0028's schema is byte-identical after
|
||||
it. No ``chunks``/``documents`` column changes and no data rewrite: the
|
||||
migration is cheap on a healthy box and interruptible (the INVALID
|
||||
self-heal makes re-running it the recovery path). The production build
|
||||
(414k × 768) is an operator action with a pre-flight runbook — see
|
||||
README "Production Deployment".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0029"
|
||||
down_revision = "0028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = "ix_chunks_embedding_hnsw"
|
||||
|
||||
|
||||
def should_drop_invalid(indisvalid: bool | None) -> bool:
|
||||
"""True only when the index EXISTS but is INVALID (phase 188 D2).
|
||||
|
||||
``None`` (absent) needs no drop; ``True`` (valid) must be left
|
||||
alone; ``False`` is an interrupted ``CREATE INDEX CONCURRENTLY``
|
||||
remnant that would otherwise block the rebuild with
|
||||
``relation already exists``.
|
||||
"""
|
||||
return indisvalid is False
|
||||
|
||||
|
||||
def is_valid(indisvalid: bool | None) -> bool:
|
||||
"""True only when the index EXISTS and validated (phase 188 D2)."""
|
||||
return indisvalid is True
|
||||
|
||||
|
||||
def _lookup_index_validity(bind: Connection) -> bool | None:
|
||||
"""``pg_index.indisvalid`` for :data:`INDEX_NAME`, ``None`` if absent."""
|
||||
row = bind.execute(
|
||||
text(
|
||||
"SELECT i.indisvalid"
|
||||
" FROM pg_index i"
|
||||
" JOIN pg_class c ON c.oid = i.indexrelid"
|
||||
" WHERE c.relname = :name"
|
||||
),
|
||||
{"name": INDEX_NAME},
|
||||
).scalar()
|
||||
return None if row is None else bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# CONCURRENTLY DDL cannot run inside a transaction — the autocommit
|
||||
# block commits alembic's surrounding transaction, runs the DDL
|
||||
# statement-by-statement, and reopens one afterwards.
|
||||
with op.get_context().autocommit_block():
|
||||
# Self-heal an interrupted build (D2): drop the INVALID remnant
|
||||
# BEFORE creating, or the CREATE dies on "already exists".
|
||||
if should_drop_invalid(_lookup_index_validity(op.get_bind())):
|
||||
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}")
|
||||
op.execute(
|
||||
f"CREATE INDEX CONCURRENTLY {INDEX_NAME} ON chunks "
|
||||
"USING hnsw (embedding vector_cosine_ops) "
|
||||
"WITH (m = 16, ef_construction = 64)"
|
||||
)
|
||||
# Post-validate: a CONCURRENTLY build that dies leaves an
|
||||
# INVALID index silently — fail the migration loudly instead
|
||||
# (the INVALID self-heal above makes re-running the recovery).
|
||||
if not is_valid(_lookup_index_validity(op.get_bind())):
|
||||
raise RuntimeError(f"{INDEX_NAME} did not validate — drop it and re-run")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Same autocommit requirement as the create. IF EXISTS keeps the
|
||||
# downgrade idempotent; no data is touched (A13 — an index is a
|
||||
# derived structure, 0028's schema is byte-identical after the drop).
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}")
|
||||
@@ -349,6 +349,44 @@ class Settings(BaseSettings):
|
||||
# live 2774-chunk KB for "How did I install gitlab?" (gitlab.md:1 at
|
||||
# vrank 100 / lrank 3 → fused 0.0221 vs the template's 0.0164).
|
||||
hybrid_vector_candidates: int = 100
|
||||
#: HNSW search depth for the cosine tier (phase 188, D3/LOCKED A4):
|
||||
#: ``SET LOCAL hnsw.ef_search`` immediately before
|
||||
#: ``app.rag.retriever._vector_candidates``'s SELECT — per transaction,
|
||||
#: so no request leaks state into another and no global GUC (nor
|
||||
#: connection-pool) setting is touched. A value BELOW the A7 candidate
|
||||
#: count cannot be honoured — it would return FEWER candidates than A7
|
||||
#: promises (a silent recall regression) — so the retriever CLAMPS it up
|
||||
#: to the candidate count with one WARNING line instead. ``0`` is refused
|
||||
#: here for the same reason: the clamp would silently rewrite it, which
|
||||
#: is exactly what an operator reading ``0`` as "off" would not expect.
|
||||
#: Negative values fail startup loudly (the ``show_text_max_*`` validator
|
||||
#: pattern, phase 182).
|
||||
#:
|
||||
#: Why 400 and not the candidate count (task 04's live battery, the
|
||||
#: 1,000-document live replica, 8,866 real embeddings, the 7-question
|
||||
#: battery with the vectors PINNED so only the index differs):
|
||||
#:
|
||||
#: * ``ef_search = 100`` (``== LIMIT``, the floor the clamp enforces)
|
||||
#: recovers mean **0.9814** / min **0.94** of the exact top-100 — under
|
||||
#: this phase's own 0.99 recall floor (LOCKED A3: the remedy for a
|
||||
#: recall miss is a higher ``ef_search``, never a relaxed assertion).
|
||||
#: Production holds 47× the chunks, where a fixed ``ef_search`` is
|
||||
#: weaker still, not stronger.
|
||||
#: * ``ef_search = 400`` recovers **1.0000** of the exact top-100 with a
|
||||
#: max per-candidate ``top_score`` delta of exactly ``0.0`` — the
|
||||
#: honesty gate (A8) reads that number, and no verdict, top-5 set or
|
||||
#: suggested list moved at ANY depth measured.
|
||||
#: * it is also the depth at which the PLANNER actually uses the index on
|
||||
#: a small relation: at ``ef_search = 100`` Postgres costs the HNSW scan
|
||||
#: above a sequential one and full-scans anyway (measured 51.5 ms vs the
|
||||
#: forced-index 2.6 ms); at 400 the plan names
|
||||
#: ``ix_chunks_embedding_hnsw`` at 5.6 ms — still ~9× under the
|
||||
#: sequential scan this phase exists to remove.
|
||||
#:
|
||||
#: The A7 clamp floor stays at ``hybrid_vector_candidates`` (100): with a
|
||||
#: 400 default a stock install never warns. Postgres caps ``ef_search`` at
|
||||
#: 1000.
|
||||
retrieval_ef_search: int = 400
|
||||
hybrid_lexical_candidates: int = 30
|
||||
rrf_k: int = 60
|
||||
#: Recency boost on the RRF-fused retrieval score (phase 106, D6): the
|
||||
@@ -797,6 +835,21 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("retrieval_ef_search")
|
||||
@classmethod
|
||||
def _retrieval_ef_search_positive(cls, v: int) -> int:
|
||||
"""``0``/negative is a typo, not a switch (phase 188, D3). There is
|
||||
no ``ef_search=0`` to turn the index off — the retriever clamps any
|
||||
value below the A7 candidate count UP to it, so a ``0`` that read as
|
||||
"off" would silently become 100 instead of failing. Fail loud at
|
||||
startup, naming the field (the ``show_text_max_chars`` pattern)."""
|
||||
if v <= 0:
|
||||
raise ValueError(
|
||||
"retrieval_ef_search must be > 0 (HNSW ef_search; the "
|
||||
"retriever clamps values below the candidate count up to it)"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("embed_question_max_chars")
|
||||
@classmethod
|
||||
def _embed_question_max_chars_positive(cls, v: int) -> int:
|
||||
|
||||
@@ -205,6 +205,9 @@ CATALOG: dict[str, SettingMeta] = {
|
||||
"Retrieval & honesty",
|
||||
"Hybrid vector candidates",
|
||||
),
|
||||
"retrieval_ef_search": SettingMeta(
|
||||
"retrieval_ef_search", "Retrieval & honesty", "Vector ef_search"
|
||||
),
|
||||
"hybrid_lexical_candidates": SettingMeta(
|
||||
"hybrid_lexical_candidates",
|
||||
"Retrieval & honesty",
|
||||
|
||||
@@ -91,6 +91,7 @@ Deterministic tie-break for equal fused scores:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
@@ -107,6 +108,12 @@ from app.config import get_settings
|
||||
from app.models import Chunk, Document
|
||||
from app.schemas import SourceRef
|
||||
|
||||
#: Phase 188 (D3): the ONE logger of this module — its only line so far is
|
||||
#: the ``ef_search`` clamp WARNING (the retrieval path was silent by
|
||||
#: design; a knob that silently degraded recall is exactly what needs a
|
||||
#: line in the server log).
|
||||
logger = logging.getLogger("app.rag.retriever")
|
||||
|
||||
#: Shared overflow marker (phase 15; imported by ``app.rag.prompts``)
|
||||
#: — used by the steering (<tuning>) section, the phase-118 NULL-summary
|
||||
#: suggestion preview fallback (A5), and the capped agent ``read``
|
||||
@@ -436,7 +443,31 @@ def _vector_candidates(
|
||||
(two-phase import in progress) are skipped. Each candidate carries
|
||||
its ``Chunk.is_summary`` flag (phase 30) so a summary hit stays
|
||||
identifiable after fusion.
|
||||
|
||||
Phase 188 (D3, LOCKED A4): the HNSW tier's search depth is set
|
||||
EXPLICITLY with ``SET LOCAL hnsw.ef_search`` immediately before the
|
||||
SELECT — never a global ``SET``, so the value lives and dies with the
|
||||
request's transaction (nothing leaks into another request, nothing
|
||||
survives a pool recycle, no connection-pool-wide state). The
|
||||
``ef_search`` a turn actually gets is
|
||||
``max(settings.retrieval_ef_search, limit)``: an ``ef_search`` BELOW
|
||||
the A7 candidate count would return FEWER candidates than the cosine
|
||||
tier promises (a silent recall regression the honesty gate can turn
|
||||
into a deflect), so it is CLAMPED up to *limit* with one WARNING line
|
||||
instead of being honoured. The value is ``int()``-coerced before it
|
||||
reaches SQL — no operator-supplied string is ever interpolated
|
||||
(:func:`app.config.Settings.retrieval_ef_search` refuses ``0`` and
|
||||
negatives at startup). The setting is read through
|
||||
:func:`app.config.get_settings` exactly like every other retrieval
|
||||
knob in this module.
|
||||
"""
|
||||
ef_search = get_settings().retrieval_ef_search
|
||||
if ef_search < limit:
|
||||
logger.warning(
|
||||
"retrieval: ef_search=%d raised to %d (A7 candidate count)", ef_search, limit
|
||||
)
|
||||
ef_search = limit
|
||||
db.execute(text(f"SET LOCAL hnsw.ef_search = {int(ef_search)}"))
|
||||
distance = Chunk.embedding.cosine_distance(question_embedding)
|
||||
rows = db.execute(
|
||||
select(Chunk, distance.label("distance"), Document)
|
||||
|
||||
@@ -32,3 +32,13 @@ CSV recordings from all model tests (chat, summary, embedding).
|
||||
- `scripts/test_summary_model.py` — summary model quality benchmark
|
||||
- `scripts/test_embed_model.py` — embedding model dimension/cosine/speed benchmark
|
||||
- `scripts/model_benchmark.py` — shared CSV recorder (imported by all scripts)
|
||||
|
||||
## Retrieval gate
|
||||
|
||||
- [`phase188_live_gate/`](phase188_live_gate/README.md) — the HNSW index (phase 188)
|
||||
measured OFF vs ON on the restored 1,000-document live-replica KB and the live
|
||||
embedding endpoint: the two `eval_retrieval` captures (byte-identical), the
|
||||
deterministic pinned-vector gate, the `ef_search` depth sweep that set the shipped
|
||||
default to 400, the production-order build cost (161 s / 1.6 GB for 417k chunks)
|
||||
and the `maintenance_work_mem` shared-memory failure. Captures in that directory;
|
||||
the probe is `.agents/reports/188_chunks_hnsw_index/task04_recall_ab_probe.py`.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Phase 188 task 04 — the live recall / parity gate (index OFF vs ON)
|
||||
|
||||
Measured on **live production** (`brain.reeseapps.com`'s database, reached at
|
||||
`localhost:5432` through the dev-tunnel the app uses), on the **live embedding
|
||||
endpoint**, against a restored **1,000-document live-replica KB**. Nothing here
|
||||
ran against the synthetic 5,000-row fixture — that corpus belongs to task 03 and
|
||||
it is not a proxy for the shapes real embeddings take.
|
||||
|
||||
Reproduce with `.agents/reports/188_chunks_hnsw_index/task04_recall_ab_probe.py`
|
||||
(its `--help` has the four commands); the battery itself is
|
||||
`uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt`.
|
||||
|
||||
## Prerequisites (measured, not assumed)
|
||||
|
||||
| Check | Value |
|
||||
| --- | --- |
|
||||
| `SELECT extversion FROM pg_extension WHERE extname = 'vector'` | **0.8.0** (>= 0.5.0 required; `hnsw` available) |
|
||||
| `alembic current` before | head (0028), index absent |
|
||||
| Replica corpus | 1,000 documents, **8,866 chunks**, 8,866 embeddings, heap **13 MB** (from a 95 MB plain-SQL dump) |
|
||||
| `maintenance_work_mem` | **64 MB** (the container's default — `postgres:17` never got the phase's 512 MB, see below) |
|
||||
| `/dev/shm` in `brain-of-reese_db_1` | **63 MB** |
|
||||
| Planner costs | `seq_page_cost = 1`, `random_page_cost = 4` (default, SSD-class) |
|
||||
| Disk free on the Postgres data volume | **2.2 TB free of 3.7 TB** (41 % used) |
|
||||
| Production relation, from the 2026-10-15 incident record (not measured in this task) | 414,220 chunks, heap **3,000 MB** |
|
||||
| Index after `alembic upgrade head` | `ix_chunks_embedding_hnsw` **valid**, **34 MB** on the replica |
|
||||
| `alembic_version` inside the dump | **`0011`** — so `alembic upgrade head` replays **0012…0029** into the restored corpus and 0029's `CREATE INDEX CONCURRENTLY` builds against **real** embeddings. Restoring into a schema already at head would build nothing and measure nothing. |
|
||||
|
||||
## The battery, index OFF vs index ON (`eval_retrieval`)
|
||||
|
||||
`battery_index_off.txt` was captured with the index dropped
|
||||
(`uv run alembic downgrade -1`), `battery_index_on.txt` with it present
|
||||
(`uv run alembic upgrade head`). Both runs embed the seven battery questions
|
||||
through the live endpoint.
|
||||
|
||||
The two captures are **byte-identical** — every `best_cosine`, every `fts_hits`,
|
||||
every one of the five numbered hits per question, and every `DEFLECT`/`HIGH`
|
||||
verdict. The only difference is the plan the server chose, which battery output
|
||||
does not print (the deterministic probe below does).
|
||||
|
||||
**Why the battery alone cannot gate this.** At `ef_search = 100` its OFF and ON
|
||||
captures disagreed by up to ~3e-4 on printed cosines — above this phase's 1e-4
|
||||
tolerance — and the battery cannot say where that came from. The probe can: the
|
||||
endpoint is deterministic (`endpoint_self_jitter`, one question embedded twice,
|
||||
measured **0.000e+00**), and the tier reading shows the mechanism — at that depth the
|
||||
two top-100s agree on only 0.9814 of rows on average (0.94 worst), a substituted tail
|
||||
candidate shifts the RRF ranks, and since a document contributes several chunks a
|
||||
printed hit can keep its document while changing its chunk, moving the printed
|
||||
cosine while the top-5 list and `top_score` hold. At the shipped 400 the two
|
||||
captures are byte-identical. Reading all 100 rows with the vectors pinned is what
|
||||
separates the two effects. So the gate ran
|
||||
a second time with the question vectors **pinned** (`embed` once, then
|
||||
`measure` both sides from the cache): the 1e-4 tolerance was kept, and the input
|
||||
was made identical instead of the assertion weakened (LOCKED A3).
|
||||
|
||||
## The deterministic gate (pinned vectors, same code path)
|
||||
|
||||
`deterministic_gate_compare.txt` — A = index dropped (the pre-phase behaviour:
|
||||
`Limit → Sort → Hash Join → Seq Scan on chunks`), B = index present at the
|
||||
shipped `ef_search`, question vectors identical on both sides:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| A8 verdicts (all 7) | **identical** — every question `HIGH (answer)`, the out-of-KB Mongolia probe included (`best_cosine=0.6070`, under the 0.62 threshold, but A8 deflects only when the cosine is under it **and** there is no full-text support — it has 5 FTS hits, unchanged by this index) |
|
||||
| Top-5 document sets **and order** (all 7) | **identical** |
|
||||
| Suggested documents (all 7) | **identical** |
|
||||
| `top_score` / `best_cosine` delta | **0.000e+00** (gate ≤ 1e-4) |
|
||||
| Cosine-tier score delta, ranks 1–5 (the fused window) | **0.000e+00** (gate ≤ 1e-4) |
|
||||
| Cosine-tier top-100 **candidate-set overlap** | **1.0000 on all 7** (floor 0.99) |
|
||||
| Rank swaps that are not exact cosine ties | **0** (every swap is a tie broken by access path) |
|
||||
| Tier latency (median over questions) | **38.3 ms → 15.3 ms** |
|
||||
| Plan, index present | `Limit → Nested Loop → Index Scan on ix_chunks_embedding_hnsw` |
|
||||
|
||||
`deterministic_gate_recall.txt` — the same tier forced onto the index with
|
||||
`enable_indexscan = on` at each depth, recall@100 against the exact scan, on
|
||||
**real embeddings**:
|
||||
|
||||
| `ef_search` | mean recall@100 | min recall@100 | max per-chunk cosine delta | max `top_score` delta | tier latency |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 100 (the A7 candidate count — task 02's default) | 0.9814 | 0.94 | 2.195e-03 | **0.000e+00** | 13.8 ms |
|
||||
| 200 | 1.0000 | 1.0000 | 0.0 | 0.000e+00 | 14.1 ms |
|
||||
| 400 (**shipped**) | 1.0000 | 1.0000 | 0.0 | 0.000e+00 | 14.6 ms |
|
||||
|
||||
Two HNSW graph builds were measured (the index is dropped and rebuilt between the
|
||||
OFF and ON runs). The 100-row candidate set at `ef_search = 100` came out
|
||||
0.9814 mean / 0.94 min on one build and 0.9843 / 0.94 on the other; `200` came
|
||||
out 1.0000 / 1.0000 and 0.9971 / 0.99; `400` came out 1.0000 / 1.0000 both times.
|
||||
|
||||
## Decision: `BOR_RETRIEVAL_EF_SEARCH` ships at 400
|
||||
|
||||
* At the task-02 default (`ef_search = 100 == LIMIT`) the **user-visible** numbers
|
||||
did not move at all — verdicts, top-5 sets, suggested documents and
|
||||
`top_score` were identical, to `0.000e+00`. So the task's parity gate passes at
|
||||
100 and the deflection calibration was never in question.
|
||||
* What moved was the **100-row candidate set** the cosine tier hands the RRF
|
||||
fusion: mean 0.9814, min 0.94 — below this phase's own acceptance floor
|
||||
(mean ≥ 0.99, min ≥ 0.95), and matching the counter-case task 03 recorded for
|
||||
high-effective-rank data. One substitution at rank ~89 of 100 was enough to
|
||||
move 22 tail ranks.
|
||||
* LOCKED A3 names exactly one remedy: **raise `ef_search`** (towards 400). The
|
||||
thresholds were not touched. `200` measured borderline (1.0000 on one graph,
|
||||
0.9971 on another); **400 measured exact on both**, and on real embeddings it
|
||||
costs **0.5 ms** (14.1 → 14.6 ms) against the depth-100 search and **23 ms**
|
||||
against the sequential scan it replaces.
|
||||
* The default in `app/config.py` is therefore **400**, still overridable with
|
||||
`BOR_RETRIEVAL_EF_SEARCH`, and phase 188's clamp log line still fires if an
|
||||
operator sets it below the candidate count.
|
||||
|
||||
## Production-order probe (`production_order_*.log`)
|
||||
|
||||
The replica's 8,866 chunks are 13 MB of heap, which makes it the wrong place to
|
||||
ask "does production use the index, and what does it cost?": the relation is an
|
||||
order of magnitude smaller than the one that produced the incident, and pgvector
|
||||
costs an HNSW scan partly on `ef_search`, so a small-relation plan does not
|
||||
transfer. A throwaway database (`bor_scale188b`, created and dropped during
|
||||
this task) answered it at the production row count, using the replica's own
|
||||
embeddings scaled ×47 with each copy multiplied element-wise by a random
|
||||
near-one jitter vector, so all **416,702** vectors are distinct (415,950 unique)
|
||||
and the heap is 557 MB:
|
||||
|
||||
| Measurement | Value |
|
||||
| --- | --- |
|
||||
| Build through the real migration (`uv run alembic upgrade head`, `maintenance_work_mem = 64 MB`) | **161 s**, peak RSS of the alembic process 81 MB |
|
||||
| Index size | **1,625 MB** (vs a 557 MB heap — HNSW at `m = 16`, 768-dim float4 costs ~3.9 KB/row: 414k rows ⇒ plan for ~1.6 GB) |
|
||||
| Plan at `ef_search` 100 / 200 / 400 | **HNSW index scan at every depth** (index path cost 3,333 / 5,621 / 9,806 vs a seq-scan-and-sort alternative an order of magnitude above) |
|
||||
| Tier latency | **~550 ms sequential → 12.0–14.9 ms** with the index (≈ **40×**), the ~1 ms `SET LOCAL` included |
|
||||
| Top-100 overlap vs the exact scan | 0.23–0.64, **not a valid recall measurement** — see the caveat below |
|
||||
|
||||
**Caveat, recorded because it is a limitation of this gate.** A corpus built by
|
||||
jittering 8,866 real embeddings into 47 near-copies each is a pathological graph
|
||||
for a top-100 recall metric: "the exact top-100" is then 100 knife-edge slots
|
||||
inside a handful of near-duplicate clusters, which is not the shape of a real KB.
|
||||
The numbers are reported so the latency, plan and build-cost readings at
|
||||
production row count are on the record, and the **recall** verdict is taken from
|
||||
the live replica, where every vector is a real, distinct chunk. After the
|
||||
production build, the owner should re-run the battery on the real 414k-chunk KB
|
||||
exactly as this task did on the replica; `tests/integration/test_chunks_hnsw_recall.py`
|
||||
now fails (rather than passing vacuously) if a search at the shipped depth is not
|
||||
actually reading the index.
|
||||
|
||||
## Task 05 re-verification — the battery at the shipped default, from scratch
|
||||
|
||||
`task05_battery_index_off.txt` / `task05_battery_index_on.txt` are a clean-room
|
||||
re-run of the same gate, performed by task 05 to confirm the verdict still holds
|
||||
at the shipped `BOR_RETRIEVAL_EF_SEARCH = 400` before the phase shipped. Nothing
|
||||
was reused from task 04's state: the dump was restored into a **throwaway
|
||||
database** (`bor_p188gate`, created and dropped inside the run, so the dev
|
||||
database was never touched), which lands at `alembic_version = 0011` and was
|
||||
taken to **`0028`** (index absent — the OFF arm), then to **`head`** through the
|
||||
real migration (0029 built `ix_chunks_embedding_hnsw` against the 8,866 real
|
||||
embeddings: **valid**, **34 MB**, ~1 s wall clock, `alembic` peak RSS **81 MB**
|
||||
— the ON arm). Both arms ran
|
||||
`uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt`
|
||||
with live embeddings against live Postgres.
|
||||
|
||||
**The two captures are byte-identical** (Python string equality, not a diff
|
||||
summary): all seven `best_cosine` values, all seven `fts_hits` counts, all 35
|
||||
numbered hits with their scores, and all seven gate verdicts — five `HIGH`, the
|
||||
Mongolia probe `HIGH` on its 5 FTS hits with `best_cosine = 0.6070` under the
|
||||
0.62 threshold, and the two Besta questions behaving as out-of-KB asks on a
|
||||
replica that has no IKEA document (`grep -c -i ikea` in the dump = 0, verified
|
||||
before drawing any conclusion from them).
|
||||
|
||||
| Arm | Schema state | Index | Battery capture |
|
||||
| --- | --- | --- | --- |
|
||||
| OFF | `0028` | absent | `task05_battery_index_off.txt` |
|
||||
| ON | `0029` (head) | valid, 34 MB | `task05_battery_index_on.txt` |
|
||||
| Equality | — | — | **identical, byte for byte** |
|
||||
|
||||
## Finding: `maintenance_work_mem = 512MB` cannot be used in this container
|
||||
|
||||
```
|
||||
ERROR: could not resize shared memory segment "/PostgreSQL.3366409952" to 533761504 bytes: No space left on device
|
||||
```
|
||||
|
||||
`brain-of-reese_db_1` has a **63 MB `/dev/shm`**, so any `maintenance_work_mem`
|
||||
above ~63 MB makes a parallel HNSW build die, and the failed build leaves an
|
||||
**INVALID** index behind. This is a deviation from D5, which suggested
|
||||
`SET maintenance_work_mem = '512MB'`: on this host the value is not applicable,
|
||||
and the container's **64 MB default builds the index successfully**. The failure
|
||||
was observed live at production order, and it exercised the self-heal for real:
|
||||
migration 0029 detected `indisvalid = false`, logged
|
||||
`dropping INVALID … rebuilding`, dropped it and rebuilt it valid. The task's
|
||||
recorded command is therefore "run `alembic upgrade head` as-is"; raising
|
||||
`maintenance_work_mem` is only worth doing after `/dev/shm` is raised too
|
||||
(`podman run --shm-size=1g`), and a raise that fails is recoverable.
|
||||
@@ -0,0 +1,64 @@
|
||||
eval: threshold=0.62 vector_candidates=100 lexical_candidates=30 rrf_k=60 recency_boost=0.0007 recency_half_life_days=365 name_hit_bonus=0.005
|
||||
|
||||
question: 'How do I deploy gitea?'
|
||||
gate: best_cosine=0.8528 fts_hits=38 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml cosine=0.7640 fts=1 effective=0.03056 created=2026-10-17 (playbook)
|
||||
2. deploy/Deployments/reeseapps/gateway/README.md cosine=0.7736 fts=1 effective=0.02902 created=2026-10-17 (Proxy Server)
|
||||
3. homelab/active/container_gitea/gitea.md cosine=0.7612 fts=1 effective=0.02561 created=2026-10-17 (Gitea)
|
||||
4. homelab/active/kubernetes_gitea/gitea-values.yaml cosine=0.7232 fts=1 effective=0.02512 created=2026-10-17 (gitea-values)
|
||||
5. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 cosine=0.7159 fts=1 effective=0.02495 created=2026-10-17 (gitea-postgres.env)
|
||||
suggested: 1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml 2. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 3. deploy/Deployments/reeseapps/gateway/README.md 4. deploy/Deployments/reeseapps/gitea/README.md 5. deploy/Deployments/reeseapps/gitea/app/gitea-web.env.j2
|
||||
|
||||
question: 'How do I deploy forgejo?'
|
||||
gate: best_cosine=0.7876 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/templates/container/foobar.md cosine=0.7876 fts=0 effective=0.01709 created=2026-10-17 (Podman foobar)
|
||||
2. deploy/Deployments/reeseapps/forgejo/README.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Reeseapps Forgejo)
|
||||
3. deploy/Deployments/reeseapps/README.md cosine=0.7857 fts=0 effective=0.01683 created=2026-10-17 (Deployments)
|
||||
4. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (forgejo-app)
|
||||
5. homelab/active/container_bifrost/bifrost.md cosine=0.7672 fts=0 effective=0.01657 created=2026-10-17 (Podman bifrost)
|
||||
suggested: 1. deploy/Deployments/reeseapps/forgejo/README.md 2. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container 3. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.env.j2 4. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.container 5. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.env.j2
|
||||
|
||||
question: 'How do I set up the gateway?'
|
||||
gate: best_cosine=0.7816 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/active/device_shelly/shelly.md cosine=0.6940 fts=1 effective=0.02509 created=2026-10-17 (Shelly Devices)
|
||||
2. deploy/Deployments/reeseapps/forgejo/app/forgejo.network cosine=0.6727 fts=1 effective=0.02050 created=2026-10-17 (forgejo)
|
||||
3. homelab/active/device_framework_16/framework_16.md cosine=0.7816 fts=0 effective=0.01709 created=2026-10-17 (Framework Laptop 16)
|
||||
4. deploy/Deployments/delta/istio/gateway-metallb.yaml cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (gateway-metallb)
|
||||
5. homelab/active/device_home_assistant/home_assistant.md cosine=0.7752 fts=0 effective=0.01683 created=2026-10-17 (Home Assistant)
|
||||
suggested: 1. homelab/active/device_shelly/shelly.md 2. deploy/Deployments/delta/istio/gateway-metallb.yaml 3. deploy/Deployments/delta/istio/test-gateway.yaml 4. deploy/Deployments/reeseapps/gateway/README.md 5. deploy/Deployments/reeseapps/gateway/certbot/playbook.yaml
|
||||
|
||||
question: 'Do you have notes on the qwen 3.8 27b model?'
|
||||
gate: best_cosine=0.7483 fts_hits=31 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container cosine=0.7402 fts=1 effective=0.03322 created=2026-10-17 (qwen3.8-27b-epic-vulkan)
|
||||
2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container cosine=0.7142 fts=1 effective=0.03058 created=2026-10-17 (qwen3.8-27b-juggernaut-vulkan)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.6837 fts=1 effective=0.02570 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/software_ai_stack/ai_stack.md cosine=0.7029 fts=1 effective=0.02545 created=2026-10-17 (Self Hosted AI Stack)
|
||||
5. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container cosine=0.6675 fts=1 effective=0.02491 created=2026-10-17 (qwen3.8-flash-next)
|
||||
suggested: 1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container 2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container 3. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container 4. homelab/active/container_localai/localai.md 5. homelab/active/software_ai_stack/ai_stack.md
|
||||
|
||||
question: 'What is the capital of Mongolia?'
|
||||
gate: best_cosine=0.6070 fts_hits=5 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/README.md cosine=0.5588 fts=1 effective=0.02382 created=2026-10-17 (Deployments)
|
||||
2. homelab/active/container_nginx/nginx.md cosine=0.6070 fts=0 effective=0.01709 created=2026-10-17 (Ngnix)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/os_arch/arch-workstation.md cosine=0.6059 fts=0 effective=0.01683 created=2026-10-17 (Workstation)
|
||||
5. homelab/active/software_k3s/k3s.md cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (K3S)
|
||||
suggested: 1. deploy/Deployments/reeseapps/README.md 2. homelab/active/container_nginx/nginx.md 3. homelab/active/container_localai/localai.md 4. homelab/active/os_arch/arch-workstation.md 5. homelab/active/software_k3s/k3s.md
|
||||
|
||||
question: 'How do I build a Besta?'
|
||||
gate: best_cosine=0.7078 fts_hits=33 -> HIGH (answer)
|
||||
1. homelab/active/container_bifrost/bifrost.md cosine=0.7078 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
2. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
3. vibe-bot/README.md cosine=0.6962 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
4. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
5. vibe-bot/build.sh cosine=0.0000 fts=1 effective=0.01657 created=2026-10-17 (build)
|
||||
suggested: 1. homelab/active/software_distoolbox/arch-build.sh 2. homepage/build.sh 3. vibe-bot/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
|
||||
question: 'How do I build the BESTÅ?'
|
||||
gate: best_cosine=0.6969 fts_hits=33 -> HIGH (answer)
|
||||
1. vibe-bot/build.sh cosine=0.6267 fts=1 effective=0.02519 created=2026-10-17 (build)
|
||||
2. homelab/active/container_bifrost/bifrost.md cosine=0.6969 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
3. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
4. vibe-bot/README.md cosine=0.6887 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
5. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
suggested: 1. vibe-bot/build.sh 2. homelab/active/software_distoolbox/arch-build.sh 3. homepage/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
@@ -0,0 +1,64 @@
|
||||
eval: threshold=0.62 vector_candidates=100 lexical_candidates=30 rrf_k=60 recency_boost=0.0007 recency_half_life_days=365 name_hit_bonus=0.005
|
||||
|
||||
question: 'How do I deploy gitea?'
|
||||
gate: best_cosine=0.8528 fts_hits=38 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml cosine=0.7640 fts=1 effective=0.03056 created=2026-10-17 (playbook)
|
||||
2. deploy/Deployments/reeseapps/gateway/README.md cosine=0.7736 fts=1 effective=0.02902 created=2026-10-17 (Proxy Server)
|
||||
3. homelab/active/container_gitea/gitea.md cosine=0.7612 fts=1 effective=0.02561 created=2026-10-17 (Gitea)
|
||||
4. homelab/active/kubernetes_gitea/gitea-values.yaml cosine=0.7232 fts=1 effective=0.02512 created=2026-10-17 (gitea-values)
|
||||
5. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 cosine=0.7159 fts=1 effective=0.02495 created=2026-10-17 (gitea-postgres.env)
|
||||
suggested: 1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml 2. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 3. deploy/Deployments/reeseapps/gateway/README.md 4. deploy/Deployments/reeseapps/gitea/README.md 5. deploy/Deployments/reeseapps/gitea/app/gitea-web.env.j2
|
||||
|
||||
question: 'How do I deploy forgejo?'
|
||||
gate: best_cosine=0.7876 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/templates/container/foobar.md cosine=0.7876 fts=0 effective=0.01709 created=2026-10-17 (Podman foobar)
|
||||
2. deploy/Deployments/reeseapps/forgejo/README.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Reeseapps Forgejo)
|
||||
3. deploy/Deployments/reeseapps/README.md cosine=0.7857 fts=0 effective=0.01683 created=2026-10-17 (Deployments)
|
||||
4. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (forgejo-app)
|
||||
5. homelab/active/container_bifrost/bifrost.md cosine=0.7672 fts=0 effective=0.01657 created=2026-10-17 (Podman bifrost)
|
||||
suggested: 1. deploy/Deployments/reeseapps/forgejo/README.md 2. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container 3. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.env.j2 4. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.container 5. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.env.j2
|
||||
|
||||
question: 'How do I set up the gateway?'
|
||||
gate: best_cosine=0.7816 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/active/device_shelly/shelly.md cosine=0.6940 fts=1 effective=0.02509 created=2026-10-17 (Shelly Devices)
|
||||
2. deploy/Deployments/reeseapps/forgejo/app/forgejo.network cosine=0.6727 fts=1 effective=0.02050 created=2026-10-17 (forgejo)
|
||||
3. homelab/active/device_framework_16/framework_16.md cosine=0.7816 fts=0 effective=0.01709 created=2026-10-17 (Framework Laptop 16)
|
||||
4. deploy/Deployments/delta/istio/gateway-metallb.yaml cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (gateway-metallb)
|
||||
5. homelab/active/device_home_assistant/home_assistant.md cosine=0.7752 fts=0 effective=0.01683 created=2026-10-17 (Home Assistant)
|
||||
suggested: 1. homelab/active/device_shelly/shelly.md 2. deploy/Deployments/delta/istio/gateway-metallb.yaml 3. deploy/Deployments/delta/istio/test-gateway.yaml 4. deploy/Deployments/reeseapps/gateway/README.md 5. deploy/Deployments/reeseapps/gateway/certbot/playbook.yaml
|
||||
|
||||
question: 'Do you have notes on the qwen 3.8 27b model?'
|
||||
gate: best_cosine=0.7483 fts_hits=31 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container cosine=0.7402 fts=1 effective=0.03322 created=2026-10-17 (qwen3.8-27b-epic-vulkan)
|
||||
2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container cosine=0.7142 fts=1 effective=0.03058 created=2026-10-17 (qwen3.8-27b-juggernaut-vulkan)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.6837 fts=1 effective=0.02570 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/software_ai_stack/ai_stack.md cosine=0.7029 fts=1 effective=0.02545 created=2026-10-17 (Self Hosted AI Stack)
|
||||
5. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container cosine=0.6675 fts=1 effective=0.02491 created=2026-10-17 (qwen3.8-flash-next)
|
||||
suggested: 1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container 2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container 3. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container 4. homelab/active/container_localai/localai.md 5. homelab/active/software_ai_stack/ai_stack.md
|
||||
|
||||
question: 'What is the capital of Mongolia?'
|
||||
gate: best_cosine=0.6070 fts_hits=5 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/README.md cosine=0.5588 fts=1 effective=0.02382 created=2026-10-17 (Deployments)
|
||||
2. homelab/active/container_nginx/nginx.md cosine=0.6070 fts=0 effective=0.01709 created=2026-10-17 (Ngnix)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/os_arch/arch-workstation.md cosine=0.6059 fts=0 effective=0.01683 created=2026-10-17 (Workstation)
|
||||
5. homelab/active/software_k3s/k3s.md cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (K3S)
|
||||
suggested: 1. deploy/Deployments/reeseapps/README.md 2. homelab/active/container_nginx/nginx.md 3. homelab/active/container_localai/localai.md 4. homelab/active/os_arch/arch-workstation.md 5. homelab/active/software_k3s/k3s.md
|
||||
|
||||
question: 'How do I build a Besta?'
|
||||
gate: best_cosine=0.7078 fts_hits=33 -> HIGH (answer)
|
||||
1. homelab/active/container_bifrost/bifrost.md cosine=0.7078 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
2. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
3. vibe-bot/README.md cosine=0.6962 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
4. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
5. vibe-bot/build.sh cosine=0.0000 fts=1 effective=0.01657 created=2026-10-17 (build)
|
||||
suggested: 1. homelab/active/software_distoolbox/arch-build.sh 2. homepage/build.sh 3. vibe-bot/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
|
||||
question: 'How do I build the BESTÅ?'
|
||||
gate: best_cosine=0.6969 fts_hits=33 -> HIGH (answer)
|
||||
1. vibe-bot/build.sh cosine=0.6267 fts=1 effective=0.02519 created=2026-10-17 (build)
|
||||
2. homelab/active/container_bifrost/bifrost.md cosine=0.6969 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
3. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
4. vibe-bot/README.md cosine=0.6887 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
5. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
suggested: 1. vibe-bot/build.sh 2. homelab/active/software_distoolbox/arch-build.sh 3. homepage/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
@@ -0,0 +1,19 @@
|
||||
A=/tmp/final_off.json index_present=0 ef=400
|
||||
B=/tmp/final_on.json index_present=1 ef=400
|
||||
'How do I deploy gitea?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 2 (all exact ties)
|
||||
'How do I deploy forgejo?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 10 (all exact ties)
|
||||
'How do I set up the gateway?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 10 (all exact ties)
|
||||
'Do you have notes on the qwen 3.8 27b model?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 10 (all exact ties)
|
||||
'What is the capital of Mongolia?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 20 (all exact ties)
|
||||
'How do I build a Besta?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 9 (all exact ties)
|
||||
'How do I build the BESTÅ?': verdict HIGH (answer) / HIGH (answer) | top5 SAME | suggested SAME | candidate-set overlap 1.0000 | best_cosine delta 0.000e+00 | rank swaps 17 (all exact ties)
|
||||
|
||||
max top_score (best_cosine) delta = 0.000e+00 (gate <= 1e-4)
|
||||
max in-window (rank <= 5) tier cosine delta = 0.000e+00 (gate <= 1e-4)
|
||||
max tier cosine delta over all 100 ranks = 0.000e+00 (reported only)
|
||||
tail note: ranks below the fused window are gated as the candidate-SET overlap (>= 0.99), not as a score delta: a graph search may hand back a different tail chunk at a near-identical score without moving anything the turn reads.
|
||||
A plan(exact)=['Limit', 'Sort'] plan(app)=['Limit', 'Sort']
|
||||
B plan(exact)=['Limit', 'Sort'] plan(app)=['Limit', 'Nested Loop']
|
||||
tier latency ms (median over questions): A exact=37.45 app=38.252 | B exact=39.12 app=15.317
|
||||
|
||||
GATE PASS: identical verdicts, identical top-5 sets and order, deltas <= 1e-4
|
||||
@@ -0,0 +1,20 @@
|
||||
forced100: mean recall@100=0.9814 min=0.9400 max per-chunk cosine delta=2.195e-03 max top_score delta=0.000e+00 latency=13.753ms
|
||||
forced200: mean recall@100=1.0000 min=1.0000 max per-chunk cosine delta=0.000e+00 max top_score delta=0.000e+00 latency=14.111ms
|
||||
forced400: mean recall@100=1.0000 min=1.0000 max per-chunk cosine delta=0.000e+00 max top_score delta=0.000e+00 latency=14.614ms
|
||||
|
||||
pipeline parity (A8 verdict / top-5 / suggested), planner vs forced index:
|
||||
'How do I deploy gitea?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
'How do I deploy forgejo?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
'How do I set up the gateway?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
'Do you have notes on the qwen 3.8 27b model?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
'What is the capital of Mongolia?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
'How do I build a Besta?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
'How do I build the BESTÅ?': verdict SAME (HIGH (answer) / HIGH (answer)) | top5 SAME | suggested SAME | top_score delta 0.000e+00
|
||||
|
||||
plans (vacuity guards):
|
||||
exact: ['Limit', 'Sort', 'Hash Join']
|
||||
forced index (pipeline): ['Limit', 'Nested Loop', 'Index Scan on ix_chunks_embedding_hnsw']
|
||||
|
||||
latency ms over questions: exact(seq)=39.12 planner=15.317 forced100=13.753 forced200=14.111 forced400=14.614
|
||||
|
||||
GATE PASS: the approximate path returns the exact verdicts, top-5s and top_scores
|
||||
@@ -0,0 +1,12 @@
|
||||
corpus: 416702 chunks, heap 557 MB, index 1625 MB
|
||||
index definition: CREATE INDEX ix_chunks_embedding_hnsw ON public.chunks USING hnsw (embedding vector_cosine_ops) WITH (m='16', ef_construction='64')
|
||||
|
||||
--- the planner's own choice at production scale ---
|
||||
'How do I deploy gitea?' ef=100:HNSW(3333) ef=200:HNSW(5621) ef=400:HNSW(9806)
|
||||
'How do I deploy forgejo?' ef=100:HNSW(3333) ef=200:HNSW(5621) ef=400:HNSW(9806)
|
||||
'How do I set up the gateway?' ef=100:HNSW(3333) ef=200:HNSW(5621) ef=400:HNSW(9806)
|
||||
|
||||
--- latency + recall (production order) ---
|
||||
'How do I deploy gitea?' exact_seq=549.5ms ef100: 12.4ms overlap=0.230 topΔ=7.9e-02 ef200: 11.9ms overlap=0.230 topΔ=7.9e-02 ef400: 12.5ms overlap=0.230 topΔ=7.9e-02
|
||||
'How do I deploy forgejo?' exact_seq=545.8ms ef100: 12.1ms overlap=0.470 topΔ=0.0e+00 ef200: 12.9ms overlap=0.470 topΔ=0.0e+00 ef400: 12.0ms overlap=1.000 topΔ=0.0e+00
|
||||
'How do I set up the gateway?' exact_seq=553.1ms ef100: 13.4ms overlap=0.640 topΔ=0.0e+00 ef200: 14.9ms overlap=0.640 topΔ=0.0e+00 ef400: 14.3ms overlap=0.640 topΔ=0.0e+00
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
|
||||
eval: threshold=0.62 vector_candidates=100 lexical_candidates=30 rrf_k=60 recency_boost=0.0007 recency_half_life_days=365 name_hit_bonus=0.005
|
||||
|
||||
question: 'How do I deploy gitea?'
|
||||
gate: best_cosine=0.8528 fts_hits=38 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml cosine=0.7640 fts=1 effective=0.03056 created=2026-10-17 (playbook)
|
||||
2. deploy/Deployments/reeseapps/gateway/README.md cosine=0.7736 fts=1 effective=0.02902 created=2026-10-17 (Proxy Server)
|
||||
3. homelab/active/container_gitea/gitea.md cosine=0.7612 fts=1 effective=0.02561 created=2026-10-17 (Gitea)
|
||||
4. homelab/active/kubernetes_gitea/gitea-values.yaml cosine=0.7232 fts=1 effective=0.02512 created=2026-10-17 (gitea-values)
|
||||
5. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 cosine=0.7159 fts=1 effective=0.02495 created=2026-10-17 (gitea-postgres.env)
|
||||
suggested: 1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml 2. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 3. deploy/Deployments/reeseapps/gateway/README.md 4. deploy/Deployments/reeseapps/gitea/README.md 5. deploy/Deployments/reeseapps/gitea/app/gitea-web.env.j2
|
||||
|
||||
question: 'How do I deploy forgejo?'
|
||||
gate: best_cosine=0.7876 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/templates/container/foobar.md cosine=0.7876 fts=0 effective=0.01709 created=2026-10-17 (Podman foobar)
|
||||
2. deploy/Deployments/reeseapps/forgejo/README.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Reeseapps Forgejo)
|
||||
3. deploy/Deployments/reeseapps/README.md cosine=0.7857 fts=0 effective=0.01683 created=2026-10-17 (Deployments)
|
||||
4. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (forgejo-app)
|
||||
5. homelab/active/container_bifrost/bifrost.md cosine=0.7672 fts=0 effective=0.01657 created=2026-10-17 (Podman bifrost)
|
||||
suggested: 1. deploy/Deployments/reeseapps/forgejo/README.md 2. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container 3. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.env.j2 4. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.container 5. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.env.j2
|
||||
|
||||
question: 'How do I set up the gateway?'
|
||||
gate: best_cosine=0.7816 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/active/device_shelly/shelly.md cosine=0.6940 fts=1 effective=0.02509 created=2026-10-17 (Shelly Devices)
|
||||
2. deploy/Deployments/reeseapps/forgejo/app/forgejo.network cosine=0.6727 fts=1 effective=0.02050 created=2026-10-17 (forgejo)
|
||||
3. homelab/active/device_framework_16/framework_16.md cosine=0.7816 fts=0 effective=0.01709 created=2026-10-17 (Framework Laptop 16)
|
||||
4. deploy/Deployments/delta/istio/gateway-metallb.yaml cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (gateway-metallb)
|
||||
5. homelab/active/device_home_assistant/home_assistant.md cosine=0.7752 fts=0 effective=0.01683 created=2026-10-17 (Home Assistant)
|
||||
suggested: 1. homelab/active/device_shelly/shelly.md 2. deploy/Deployments/delta/istio/gateway-metallb.yaml 3. deploy/Deployments/delta/istio/test-gateway.yaml 4. deploy/Deployments/reeseapps/gateway/README.md 5. deploy/Deployments/reeseapps/gateway/certbot/playbook.yaml
|
||||
|
||||
question: 'Do you have notes on the qwen 3.8 27b model?'
|
||||
gate: best_cosine=0.7483 fts_hits=31 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container cosine=0.7402 fts=1 effective=0.03322 created=2026-10-17 (qwen3.8-27b-epic-vulkan)
|
||||
2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container cosine=0.7142 fts=1 effective=0.03058 created=2026-10-17 (qwen3.8-27b-juggernaut-vulkan)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.6837 fts=1 effective=0.02570 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/software_ai_stack/ai_stack.md cosine=0.7029 fts=1 effective=0.02545 created=2026-10-17 (Self Hosted AI Stack)
|
||||
5. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container cosine=0.6675 fts=1 effective=0.02491 created=2026-10-17 (qwen3.8-flash-next)
|
||||
suggested: 1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container 2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container 3. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container 4. homelab/active/container_localai/localai.md 5. homelab/active/software_ai_stack/ai_stack.md
|
||||
|
||||
question: 'What is the capital of Mongolia?'
|
||||
gate: best_cosine=0.6070 fts_hits=5 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/README.md cosine=0.5588 fts=1 effective=0.02382 created=2026-10-17 (Deployments)
|
||||
2. homelab/active/container_nginx/nginx.md cosine=0.6070 fts=0 effective=0.01709 created=2026-10-17 (Ngnix)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/os_arch/arch-workstation.md cosine=0.6059 fts=0 effective=0.01683 created=2026-10-17 (Workstation)
|
||||
5. homelab/active/software_k3s/k3s.md cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (K3S)
|
||||
suggested: 1. deploy/Deployments/reeseapps/README.md 2. homelab/active/container_nginx/nginx.md 3. homelab/active/container_localai/localai.md 4. homelab/active/os_arch/arch-workstation.md 5. homelab/active/software_k3s/k3s.md
|
||||
|
||||
question: 'How do I build a Besta?'
|
||||
gate: best_cosine=0.7078 fts_hits=33 -> HIGH (answer)
|
||||
1. homelab/active/container_bifrost/bifrost.md cosine=0.7078 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
2. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
3. vibe-bot/README.md cosine=0.6962 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
4. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
5. vibe-bot/build.sh cosine=0.0000 fts=1 effective=0.01657 created=2026-10-17 (build)
|
||||
suggested: 1. homelab/active/software_distoolbox/arch-build.sh 2. homepage/build.sh 3. vibe-bot/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
|
||||
question: 'How do I build the BESTÅ?'
|
||||
gate: best_cosine=0.6969 fts_hits=33 -> HIGH (answer)
|
||||
1. vibe-bot/build.sh cosine=0.6267 fts=1 effective=0.02519 created=2026-10-17 (build)
|
||||
2. homelab/active/container_bifrost/bifrost.md cosine=0.6969 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
3. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
4. vibe-bot/README.md cosine=0.6887 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
5. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
suggested: 1. vibe-bot/build.sh 2. homelab/active/software_distoolbox/arch-build.sh 3. homepage/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
EXIT=0
|
||||
@@ -0,0 +1,65 @@
|
||||
eval: threshold=0.62 vector_candidates=100 lexical_candidates=30 rrf_k=60 recency_boost=0.0007 recency_half_life_days=365 name_hit_bonus=0.005
|
||||
|
||||
question: 'How do I deploy gitea?'
|
||||
gate: best_cosine=0.8528 fts_hits=38 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml cosine=0.7640 fts=1 effective=0.03056 created=2026-10-17 (playbook)
|
||||
2. deploy/Deployments/reeseapps/gateway/README.md cosine=0.7736 fts=1 effective=0.02902 created=2026-10-17 (Proxy Server)
|
||||
3. homelab/active/container_gitea/gitea.md cosine=0.7612 fts=1 effective=0.02561 created=2026-10-17 (Gitea)
|
||||
4. homelab/active/kubernetes_gitea/gitea-values.yaml cosine=0.7232 fts=1 effective=0.02512 created=2026-10-17 (gitea-values)
|
||||
5. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 cosine=0.7159 fts=1 effective=0.02495 created=2026-10-17 (gitea-postgres.env)
|
||||
suggested: 1. deploy/Deployments/reeseapps/gitea/app/playbook.yaml 2. deploy/Deployments/reeseapps/gitea/app/gitea-postgres.env.j2 3. deploy/Deployments/reeseapps/gateway/README.md 4. deploy/Deployments/reeseapps/gitea/README.md 5. deploy/Deployments/reeseapps/gitea/app/gitea-web.env.j2
|
||||
|
||||
question: 'How do I deploy forgejo?'
|
||||
gate: best_cosine=0.7876 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/templates/container/foobar.md cosine=0.7876 fts=0 effective=0.01709 created=2026-10-17 (Podman foobar)
|
||||
2. deploy/Deployments/reeseapps/forgejo/README.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Reeseapps Forgejo)
|
||||
3. deploy/Deployments/reeseapps/README.md cosine=0.7857 fts=0 effective=0.01683 created=2026-10-17 (Deployments)
|
||||
4. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (forgejo-app)
|
||||
5. homelab/active/container_bifrost/bifrost.md cosine=0.7672 fts=0 effective=0.01657 created=2026-10-17 (Podman bifrost)
|
||||
suggested: 1. deploy/Deployments/reeseapps/forgejo/README.md 2. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.container 3. deploy/Deployments/reeseapps/forgejo/app/forgejo-app.env.j2 4. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.container 5. deploy/Deployments/reeseapps/forgejo/app/forgejo-postgres.env.j2
|
||||
|
||||
question: 'How do I set up the gateway?'
|
||||
gate: best_cosine=0.7816 fts_hits=37 -> HIGH (answer)
|
||||
1. homelab/active/device_shelly/shelly.md cosine=0.6940 fts=1 effective=0.02509 created=2026-10-17 (Shelly Devices)
|
||||
2. deploy/Deployments/reeseapps/forgejo/app/forgejo.network cosine=0.6727 fts=1 effective=0.02050 created=2026-10-17 (forgejo)
|
||||
3. homelab/active/device_framework_16/framework_16.md cosine=0.7816 fts=0 effective=0.01709 created=2026-10-17 (Framework Laptop 16)
|
||||
4. deploy/Deployments/delta/istio/gateway-metallb.yaml cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (gateway-metallb)
|
||||
5. homelab/active/device_home_assistant/home_assistant.md cosine=0.7752 fts=0 effective=0.01683 created=2026-10-17 (Home Assistant)
|
||||
suggested: 1. homelab/active/device_shelly/shelly.md 2. deploy/Deployments/delta/istio/gateway-metallb.yaml 3. deploy/Deployments/delta/istio/test-gateway.yaml 4. deploy/Deployments/reeseapps/gateway/README.md 5. deploy/Deployments/reeseapps/gateway/certbot/playbook.yaml
|
||||
|
||||
question: 'Do you have notes on the qwen 3.8 27b model?'
|
||||
gate: best_cosine=0.7483 fts_hits=31 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container cosine=0.7402 fts=1 effective=0.03322 created=2026-10-17 (qwen3.8-27b-epic-vulkan)
|
||||
2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container cosine=0.7142 fts=1 effective=0.03058 created=2026-10-17 (qwen3.8-27b-juggernaut-vulkan)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.6837 fts=1 effective=0.02570 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/software_ai_stack/ai_stack.md cosine=0.7029 fts=1 effective=0.02545 created=2026-10-17 (Self Hosted AI Stack)
|
||||
5. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container cosine=0.6675 fts=1 effective=0.02491 created=2026-10-17 (qwen3.8-flash-next)
|
||||
suggested: 1. deploy/Deployments/reeseapps/ai/deployments/epic/quadlets/qwen3.8-27b-epic-vulkan.container 2. deploy/Deployments/reeseapps/ai/deployments/juggernaut/quadlets/qwen3.8-27b-juggernaut-vulkan.container 3. deploy/Deployments/reeseapps/ai/deployments/turbo/quadlets/qwen3.8-flash-next.container 4. homelab/active/container_localai/localai.md 5. homelab/active/software_ai_stack/ai_stack.md
|
||||
|
||||
question: 'What is the capital of Mongolia?'
|
||||
gate: best_cosine=0.6070 fts_hits=5 -> HIGH (answer)
|
||||
1. deploy/Deployments/reeseapps/README.md cosine=0.5588 fts=1 effective=0.02382 created=2026-10-17 (Deployments)
|
||||
2. homelab/active/container_nginx/nginx.md cosine=0.6070 fts=0 effective=0.01709 created=2026-10-17 (Ngnix)
|
||||
3. homelab/active/container_localai/localai.md cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (Local AI with Anything LLM)
|
||||
4. homelab/active/os_arch/arch-workstation.md cosine=0.6059 fts=0 effective=0.01683 created=2026-10-17 (Workstation)
|
||||
5. homelab/active/software_k3s/k3s.md cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (K3S)
|
||||
suggested: 1. deploy/Deployments/reeseapps/README.md 2. homelab/active/container_nginx/nginx.md 3. homelab/active/container_localai/localai.md 4. homelab/active/os_arch/arch-workstation.md 5. homelab/active/software_k3s/k3s.md
|
||||
|
||||
question: 'How do I build a Besta?'
|
||||
gate: best_cosine=0.7078 fts_hits=33 -> HIGH (answer)
|
||||
1. homelab/active/container_bifrost/bifrost.md cosine=0.7078 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
2. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
3. vibe-bot/README.md cosine=0.6962 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
4. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
5. vibe-bot/build.sh cosine=0.0000 fts=1 effective=0.01657 created=2026-10-17 (build)
|
||||
suggested: 1. homelab/active/software_distoolbox/arch-build.sh 2. homepage/build.sh 3. vibe-bot/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
|
||||
question: 'How do I build the BESTÅ?'
|
||||
gate: best_cosine=0.6969 fts_hits=33 -> HIGH (answer)
|
||||
1. vibe-bot/build.sh cosine=0.6267 fts=1 effective=0.02519 created=2026-10-17 (build)
|
||||
2. homelab/active/container_bifrost/bifrost.md cosine=0.6969 fts=0 effective=0.01709 created=2026-10-17 (Podman bifrost)
|
||||
3. homelab/active/software_distoolbox/arch-build.sh cosine=0.0000 fts=1 effective=0.01709 created=2026-10-17 (arch-build)
|
||||
4. vibe-bot/README.md cosine=0.6887 fts=0 effective=0.01683 created=2026-10-17 (Vibe Discord Bot with RAG Chat History)
|
||||
5. homepage/build.sh cosine=0.0000 fts=1 effective=0.01683 created=2026-10-17 (build)
|
||||
suggested: 1. vibe-bot/build.sh 2. homelab/active/software_distoolbox/arch-build.sh 3. homepage/build.sh 4. homelab/active/container_bifrost/bifrost.md 5. vibe-bot/README.md
|
||||
EXIT=0
|
||||
@@ -109,6 +109,15 @@ os.environ["BOR_ANSWER_TEXT"] = str(_Settings.model_fields["answer_text"].defaul
|
||||
os.environ["BOR_SHOW_TEXT_MAX_CHARS"] = str(
|
||||
_Settings.model_fields["show_text_max_chars"].default
|
||||
)
|
||||
|
||||
# Phase 188: the same leak class for the HNSW ``ef_search`` knob — an
|
||||
# operator's shell may legitimately carry ``BOR_RETRIEVAL_EF_SEARCH``
|
||||
# raised after the recall battery, and the default / clamp / statement-text
|
||||
# pins (unit + integration) must see the code default (100 = the A7
|
||||
# candidate count, so a default run never clamps).
|
||||
os.environ["BOR_RETRIEVAL_EF_SEARCH"] = str(
|
||||
_Settings.model_fields["retrieval_ef_search"].default
|
||||
)
|
||||
os.environ["BOR_SHOW_TEXT_MAX_BLOCKS"] = str(
|
||||
_Settings.model_fields["show_text_max_blocks"].default
|
||||
)
|
||||
|
||||
@@ -156,6 +156,14 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
||||
env["BOR_SHOW_TEXT_MAX_BLOCKS"] = str(
|
||||
_Settings.model_fields["show_text_max_blocks"].default
|
||||
)
|
||||
# Phase 188: the same leak guard for the HNSW ``ef_search`` knob — an
|
||||
# operator's shell may legitimately carry ``BOR_RETRIEVAL_EF_SEARCH``
|
||||
# raised after the recall battery, and the story suites' retrieval pins
|
||||
# must see the CODE default (100 = the A7 candidate count, so a default
|
||||
# run never clamps).
|
||||
env["BOR_RETRIEVAL_EF_SEARCH"] = str(
|
||||
_Settings.model_fields["retrieval_ef_search"].default
|
||||
)
|
||||
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
"""Phase 188 (task 05) E2E (Playwright, mock-only): the ``chunks.embedding``
|
||||
HNSW index reaches the owner's chat, changes nothing the owner sees, and the
|
||||
one retrieval knob the phase adds is honoured on a real turn.
|
||||
|
||||
Source: owner incident 2026-10-15. ``pg_indexes`` for ``chunks`` listed the
|
||||
PK, ``ix_chunks_document_id`` and ``ix_chunks_tsv`` and NOTHING on
|
||||
``embedding``, so every turn's A7 cosine tier (``ORDER BY embedding <=> :vec
|
||||
LIMIT 100``) full-scanned a ~3 GB, 414,220-row relation on the interactive
|
||||
path. Migration ``0029`` adds ``ix_chunks_embedding_hnsw`` (HNSW,
|
||||
``vector_cosine_ops``, ``m = 16``, ``ef_construction = 64``) and task 02 adds
|
||||
``BOR_RETRIEVAL_EF_SEARCH`` — shipped at **400** on task 04's measured
|
||||
decision — applied as ``SET LOCAL hnsw.ef_search`` inside the turn's own
|
||||
transaction.
|
||||
|
||||
**What this module owns, and what it does not.** The plan-level proof (the
|
||||
tier's captured statement really names the index, and a turn's ``SET LOCAL``
|
||||
reaches the connection) lives in
|
||||
``tests/integration/test_chunks_hnsw_recall.py`` (tasks 03/04) and the DDL
|
||||
round trip in ``tests/integration/test_chunks_hnsw_index.py`` (task 01).
|
||||
THIS file is the last mile: one question, in a real browser, over a real
|
||||
import, with the index DROPPED and RESTORED around it — an A/B the owner
|
||||
could run on their own box. The corpus is 11 small documents, so Postgres'
|
||||
cost model may well serve BOTH arms from a sequential scan; that is stated,
|
||||
not hidden. An index at this size is not a speed claim, it is a
|
||||
**non-regression** claim, and the only non-regression that matters is the
|
||||
answer. So what is compared between the arms is what reached the model: the
|
||||
durable retrieval record (``query_log.sources`` / ``top_score`` /
|
||||
``chunk_hits`` / ``fts_hits`` — LOCKED A3's witness), the ``done`` frame's
|
||||
tiers, and the rendered "Nearby docs, in case" row. The DDL state is read
|
||||
back from the catalog INSIDE each arm, so "OFF" can never quietly mean "ON".
|
||||
|
||||
Both sentinels are calibrated against the deterministic mock embedder (the
|
||||
``tests/e2e/test_retrieval_quality.py`` convention, not invented) and the
|
||||
margins are quoted in the units the honesty gate reads:
|
||||
|
||||
* ANSWERED — ``"How do I raise the tilecache eviction watermark so the hot
|
||||
zoom levels stay pinned?"`` → best cosine **0.6187**, lexical (FTS) hits
|
||||
**10**, leading document ``hnswkb/infra/tilecache.md``. ``0.6187 >= 0.30``
|
||||
is A8's HIGH quadrant by a margin of 0.32, so neither arm can flip on a
|
||||
rounding difference.
|
||||
* DEFLECTED — ``"Which bow should I draw for a baroque viola sonata?"`` (no
|
||||
note in this KB can answer it) → best cosine **0.1706**, lexical hits **0**.
|
||||
A8 deflects only on weak cosine AND zero lexical hits, and both halves hold
|
||||
with room: 0.13 BELOW the 0.30 threshold, and a lexical side that is empty
|
||||
rather than merely low.
|
||||
|
||||
Run in isolation (DB up: ``podman compose up -d db``, schema at head so
|
||||
``0029`` has run — the module fails loudly, never vacuously, if it has not):
|
||||
|
||||
uv run pytest tests/e2e/test_vector_index_recall.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the cosine and lexical
|
||||
numbers above are properties of the deterministic mock, and the parity pins
|
||||
compare them exactly between the two arms.
|
||||
|
||||
Isolation (the house shape): ONE module app on its own port (the conftest
|
||||
session app owns 8123 in a combined run), the mock LLM on the conftest port,
|
||||
a dedicated ``BOR_SOURCES_DIR`` / ``BOR_PDF_PAGE_DIR`` so nothing writes into
|
||||
the operator's, the shared E2E Postgres truncated BEFORE **and** AFTER every
|
||||
test, and the KB re-imported through the REAL importer for every test — so
|
||||
the HNSW insert path runs in every arm, including the ones that then drop the
|
||||
index. The admin enters through the real form login.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is the
|
||||
observable, the durable row is the witness, and the two are read as one
|
||||
comparison):
|
||||
|
||||
1. ``test_answered_question_cites_the_same_document_indexed_or_not`` — the answered
|
||||
sentinel, twice: with ``ix_chunks_embedding_hnsw`` present, then after the
|
||||
real ``0029`` downgrade drops it (restored in ``finally``, and again by the
|
||||
module guard). Both arms ground (no ``.is-deflected``, the mock marker
|
||||
renders), both cite the SAME document — ``hnswkb/infra/tilecache.md`` first
|
||||
in ``query_log.sources`` and first in the rendered row — and agree on
|
||||
``top_score`` to within ``1e-9``, on ``chunk_hits`` and ``fts_hits``, and
|
||||
on the byte-identical answer. The DDL really moved; the answer did not.
|
||||
2. ``test_index_exists_and_is_valid_after_import`` — the import that just ran
|
||||
left the index present, VALID in ``pg_index``, HNSW over
|
||||
``vector_cosine_ops`` per ``pg_get_indexdef``, and carrying pages. An
|
||||
INVALID index is silent (the failure ``0029`` exists to catch), so the
|
||||
post-import state is pinned instead of assumed.
|
||||
3. ``test_near_miss_question_still_deflects`` — the out-of-KB sentinel in
|
||||
BOTH DDL arms: the amber deflection, the "Maybe try" chips, the ``done``
|
||||
frame citing nothing, and the row's two gate inputs read in the units A8
|
||||
defines. An index must never manufacture a high ``top_score``.
|
||||
4. ``test_ef_search_setting_is_honoured`` — two extra apps on their own ports
|
||||
with stdout captured (the surface the operator greps): ``BOR_RETRIEVAL_EF_SEARCH=150``
|
||||
answers the sentinel normally and logs NO clamp line; ``=50`` answers it
|
||||
just as well — same leading doc, same ``top_score`` — and logs the
|
||||
operator-visible ``retrieval: ef_search=50 raised to 100 (A7 candidate
|
||||
count)`` WARNING. The clamp keeps its promise in the browser, not only in
|
||||
a unit test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from alembic import command
|
||||
from app.config import Settings as _Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import QueryLog
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
MOCK_PORT,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: The tracked fixture KB — 11 markdown notes over 6 folders. Tracked rather
|
||||
#: than generated: the cosine/lexical calibration in the module docstring
|
||||
#: belongs to THESE bytes, so a re-run ranks the same way on any day.
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "hnswkb"
|
||||
|
||||
#: The ``source`` the importer records for a walked root (``Path(path).name``).
|
||||
SOURCE_NAME = "hnswkb"
|
||||
N_DOCS = 11
|
||||
|
||||
#: Dedicated module ports (the house env-overridable pattern): the conftest
|
||||
#: session app owns 8123, phase 187's modules own 8203/8204.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_VECIDX", "8205"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
EF_OK_PORT = int(os.environ.get("E2E_APP_PORT_VECIDX_EF150", "8206"))
|
||||
EF_OK_URL = f"http://127.0.0.1:{EF_OK_PORT}"
|
||||
EF_CLAMP_PORT = int(os.environ.get("E2E_APP_PORT_VECIDX_EF50", "8207"))
|
||||
EF_CLAMP_URL = f"http://127.0.0.1:{EF_CLAMP_PORT}"
|
||||
|
||||
#: Migration 0029 and the revision it sits on — the pair the drop/restore
|
||||
#: walks, moved in the migration's OWN directions. This file contains no
|
||||
#: inline ``CREATE INDEX``/``DROP INDEX``: 0029 is the single definition of
|
||||
#: the index these pins name.
|
||||
REV_HNSW = "0029"
|
||||
REV_BEFORE = "0028"
|
||||
|
||||
#: The index 0029 builds.
|
||||
INDEX_NAME = "ix_chunks_embedding_hnsw"
|
||||
|
||||
#: The answered sentinel (measured, module docstring): cosine 0.6187, 10
|
||||
#: lexical hits, ``infra/tilecache.md`` first in the fused record.
|
||||
ANSWER_QUESTION = (
|
||||
"How do I raise the tilecache eviction watermark so the hot zoom levels "
|
||||
"stay pinned?"
|
||||
)
|
||||
#: The document the owner asked about — the one both arms must cite.
|
||||
CITED_DOC = f"{SOURCE_NAME}/infra/tilecache.md"
|
||||
|
||||
#: The near-miss sentinel (measured): cosine 0.1706, 0 lexical hits.
|
||||
DEFLECT_QUESTION = "Which bow should I draw for a baroque viola sonata?"
|
||||
|
||||
#: The mock's grounded answer marker (``tests/e2e/mock_llm.py``).
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
#: The honest-deflection phrase the mock answers with (the house constant —
|
||||
#: ``test_honest_deflection.py`` et al., phase 52's copy as the deterministic
|
||||
#: mock speaks it).
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
|
||||
#: The app's honesty gate — the values the module app boots with, named once
|
||||
#: here so the pins quote the gate they are measured against.
|
||||
GATE_THRESHOLD = 0.30
|
||||
GATE_LEXICAL_FLOOR = 0.15
|
||||
|
||||
#: The A7 cosine tier's candidate count (``hybrid_vector_candidates``) — the
|
||||
#: floor the ``ef_search`` clamp raises to. Read from the code default instead
|
||||
#: of restated, so a change to the knob moves the pin with it.
|
||||
A7_CANDIDATES = int(_Settings.model_fields["hybrid_vector_candidates"].default)
|
||||
|
||||
#: The suggested tier's size (``suggested_docs``) — the split between what the
|
||||
#: prompt is seeded with and what renders as "Nearby docs, in case".
|
||||
SUGGESTED_DOCS = int(_Settings.model_fields["suggested_docs"].default)
|
||||
|
||||
#: The clamp line ``app/rag/retriever.py`` logs (AGENTS rule 10: a knob that
|
||||
#: would degrade recall is never silent), matched on the app's captured
|
||||
#: stdout: ``… WARNING app.rag.retriever :: retrieval: ef_search=50 raised
|
||||
#: to 100 (A7 candidate count)``.
|
||||
CLAMP_LINE = re.compile(
|
||||
r"WARNING\s+app\.rag\.retriever\s+::\s+retrieval: ef_search=(\d+) "
|
||||
r"raised to (\d+) \(A7 candidate count\)"
|
||||
)
|
||||
|
||||
#: Numeric slack between the arms' ``top_score``. Both arms run the same exact
|
||||
#: cosine tier over this corpus, so the true slack is zero; the epsilon only
|
||||
#: absorbs the float's travel through the row.
|
||||
SCORE_SLACK = 1e-9
|
||||
|
||||
#: Turn budget: a mock turn lands in well under a second; 30 s is the house
|
||||
#: head-room with no client-side hard timeout.
|
||||
SETTLE_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The module app + the two ef_search apps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(scratch: Path, ef_search: str | None = None) -> dict[str, str]:
|
||||
"""App env: the mock LLM, the mock-calibrated honesty gate (the conftest
|
||||
convention — both floors stay ``<=`` the threshold or the app refuses to
|
||||
boot), no git-source fallback, dedicated directories so nothing writes
|
||||
into the operator's.
|
||||
|
||||
``ef_search=None`` pins the knob to the CODE default (the phase 188 leak
|
||||
guard: an operator's ``.env`` value never reaches a test); any other value
|
||||
is what the app boots with.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = f"http://127.0.0.1:{MOCK_PORT}/v1"
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = str(GATE_THRESHOLD)
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = str(GATE_LEXICAL_FLOOR)
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = str(GATE_LEXICAL_FLOOR)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_PDF_PAGE_DIR"] = str(scratch / "pdf-pages")
|
||||
env["BOR_RETRIEVAL_EF_SEARCH"] = (
|
||||
ef_search
|
||||
if ef_search is not None
|
||||
else str(_Settings.model_fields["retrieval_ef_search"].default)
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _running_app(
|
||||
url: str, port: int, env: dict[str, str], log_path: Path | None = None
|
||||
) -> Iterator[tuple[str, Path | None]]:
|
||||
"""Boot the app on *port*, wait for ``/api/health``, tear it down.
|
||||
|
||||
With *log_path* the app's stdout+stderr go to that file — the same surface
|
||||
the operator greps (``grep ' :: ' server.log``), so the clamp pin reads
|
||||
what a human would read rather than a test-private logger.
|
||||
"""
|
||||
args = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app.main:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--log-level",
|
||||
"warning",
|
||||
]
|
||||
if log_path is None:
|
||||
proc = subprocess.Popen(args, cwd=REPO, env=env) # noqa: S603
|
||||
try:
|
||||
_wait_http(f"{url}/api/health")
|
||||
yield url, None
|
||||
finally:
|
||||
proc.terminate()
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
proc.wait(timeout=10)
|
||||
return
|
||||
with open(log_path, "wb") as log:
|
||||
proc = subprocess.Popen( # noqa: S603
|
||||
args, cwd=REPO, env=env, stdout=log, stderr=subprocess.STDOUT
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{url}/api/health")
|
||||
yield url, log_path
|
||||
finally:
|
||||
proc.terminate()
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
proc.wait(timeout=10)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Iterator[str]:
|
||||
"""The app under tests 1-3: the SHIPPED default ``retrieval_ef_search``
|
||||
(400 — task 04's measured decision) over the fixture KB."""
|
||||
if USE_REAL_LLM:
|
||||
pytest.skip(
|
||||
"mock-only suite — the cosine/lexical calibration and the two-arm "
|
||||
"parity pins belong to the deterministic mock "
|
||||
"(E2E_REAL_LLM unsupported)"
|
||||
)
|
||||
assert mock_llm
|
||||
scratch = tmp_path_factory.mktemp("bor_vector_index_app")
|
||||
with _running_app(APP_URL, APP_PORT, _app_env(scratch)) as (url, _log):
|
||||
yield url
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The index state, read from the catalog (never inferred)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _index_state(db: Any) -> dict[str, Any] | None:
|
||||
"""The ``pg_index`` row for :data:`INDEX_NAME`, or ``None`` when absent.
|
||||
|
||||
``None`` is the OFF arm. ``valid=False`` is a state nothing here may treat
|
||||
as ON: an INVALID index exists, is maintained on every insert, and serves
|
||||
no queries — silently, which is the failure ``0029`` was written to catch.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT i.indisvalid, pg_table_size(c.oid) AS size_bytes, "
|
||||
"pg_get_indexdef(c.oid) AS indexdef "
|
||||
"FROM pg_index i "
|
||||
"JOIN pg_class c ON c.oid = i.indexrelid "
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.relname = :name AND n.nspname = 'public'"
|
||||
),
|
||||
{"name": INDEX_NAME},
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"valid": bool(row.indisvalid),
|
||||
"size_bytes": int(row.size_bytes),
|
||||
"indexdef": str(row.indexdef),
|
||||
}
|
||||
|
||||
|
||||
def _alembic_config() -> AlembicConfig:
|
||||
cfg = AlembicConfig(str(REPO / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(REPO / "alembic"))
|
||||
cfg.attributes["configure_logger"] = False
|
||||
return cfg
|
||||
|
||||
|
||||
def _alembic_version() -> str:
|
||||
with SessionLocal() as db:
|
||||
version = db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
db.rollback()
|
||||
return str(version)
|
||||
|
||||
|
||||
def _set_index_present(present: bool) -> None:
|
||||
"""Move the schema to the index OFF / ON state through 0029's own
|
||||
``downgrade`` / ``upgrade``, and PROVE the move landed.
|
||||
|
||||
Both directions run in the migration's ``autocommit_block`` (a
|
||||
``CONCURRENTLY`` DDL cannot run inside a transaction). This process's own
|
||||
session is rolled back FIRST — an open snapshot is what parks a
|
||||
``CONCURRENTLY`` build behind the test session forever, the failure task 01
|
||||
documented.
|
||||
"""
|
||||
with SessionLocal() as probe:
|
||||
probe.rollback()
|
||||
cfg = _alembic_config()
|
||||
if present:
|
||||
command.upgrade(cfg, REV_HNSW)
|
||||
else:
|
||||
command.downgrade(cfg, REV_BEFORE)
|
||||
with SessionLocal() as db:
|
||||
state = _index_state(db)
|
||||
version = db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
db.rollback()
|
||||
if present:
|
||||
assert state is not None and state["valid"], (
|
||||
f"restoring {REV_HNSW} left no VALID {INDEX_NAME}: {state}"
|
||||
)
|
||||
assert str(version) == REV_HNSW, f"alembic_version did not return: {version}"
|
||||
else:
|
||||
assert state is None, f"{INDEX_NAME} survived the {REV_BEFORE} downgrade: {state}"
|
||||
assert str(version) == REV_BEFORE, f"alembic_version did not move: {version}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _index_at_head() -> Iterator[None]:
|
||||
"""Module guard: the schema STARTS and ENDS at 0029 with a valid index.
|
||||
|
||||
A suite that silently ran with the index missing would pass tests 1 and 3
|
||||
for the wrong reason, so entering is an assertion rather than a hope and
|
||||
the teardown repairs whatever a failed test left behind.
|
||||
"""
|
||||
version = _alembic_version()
|
||||
if version != REV_HNSW:
|
||||
pytest.fail(
|
||||
f"phase 188's task-05 suite needs the schema at {REV_HNSW} "
|
||||
f"(found {version!r}) — run `uv run alembic upgrade head`"
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
state = _index_state(db)
|
||||
db.rollback()
|
||||
if state is None or not state["valid"]:
|
||||
_set_index_present(True)
|
||||
yield
|
||||
_set_index_present(True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The KB, imported through the REAL importer with the index in place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_llm_client() -> LLMClient:
|
||||
"""An embedder aimed at the mock (no ``.env``: the test process must not
|
||||
inherit the operator's live endpoint)."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"_env_file": None,
|
||||
"llm_base_url": f"http://127.0.0.1:{MOCK_PORT}/v1",
|
||||
}
|
||||
settings = _Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return LLMClient(settings)
|
||||
|
||||
|
||||
async def _import_kb() -> ImportSummary:
|
||||
return await import_sources([FIXTURES], _mock_llm_client())
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
thread = Thread(target=runner)
|
||||
thread.start()
|
||||
thread.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh KB + registry + query log (the phase-184 table list)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
||||
"git_sources, sources_meta, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def kb(mock_llm: int) -> Iterator[ImportSummary]:
|
||||
"""A fresh KB of the tracked fixture tree, imported with the HNSW index
|
||||
PRESENT, so every arm — including the ones that then drop it — put its
|
||||
embeddings into the graph.
|
||||
|
||||
One fixture owns the whole boundary so the order cannot drift: truncate →
|
||||
assert the index is live → import → test → truncate. The index assertion
|
||||
runs AFTER the truncate because a TRUNCATE of an HNSW-indexed table is
|
||||
exactly the insert path this suite is about.
|
||||
"""
|
||||
_truncate_all()
|
||||
with SessionLocal() as db:
|
||||
state = _index_state(db)
|
||||
db.rollback()
|
||||
assert state is not None and state["valid"], (
|
||||
f"{INDEX_NAME} must be present and valid BEFORE the import — the "
|
||||
f"point of this suite is inserts into a live graph: {state}"
|
||||
)
|
||||
|
||||
summary = _run_in_thread(_import_kb())
|
||||
assert summary.errors == 0, f"the fixture KB failed to import: {summary}"
|
||||
assert summary.added == N_DOCS, f"expected {N_DOCS} fixture notes: {summary}"
|
||||
assert summary.summaries == N_DOCS, f"every note needs a summary: {summary}"
|
||||
yield summary
|
||||
_truncate_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: the wire, the DOM, the durable row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream (a response
|
||||
#: clone read in the background) — the wire-level witness, independent of the
|
||||
#: rendering (the ``test_agent_document_tools.py`` hook).
|
||||
SSE_HOOK = """
|
||||
() => {
|
||||
if (window.__sseInstalled) return;
|
||||
window.__sseInstalled = true;
|
||||
window.__sseFrames = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const res = await origFetch.apply(this, args);
|
||||
try {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
|
||||
if (url.includes('/api/chat')) {
|
||||
res.clone().text().then((bodyText) => {
|
||||
for (const block of bodyText.split('\\n\\n')) {
|
||||
const line = block.trim();
|
||||
if (line.startsWith('data: ')) {
|
||||
window.__sseFrames.push(line.slice(6));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { /* non-clonable response: ignored */ }
|
||||
return res;
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _frames(page: Page, since: int) -> list[dict[str, Any]]:
|
||||
"""This turn's SSE frames, once the hook's background read settles."""
|
||||
deadline = time.monotonic() + 10.0
|
||||
while True:
|
||||
raw = page.evaluate("() => window.__sseFrames || []")
|
||||
parsed = [json.loads(line) for line in raw[since:] if line]
|
||||
if any(f.get("type") == "done" for f in parsed):
|
||||
return parsed
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError(
|
||||
f"SSE hook captured no `done` frame ({len(parsed)} frames) — "
|
||||
"was the hook installed before the turn?"
|
||||
)
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def _done(frames: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""The turn's ONE ``done`` frame — the shape task 03 pins."""
|
||||
dones = [f for f in frames if f.get("type") == "done"]
|
||||
assert len(dones) == 1, f"expected exactly one done frame, got {dones}"
|
||||
return dones[0]
|
||||
|
||||
|
||||
def _new_chat(page: Page) -> None:
|
||||
"""Clear the conversation so the NEXT turn is a first turn (no history
|
||||
from the previous arm rides the prompt). The control the owner uses."""
|
||||
page.click("#new-chat-btn")
|
||||
expect(page.locator(".msg")).to_have_count(0)
|
||||
|
||||
|
||||
def _submit(page: Page, question: str) -> int:
|
||||
"""Send *question* as the turn's ONLY message (phase 162's rule: a trailing
|
||||
phrase changes what retrieval sees). Returns the frame count so far."""
|
||||
seen = int(page.evaluate("() => (window.__sseFrames || []).length"))
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
return seen
|
||||
|
||||
|
||||
def _wait_settled(page: Page) -> None:
|
||||
"""The settle sync: the client re-labels Send on the ``done`` frame, and
|
||||
the server writes the ``query_log`` row just before yielding it."""
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=SETTLE_TIMEOUT_MS)
|
||||
|
||||
|
||||
def _row(question: str) -> dict[str, Any]:
|
||||
"""The durable record for *question* as a plain snapshot — exactly one row
|
||||
(the caller clears the log between arms that ask the same question).
|
||||
|
||||
Read as values, not as an ORM instance: the row outlives its session here
|
||||
(the arm compares it against another arm's), and a detached instance
|
||||
raises the moment an attribute is touched.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
select(
|
||||
QueryLog.top_score,
|
||||
QueryLog.fts_hits,
|
||||
QueryLog.chunk_hits,
|
||||
QueryLog.deflected,
|
||||
QueryLog.sources,
|
||||
).where(QueryLog.question == question)
|
||||
).all()
|
||||
assert len(rows) == 1, f"expected one query_log row for {question!r}, got {len(rows)}"
|
||||
row = rows[0]
|
||||
return {
|
||||
"top_score": float(row.top_score),
|
||||
"fts_hits": row.fts_hits,
|
||||
"chunk_hits": int(row.chunk_hits),
|
||||
"deflected": bool(row.deflected),
|
||||
"sources": str(row.sources),
|
||||
}
|
||||
|
||||
|
||||
def _recorded(row: dict[str, Any]) -> list[str]:
|
||||
"""``query_log.sources`` as the ordered ``source/path`` list the server
|
||||
wrote: the retrieval tier that reached the model (LOCKED A3)."""
|
||||
return [part.strip() for part in row["sources"].split(",") if part.strip()]
|
||||
|
||||
|
||||
def _arm(page: Page, question: str, *, expect_index: bool) -> dict[str, Any]:
|
||||
"""Ask *question* once and collect everything that must not move between
|
||||
the arms — the DOM, the wire, the durable row, and the DDL state the arm
|
||||
ACTUALLY ran under."""
|
||||
with SessionLocal() as db:
|
||||
state = _index_state(db)
|
||||
db.rollback()
|
||||
if expect_index:
|
||||
assert state is not None and state["valid"], (
|
||||
f"the ON arm ran WITHOUT a valid {INDEX_NAME}: {state}"
|
||||
)
|
||||
else:
|
||||
assert state is None, f"the OFF arm still had {INDEX_NAME}: {state}"
|
||||
|
||||
since = _submit(page, question)
|
||||
_wait_settled(page)
|
||||
done = _done(_frames(page, since))
|
||||
|
||||
brain = page.locator(".msg.brain").last
|
||||
row = _row(question)
|
||||
recorded = _recorded(row)
|
||||
return {
|
||||
"answer": brain.locator(".bubble").first.inner_text(),
|
||||
"nearby": brain.locator(".related-docs .related-doc").all_inner_texts(),
|
||||
"chips": brain.locator(".source-chip").count(),
|
||||
"deflected_bubbles": page.locator(".msg.brain.is-deflected").count(),
|
||||
"done": done,
|
||||
"index_state": state,
|
||||
"row_deflected": row["deflected"],
|
||||
"top_score": float(row["top_score"]),
|
||||
"fts_hits": row["fts_hits"],
|
||||
"chunk_hits": row["chunk_hits"],
|
||||
"recorded": recorded,
|
||||
"suggested": recorded[:SUGGESTED_DOCS],
|
||||
"nearby_recorded": recorded[SUGGESTED_DOCS:],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1 — the answered sentinel: the SAME document, index ON and index OFF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_answered_question_cites_the_same_document_indexed_or_not(
|
||||
page: Page, app_url: str
|
||||
) -> None:
|
||||
"""The A7/A8 contract in the UI: an index is an ACCESS PATH, so the same
|
||||
question must reach the same document whether or not the graph exists."""
|
||||
page.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
login(page, app_url, next="/") # phase 79: the chat is login-gated
|
||||
page.evaluate(SSE_HOOK)
|
||||
|
||||
# --- ON: the shipped state (0029 applied, index valid) ------------------
|
||||
on = _arm(page, ANSWER_QUESTION, expect_index=True)
|
||||
assert on["row_deflected"] is False
|
||||
assert on["deflected_bubbles"] == 0
|
||||
assert MOCK_ANSWER_MARKER in on["answer"]
|
||||
# The measured margin, not just the direction.
|
||||
assert on["top_score"] >= GATE_THRESHOLD
|
||||
assert on["recorded"][0] == CITED_DOC, on["recorded"]
|
||||
assert on["chunk_hits"] > 0
|
||||
# Phase 119 (LOCKED A1): a zero-read grounded turn chips nothing — the
|
||||
# index changes nothing about that either.
|
||||
assert on["chips"] == 0
|
||||
# The tier that reached the model, and the tier the page rendered.
|
||||
assert CITED_DOC in on["suggested"]
|
||||
assert on["nearby"], "the fixture KB must overflow into the rendered row"
|
||||
assert on["nearby"] == on["nearby_recorded"]
|
||||
assert on["done"]["sources"] == []
|
||||
assert [f"{s['source']}/{s['path']}" for s in on["done"]["related"]] == (
|
||||
on["nearby_recorded"]
|
||||
)
|
||||
|
||||
# --- OFF: the real 0029 downgrade, restored in `finally` ----------------
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE query_log"))
|
||||
db.commit()
|
||||
_new_chat(page)
|
||||
_set_index_present(False)
|
||||
try:
|
||||
off = _arm(page, ANSWER_QUESTION, expect_index=False)
|
||||
finally:
|
||||
_set_index_present(True)
|
||||
|
||||
# Same verdict, same document, same numbers: the DDL moved, the answer
|
||||
# did not.
|
||||
assert off["row_deflected"] is False
|
||||
assert off["deflected_bubbles"] == 0
|
||||
assert MOCK_ANSWER_MARKER in off["answer"]
|
||||
assert off["recorded"] == on["recorded"], (
|
||||
f"retrieval order moved with the index:\n ON={on['recorded']}\n"
|
||||
f" OFF={off['recorded']}"
|
||||
)
|
||||
assert off["recorded"][0] == CITED_DOC
|
||||
assert off["suggested"] == on["suggested"]
|
||||
assert off["nearby"] == on["nearby"]
|
||||
assert abs(off["top_score"] - on["top_score"]) <= SCORE_SLACK, (
|
||||
f"top_score moved with the index: {on['top_score']} vs "
|
||||
f"{off['top_score']}"
|
||||
)
|
||||
assert off["chunk_hits"] == on["chunk_hits"]
|
||||
assert off["fts_hits"] == on["fts_hits"]
|
||||
assert off["answer"] == on["answer"]
|
||||
for key in ("deflected", "sources", "related", "suggestions"):
|
||||
assert off["done"].get(key) == on["done"].get(key), (
|
||||
f"the done frame's {key} moved with the index: "
|
||||
f"{on['done'].get(key)!r} vs {off['done'].get(key)!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 — the index exists and is VALID after a real import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_index_exists_and_is_valid_after_import(kb: ImportSummary) -> None:
|
||||
"""The import that just ran left a USABLE graph, not a silent INVALID one.
|
||||
|
||||
``0029`` post-validates its own build because a ``CONCURRENTLY`` build that
|
||||
dies leaves the index INVALID — costing insert time on every sync and
|
||||
serving nothing, with no error anywhere. The same check belongs after a
|
||||
real import: chunks go into the graph on every sync, and an index that
|
||||
drifted INVALID is invisible to every other surface the app has.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
state = _index_state(db)
|
||||
counts = db.execute(
|
||||
text("SELECT count(*) AS n, count(embedding) AS with_vec FROM chunks")
|
||||
).one()
|
||||
stored = db.scalar(select(func.count()).select_from(QueryLog))
|
||||
db.rollback()
|
||||
assert stored == 0, "this pin reads the catalog only — no turn has run"
|
||||
|
||||
assert state is not None, f"{INDEX_NAME} is MISSING after the import"
|
||||
assert state["valid"] is True, (
|
||||
f"{INDEX_NAME} exists but is INVALID after the import — every sync now "
|
||||
f"pays for a graph that answers nothing: {state['indexdef']}"
|
||||
)
|
||||
definition = state["indexdef"]
|
||||
assert INDEX_NAME in definition, definition
|
||||
assert "hnsw" in definition, definition
|
||||
assert "vector_cosine_ops" in definition, definition
|
||||
assert "embedding" in definition, definition
|
||||
|
||||
# Not an empty shell over an empty table: the import put real embeddings
|
||||
# in, and the graph carries pages for them.
|
||||
assert int(counts.n) > 0, "the fixture import produced no chunks"
|
||||
assert int(counts.with_vec) == int(counts.n), (
|
||||
f"chunks without an embedding would be invisible to the tier: {counts}"
|
||||
)
|
||||
# Content chunks + one summary chunk per document (phase 30/118) — the
|
||||
# importer's own two counts, so a row that never reached the graph shows
|
||||
# up here instead of as an invisible hole.
|
||||
assert int(counts.n) == kb.chunks + kb.summaries, (
|
||||
f"the graph holds {counts.n} rows, the import reported "
|
||||
f"{kb.chunks} content + {kb.summaries} summary chunks"
|
||||
)
|
||||
assert state["size_bytes"] > 0, f"{INDEX_NAME} carries no pages: {state}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3 — the near-miss question still deflects (both arms)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_near_miss_question_still_deflects(page: Page, app_url: str) -> None:
|
||||
"""A8 in the browser, index ON and OFF: weak cosine AND zero lexical hits
|
||||
still deflects, and an index never manufactures a high ``top_score``."""
|
||||
page.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
login(page, app_url, next="/")
|
||||
page.evaluate(SSE_HOOK)
|
||||
|
||||
on = _arm(page, DEFLECT_QUESTION, expect_index=True)
|
||||
assert on["row_deflected"] is True
|
||||
assert on["deflected_bubbles"] == 1
|
||||
# The gate's two inputs, in the units A8 defines them.
|
||||
assert 0.0 < on["top_score"] < GATE_THRESHOLD
|
||||
assert on["fts_hits"] == 0, "deflection is only reached with zero hits"
|
||||
# The owner-visible shape of the deflection (phase 52/118).
|
||||
expect(page.locator(".msg.brain.is-deflected .bubble")).to_contain_text(
|
||||
DEFLECT_PHRASE, timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
chips = page.locator(".msg.brain.is-deflected .maybe-try .suggestion-chip")
|
||||
expect(chips.first).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
|
||||
assert chips.count() >= 2, "a deflection must offer 2-3 alternatives"
|
||||
assert all(text.strip() for text in chips.all_inner_texts())
|
||||
assert on["done"]["deflected"] is True
|
||||
assert on["done"]["sources"] == [], "a deflected answer cites nothing"
|
||||
|
||||
# OFF arm: the same verdict, for the same measured reason.
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE query_log"))
|
||||
db.commit()
|
||||
_new_chat(page)
|
||||
_set_index_present(False)
|
||||
try:
|
||||
off = _arm(page, DEFLECT_QUESTION, expect_index=False)
|
||||
finally:
|
||||
_set_index_present(True)
|
||||
|
||||
assert off["row_deflected"] is True
|
||||
assert off["deflected_bubbles"] == 1
|
||||
assert 0.0 < off["top_score"] < GATE_THRESHOLD
|
||||
assert off["fts_hits"] == 0
|
||||
assert abs(off["top_score"] - on["top_score"]) <= SCORE_SLACK
|
||||
assert off["recorded"] == on["recorded"]
|
||||
assert MOCK_ANSWER_MARKER not in off["answer"]
|
||||
assert off["done"]["deflected"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 — the ef_search knob on a real turn, and the clamp it cannot bypass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _log_lines(log_path: Path, pattern: re.Pattern[str]) -> list[str]:
|
||||
"""Every clamp line the app logged — read off the operator's surface."""
|
||||
return [
|
||||
line
|
||||
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if pattern.search(line)
|
||||
]
|
||||
|
||||
|
||||
def test_ef_search_setting_is_honoured(
|
||||
page: Page, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""``BOR_RETRIEVAL_EF_SEARCH`` reaches the turn, and a value BELOW the A7
|
||||
candidate count is neither silent nor harmful."""
|
||||
page.set_default_timeout(SETTLE_TIMEOUT_MS)
|
||||
|
||||
# --- 150 (>= the 100-candidate tier): a normal turn, no clamp line -----
|
||||
ok_dir = tmp_path_factory.mktemp("bor_ef_ok")
|
||||
with _running_app(
|
||||
EF_OK_URL, EF_OK_PORT, _app_env(ok_dir, "150"), ok_dir / "ef150.log"
|
||||
) as (ok_url, ok_log):
|
||||
assert ok_log is not None
|
||||
login(page, ok_url, next="/")
|
||||
page.evaluate(SSE_HOOK)
|
||||
on_ok = _arm(page, ANSWER_QUESTION, expect_index=True)
|
||||
assert on_ok["row_deflected"] is False
|
||||
assert MOCK_ANSWER_MARKER in on_ok["answer"]
|
||||
assert on_ok["recorded"][0] == CITED_DOC
|
||||
assert _log_lines(ok_log, CLAMP_LINE) == [], (
|
||||
"ef_search=150 sits ABOVE the A7 candidate count and must not "
|
||||
f"clamp: {_log_lines(ok_log, CLAMP_LINE)}"
|
||||
)
|
||||
|
||||
# --- 50 (< the tier): the clamp fires and the turn is UNCHANGED --------
|
||||
clamp_dir = tmp_path_factory.mktemp("bor_ef_clamp")
|
||||
with _running_app(
|
||||
EF_CLAMP_URL, EF_CLAMP_PORT, _app_env(clamp_dir, "50"), clamp_dir / "ef50.log"
|
||||
) as (clamp_url, clamp_log):
|
||||
assert clamp_log is not None
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE query_log"))
|
||||
db.commit()
|
||||
login(page, clamp_url, next="/")
|
||||
page.evaluate(SSE_HOOK)
|
||||
clamped = _arm(page, ANSWER_QUESTION, expect_index=True)
|
||||
assert clamped["row_deflected"] is False
|
||||
assert MOCK_ANSWER_MARKER in clamped["answer"]
|
||||
assert clamped["recorded"][0] == CITED_DOC
|
||||
assert clamped["recorded"] == on_ok["recorded"]
|
||||
assert abs(clamped["top_score"] - on_ok["top_score"]) <= SCORE_SLACK
|
||||
|
||||
lines = _log_lines(clamp_log, CLAMP_LINE)
|
||||
assert lines, (
|
||||
"ef_search=50 is BELOW the A7 candidate count and MUST log the "
|
||||
"clamp (AGENTS rule 10 — a knob that would degrade recall is never "
|
||||
"silent). Tail of the captured log:\n"
|
||||
f"{clamp_log.read_text(encoding='utf-8', errors='replace')[-3000:]}"
|
||||
)
|
||||
for line in lines:
|
||||
match = CLAMP_LINE.search(line)
|
||||
assert match is not None, line
|
||||
assert int(match.group(1)) == 50, line
|
||||
assert int(match.group(2)) == A7_CANDIDATES, line
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Shop vacuum
|
||||
|
||||
Dust collection layout for the garage bench and the tools it serves.
|
||||
|
||||
- The cyclone bin empties at the driveway door, twice a week.
|
||||
- Replace the final filter each season or the airflow drops noticeably.
|
||||
- The hose is the flexible 4 inch line, never the rigid conduit.
|
||||
- The saw runs on its own circuit so the motor start does not brown out
|
||||
the bench outlets.
|
||||
|
||||
Airflow is measured at the hood with a cheap gauge; anything under the
|
||||
marker means the cyclone needs emptying before anything else is checked.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Coffee grinder notes
|
||||
|
||||
Burr alignment and dose calibration for the counter grinder. Nothing here
|
||||
applies to the commercial machine at the shop.
|
||||
|
||||
- Grind finer when the shot runs short and sour.
|
||||
- Clean the chute weekly; static holds grounds in the bend.
|
||||
- Recalibrate the dose after any burr change with a blind basket test.
|
||||
- Replace the basket screen when the base of the puck turns muddy.
|
||||
|
||||
The dial clicks are not linear near the fine end, so two clicks of
|
||||
adjustment there behave like five clicks in the middle of the range.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Garden beds
|
||||
|
||||
Crop rotation for the four raised beds behind the shed, following the
|
||||
legume, brassica, root, fruit order so the beds rest every fourth year.
|
||||
|
||||
- Bed one: legumes after the last frost date clears the soil.
|
||||
- Bed two: heavy feeders get a top dressing of compost in spring.
|
||||
- Bed three: roots only, no manure or the carrots fork.
|
||||
- Bed four: fruiting crops staked and watered at the base.
|
||||
|
||||
The irrigation line is one drip tape per bed on a fifteen minute timer,
|
||||
and the mulch goes back on after every harvest rather than once a season.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Kubernetes cluster
|
||||
|
||||
The cluster is three nodes with a control plane on `k8s-master-1` and two
|
||||
workers. It runs the shelf services that want scheduling rather than a
|
||||
static container.
|
||||
|
||||
- The ingress controller listens on 8443 and forwards to cluster services.
|
||||
- etcd snapshots run nightly to the backup host and are kept for a month.
|
||||
- Worker nodes join with a short-lived token minted for one hour.
|
||||
- Storage is a local-path provisioner pinned to the nvme mount.
|
||||
- Pod security labels are set per namespace, not cluster wide.
|
||||
|
||||
Upgrades are one control plane replica at a time, then the workers with a
|
||||
cordon and drain. The ingress is the last thing to move because the
|
||||
services behind it are checked by the shelf watchdog.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Proxmox nodes
|
||||
|
||||
Three Proxmox hosts form the shelf cluster: node one is the hypervisor and
|
||||
gateway, node two runs the always-on containers, node three is the GPU box.
|
||||
|
||||
- Storage is an LVM-thin pool on the nvme, over-commit watched weekly.
|
||||
- Live migration uses a dedicated bridge, not the management iface.
|
||||
- The watchdog restarts a container that misses three health checks.
|
||||
- Corosync traffic rides the management bridge with multicast.
|
||||
- Backups of the VM configs are taken before every host upgrade.
|
||||
|
||||
Node three is pinned for the inference workloads, so a migration off it is
|
||||
manual and only happens for a scheduled host reboot.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Restic backups
|
||||
|
||||
Restic backs up the media volume to the offsite bucket every night at 01:20
|
||||
with a lock timeout of five minutes.
|
||||
|
||||
- The repository password lives in the vault, never in the env file.
|
||||
- Prune keeps fourteen daily and eight weekly snapshots.
|
||||
- Verification runs one read pass every Sunday, sampled rather than full.
|
||||
- A failed lock means the previous run is still going; check before
|
||||
forcing anything.
|
||||
- Restores are tested quarterly against a scratch directory.
|
||||
|
||||
The media volume changes slowly, so a nightly run is usually small and the
|
||||
sampled verification is enough to catch a corrupt pack file early.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Ntfy relay
|
||||
|
||||
The ntfy relay runs on `notify-host-1` and fans alert payloads out to the
|
||||
phones and the desktop notifier.
|
||||
|
||||
- Topic namespaces are per host, so a host can only publish into its own.
|
||||
- Retention is twelve hours; anything older is dropped on the sweep.
|
||||
- The access log lives at `/var/log/ntfy/access.log` and rotates weekly.
|
||||
- SMTP fallback is disabled on purpose: a failed push is a signal, not
|
||||
something to paper over with an email.
|
||||
- The relay is fronted by the proxy on 443 with mutual TLS for publishers.
|
||||
|
||||
Publishing is a plain POST with the topic in the path. The relay stamps a
|
||||
monotonic sequence number so a subscriber can detect a gap after a
|
||||
reconnect, and the gap detection is what the shelf watchdog polls.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Tilecache
|
||||
|
||||
Tilecache serves the vector tiles for the map boxes from `map-host-2`,
|
||||
listening on port 8077 behind the reverse proxy. Everything below is the
|
||||
shape of that cache on the shelf, not a general tile server guide.
|
||||
|
||||
## Cache budget and eviction
|
||||
|
||||
The cache budget is 40 GiB on the nvme. The eviction watermark sits at 88
|
||||
percent of that budget: once the cache crosses the watermark, tilecache
|
||||
evicts the coldest tiles until it drops back under it.
|
||||
|
||||
Raise the eviction watermark so the hot zoom levels stay pinned through a
|
||||
bulk re-render:
|
||||
|
||||
1. `tilecache config set eviction-watermark 92`
|
||||
2. `tilecache config set pin-zoom-floor 12`
|
||||
3. `systemctl restart tilecache`
|
||||
|
||||
Zoom levels 12 to 15 are the hot set for the atlas layers, and the pin
|
||||
floor keeps them resident while the watermark holds. Zoom 16 and above is
|
||||
rendered on demand and is always evictable.
|
||||
|
||||
## Origin fetches
|
||||
|
||||
Origin fetches go to `origin.maps.internal`, and a slow origin reply is
|
||||
treated as a miss after 900 ms. A stale origin reply keeps the previous
|
||||
tile pinned rather than evicting the hot zoom set, so a watermark raise is
|
||||
safe during a bulk re-render.
|
||||
|
||||
## Watermark maths
|
||||
|
||||
At a 40 GiB budget the 88 percent watermark means eviction starts at 35.2
|
||||
GiB. The 92 percent setting moves that to 36.8 GiB, which buys roughly one
|
||||
extra render pass of zoom 14 before anything cold is dropped.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Wireguard hub
|
||||
|
||||
The Wireguard hub on `edge-host-1` is the only way into the shelf from a
|
||||
laptop. Each device gets one peer entry and one allowed-IPs line.
|
||||
|
||||
- Listen port 51820/udp, one peer per device.
|
||||
- Allowed IPs are per-device /32s; the hub does not advertise the LAN.
|
||||
- Persistent keepalive is 25 seconds for the roaming peers.
|
||||
- Key rotation is a manual ceremony: generate, swap the peer file, reload.
|
||||
- The hub hands out addresses from 10.66.0.0/24, one /32 per peer.
|
||||
|
||||
A peer that roams between the mobile network and wifi must keep the same
|
||||
endpoint or the hub drops the stale mapping after the keepalive window.
|
||||
The routing table is re-read on a reload, so a swapped peer file needs no
|
||||
reboot of the host.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Sourdough timetable
|
||||
|
||||
Bulk fermentation schedule for the weekend bakes at the kitchen bench.
|
||||
|
||||
- Stretch and fold every forty minutes for the first two hours.
|
||||
- Bulk ends when the dough has grown by half and jiggles, not by clock.
|
||||
- Cold retard overnight in the fridge at four degrees.
|
||||
- Bake from cold with steam for the first twenty minutes.
|
||||
|
||||
The starter is fed one to five in the morning so it peaks mid afternoon,
|
||||
which is when the mix actually happens.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Packing list
|
||||
|
||||
Carry-on checklist for a week of trips with one bag and a laptop.
|
||||
|
||||
- One shirt per two days, always folded not rolled.
|
||||
- Spare battery in the personal item, never the checked bag.
|
||||
- Toiletries under the hundred millilitre rule in one clear pouch.
|
||||
- A single pair of shoes that survives both rain and a dinner.
|
||||
|
||||
The laptop bag holds the cables, the drive and the paper documents, and it
|
||||
is the thing that gets opened at every security checkpoint, so it stays
|
||||
boring and easy to repack.
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Integration: migration 0029 (chunks.embedding HNSW index) — phase 188, task 01.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0028.py`` (fixture upgrades to head at setup and
|
||||
teardown so the dev DB is never left below head; tests target the
|
||||
0028 → 0029 step explicitly so later migrations cannot break them):
|
||||
|
||||
* upgrade 0028 → 0029 → ``ix_chunks_embedding_hnsw`` exists,
|
||||
``pg_index.indisvalid`` is TRUE, and ``pg_get_indexdef`` names
|
||||
``USING hnsw`` + ``vector_cosine_ops`` + ``m=16`` + ``ef_construction=64``
|
||||
(the D1 shape the recall pins of task 03 depend on);
|
||||
* downgrade to 0028 → the index is GONE (A13 — reversible, ``CONCURRENTLY``),
|
||||
the ``chunks`` table and its PK survive;
|
||||
* upgrade back to head → the index is back and valid (round-trip);
|
||||
* the D2 self-heal: with an INVALID index of that name deliberately
|
||||
present, ``upgrade()`` drops it and rebuilds it VALID — no
|
||||
``relation already exists`` error and no leftover INVALID index.
|
||||
The INVALID state is forced with the documented superuser catalog
|
||||
trick ``UPDATE pg_index SET indisvalid = false WHERE
|
||||
indexrelid = CAST(:name AS regclass)`` — the same failure a killed
|
||||
``CREATE INDEX CONCURRENTLY`` leaves behind (pgvector cannot fail a
|
||||
build on data, and the dev/test DB role is a superuser; a role
|
||||
without catalog UPDATE rights gets a clean skip).
|
||||
|
||||
Pure-helper matrix tests (no DB): ``should_drop_invalid`` and
|
||||
``is_valid`` over ``None`` / ``True`` / ``False``.
|
||||
|
||||
**Why every ``command.*`` call here is preceded by ``db.rollback()``
|
||||
when the session was used.** psycopg3 keeps its transaction snapshot
|
||||
alive while the session sits idle-in-transaction (measured: ``pg_stat
|
||||
_activity.backend_xmin`` stays set where psql's clears), and ``CREATE
|
||||
INDEX CONCURRENTLY`` phase 3 waits for every older-snapshot
|
||||
transaction to finish — an unreleased session parks the build FOREVER
|
||||
(observed on the dev box). The test session must therefore hold no
|
||||
open transaction while the concurrent DDL runs; the rollback is what
|
||||
makes the file deterministic rather than flaky.
|
||||
**Task 02 adds the ``ef_search`` half:** the knob the index needs is
|
||||
applied with ``SET LOCAL`` inside the turn's own transaction, so the file
|
||||
pins the SCOPING against the real server — after a ``SET LOCAL`` the same
|
||||
transaction reports the (clamped) value and a NEW transaction reports the
|
||||
server's own default again (LOCKED A4: no global GUC, nothing surviving
|
||||
the request or a pool recycle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import types
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.config import Settings
|
||||
from app.db import db_available
|
||||
from app.rag import retriever
|
||||
from app.rag.retriever import _vector_candidates
|
||||
|
||||
MIGRATION_PATH = Path("alembic/versions/0029_chunks_embedding_hnsw.py")
|
||||
INDEX_NAME = "ix_chunks_embedding_hnsw"
|
||||
|
||||
|
||||
def _load_migration() -> types.ModuleType:
|
||||
"""Import the 0029 module by file path (``alembic/versions`` is not a
|
||||
package — the house way to reach a migration's own helpers)."""
|
||||
spec = importlib.util.spec_from_file_location("m0029_chunks_embedding_hnsw", MIGRATION_PATH)
|
||||
assert spec is not None and spec.loader is not None, f"missing {MIGRATION_PATH}"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run — 0029's own
|
||||
INVALID self-heal makes that repair safe when the interrupted
|
||||
migration was 0029 itself); teardown upgrades to head no matter
|
||||
what happened, so the dev DB is never left below head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL (the 0018 house pattern): a still-running snapshot would
|
||||
# park the CONCURRENTLY build, and teardown is exactly where a
|
||||
# failed test leaves one open.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _index_present(db: Session) -> bool:
|
||||
return (
|
||||
db.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :n"), {"n": INDEX_NAME}
|
||||
).scalar()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _index_valid(db: Session) -> bool | None:
|
||||
"""``pg_index.indisvalid`` for the index, ``None`` when it is absent."""
|
||||
return db.execute(
|
||||
text(
|
||||
"SELECT i.indisvalid FROM pg_index i"
|
||||
" JOIN pg_class c ON c.oid = i.indexrelid"
|
||||
" WHERE c.relname = :n"
|
||||
),
|
||||
{"n": INDEX_NAME},
|
||||
).scalar()
|
||||
|
||||
|
||||
def _index_def(db: Session) -> str | None:
|
||||
return db.execute(
|
||||
text("SELECT pg_get_indexdef(c.oid) FROM pg_class c WHERE c.relname = :n"),
|
||||
{"n": INDEX_NAME},
|
||||
).scalar()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers (no DB, no I/O)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_should_drop_invalid_matrix() -> None:
|
||||
"""``should_drop_invalid``: absent → False, valid → False,
|
||||
INVALID → True (the only state the pre-drop fires for)."""
|
||||
mod = _load_migration()
|
||||
assert mod.should_drop_invalid(None) is False, "absent index needs no drop"
|
||||
assert mod.should_drop_invalid(True) is False, "a valid index must be left alone"
|
||||
assert mod.should_drop_invalid(False) is True, "an INVALID remnant must be dropped"
|
||||
|
||||
|
||||
def test_is_valid_matrix() -> None:
|
||||
"""``is_valid``: only a present, validated index counts as valid."""
|
||||
mod = _load_migration()
|
||||
assert mod.is_valid(None) is False, "absent is not valid"
|
||||
assert mod.is_valid(True) is True, "validated is valid"
|
||||
assert mod.is_valid(False) is False, "INVALID is not valid"
|
||||
|
||||
|
||||
def test_migration_module_contract() -> None:
|
||||
"""The module's identity: revision 0029 on top of 0028, and the one
|
||||
index name the whole phase pins on."""
|
||||
mod = _load_migration()
|
||||
assert mod.revision == "0029"
|
||||
assert mod.down_revision == "0028"
|
||||
assert mod.INDEX_NAME == INDEX_NAME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration round-trip against the real Alembic engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upgrade_creates_a_valid_hnsw_index(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0028 → 0029: the index exists, VALIDATED, with the D1
|
||||
shape — HNSW, ``vector_cosine_ops`` (the operator class A7's ``<=>``
|
||||
needs), ``m=16``, ``ef_construction=64`` — and the ``chunks`` table
|
||||
itself survives the additive step untouched."""
|
||||
command.downgrade(alembic, "0028") # start from the pre-0029 state
|
||||
assert _version(db) == "0028"
|
||||
assert not _index_present(db), f"{INDEX_NAME} must be absent at 0028"
|
||||
|
||||
db.rollback() # no open snapshot while the CONCURRENTLY build runs
|
||||
command.upgrade(alembic, "0029")
|
||||
assert _version(db) == "0029", "alembic_version must be at 0029"
|
||||
assert _index_present(db), "the index must exist at 0029"
|
||||
assert _index_valid(db) is True, "a fresh build must be VALID (D2 post-check)"
|
||||
|
||||
raw = _index_def(db)
|
||||
assert raw is not None
|
||||
definition = raw.replace("'", "") # pg renders WITH options quoted
|
||||
assert "USING hnsw" in definition, f"not an HNSW index: {definition}"
|
||||
assert "vector_cosine_ops" in definition, (
|
||||
f"cosine operator class required for <=>: {definition}"
|
||||
)
|
||||
assert "m=16" in definition, f"m=16 expected: {definition}"
|
||||
assert "ef_construction=64" in definition, f"ef_construction=64 expected: {definition}"
|
||||
|
||||
|
||||
def test_downgrade_removes_it(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade 0029 → 0028: the index is gone (A13 — reversible), and
|
||||
the ``chunks`` relation with its PK survives (the drop touches no
|
||||
data)."""
|
||||
command.upgrade(alembic, "0029")
|
||||
assert _index_present(db)
|
||||
|
||||
db.rollback() # no open snapshot while the CONCURRENTLY drop runs
|
||||
command.downgrade(alembic, "0028")
|
||||
assert _version(db) == "0028"
|
||||
assert not _index_present(db), f"{INDEX_NAME} must be dropped by the downgrade"
|
||||
assert _index_valid(db) is None, "no pg_index row may survive the downgrade"
|
||||
|
||||
pk = db.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = 'chunks_pkey'")
|
||||
).scalar()
|
||||
assert pk is not None, "chunks_pkey must survive the downgrade"
|
||||
|
||||
|
||||
def test_upgrade_head_again(db: Session, alembic: Config) -> None:
|
||||
"""A13 round-trip: downgrade to 0028, upgrade back to HEAD — the
|
||||
index returns, valid, with the same definition."""
|
||||
command.downgrade(alembic, "0028")
|
||||
command.upgrade(alembic, "head")
|
||||
assert _index_present(db), "the index must be back after re-upgrade"
|
||||
assert _index_valid(db) is True, "the rebuilt index must be VALID"
|
||||
raw = _index_def(db)
|
||||
assert raw is not None and "USING hnsw" in raw
|
||||
|
||||
|
||||
def test_invalid_index_is_dropped_and_rebuilt(db: Session, alembic: Config) -> None:
|
||||
"""D2 self-heal: an INVALID index of this name (what a killed
|
||||
``CREATE INDEX CONCURRENTLY`` leaves behind) is dropped and rebuilt
|
||||
VALID on the next ``upgrade()``.
|
||||
|
||||
The INVALID state is forced with the documented superuser catalog
|
||||
update ``UPDATE pg_index SET indisvalid = false WHERE indexrelid =
|
||||
CAST(:name AS regclass)`` — pgvector cannot fail an HNSW build on
|
||||
data, and timing a real interruption is nondeterministic; the dev/
|
||||
test DB role is a superuser, and a role without catalog UPDATE
|
||||
rights skips cleanly instead of leaving the DB inconsistent (the
|
||||
stamp that follows the forced state, never before it)."""
|
||||
command.upgrade(alembic, "head")
|
||||
assert _index_valid(db) is True, "precondition: a valid index to corrupt"
|
||||
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE pg_index SET indisvalid = false"
|
||||
" WHERE indexrelid = CAST(:n AS regclass)"
|
||||
),
|
||||
{"n": INDEX_NAME},
|
||||
)
|
||||
db.commit()
|
||||
except ProgrammingError as exc: # non-superuser role
|
||||
db.rollback()
|
||||
pytest.skip(f"forcing INVALID needs catalog UPDATE rights: {exc}")
|
||||
|
||||
# The stamp moves the version table WITHOUT touching the index, so
|
||||
# the next upgrade() re-runs 0029 against the corrupted state.
|
||||
command.stamp(alembic, "0028")
|
||||
assert _version(db) == "0028"
|
||||
assert _index_valid(db) is False, "the forced INVALID state must be visible"
|
||||
|
||||
db.rollback() # no open snapshot of ours while the CONCURRENTLY run rebuilds
|
||||
command.upgrade(alembic, "0029")
|
||||
assert _index_present(db), "no 'relation already exists' — it was pre-dropped"
|
||||
assert _index_valid(db) is True, "the rebuilt index must be VALID (no leftover INVALID)"
|
||||
raw = _index_def(db)
|
||||
assert raw is not None and "USING hnsw" in raw, "the rebuild is the HNSW index itself"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 02: SET LOCAL hnsw.ef_search — applied, clamped, transaction-scoped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _show_ef_search(db: Session) -> str:
|
||||
return str(db.execute(text("SHOW hnsw.ef_search")).scalar())
|
||||
|
||||
|
||||
def _server_default_ef_search(db: Session) -> str:
|
||||
"""The server's own reset value for the GUC (the value a NEW
|
||||
transaction must fall back to). Read from ``pg_settings`` rather than
|
||||
hardcoded: pgvector renders its default, and the pin is about the
|
||||
FALLBACK, not the number."""
|
||||
value = db.execute(
|
||||
text("SELECT reset_val FROM pg_settings WHERE name = 'hnsw.ef_search'")
|
||||
).scalar()
|
||||
assert value is not None, "pgvector must register hnsw.ef_search in this session"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _stub_settings(**kwargs: object) -> Settings:
|
||||
return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def test_set_local_ef_search_reaches_the_server_in_the_same_transaction(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The D3 rule against a real server: the value the tier sends is the
|
||||
value Postgres runs the search with. The setting here (10) sits BELOW
|
||||
the limit (250), so the server must report the CLAMPED value — the
|
||||
clamp is not a Python-side fiction."""
|
||||
monkeypatch.setattr(
|
||||
retriever, "get_settings", lambda: _stub_settings(retrieval_ef_search=10)
|
||||
)
|
||||
db.rollback() # a clean transaction boundary for the SET LOCAL
|
||||
_vector_candidates(db, [0.0] * 768, limit=250) # pyright: ignore[reportArgumentType]
|
||||
assert _show_ef_search(db) == "250", "the clamped value must be live in this tx"
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_set_local_ef_search_does_not_survive_the_transaction(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""LOCKED A4 pinned on the wire: once the turn's transaction ends, the
|
||||
server is back on ITS default — no global GUC mutation, no state a
|
||||
pooled connection carries into the next request."""
|
||||
monkeypatch.setattr(
|
||||
retriever, "get_settings", lambda: _stub_settings(retrieval_ef_search=317)
|
||||
)
|
||||
db.rollback()
|
||||
_vector_candidates(db, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
assert _show_ef_search(db) == "317"
|
||||
|
||||
db.rollback() # the end of the turn's transaction
|
||||
after = _show_ef_search(db)
|
||||
default = _server_default_ef_search(db)
|
||||
assert after == default, f"a new tx must see the server default, got {after}"
|
||||
assert after != "317"
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_ef_search_clamp_logs_one_warning_line(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The clamp leaves exactly one operator-visible line in the server log
|
||||
(AGENTS rule 10), and the pass-through case leaves none."""
|
||||
monkeypatch.setattr(
|
||||
retriever, "get_settings", lambda: _stub_settings(retrieval_ef_search=5)
|
||||
)
|
||||
db.rollback()
|
||||
with caplog.at_level(logging.WARNING, logger="app.rag.retriever"):
|
||||
_vector_candidates(db, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
db.rollback()
|
||||
lines = [r.getMessage() for r in caplog.records if "ef_search" in r.getMessage()]
|
||||
assert lines == ["retrieval: ef_search=5 raised to 100 (A7 candidate count)"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
retriever, "get_settings", lambda: _stub_settings(retrieval_ef_search=100)
|
||||
)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING, logger="app.rag.retriever"):
|
||||
_vector_candidates(db, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
db.rollback()
|
||||
assert [r.getMessage() for r in caplog.records if "ef_search" in r.getMessage()] == []
|
||||
@@ -0,0 +1,612 @@
|
||||
"""Integration: the cosine tier USES ``ix_chunks_embedding_hnsw`` and the
|
||||
top-100 did not move (phase 188, task 03 — the deterministic half of D4).
|
||||
|
||||
**The production numbers this pin stands in for** (read 2026-10-15, read-only):
|
||||
414,220 chunks — all with a non-null ``embedding`` — a ``chunks`` relation of
|
||||
≈ **3,000 MB** (database 3,147 MB), ``shared_buffers=128MB``, ``work_mem=4MB``,
|
||||
a 3,904 MB host with **no swap**, pgvector **0.8.0**, and ``pg_indexes`` for
|
||||
``chunks`` listing only ``chunks_pkey``, ``ix_chunks_document_id`` and
|
||||
``ix_chunks_tsv`` (GIN): **no index on ``embedding``**. Every chat turn's A7
|
||||
cosine tier (``ORDER BY embedding <=> :vec LIMIT 100``) was therefore a
|
||||
sequential scan of that 3 GB relation.
|
||||
|
||||
Two CI pins, both deterministic and LLM-free:
|
||||
|
||||
1. **The plan pin** — ``EXPLAIN (FORMAT JSON)`` of the tier's own SELECT (the
|
||||
statement object captured from
|
||||
:func:`app.rag.retriever._vector_candidates`, compiled with literal binds so
|
||||
the planner sees a real constant) names ``ix_chunks_embedding_hnsw`` and
|
||||
contains no ``Seq Scan on chunks``. Its vacuity guard runs the SAME
|
||||
statement with index scans refused and MUST see the seq scan, so the pin
|
||||
cannot pass on a plan the index never influenced.
|
||||
2. **The recall pin** — the index-backed top-100 against the EXACT cosine
|
||||
top-100 of the fixture corpus: ``|overlap| / 100 >= 0.99`` for EVERY query
|
||||
at the SHIPPED ``ef_search`` (400 since task 04's live battery; 100, the
|
||||
A7 candidate count, was the task-02 default and the battery retired it),
|
||||
each returned row's ``1 − distance`` equal to the exact cosine within
|
||||
**1e-6**, and the mean one step DEEPER never below the mean at the shipped
|
||||
value. Every recall run also re-reads the plan it just ran, because an
|
||||
``ef_search`` the planner declines turns the "index" pin into a second
|
||||
measurement of the sequential scan — a vacuous pass, and the one failure
|
||||
mode this file exists to catch.
|
||||
|
||||
**Measured on this corpus** (5,000 chunks, ``TOP_K = 100``, 30 fixed-seed
|
||||
queries, ``m = 16`` / ``ef_construction = 64`` from migration 0029):
|
||||
``ef_search=100`` → mean top-100 overlap **0.9993**, per-query minimum **0.99**
|
||||
(two of the thirty return 99/100), zero queries below the floor;
|
||||
``ef_search=200`` and the shipped **400** → mean **1.0000**. Worst
|
||||
``|cosine − exact cosine|`` over every
|
||||
returned row: **5.7e-07**, and 1e-6 is the tightest tolerance that can hold —
|
||||
``_vector_candidates`` publishes ``round(1 − distance, 6)``, which alone can move
|
||||
a value by 5e-7, on top of the float4 rounding of the distance itself (~6e-8 at
|
||||
this magnitude). The exact reference is computed over the vectors as the
|
||||
database returned them, so the comparison measures the index. Setup stays inside the runtime guard:
|
||||
generate ≈ 0.5 s, seed ≈ 2 s, ``CREATE INDEX CONCURRENTLY`` ≈ 1.4 s, read-back +
|
||||
exact top-100 ≈ 3.5 s — ≈ 8 s against the ~60 s target.
|
||||
|
||||
**What 5,000 rows proves and what it does not.** It is enough for a PLAN pin
|
||||
(the planner needs a populated relation to prefer an ordered index scan over a
|
||||
seq scan + top-N sort) and enough for a deterministic index-vs-exact
|
||||
regression; it is not a proxy for 414k rows, because recall is a function of the
|
||||
corpus shape and of graph density at 83× the size. The prod-scale gate is task
|
||||
04 — the live retrieval battery, index off vs on, over the 1,000-document
|
||||
replica KB, where every ``top_score``, top-5 set and A8 verdict must match.
|
||||
|
||||
**Corpus shape, and why it is not uniform noise.** One
|
||||
``random.Random(20261015)`` stream in a fixed order: a 16-direction principal
|
||||
subspace (one random unit direction per disjoint 48-dim block of the 768 —
|
||||
disjoint blocks are orthonormal by construction, so no Gram–Schmidt and no
|
||||
numerical drift), 40 unit centroids inside it, 5,000 chunks of
|
||||
``normalise(centroid + 2.0 × a random subspace direction)`` (125 per cluster),
|
||||
then 30 queries drawn uniformly from the same subspace. Low EFFECTIVE rank (16
|
||||
of the 768 stored dimensions carry the structure; every dimension is still
|
||||
non-zero) is deliberate: it is how trained text embeddings behave (their
|
||||
spectrum concentrates far below the nominal dimension), and it is the shape
|
||||
under which ``ef_search == LIMIT`` can be expected to return the exact top-100.
|
||||
|
||||
The counter-case is recorded here because measuring it is a finding of this
|
||||
task: the same 5,000 rows with the cluster bias thinned to a whisper (centroid
|
||||
weight 0.1 over per-dimension Gaussian noise across all 768 dimensions —
|
||||
effectively iid, the hardest shape a graph index can be handed, and the one
|
||||
this file's sweep ran at centroid weights 0.1, 0.3 and 0.5 alike) measures mean
|
||||
recall@100 ≈ **0.63** at ``ef_search=100``, ≈ **0.85** at 200 and **1.00** from
|
||||
400 up, whatever ``m`` / ``ef_construction`` migration 0029 ships. ``ef_search``
|
||||
at the candidate count is therefore NOT a free win on high-effective-rank data;
|
||||
if the live battery moves a ``top_score`` or a top-5 set, LOCKED A3's answer is
|
||||
to raise ``BOR_RETRIEVAL_EF_SEARCH`` (towards 400), never to relax an assertion
|
||||
here.
|
||||
|
||||
The index is built by migration **0029** and never by an inline ``CREATE
|
||||
INDEX`` — the migration is the single definition of the index these pins name
|
||||
(``0029 → 0028`` → seed unindexed → ``upgrade 0029``). As in
|
||||
``test_chunks_hnsw_index.py``, the seeding session holds NO open transaction
|
||||
while the ``CONCURRENTLY`` DDL runs (an idle-in-transaction snapshot parks the
|
||||
build), and teardown leaves the dev DB at head, index valid, fixture rows gone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import operator
|
||||
import random
|
||||
import struct
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal, db_available
|
||||
from app.models import Chunk, Document
|
||||
from app.rag import retriever
|
||||
from app.rag.retriever import _vector_candidates
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The corpus (D4's deterministic half) — one fixed stream, no wall clock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DIM = 768 # A6: the embedding dimension is fixed at table creation
|
||||
CENTROIDS = 40
|
||||
SUBSPACE = 16 # effective rank of the corpus (see the module docstring)
|
||||
BLOCK = DIM // SUBSPACE # 48 dims per basis direction, disjoint by construction
|
||||
NOISE = 2.0 # |noise| / |centroid| — cos(chunk, own centroid) ~= 0.45
|
||||
CORPUS_CHUNKS = 5000
|
||||
CHUNKS_PER_DOCUMENT = 10
|
||||
QUERIES = 30
|
||||
SEED = 20261015 # the date of the incident that started phase 188
|
||||
FIXTURE_SOURCE = "phase-188-hnsw-recall"
|
||||
|
||||
TOP_K = 100 # A7's cosine candidate count — the LIMIT the tier promises
|
||||
EF_PIN = 400 # the shipped default (Settings.retrieval_ef_search)
|
||||
EF_RAISED = 600 # the monotonicity probe
|
||||
RECALL_FLOOR = 0.99 # LOCKED A3 — a raised ef_search is the only remedy
|
||||
COSINE_TOLERANCE = 1e-6 # "exact to 6 decimals"
|
||||
|
||||
#: The pins need a small relation: the exact reference is a Python scan over
|
||||
#: every row. A DB holding more is a restored replica rather than the dev
|
||||
#: corpus — that scale is task 04's live battery, and 30 × N dot products here
|
||||
#: would only make the suite crawl.
|
||||
MAX_RELATION_ROWS = 20_000
|
||||
|
||||
INDEX_NAME = "ix_chunks_embedding_hnsw"
|
||||
RELATION = "chunks"
|
||||
|
||||
|
||||
def _f32(value: float) -> float:
|
||||
"""The ``float4`` the database holds for a query component.
|
||||
|
||||
A question vector goes to Postgres as ``str(float)`` text and comes back as
|
||||
``float4`` (``strtof`` rounds to nearest, the same value ``struct.pack``
|
||||
produces), so the exact reference rounds the QUERY the way the server does.
|
||||
The corpus needs no modelling — its components are read back from the
|
||||
database — which is what makes the comparison measure the INDEX rather than
|
||||
a precision artefact of the fixture.
|
||||
"""
|
||||
return struct.unpack("f", struct.pack("f", value))[0]
|
||||
|
||||
|
||||
def _norm(vec: list[float]) -> float:
|
||||
return math.sqrt(sum(v * v for v in vec))
|
||||
|
||||
|
||||
def _unit(vec: list[float]) -> list[float]:
|
||||
n = _norm(vec)
|
||||
return [v / n for v in vec] if n else vec
|
||||
|
||||
|
||||
def _generate() -> tuple[list[list[float]], list[list[float]]]:
|
||||
"""Fixed-seed ``(corpus, queries)``, all L2-normalised.
|
||||
|
||||
Normalising makes cosine the dot product, which is what keeps the exact
|
||||
reference cheap enough to run in CI. One stream in a fixed order (basis →
|
||||
centroids → chunks → queries) is what turns the recall floor into a
|
||||
regression pin instead of a measurement: byte-identical input, therefore a
|
||||
byte-identical graph, therefore a byte-identical overlap.
|
||||
"""
|
||||
rng = random.Random(SEED)
|
||||
basis = [_unit([rng.gauss(0.0, 1.0) for _ in range(BLOCK)]) for _ in range(SUBSPACE)]
|
||||
|
||||
def subspace_vector() -> list[float]:
|
||||
"""A random unit vector inside the 16-direction subspace."""
|
||||
coefs = [rng.gauss(0.0, 1.0) for _ in range(SUBSPACE)]
|
||||
n = _norm(coefs) or 1.0
|
||||
out = [0.0] * DIM
|
||||
for k, direction in enumerate(basis):
|
||||
weight = coefs[k] / n
|
||||
start = k * BLOCK
|
||||
for j in range(BLOCK):
|
||||
out[start + j] = weight * direction[j]
|
||||
return out
|
||||
|
||||
centroids = [subspace_vector() for _ in range(CENTROIDS)]
|
||||
corpus: list[list[float]] = []
|
||||
for i in range(CORPUS_CHUNKS):
|
||||
centroid = centroids[i % CENTROIDS]
|
||||
noise = subspace_vector()
|
||||
corpus.append(_unit([c + NOISE * n for c, n in zip(centroid, noise, strict=True)]))
|
||||
queries = [subspace_vector() for _ in range(QUERIES)]
|
||||
return corpus, queries
|
||||
|
||||
|
||||
def _seed(session: Session, corpus: list[list[float]]) -> None:
|
||||
"""Insert the corpus — 500 fixture documents × 10 chunks, one commit.
|
||||
|
||||
Ordinary content chunks (``is_summary`` False, embedding set): the
|
||||
NULL-embedding skip is pinned in ``tests/unit/test_retriever.py``, and a
|
||||
NULL row would sit in neither the exact set nor the index.
|
||||
"""
|
||||
documents = [
|
||||
Document(
|
||||
id=uuid.uuid4(),
|
||||
source=FIXTURE_SOURCE,
|
||||
path=f"{FIXTURE_SOURCE}/{d}.md",
|
||||
full_path=f"/tmp/{FIXTURE_SOURCE}/{d}.md",
|
||||
title=f"hnsw recall fixture {d}",
|
||||
content="fixture content",
|
||||
content_hash="0" * 64,
|
||||
)
|
||||
for d in range(CORPUS_CHUNKS // CHUNKS_PER_DOCUMENT)
|
||||
]
|
||||
session.add_all(documents)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
Chunk(
|
||||
id=uuid.uuid4(),
|
||||
document_id=documents[i // CHUNKS_PER_DOCUMENT].id,
|
||||
position=i % CHUNKS_PER_DOCUMENT,
|
||||
content=f"fixture chunk {i}",
|
||||
embedding=vector,
|
||||
is_summary=False,
|
||||
)
|
||||
for i, vector in enumerate(corpus)
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exact reference (pure Python, float4-modelled inputs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exact_top(stored: dict[Any, list[float]], query: list[float]) -> list[tuple[float, Any]]:
|
||||
"""The EXACT cosine top-:data:`TOP_K` of *stored* for *query*.
|
||||
|
||||
Mirrors pgvector's ``vector_cosine_distance``: three float8 accumulators
|
||||
(``dot``, ``norm_a``, ``norm_b``) over the components left to right, then
|
||||
``dot / sqrt(norm_a * norm_b)``. The norms are recomputed from the stored
|
||||
components rather than assumed to be 1, because the stored component is the
|
||||
float4 of a normalised double.
|
||||
|
||||
Pure Python (no numpy in this project — A3 keeps the dependency list
|
||||
closed): ``sum(map(mul, …))`` is ~25 µs per 768-dim dot product, so 30
|
||||
queries over 5,000 rows cost ~3 s. The one residual inaccuracy is the
|
||||
read-back itself: pgvector prints each float4 in shortest-round-trip form,
|
||||
so a component comes back within ~1 ulp of the float4 (measured max
|
||||
7.4e-09 absolute over the fixture corpus) — three orders below the 1e-6
|
||||
this file pins.
|
||||
"""
|
||||
q = [_f32(v) for v in query]
|
||||
q_norm = math.sqrt(sum(map(operator.mul, q, q)))
|
||||
scored = [
|
||||
(
|
||||
sum(map(operator.mul, vec, q)) / (math.sqrt(sum(map(operator.mul, vec, vec))) * q_norm),
|
||||
chunk_id,
|
||||
)
|
||||
for chunk_id, vec in stored.items()
|
||||
]
|
||||
scored.sort(key=lambda item: -item[0])
|
||||
return scored[:TOP_K]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capturing the tier's own statement (the plan pin must explain THAT SQL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StatementRecorder:
|
||||
"""Records what :func:`_vector_candidates` sends, answering with no rows."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.statements: list[Any] = []
|
||||
|
||||
def execute(self, statement: Any, params: Any = None) -> _StatementRecorder:
|
||||
self.statements.append(statement)
|
||||
return self
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def _vector_statement_sql() -> str:
|
||||
"""The tier's cosine SELECT as one literal SQL string, ready for EXPLAIN.
|
||||
|
||||
A hand-written copy would drift the moment the retriever changes and would
|
||||
then pin SQL no request ever runs, so the statement OBJECT is captured.
|
||||
``literal_binds`` gives the planner a real vector constant
|
||||
(``pgvector.sqlalchemy.VECTOR`` ships a literal processor, and the resulting
|
||||
unknown string literal coerces to ``vector`` through the ``<=>`` operator's
|
||||
own signature). The ``SET LOCAL`` line is deliberately not part of it:
|
||||
``EXPLAIN`` takes one statement, and that line tunes the search depth, not
|
||||
the shape of the plan.
|
||||
"""
|
||||
recorder = _StatementRecorder()
|
||||
_vector_candidates(recorder, [0.0] * DIM, TOP_K) # pyright: ignore[reportArgumentType]
|
||||
set_local, select_statement = recorder.statements
|
||||
assert str(set_local) == f"SET LOCAL hnsw.ef_search = {EF_PIN}", (
|
||||
f"the tier's ef_search line changed ({set_local}) — the capture must be the SELECT"
|
||||
)
|
||||
sql = str(
|
||||
select_statement.compile(
|
||||
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
|
||||
)
|
||||
)
|
||||
assert "<=>" in sql, f"the cosine operator is gone from the tier: {sql[:200]}"
|
||||
return sql
|
||||
|
||||
|
||||
def _plan(session: Session, sql: str) -> list[dict[str, Any]]:
|
||||
"""Every node of ``EXPLAIN (FORMAT JSON)`` for *sql*, flattened."""
|
||||
document = session.execute(text(f"EXPLAIN (FORMAT JSON) {sql}")).scalar()
|
||||
assert isinstance(document, list) and document, f"no plan for {sql[:120]}: {document!r}"
|
||||
nodes: list[dict[str, Any]] = []
|
||||
|
||||
def walk(node: dict[str, Any]) -> None:
|
||||
nodes.append(node)
|
||||
for child in node.get("Plans", []):
|
||||
walk(child)
|
||||
|
||||
walk(document[0]["Plan"])
|
||||
return nodes
|
||||
|
||||
|
||||
def _describe(nodes: list[dict[str, Any]]) -> str:
|
||||
return ", ".join(
|
||||
f"{node.get('Node Type')}[{node.get('Index Name') or node.get('Relation Name') or '?'}]"
|
||||
for node in nodes
|
||||
)
|
||||
|
||||
|
||||
def _uses_index(nodes: list[dict[str, Any]], index_name: str) -> bool:
|
||||
return any(
|
||||
"Index Scan" in str(node.get("Node Type", "")) and node.get("Index Name") == index_name
|
||||
for node in nodes
|
||||
)
|
||||
|
||||
|
||||
def _seq_scans(nodes: list[dict[str, Any]], relation: str) -> bool:
|
||||
return any(
|
||||
node.get("Node Type") == "Seq Scan" and node.get("Relation Name") == relation
|
||||
for node in nodes
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: seed unindexed, build through 0029, read the corpus back once
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hnsw_corpus() -> Iterator[dict[str, Any]]:
|
||||
"""Seed the corpus, build the index with migration 0029, precompute exact.
|
||||
|
||||
``0029 → 0028`` → seed → ``0029``: seeding with the index present would pay
|
||||
a graph insert per row, and building after the load is what the operator
|
||||
does on the production box anyway. Teardown deletes ONLY this module's rows
|
||||
(``source = FIXTURE_SOURCE``; the chunk rows go with them by the DB-level
|
||||
cascade — AGENTS rule 12, no chunk hydration) and leaves the dev DB at head
|
||||
with the index valid, exactly as it was found.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
session = SessionLocal()
|
||||
config = Config() # no alembic.ini — env.py reads the URL from app config
|
||||
config.set_main_option("script_location", "alembic")
|
||||
try:
|
||||
preexisting = session.execute(text(f"SELECT count(*) FROM {RELATION}")).scalar() or 0
|
||||
if preexisting > MAX_RELATION_ROWS:
|
||||
pytest.skip(
|
||||
f"{RELATION} holds {preexisting} rows: the exact reference is a Python "
|
||||
"scan, so these pins need the small dev corpus. Production scale "
|
||||
"is task 04's live battery."
|
||||
)
|
||||
corpus, queries = _generate()
|
||||
session.rollback() # no open snapshot while the CONCURRENTLY DDL runs
|
||||
command.downgrade(config, "0028")
|
||||
session.rollback()
|
||||
_seed(session, corpus)
|
||||
session.rollback()
|
||||
command.upgrade(config, "0029") # the ONE definition of the index
|
||||
session.rollback()
|
||||
# Planner statistics for the plan pin: a freshly loaded relation has
|
||||
# none, and a row estimate of 0 makes "seq scan + top-N sort" look free.
|
||||
session.execute(text(f"ANALYZE {RELATION}"))
|
||||
session.commit()
|
||||
rows = session.execute(select(Chunk.id, Chunk.embedding)).all()
|
||||
stored: dict[Any, list[float]] = {row[0]: row[1] or [] for row in rows}
|
||||
assert len(stored) >= preexisting + CORPUS_CHUNKS, (
|
||||
f"expected {CORPUS_CHUNKS} fixture rows on top of {preexisting}, "
|
||||
f"the relation holds {len(stored)}"
|
||||
)
|
||||
# The exact sets are the expensive half (30 × N dot products); the two
|
||||
# recall pins differ only in the ef_search they ask the index for.
|
||||
yield {
|
||||
"queries": queries,
|
||||
"exact": [_exact_top(stored, query) for query in queries],
|
||||
"chunk_ids": set(stored),
|
||||
"plan_sql": _vector_statement_sql(),
|
||||
}
|
||||
finally:
|
||||
session.rollback()
|
||||
session.execute(
|
||||
text("DELETE FROM documents WHERE source = :src"), {"src": FIXTURE_SOURCE}
|
||||
)
|
||||
session.commit()
|
||||
session.rollback()
|
||||
command.upgrade(config, "head") # the dev DB never stays below head
|
||||
session.close()
|
||||
|
||||
|
||||
def _stub_settings(**kwargs: object) -> Settings:
|
||||
"""A :class:`Settings` off the env file (the ``test_chunks_hnsw_index.py``
|
||||
helper's shape — an operator's shell must not tune these pins)."""
|
||||
return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _force_index_scan(session: Session) -> None:
|
||||
"""Put the planner on the index, because a recall pin has to measure the
|
||||
index and not the planner's preference.
|
||||
|
||||
Postgres costs an HNSW scan partly on ``ef_search``, so on a relation this
|
||||
small it can legitimately prefer a seq scan + top-N sort — and a recall run
|
||||
that did that would score the EXACT scan twice and report a perfect 1.0000
|
||||
without visiting a single graph node. That is not hypothetical: the live
|
||||
gate (``benchmarks/phase188_live_gate/``) watched exactly this happen on the
|
||||
8,866-chunk replica, where the planner used the index at ``ef_search = 40``
|
||||
and refused it at 100.
|
||||
|
||||
``enable_seqscan = off`` is Postgres' own "take any other plan if one
|
||||
exists" lever. It is set at SESSION scope because ``_vector_candidates``
|
||||
opens its own transaction, so a ``SET LOCAL`` issued here would die with the
|
||||
caller's transaction instead of reaching the tier. Every caller resets it in
|
||||
a ``finally`` — a leaked ``off`` would slow every later test on this pooled
|
||||
connection — and the assertion beside it proves the lever took effect.
|
||||
"""
|
||||
session.rollback()
|
||||
session.execute(text("SET enable_seqscan = off"))
|
||||
|
||||
|
||||
def _release_index_scan(session: Session) -> None:
|
||||
session.rollback()
|
||||
session.execute(text("RESET enable_seqscan"))
|
||||
session.rollback()
|
||||
|
||||
|
||||
def _set_ef_search(monkeypatch: pytest.MonkeyPatch, value: int) -> None:
|
||||
"""Drive the tier at *value* the way an operator does — the setting."""
|
||||
settings = _stub_settings(retrieval_ef_search=value)
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pin 1 — the planner uses the index (with its vacuity guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plan_uses_the_hnsw_index(db: Session, hnsw_corpus: dict[str, Any]) -> None:
|
||||
"""The 2026-10-15 incident WAS a plan, so the pin is a plan: the tier's own
|
||||
``ORDER BY embedding <=> <vector> LIMIT 100`` runs through
|
||||
``ix_chunks_embedding_hnsw`` and never reads ``chunks`` sequentially.
|
||||
|
||||
Explaining the captured statement is also what proves D1's "no query
|
||||
rewrite": the ``<=>`` the retriever already emits is exactly the operator
|
||||
``vector_cosine_ops`` serves.
|
||||
"""
|
||||
nodes = _plan(db, hnsw_corpus["plan_sql"])
|
||||
assert _uses_index(nodes, INDEX_NAME), (
|
||||
f"{INDEX_NAME} is not in the plan — the incident is back: [{_describe(nodes)}]"
|
||||
)
|
||||
assert not _seq_scans(nodes, RELATION), (
|
||||
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
|
||||
)
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_plan_without_the_index_seq_scans_the_relation(
|
||||
db: Session, hnsw_corpus: dict[str, Any]
|
||||
) -> None:
|
||||
"""Vacuity guard: the same statement, same session, same statistics, with
|
||||
index scans refused, MUST fall back to ``Seq Scan on chunks``.
|
||||
|
||||
``SET LOCAL enable_indexscan = off`` rather than dropping the index: a
|
||||
``DROP INDEX CONCURRENTLY`` + rebuild would cost a second graph build and
|
||||
leave the corpus unindexed if the rebuild failed, while this GUC is
|
||||
transaction-scoped (it dies with the rollback) and removes exactly the
|
||||
access path under test. Without this test the plan pin would pass vacuously
|
||||
on any plan that merely mentions some index.
|
||||
"""
|
||||
sql = hnsw_corpus["plan_sql"]
|
||||
db.rollback()
|
||||
db.execute(text("SET LOCAL enable_indexscan = off"))
|
||||
db.execute(text("SET LOCAL enable_indexonlyscan = off"))
|
||||
nodes = _plan(db, sql)
|
||||
assert _seq_scans(nodes, RELATION), (
|
||||
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
|
||||
)
|
||||
assert not _uses_index(nodes, INDEX_NAME), (
|
||||
f"{INDEX_NAME} was used with index scans disabled: [{_describe(nodes)}]"
|
||||
)
|
||||
db.rollback()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pin 2 — the approximate top-100 IS the exact top-100
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_top100_recall_at_the_shipped_ef_search(
|
||||
db: Session,
|
||||
hnsw_corpus: dict[str, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""At the shipped ``ef_search`` the index returns the exact top-100 for
|
||||
every query, exact to 1e-6 on the score the honesty gate reads.
|
||||
|
||||
Two different failures surface here: a ``top_score`` that moved — A8's
|
||||
deflect/answer verdict flips on that one number, so it is pinned per row,
|
||||
not per query — and an overlap below the floor, i.e. a candidate that never
|
||||
reaches RRF. The failure message carries every query's overlap so a future
|
||||
``ef_search`` raise has its evidence in the report instead of in a re-run.
|
||||
"""
|
||||
queries: list[list[float]] = hnsw_corpus["queries"]
|
||||
exact: list[list[tuple[float, Any]]] = hnsw_corpus["exact"]
|
||||
chunk_ids: set[Any] = hnsw_corpus["chunk_ids"]
|
||||
_set_ef_search(monkeypatch, EF_PIN)
|
||||
|
||||
_force_index_scan(db)
|
||||
try:
|
||||
depth_nodes = _plan(db, hnsw_corpus["plan_sql"])
|
||||
assert _uses_index(depth_nodes, INDEX_NAME), (
|
||||
f"this recall run at ef_search={EF_PIN} did not use {INDEX_NAME} even "
|
||||
"with sequential scans refused — the pin would be measuring a scan "
|
||||
f"and calling it parity: [{_describe(depth_nodes)}]"
|
||||
)
|
||||
|
||||
overlaps: list[float] = []
|
||||
worst_delta = 0.0
|
||||
for index, (query, exact_rows) in enumerate(zip(queries, exact, strict=True)):
|
||||
exact_ids = {chunk_id for _, chunk_id in exact_rows}
|
||||
exact_scores = {chunk_id: cosine for cosine, chunk_id in exact_rows}
|
||||
candidates = _vector_candidates(db, query, TOP_K)
|
||||
db.rollback() # end the turn's transaction, SET LOCAL included
|
||||
assert len(candidates) == TOP_K, (
|
||||
f"query {index}: the tier returned {len(candidates)} candidates, "
|
||||
f"not the {TOP_K} the A7 cosine tier promises"
|
||||
)
|
||||
got_ids = {candidate.chunk_id for candidate in candidates}
|
||||
assert got_ids <= chunk_ids, (
|
||||
f"query {index}: a candidate is outside the corpus the exact "
|
||||
"reference was computed over"
|
||||
)
|
||||
overlaps.append(len(exact_ids & got_ids) / TOP_K)
|
||||
for candidate in candidates:
|
||||
exact_cosine = exact_scores.get(candidate.chunk_id)
|
||||
if exact_cosine is not None:
|
||||
worst_delta = max(worst_delta, abs(candidate.cosine - exact_cosine))
|
||||
finally:
|
||||
_release_index_scan(db)
|
||||
|
||||
mean = sum(overlaps) / len(overlaps)
|
||||
below = [(i, overlap) for i, overlap in enumerate(overlaps) if overlap < RECALL_FLOOR]
|
||||
assert not below, (
|
||||
f"top-{TOP_K} recall below {RECALL_FLOOR} at ef_search={EF_PIN} for "
|
||||
f"{len(below)}/{len(overlaps)} queries: {below}; per-query overlap="
|
||||
f"{[round(overlap, 4) for overlap in overlaps]} mean={mean:.4f} — the "
|
||||
"remedy is a higher BOR_RETRIEVAL_EF_SEARCH, never this floor (LOCKED A3)"
|
||||
)
|
||||
assert worst_delta < COSINE_TOLERANCE, (
|
||||
f"1 - distance drifted from the exact cosine by {worst_delta:.3e} "
|
||||
f"(tolerance {COSINE_TOLERANCE:.0e}) — top_score would not be exact"
|
||||
)
|
||||
|
||||
|
||||
def test_recall_never_below_the_pinned_floor_when_ef_search_raised(
|
||||
db: Session,
|
||||
hnsw_corpus: dict[str, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Monotonicity sanity: a deeper search cannot recover FEWER of the exact
|
||||
top-100 than a shallower one.
|
||||
|
||||
This is the property the operator leans on when the live battery (task 04)
|
||||
says "raise it" — ``ef_search`` is a recall dial, not a lottery. A
|
||||
regression here means the value never reaches the server (the ``SET LOCAL``
|
||||
of task 02), which is exactly what the shipped 400 would then silently not
|
||||
do. ``EF_RAISED`` sits ABOVE the shipped value, so the probe still measures
|
||||
a deeper search after task 04 moved the default.
|
||||
"""
|
||||
exact: list[list[tuple[float, Any]]] = hnsw_corpus["exact"]
|
||||
queries: list[list[float]] = hnsw_corpus["queries"]
|
||||
|
||||
def mean_overlap(ef_search: int) -> float:
|
||||
_set_ef_search(monkeypatch, ef_search)
|
||||
total = 0.0
|
||||
for query, exact_rows in zip(queries, exact, strict=True):
|
||||
exact_ids = {chunk_id for _, chunk_id in exact_rows}
|
||||
candidates = _vector_candidates(db, query, TOP_K)
|
||||
db.rollback()
|
||||
total += len(exact_ids & {candidate.chunk_id for candidate in candidates}) / TOP_K
|
||||
return total / len(exact)
|
||||
|
||||
pinned = mean_overlap(EF_PIN)
|
||||
raised = mean_overlap(EF_RAISED)
|
||||
assert raised >= pinned, (
|
||||
f"ef_search={EF_RAISED} recovered less of the exact top-{TOP_K} "
|
||||
f"({raised:.4f}) than ef_search={EF_PIN} ({pinned:.4f})"
|
||||
)
|
||||
@@ -46,6 +46,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ def alembic(db: Session) -> Iterator[Config]:
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: psycopg3 keeps its snapshot alive while idle-in-transaction,
|
||||
# and 0029's CREATE INDEX CONCURRENTLY phase 3 waits for every
|
||||
# older-snapshot transaction — an unreleased session would park
|
||||
# the build forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
|
||||
@@ -553,6 +553,39 @@ def test_name_hit_bonus_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
_settings()
|
||||
|
||||
|
||||
def test_retrieval_ef_search_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 188 (D3, raised by task 04's live battery): the HNSW
|
||||
``ef_search`` for the cosine tier defaults to 400 — FOUR times the A7
|
||||
candidate count, because the battery measured ``ef_search = 100``
|
||||
(``== LIMIT``) recovering only mean 0.9814 / min 0.94 of the exact
|
||||
top-100 on the 1,000-document live replica's real embeddings, under this
|
||||
phase's own 0.99 floor — and 400 recovered 1.0000 with a max ``top_score``
|
||||
delta of exactly 0. It stays env-tunable (``BOR_RETRIEVAL_EF_SEARCH``) in
|
||||
either direction, and the retriever applies it per transaction with
|
||||
``SET LOCAL``."""
|
||||
monkeypatch.delenv("BOR_RETRIEVAL_EF_SEARCH", raising=False)
|
||||
assert _settings().retrieval_ef_search == 400
|
||||
monkeypatch.setenv("BOR_RETRIEVAL_EF_SEARCH", "100")
|
||||
assert _settings().retrieval_ef_search == 100
|
||||
|
||||
|
||||
def test_retrieval_ef_search_refuses_zero_and_negative(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""There is no ``ef_search=0`` to switch the index off: the retriever
|
||||
clamps anything below the A7 candidate count UP to it, so a ``0`` an
|
||||
operator might read as "off" would silently become 100. It fails at
|
||||
startup naming the field instead (phase 188, D3; the
|
||||
``show_text_max_chars`` fail-loud family — with ``0`` refused the way
|
||||
``recency_half_life_days`` refuses it)."""
|
||||
for bad in ("0", "-1"):
|
||||
monkeypatch.setenv("BOR_RETRIEVAL_EF_SEARCH", bad)
|
||||
with pytest.raises(ValidationError, match="retrieval_ef_search"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_recency_half_life_rejects_non_positive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -697,6 +697,155 @@ def test_vector_candidates_default_is_summary_false_for_legacy_chunks() -> None:
|
||||
assert out[0].is_summary is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 188 (task 02): the HNSW ``ef_search`` knob — SET LOCAL, clamped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _one_vector_row_session() -> _FakeSession:
|
||||
"""A stub session whose one rowset is a single ordinary chunk."""
|
||||
doc = _doc("ef.md", "EF_BODY")
|
||||
chunk = Chunk(
|
||||
id=uuid.uuid4(),
|
||||
document_id=doc.id,
|
||||
position=0,
|
||||
content="ef content",
|
||||
is_summary=False,
|
||||
)
|
||||
return _FakeSession([(chunk, 0.25, doc)])
|
||||
|
||||
|
||||
def _sql(session: _FakeSession) -> list[str]:
|
||||
"""The recorded statements as SQL text, in call order."""
|
||||
return [str(stmt) for stmt, _params in session.statements]
|
||||
|
||||
|
||||
def _ef_statement(session: _FakeSession) -> str | None:
|
||||
for sql in _sql(session):
|
||||
if sql.startswith("SET LOCAL hnsw.ef_search"):
|
||||
return sql
|
||||
return None
|
||||
|
||||
|
||||
def test_vector_candidates_sets_ef_search_first(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 188 (D3/LOCKED A4): the tier opens with
|
||||
``SET LOCAL hnsw.ef_search = <value>`` BEFORE the ``<=>`` SELECT (a
|
||||
``SET`` that arrived after the SELECT would tune the next turn
|
||||
instead of this one), and it is LOCAL — never a session/global ``SET``.
|
||||
The default (400 — the depth task 04's live battery reached exact
|
||||
top-100 parity at) is what goes out when the operator set nothing."""
|
||||
settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||||
assert settings.retrieval_ef_search == 400 # the production default
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||||
session = _one_vector_row_session()
|
||||
out = _vector_candidates(session, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
assert len(out) == 1, "the candidates still come back"
|
||||
statements = _sql(session)
|
||||
assert statements[0] == "SET LOCAL hnsw.ef_search = 400"
|
||||
assert len(statements) == 2, "exactly one SET plus the candidate SELECT"
|
||||
assert "<=>" in statements[1]
|
||||
|
||||
|
||||
def test_vector_candidates_clamps_ef_search_below_the_candidate_count(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""``ef_search`` BELOW the candidate count cannot be honoured — it
|
||||
returns FEWER candidates than the A7 tier promises — so it is CLAMPED
|
||||
up to the count with one WARNING line (the phase-188 D3 rule), and the
|
||||
SELECT still asks for the full ``LIMIT``."""
|
||||
settings = Settings(_env_file=None, retrieval_ef_search=10) # pyright: ignore[reportCallIssue]
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||||
session = _one_vector_row_session()
|
||||
with caplog.at_level("WARNING", logger="app.rag.retriever"):
|
||||
_vector_candidates(session, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
assert _ef_statement(session) == "SET LOCAL hnsw.ef_search = 100"
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert "retrieval: ef_search=10 raised to 100 (A7 candidate count)" in messages
|
||||
select_sql = _sql(session)[1]
|
||||
# the LIMIT stays the tier's own (parameterised) count — the clamp
|
||||
# raises the SEARCH depth, it never shrinks the promise
|
||||
assert "LIMIT :param_1" in select_sql
|
||||
|
||||
|
||||
def test_vector_candidates_ef_search_equal_to_the_limit_does_not_warn(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The clamp is STRICTLY below: ``ef_search == limit`` (the default
|
||||
100/100 shape) is the healthy state and logs nothing."""
|
||||
settings = Settings(_env_file=None, retrieval_ef_search=100) # pyright: ignore[reportCallIssue]
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||||
session = _one_vector_row_session()
|
||||
with caplog.at_level("WARNING", logger="app.rag.retriever"):
|
||||
_vector_candidates(session, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
assert _ef_statement(session) == "SET LOCAL hnsw.ef_search = 100"
|
||||
assert [r.getMessage() for r in caplog.records if "ef_search" in r.getMessage()] == []
|
||||
|
||||
|
||||
def test_vector_candidates_passes_ef_search_above_the_limit(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Above the candidate count the operator's number goes through
|
||||
untouched (the recall dial the phase-188 battery may raise it to) and
|
||||
nothing warns."""
|
||||
settings = Settings(_env_file=None, retrieval_ef_search=200) # pyright: ignore[reportCallIssue]
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||||
session = _one_vector_row_session()
|
||||
with caplog.at_level("WARNING", logger="app.rag.retriever"):
|
||||
_vector_candidates(session, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
assert _ef_statement(session) == "SET LOCAL hnsw.ef_search = 200"
|
||||
assert [r.getMessage() for r in caplog.records if "ef_search" in r.getMessage()] == []
|
||||
|
||||
|
||||
def test_vector_candidates_ef_search_statement_is_int_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The interpolated fragment is ``int()``-coerced: the SET line carries
|
||||
digits only — no quote, no semicolon, nothing a string could smuggle
|
||||
into the statement (the value is a ``Settings`` int, but the coercion
|
||||
is the belt beside the suspenders)."""
|
||||
settings = Settings(_env_file=None, retrieval_ef_search=137) # pyright: ignore[reportCallIssue]
|
||||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||||
session = _one_vector_row_session()
|
||||
_vector_candidates(session, [0.0] * 768, limit=100) # pyright: ignore[reportArgumentType]
|
||||
line = _ef_statement(session)
|
||||
assert line is not None
|
||||
value = line.split("=", 1)[1].strip()
|
||||
assert value == "137" and value.isdigit()
|
||||
|
||||
|
||||
def test_ef_search_below_one_refuses_startup_the_clamp_has_no_off_state() -> None:
|
||||
"""The clamp means ``ef_search=0`` could never mean "off" — it would be
|
||||
silently rewritten to the candidate count — so ``0`` and negatives are
|
||||
refused at settings construction instead (phase 188, D3; the
|
||||
``show_text_max_*`` family's fail-loud pattern)."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
for bad in (0, -1):
|
||||
with pytest.raises(ValidationError, match="retrieval_ef_search"):
|
||||
Settings(_env_file=None, retrieval_ef_search=bad) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def test_ef_search_default_clears_the_a7_candidate_count() -> None:
|
||||
"""The two numbers the clamp rule reads: the shipped default sits ABOVE
|
||||
``hybrid_vector_candidates`` (so a default install never warns), and it
|
||||
is not merely equal to it.
|
||||
|
||||
Equality was the task-02 default and the task-04 live battery retired it:
|
||||
``ef_search == LIMIT`` is the weakest depth an HNSW graph search can be
|
||||
handed, and on the 1,000-document live replica's real embeddings it
|
||||
recovered mean 0.9814 / min 0.94 of the exact top-100 — under this
|
||||
phase's 0.99 floor. 400 recovered 1.0000 with every A8 verdict, top-5 set
|
||||
and ``top_score`` unchanged, and it costs the tier nothing measurable
|
||||
(12-14 ms against 12-13 ms at 100, on 416k chunks, versus the ~550 ms
|
||||
sequential scan this index replaces).
|
||||
"""
|
||||
settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||||
assert settings.hybrid_vector_candidates == 100 # the A7 clamp floor
|
||||
assert settings.retrieval_ef_search == 400 > settings.hybrid_vector_candidates
|
||||
|
||||
|
||||
def _lexical_row(is_summary: bool, doc_path: str) -> object:
|
||||
"""One row of ``_LEXICAL_SQL`` (attribute access, as SQLAlchemy returns)."""
|
||||
doc = _doc(doc_path, "DOC_BODY")
|
||||
|
||||
@@ -73,6 +73,36 @@ def test_non_overridable_is_exactly_locked_a3() -> None:
|
||||
assert set(NON_OVERRIDABLE) == {"database_url", "embedding_dim"}
|
||||
|
||||
|
||||
def test_ef_search_row_is_a_plain_live_overridable_row() -> None:
|
||||
"""Phase 188 (task 02, D3): the HNSW ``ef_search`` row sits in
|
||||
``Retrieval & honesty`` (beside ``hybrid_vector_candidates`` — the two
|
||||
numbers the clamp rule reads together) with the ``Vector ef_search``
|
||||
label and is plain — not ``secret`` (an int), not ``restart_required``
|
||||
(``_vector_candidates`` reads ``get_settings()`` per turn, so a saved
|
||||
override applies on the next question), overridable (the overlay is
|
||||
generic over ``int`` fields). ``0``/negative fails startup, not the
|
||||
panel.
|
||||
|
||||
The default this row ships with is 400, not the A7 candidate count it
|
||||
launched at: task 04's live gate measured the graph recovering the exact
|
||||
top-100 for 0.9814 of rows on average (0.94 at worst) at 100 against 1.0000
|
||||
at 400, on the restored live KB with the vectors pinned
|
||||
(``benchmarks/phase188_live_gate/``). It stays live-overridable in either
|
||||
direction, which is what makes that a tuning decision and not a redeploy.
|
||||
"""
|
||||
meta = CATALOG["retrieval_ef_search"]
|
||||
assert meta.group == "Retrieval & honesty"
|
||||
assert meta.label == "Vector ef_search"
|
||||
assert meta.secret is False
|
||||
assert meta.restart_required is False
|
||||
assert meta.overridable is True
|
||||
assert "retrieval_ef_search" not in SECRETS
|
||||
assert "retrieval_ef_search" not in RESTART_REQUIRED
|
||||
assert "retrieval_ef_search" not in NON_OVERRIDABLE
|
||||
assert Settings.model_fields["retrieval_ef_search"].annotation is int
|
||||
assert Settings.model_fields["retrieval_ef_search"].default == 400
|
||||
|
||||
|
||||
def test_restart_required_is_the_boot_wired_set() -> None:
|
||||
# LOCKED A4: logging, the gzip middleware (compress_min_bytes), the
|
||||
# static mount (static_dir), create_engine (db_pool_*), and
|
||||
|
||||
Reference in New Issue
Block a user