**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`.
201 lines
8.0 KiB
Python
201 lines
8.0 KiB
Python
"""Integration: migration 0016 (ui_settings semantic colors) schema
|
|
contract (phase 93, task 01).
|
|
|
|
Drives the **real Alembic engine** against the live dev database
|
|
(``podman compose up -d db``), mirroring the house pattern of
|
|
``test_migration_0014.py`` (information_schema assertions on the state
|
|
the migration must leave). The tests target the 0015 → 0016 step
|
|
explicitly so later migrations cannot break them:
|
|
|
|
* upgrade 0015 → 0016 → the 8 semantic columns exist with the full
|
|
contract (VARCHAR(7) NULL, no server defaults — the row is created
|
|
only by the PUT upsert, house rule) while the 0014/0015 columns
|
|
(``app_name``, ``grid_line``, ``brand_ink``) survive;
|
|
* an inserted id-1 row round-trips its semantic values (the PUT
|
|
upsert's shape);
|
|
* downgrade to 0015 → the 8 columns are GONE (A13 — reversible), the
|
|
rest of the ``ui_settings`` schema (and ``api_tokens``) survives;
|
|
* upgrade back to 0016 → the 8 columns 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
|
|
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from alembic.config import Config
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from alembic import command
|
|
from app.db import db_available
|
|
|
|
#: The 8 semantic columns (phase 93 — B3 revised): ok / err / accent
|
|
#: families, VARCHAR(7) NULL ``#rrggbb``, NULL = the built-in (B1).
|
|
SEMANTIC_COLUMNS = (
|
|
"ok_bg", "ok_ink",
|
|
"err_bg", "err_ink", "err_line",
|
|
"accent_bg", "accent_ink", "accent_line",
|
|
)
|
|
|
|
|
|
@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, table: str, column: str) -> tuple[Any, ...] | None:
|
|
"""(data_type, is_nullable, column_default, character_maximum_length)
|
|
for one table column."""
|
|
row = db.execute(
|
|
text(
|
|
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
|
" FROM information_schema.columns"
|
|
" WHERE table_name = :t AND column_name = :c"
|
|
),
|
|
{"t": table, "c": column},
|
|
).fetchone()
|
|
return tuple(row) if row is not None else None
|
|
|
|
|
|
def _insert_row(db: Session) -> None:
|
|
"""Insert the single row (the PUT upsert's shape) with two semantic
|
|
values set and the rest NULL — the NULL = built-in state the
|
|
resolver merges."""
|
|
db.execute(
|
|
text(
|
|
"INSERT INTO ui_settings (id, ok_ink, accent_bg) VALUES (1, :o, :a)"
|
|
),
|
|
{"o": "#444444", "a": "#222222"},
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _delete_row(db: Session) -> None:
|
|
db.execute(text("DELETE FROM ui_settings WHERE id = 1"))
|
|
db.commit()
|
|
|
|
|
|
def test_upgrade_to_0016_adds_the_8_semantic_columns(
|
|
db: Session, alembic: Config
|
|
) -> None:
|
|
"""Upgrade 0015 → 0016: the 8 semantic columns exist with the full
|
|
contract (VARCHAR(7) NULL — NULL = the built-in, B1; no server
|
|
defaults anywhere: a missing row means "defaults"); all 8 are
|
|
ABSENT at 0015 and the pre-0016 columns survive the upgrade."""
|
|
command.downgrade(alembic, "0015") # start from the pre-0016 state
|
|
assert _version(db) == "0015"
|
|
for name in SEMANTIC_COLUMNS:
|
|
assert _column(db, "ui_settings", name) is None, (
|
|
f"ui_settings.{name} must be absent at 0015"
|
|
)
|
|
|
|
command.upgrade(alembic, "0016")
|
|
assert _version(db) == "0016", "alembic_version must be at 0016"
|
|
|
|
for name in SEMANTIC_COLUMNS:
|
|
col = _column(db, "ui_settings", name)
|
|
assert col is not None, f"ui_settings.{name} is missing"
|
|
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
|
|
assert col[1] == "YES", f"ui_settings.{name} must be NULL (the built-in, B1)"
|
|
assert col[2] is None, f"ui_settings.{name} must have no server default"
|
|
assert col[3] == 7, f"ui_settings.{name} must be String(7) — #rrggbb"
|
|
|
|
# The 0014/0015 columns survive the additive upgrade.
|
|
for name in ("app_name", "grid_line", "brand_ink"):
|
|
col = _column(db, "ui_settings", name)
|
|
assert col is not None, f"ui_settings.{name} must survive the upgrade"
|
|
|
|
|
|
def test_inserted_id_1_row_round_trips_semantic_values(
|
|
db: Session, alembic: Config
|
|
) -> None:
|
|
"""At 0016, the single row (id 1, the PUT upsert's shape)
|
|
round-trips its semantic values verbatim and keeps the unset
|
|
columns NULL (identity and the other semantic columns)."""
|
|
command.upgrade(alembic, "head")
|
|
_insert_row(db)
|
|
try:
|
|
row = db.execute(
|
|
text(
|
|
"SELECT id, ok_ink, accent_bg, ok_bg, err_ink, accent_line"
|
|
" FROM ui_settings WHERE id = 1"
|
|
)
|
|
).fetchone()
|
|
assert row is not None, "the ui_settings row must exist"
|
|
assert row[0] == 1, "the single row is always id 1"
|
|
assert row[1] == "#444444", "ok_ink must round-trip verbatim"
|
|
assert row[2] == "#222222", "accent_bg must round-trip verbatim"
|
|
assert row[3] is None, "ok_bg must stay NULL (the built-in, B1)"
|
|
assert row[4] is None, "err_ink must stay NULL (the built-in, B1)"
|
|
assert row[5] is None, "accent_line must stay NULL (the built-in, B1)"
|
|
finally:
|
|
_delete_row(db)
|
|
|
|
|
|
def test_downgrade_to_0015_drops_the_8_columns(db: Session, alembic: Config) -> None:
|
|
"""Downgrade 0016 → 0015: the 8 semantic columns are gone (A13 —
|
|
fully reversible) while the rest of the schema survives (the 0015
|
|
``grid_line`` column, the 0014 strings, ``api_tokens``)."""
|
|
command.downgrade(alembic, "0015")
|
|
assert _version(db) == "0015"
|
|
for name in SEMANTIC_COLUMNS:
|
|
assert _column(db, "ui_settings", name) is None, (
|
|
f"ui_settings.{name} must be dropped"
|
|
)
|
|
|
|
grid = _column(db, "ui_settings", "grid_line")
|
|
assert grid is not None and grid[3] == 7, (
|
|
"grid_line (0015) must survive the downgrade"
|
|
)
|
|
app_name = _column(db, "ui_settings", "app_name")
|
|
assert app_name is not None and app_name[3] == 300, (
|
|
"app_name (0014) must survive the downgrade"
|
|
)
|
|
token_col = _column(db, "api_tokens", "token_hash")
|
|
assert token_col is not None and token_col[0] == "character varying", (
|
|
"api_tokens.token_hash must survive the downgrade"
|
|
)
|
|
|
|
|
|
def test_upgrade_round_trip_restores_the_columns(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0015, then upgrade back to 0016: the 8 columns are
|
|
back with the column contract intact."""
|
|
command.downgrade(alembic, "0015")
|
|
command.upgrade(alembic, "0016")
|
|
assert _version(db) == "0016", "round-trip upgrade must land at 0016"
|
|
|
|
for name in SEMANTIC_COLUMNS:
|
|
col = _column(db, "ui_settings", name)
|
|
assert col is not None, f"ui_settings.{name} must be back"
|
|
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
|
|
assert col[1] == "YES", f"ui_settings.{name} must be NULL after the round-trip"
|
|
assert col[3] == 7, f"ui_settings.{name} must be String(7) after the round-trip"
|