Files
brain-of-reese/tests/integration/test_chunks_hnsw_index.py
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

355 lines
15 KiB
Python

"""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()] == []