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

237 lines
8.7 KiB
Python

"""Integration: migration 0009 (saved_chats.share_token) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0008.py`` (information_schema / pg_indexes assertions
on the state the migration must leave). The tests target revision
``0009`` explicitly so later migrations cannot break them:
* upgrade 0008 → 0009 → ``saved_chats.share_token`` exists as
``UUID`` **NULLable** (NULL = not shared) and the UNIQUE index
``ix_saved_chats_share_token`` exists; pre-0009 rows come back
unshared (NULL);
* the NULLs-distinct behavior (the phase-38 ``git_sources.path``
precedent): two rows may both carry NULL, while two identical
non-NULL tokens are rejected by the unique index;
* downgrade to 0008 → the column and the index are gone (A13 —
reversible), the rest of the table survives;
* upgrade back to 0009 → both are back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@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); 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: 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")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one saved_chats column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'saved_chats' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _unique_token_index(db: Session) -> int:
"""1 iff ``ix_saved_chats_share_token`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'saved_chats'"
" AND indexname = 'ix_saved_chats_share_token'"
" AND indexdef ILIKE 'CREATE UNIQUE%'"
)
).scalar()
assert count is not None, "pg_indexes count must be an int"
return int(count)
def _insert(db: Session, token: uuid.UUID | None) -> uuid.UUID:
"""Insert one saved_chats row with an explicit ``share_token``."""
chat_id: uuid.UUID = db.execute(
text(
"INSERT INTO saved_chats (id, title, messages, share_token)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :tok)"
" RETURNING id"
),
{
"t": "Mig 0009",
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
"tok": token,
},
).scalar_one()
db.commit()
return chat_id
def _legacy_insert(db: Session) -> uuid.UUID:
"""Insert one row WITHOUT the ``share_token`` column — the only
possible shape at revision 0008 (the column does not exist yet)."""
chat_id: uuid.UUID = db.execute(
text(
"INSERT INTO saved_chats (id, title, messages)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
" RETURNING id"
),
{
"t": "Mig 0009",
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
},
).scalar_one()
db.commit()
return chat_id
def _delete(db: Session, chat_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id})
db.commit()
def test_upgrade_to_0009_adds_share_token(db: Session, alembic: Config) -> None:
"""Upgrade 0008 → 0009: the column is UUID + NULLable, the unique
index exists, and a pre-0009 row comes back unshared (NULL)."""
command.downgrade(alembic, "0008") # start from the pre-0009 state
assert _version(db) == "0008"
assert _column(db, "share_token") is None, "share_token must be absent at 0008"
assert _unique_token_index(db) == 0, "the index must be absent at 0008"
# A pre-0009 row (no share_token in the INSERT — the column does
# not exist at 0008): its data must survive the additive migration.
legacy = _legacy_insert(db)
try:
command.upgrade(alembic, "0009")
assert _version(db) == "0009", "alembic_version must be at 0009"
col = _column(db, "share_token")
assert col is not None, "saved_chats.share_token is missing"
assert col[0] == "uuid", "share_token must be UUID"
assert col[1] == "YES", "share_token must be NULLable (NULL = not shared)"
assert _unique_token_index(db) == 1, "the unique token index is missing"
token = db.execute(
text("SELECT share_token FROM saved_chats WHERE id = :i"), {"i": legacy}
).scalar_one()
assert token is None, "a pre-0009 row must upgrade as unshared (NULL)"
finally:
_delete(db, legacy)
def test_unique_index_treats_nulls_as_distinct(db: Session, alembic: Config) -> None:
"""NULLs are distinct under the unique index (the phase-38
``git_sources.path`` precedent): any number of unshared chats
coexist."""
command.upgrade(alembic, "head")
a = _insert(db, None)
b = _insert(db, None)
try:
count = db.execute(
text(
"SELECT count(*) FROM saved_chats"
" WHERE id IN (:a, :b) AND share_token IS NULL"
),
{"a": a, "b": b},
).scalar_one()
assert count == 2, "two NULL tokens must coexist (NULLs are distinct)"
finally:
_delete(db, a)
_delete(db, b)
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
"""Two identical non-NULL tokens are rejected by the unique index —
the share link is a unique handle (and a distinct token still lands).
"""
command.upgrade(alembic, "head")
token = uuid.uuid4()
a = _insert(db, token)
b: uuid.UUID | None = None
try:
try:
_insert(db, token)
except IntegrityError:
db.rollback() # the aborted transaction must not leak
else:
pytest.fail("a duplicate non-NULL share_token must be rejected")
# A different token is fine — only the exact duplicate is unique.
b = _insert(db, uuid.uuid4())
finally:
_delete(db, a)
if b is not None:
_delete(db, b)
def test_downgrade_to_0008_drops_share_token(db: Session, alembic: Config) -> None:
"""Downgrade to 0008: the column and the index are gone (A13 —
reversible) while the rest of the table survives."""
command.downgrade(alembic, "0008")
assert _version(db) == "0008"
assert _column(db, "share_token") is None, "share_token must be dropped"
assert _unique_token_index(db) == 0, "the unique index must be dropped"
id_col = _column(db, "id")
assert id_col is not None and id_col[0] == "uuid", (
"saved_chats.id must survive the downgrade"
)
def test_upgrade_round_trip_restores_share_token(db: Session, alembic: Config) -> None:
"""Downgrade to 0008, then upgrade back to 0009: the column and the
unique index are back."""
command.downgrade(alembic, "0008")
command.upgrade(alembic, "0009")
assert _version(db) == "0009", "round-trip upgrade must land at 0009"
col = _column(db, "share_token")
assert col is not None, "share_token must be back after the round-trip"
assert col[0] == "uuid" and col[1] == "YES", (
"share_token must be UUID + NULLable after the round-trip"
)
assert _unique_token_index(db) == 1, "the unique index must be back"