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

186 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.