**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`.
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 andtop_scorewere identical, to0.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.200measured 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.pyis therefore 400, still overridable withBOR_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.