Files
brain-of-reese/benchmarks/phase188_live_gate/README.md
T
ducoterra f20d40e4fe
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 12s
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`.
2026-10-17 03:18:23 -04:00

12 KiB
Raw Blame History

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.