All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
725 lines
33 KiB
Python
725 lines
33 KiB
Python
"""Integration: the cosine tier USES ``ix_chunks_embedding_hnsw`` and the
|
||
top-100 did not move (phase 188, task 03 — the deterministic half of D4).
|
||
|
||
**The production numbers this pin stands in for** (read 2026-10-15, read-only):
|
||
414,220 chunks — all with a non-null ``embedding`` — a ``chunks`` relation of
|
||
≈ **3,000 MB** (database 3,147 MB), ``shared_buffers=128MB``, ``work_mem=4MB``,
|
||
a 3,904 MB host with **no swap**, pgvector **0.8.0**, and ``pg_indexes`` for
|
||
``chunks`` listing only ``chunks_pkey``, ``ix_chunks_document_id`` and
|
||
``ix_chunks_tsv`` (GIN): **no index on ``embedding``**. Every chat turn's A7
|
||
cosine tier (``ORDER BY embedding <=> :vec LIMIT 100``) was therefore a
|
||
sequential scan of that 3 GB relation.
|
||
|
||
Two CI pins, both deterministic and LLM-free:
|
||
|
||
1. **The plan pin** — ``EXPLAIN (FORMAT JSON)`` of the tier's own SELECT (the
|
||
statement object captured from
|
||
:func:`app.rag.retriever._vector_candidates`, compiled with literal binds so
|
||
the planner sees a real constant) names ``ix_chunks_embedding_hnsw`` and
|
||
contains no ``Seq Scan on chunks``. Its vacuity guard runs the SAME
|
||
statement with index scans refused and MUST see the seq scan, so the pin
|
||
cannot pass on a plan the index never influenced.
|
||
2. **The recall pin** — the index-backed top-100 against the EXACT cosine
|
||
top-100 of the fixture corpus: ``|overlap| / 100 >= 0.99`` for EVERY query
|
||
at the SHIPPED ``ef_search`` (400 since task 04's live battery; 100, the
|
||
A7 candidate count, was the task-02 default and the battery retired it),
|
||
each returned row's ``1 − distance`` equal to the exact cosine within
|
||
**1e-6**, and the mean one step DEEPER never below the mean at the shipped
|
||
value. Every recall run also re-reads the plan it just ran, because an
|
||
``ef_search`` the planner declines turns the "index" pin into a second
|
||
measurement of the sequential scan — a vacuous pass, and the one failure
|
||
mode this file exists to catch.
|
||
|
||
**Measured on this corpus** (5,000 chunks, ``TOP_K = 100``, 30 fixed-seed
|
||
queries, ``m = 16`` / ``ef_construction = 64`` from migration 0029):
|
||
``ef_search=100`` → mean top-100 overlap **0.9993**, per-query minimum **0.99**
|
||
(two of the thirty return 99/100), zero queries below the floor;
|
||
``ef_search=200`` and the shipped **400** → mean **1.0000**. Worst
|
||
``|cosine − exact cosine|`` over every
|
||
returned row: **5.7e-07**, and 1e-6 is the tightest tolerance that can hold —
|
||
``_vector_candidates`` publishes ``round(1 − distance, 6)``, which alone can move
|
||
a value by 5e-7, on top of the float4 rounding of the distance itself (~6e-8 at
|
||
this magnitude). The exact reference is computed over the vectors as the
|
||
database returned them, so the comparison measures the index. Setup stays inside the runtime guard:
|
||
generate ≈ 0.5 s, seed ≈ 2 s, ``CREATE INDEX CONCURRENTLY`` ≈ 1.4 s, read-back +
|
||
exact top-100 ≈ 3.5 s — ≈ 8 s against the ~60 s target.
|
||
|
||
**The determinism premise (measured while fixing the 2026-10-18 flake).** A
|
||
plan is a cost decision, and two things outside this module's control move it
|
||
on a shared dev DB: the statistics of BOTH relations the tier's SELECT joins
|
||
(the fixture used to ``ANALYZE`` only ``chunks``, so the join side could carry
|
||
an estimate from whatever ran before it) and the ``embedding IS NULL`` fraction
|
||
of ``chunks`` — a NULL-embedding row is a candidate the index scan must skip
|
||
before the LIMIT fills, so ambient NULL rows inflate the estimated index cost.
|
||
Measured on this corpus with ambient NULL-embedding rows inserted alongside the
|
||
5,000 fixture rows: at 4,000 of them the UNFORCED planner leaves
|
||
``ix_chunks_embedding_hnsw`` for ``Sort → Hash Join → Seq Scan on chunks`` with
|
||
the index present and valid (at 2,000 and at 18,000 it uses it again — the band
|
||
moves with the statistics, which is exactly what makes it a flake rather than a
|
||
finding). Therefore: the fixture analyzes both joined relations, asserts the
|
||
index is present and VALID before any plan is read, and the index assertions run
|
||
with the alternatives that can disguise a scan refused
|
||
(``enable_seqscan`` AND ``enable_bitmapscan`` — verified across the pollution
|
||
levels above, and verified to still report ``False`` with the index dropped, so
|
||
the pin cannot pass on a missing index). The UNFORCED preference — the shape of
|
||
the 2026-10-15 incident — is asserted when the relation holds nothing but the
|
||
fixture corpus, which is the case a clean CI DB and this file's own teardown
|
||
leave.
|
||
|
||
**What 5,000 rows proves and what it does not.** It is enough for a PLAN pin
|
||
(the planner needs a populated relation to prefer an ordered index scan over a
|
||
seq scan + top-N sort) and enough for a deterministic index-vs-exact
|
||
regression; it is not a proxy for 414k rows, because recall is a function of the
|
||
corpus shape and of graph density at 83× the size. The prod-scale gate is task
|
||
04 — the live retrieval battery, index off vs on, over the 1,000-document
|
||
replica KB, where every ``top_score``, top-5 set and A8 verdict must match.
|
||
|
||
**Corpus shape, and why it is not uniform noise.** One
|
||
``random.Random(20261015)`` stream in a fixed order: a 16-direction principal
|
||
subspace (one random unit direction per disjoint 48-dim block of the 768 —
|
||
disjoint blocks are orthonormal by construction, so no Gram–Schmidt and no
|
||
numerical drift), 40 unit centroids inside it, 5,000 chunks of
|
||
``normalise(centroid + 2.0 × a random subspace direction)`` (125 per cluster),
|
||
then 30 queries drawn uniformly from the same subspace. Low EFFECTIVE rank (16
|
||
of the 768 stored dimensions carry the structure; every dimension is still
|
||
non-zero) is deliberate: it is how trained text embeddings behave (their
|
||
spectrum concentrates far below the nominal dimension), and it is the shape
|
||
under which ``ef_search == LIMIT`` can be expected to return the exact top-100.
|
||
|
||
The counter-case is recorded here because measuring it is a finding of this
|
||
task: the same 5,000 rows with the cluster bias thinned to a whisper (centroid
|
||
weight 0.1 over per-dimension Gaussian noise across all 768 dimensions —
|
||
effectively iid, the hardest shape a graph index can be handed, and the one
|
||
this file's sweep ran at centroid weights 0.1, 0.3 and 0.5 alike) measures mean
|
||
recall@100 ≈ **0.63** at ``ef_search=100``, ≈ **0.85** at 200 and **1.00** from
|
||
400 up, whatever ``m`` / ``ef_construction`` migration 0029 ships. ``ef_search``
|
||
at the candidate count is therefore NOT a free win on high-effective-rank data;
|
||
if the live battery moves a ``top_score`` or a top-5 set, LOCKED A3's answer is
|
||
to raise ``BOR_RETRIEVAL_EF_SEARCH`` (towards 400), never to relax an assertion
|
||
here.
|
||
|
||
The index is built by migration **0029** and never by an inline ``CREATE
|
||
INDEX`` — the migration is the single definition of the index these pins name
|
||
(``0029 → 0028`` → seed unindexed → ``upgrade 0029``). As in
|
||
``test_chunks_hnsw_index.py``, the seeding session holds NO open transaction
|
||
while the ``CONCURRENTLY`` DDL runs (an idle-in-transaction snapshot parks the
|
||
build), and teardown leaves the dev DB at head, index valid, fixture rows gone.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import operator
|
||
import random
|
||
import struct
|
||
import uuid
|
||
from collections.abc import Iterator
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from alembic.config import Config
|
||
from sqlalchemy import select, text
|
||
from sqlalchemy.dialects import postgresql
|
||
from sqlalchemy.orm import Session
|
||
|
||
from alembic import command
|
||
from app.config import Settings
|
||
from app.db import SessionLocal, db_available
|
||
from app.models import Chunk, Document
|
||
from app.rag import retriever
|
||
from app.rag.retriever import _vector_candidates
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The corpus (D4's deterministic half) — one fixed stream, no wall clock
|
||
# ---------------------------------------------------------------------------
|
||
|
||
DIM = 768 # A6: the embedding dimension is fixed at table creation
|
||
CENTROIDS = 40
|
||
SUBSPACE = 16 # effective rank of the corpus (see the module docstring)
|
||
BLOCK = DIM // SUBSPACE # 48 dims per basis direction, disjoint by construction
|
||
NOISE = 2.0 # |noise| / |centroid| — cos(chunk, own centroid) ~= 0.45
|
||
CORPUS_CHUNKS = 5000
|
||
CHUNKS_PER_DOCUMENT = 10
|
||
QUERIES = 30
|
||
SEED = 20261015 # the date of the incident that started phase 188
|
||
FIXTURE_SOURCE = "phase-188-hnsw-recall"
|
||
|
||
TOP_K = 100 # A7's cosine candidate count — the LIMIT the tier promises
|
||
EF_PIN = 400 # the shipped default (Settings.retrieval_ef_search)
|
||
EF_RAISED = 600 # the monotonicity probe
|
||
RECALL_FLOOR = 0.99 # LOCKED A3 — a raised ef_search is the only remedy
|
||
COSINE_TOLERANCE = 1e-6 # "exact to 6 decimals"
|
||
|
||
#: The pins need a small relation: the exact reference is a Python scan over
|
||
#: every row. A DB holding more is a restored replica rather than the dev
|
||
#: corpus — that scale is task 04's live battery, and 30 × N dot products here
|
||
#: would only make the suite crawl.
|
||
MAX_RELATION_ROWS = 20_000
|
||
|
||
INDEX_NAME = "ix_chunks_embedding_hnsw"
|
||
RELATION = "chunks"
|
||
|
||
|
||
def _f32(value: float) -> float:
|
||
"""The ``float4`` the database holds for a query component.
|
||
|
||
A question vector goes to Postgres as ``str(float)`` text and comes back as
|
||
``float4`` (``strtof`` rounds to nearest, the same value ``struct.pack``
|
||
produces), so the exact reference rounds the QUERY the way the server does.
|
||
The corpus needs no modelling — its components are read back from the
|
||
database — which is what makes the comparison measure the INDEX rather than
|
||
a precision artefact of the fixture.
|
||
"""
|
||
return struct.unpack("f", struct.pack("f", value))[0]
|
||
|
||
|
||
def _norm(vec: list[float]) -> float:
|
||
return math.sqrt(sum(v * v for v in vec))
|
||
|
||
|
||
def _unit(vec: list[float]) -> list[float]:
|
||
n = _norm(vec)
|
||
return [v / n for v in vec] if n else vec
|
||
|
||
|
||
def _generate() -> tuple[list[list[float]], list[list[float]]]:
|
||
"""Fixed-seed ``(corpus, queries)``, all L2-normalised.
|
||
|
||
Normalising makes cosine the dot product, which is what keeps the exact
|
||
reference cheap enough to run in CI. One stream in a fixed order (basis →
|
||
centroids → chunks → queries) is what turns the recall floor into a
|
||
regression pin instead of a measurement: byte-identical input, therefore a
|
||
byte-identical graph, therefore a byte-identical overlap.
|
||
"""
|
||
rng = random.Random(SEED)
|
||
basis = [_unit([rng.gauss(0.0, 1.0) for _ in range(BLOCK)]) for _ in range(SUBSPACE)]
|
||
|
||
def subspace_vector() -> list[float]:
|
||
"""A random unit vector inside the 16-direction subspace."""
|
||
coefs = [rng.gauss(0.0, 1.0) for _ in range(SUBSPACE)]
|
||
n = _norm(coefs) or 1.0
|
||
out = [0.0] * DIM
|
||
for k, direction in enumerate(basis):
|
||
weight = coefs[k] / n
|
||
start = k * BLOCK
|
||
for j in range(BLOCK):
|
||
out[start + j] = weight * direction[j]
|
||
return out
|
||
|
||
centroids = [subspace_vector() for _ in range(CENTROIDS)]
|
||
corpus: list[list[float]] = []
|
||
for i in range(CORPUS_CHUNKS):
|
||
centroid = centroids[i % CENTROIDS]
|
||
noise = subspace_vector()
|
||
corpus.append(_unit([c + NOISE * n for c, n in zip(centroid, noise, strict=True)]))
|
||
queries = [subspace_vector() for _ in range(QUERIES)]
|
||
return corpus, queries
|
||
|
||
|
||
def _seed(session: Session, corpus: list[list[float]]) -> None:
|
||
"""Insert the corpus — 500 fixture documents × 10 chunks, one commit.
|
||
|
||
Ordinary content chunks (``is_summary`` False, embedding set): the
|
||
NULL-embedding skip is pinned in ``tests/unit/test_retriever.py``, and a
|
||
NULL row would sit in neither the exact set nor the index.
|
||
"""
|
||
documents = [
|
||
Document(
|
||
id=uuid.uuid4(),
|
||
source=FIXTURE_SOURCE,
|
||
path=f"{FIXTURE_SOURCE}/{d}.md",
|
||
full_path=f"/tmp/{FIXTURE_SOURCE}/{d}.md",
|
||
title=f"hnsw recall fixture {d}",
|
||
content="fixture content",
|
||
content_hash="0" * 64,
|
||
)
|
||
for d in range(CORPUS_CHUNKS // CHUNKS_PER_DOCUMENT)
|
||
]
|
||
session.add_all(documents)
|
||
session.flush()
|
||
session.add_all(
|
||
[
|
||
Chunk(
|
||
id=uuid.uuid4(),
|
||
document_id=documents[i // CHUNKS_PER_DOCUMENT].id,
|
||
position=i % CHUNKS_PER_DOCUMENT,
|
||
content=f"fixture chunk {i}",
|
||
embedding=vector,
|
||
is_summary=False,
|
||
)
|
||
for i, vector in enumerate(corpus)
|
||
]
|
||
)
|
||
session.commit()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exact reference (pure Python, float4-modelled inputs)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _exact_top(stored: dict[Any, list[float]], query: list[float]) -> list[tuple[float, Any]]:
|
||
"""The EXACT cosine top-:data:`TOP_K` of *stored* for *query*.
|
||
|
||
Mirrors pgvector's ``vector_cosine_distance``: three float8 accumulators
|
||
(``dot``, ``norm_a``, ``norm_b``) over the components left to right, then
|
||
``dot / sqrt(norm_a * norm_b)``. The norms are recomputed from the stored
|
||
components rather than assumed to be 1, because the stored component is the
|
||
float4 of a normalised double.
|
||
|
||
Pure Python (no numpy in this project — A3 keeps the dependency list
|
||
closed): ``sum(map(mul, …))`` is ~25 µs per 768-dim dot product, so 30
|
||
queries over 5,000 rows cost ~3 s. The one residual inaccuracy is the
|
||
read-back itself: pgvector prints each float4 in shortest-round-trip form,
|
||
so a component comes back within ~1 ulp of the float4 (measured max
|
||
7.4e-09 absolute over the fixture corpus) — three orders below the 1e-6
|
||
this file pins.
|
||
"""
|
||
q = [_f32(v) for v in query]
|
||
q_norm = math.sqrt(sum(map(operator.mul, q, q)))
|
||
scored = [
|
||
(
|
||
sum(map(operator.mul, vec, q)) / (math.sqrt(sum(map(operator.mul, vec, vec))) * q_norm),
|
||
chunk_id,
|
||
)
|
||
for chunk_id, vec in stored.items()
|
||
]
|
||
scored.sort(key=lambda item: -item[0])
|
||
return scored[:TOP_K]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Capturing the tier's own statement (the plan pin must explain THAT SQL)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _StatementRecorder:
|
||
"""Records what :func:`_vector_candidates` sends, answering with no rows."""
|
||
|
||
def __init__(self) -> None:
|
||
self.statements: list[Any] = []
|
||
|
||
def execute(self, statement: Any, params: Any = None) -> _StatementRecorder:
|
||
self.statements.append(statement)
|
||
return self
|
||
|
||
def all(self) -> list[Any]:
|
||
return []
|
||
|
||
|
||
def _vector_statement_sql() -> str:
|
||
"""The tier's cosine SELECT as one literal SQL string, ready for EXPLAIN.
|
||
|
||
A hand-written copy would drift the moment the retriever changes and would
|
||
then pin SQL no request ever runs, so the statement OBJECT is captured.
|
||
``literal_binds`` gives the planner a real vector constant
|
||
(``pgvector.sqlalchemy.VECTOR`` ships a literal processor, and the resulting
|
||
unknown string literal coerces to ``vector`` through the ``<=>`` operator's
|
||
own signature). The ``SET LOCAL`` line is deliberately not part of it:
|
||
``EXPLAIN`` takes one statement, and that line tunes the search depth, not
|
||
the shape of the plan.
|
||
"""
|
||
recorder = _StatementRecorder()
|
||
_vector_candidates(recorder, [0.0] * DIM, TOP_K) # pyright: ignore[reportArgumentType]
|
||
set_local, select_statement = recorder.statements
|
||
assert str(set_local) == f"SET LOCAL hnsw.ef_search = {EF_PIN}", (
|
||
f"the tier's ef_search line changed ({set_local}) — the capture must be the SELECT"
|
||
)
|
||
sql = str(
|
||
select_statement.compile(
|
||
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
|
||
)
|
||
)
|
||
assert "<=>" in sql, f"the cosine operator is gone from the tier: {sql[:200]}"
|
||
return sql
|
||
|
||
|
||
def _plan(session: Session, sql: str) -> list[dict[str, Any]]:
|
||
"""Every node of ``EXPLAIN (FORMAT JSON)`` for *sql*, flattened."""
|
||
document = session.execute(text(f"EXPLAIN (FORMAT JSON) {sql}")).scalar()
|
||
assert isinstance(document, list) and document, f"no plan for {sql[:120]}: {document!r}"
|
||
nodes: list[dict[str, Any]] = []
|
||
|
||
def walk(node: dict[str, Any]) -> None:
|
||
nodes.append(node)
|
||
for child in node.get("Plans", []):
|
||
walk(child)
|
||
|
||
walk(document[0]["Plan"])
|
||
return nodes
|
||
|
||
|
||
def _index_validity(session: Session) -> bool | None:
|
||
"""``pg_index.indisvalid`` for :data:`INDEX_NAME` (``None`` = absent) — the
|
||
catalog fact behind every plan assertion here, so a failure names the
|
||
missing/INVALID index instead of a plan shape."""
|
||
value = session.execute(
|
||
text(
|
||
"SELECT i.indisvalid"
|
||
" FROM pg_index i"
|
||
" JOIN pg_class c ON c.oid = i.indexrelid"
|
||
" WHERE c.relname = :name"
|
||
),
|
||
{"name": INDEX_NAME},
|
||
).scalar()
|
||
session.rollback()
|
||
return None if value is None else bool(value)
|
||
|
||
|
||
def _describe(nodes: list[dict[str, Any]]) -> str:
|
||
return ", ".join(
|
||
f"{node.get('Node Type')}[{node.get('Index Name') or node.get('Relation Name') or '?'}]"
|
||
for node in nodes
|
||
)
|
||
|
||
|
||
def _uses_index(nodes: list[dict[str, Any]], index_name: str) -> bool:
|
||
return any(
|
||
"Index Scan" in str(node.get("Node Type", "")) and node.get("Index Name") == index_name
|
||
for node in nodes
|
||
)
|
||
|
||
|
||
def _seq_scans(nodes: list[dict[str, Any]], relation: str) -> bool:
|
||
return any(
|
||
node.get("Node Type") == "Seq Scan" and node.get("Relation Name") == relation
|
||
for node in nodes
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixture: seed unindexed, build through 0029, read the corpus back once
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def hnsw_corpus() -> Iterator[dict[str, Any]]:
|
||
"""Seed the corpus, build the index with migration 0029, precompute exact.
|
||
|
||
``0029 → 0028`` → seed → ``0029``: seeding with the index present would pay
|
||
a graph insert per row, and building after the load is what the operator
|
||
does on the production box anyway. Teardown deletes ONLY this module's rows
|
||
(``source = FIXTURE_SOURCE``; the chunk rows go with them by the DB-level
|
||
cascade — AGENTS rule 12, no chunk hydration) and leaves the dev DB at head
|
||
with the index valid, exactly as it was found.
|
||
"""
|
||
if not db_available():
|
||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||
session = SessionLocal()
|
||
config = Config() # no alembic.ini — env.py reads the URL from app config
|
||
config.set_main_option("script_location", "alembic")
|
||
try:
|
||
# `preexisting` is the relation's own state (the skip guard below);
|
||
# its EMBEDDED half is what the exact reference can score, and the
|
||
# pristine flag is the premise of the unforced-plan arm of pin 1.
|
||
preexisting, preexisting_embedded = session.execute(
|
||
text(f"SELECT count(*), count(embedding) FROM {RELATION}")
|
||
).one()
|
||
if preexisting > MAX_RELATION_ROWS:
|
||
pytest.skip(
|
||
f"{RELATION} holds {preexisting} rows: the exact reference is a Python "
|
||
"scan, so these pins need the small dev corpus. Production scale "
|
||
"is task 04's live battery."
|
||
)
|
||
corpus, queries = _generate()
|
||
session.rollback() # no open snapshot while the CONCURRENTLY DDL runs
|
||
command.downgrade(config, "0028")
|
||
session.rollback()
|
||
_seed(session, corpus)
|
||
session.rollback()
|
||
command.upgrade(config, "0029") # the ONE definition of the index
|
||
session.rollback()
|
||
# The premise of every pin below, checked where it is established: an
|
||
# INVALID remnant (phase 188 D2) or a drop by whatever else owns the
|
||
# schema on this box must report ITSELF, not surface three tests later
|
||
# as "the plan did not name the index".
|
||
validity = _index_validity(session)
|
||
assert validity is True, (
|
||
f"{INDEX_NAME} is {validity!r} after migration 0029 "
|
||
"(None = absent, False = the INVALID remnant D2 describes) — every "
|
||
"plan assertion below would be measuring a scan"
|
||
)
|
||
# Planner statistics for the plan pins — BOTH relations the tier's
|
||
# SELECT joins, because the plan is a JOIN plan and a stale estimate on
|
||
# either side flips it (see the module docstring). A freshly loaded
|
||
# relation has none, and a row estimate of 0 makes
|
||
# "seq scan + top-N sort" look free.
|
||
session.execute(text(f"ANALYZE {RELATION}"))
|
||
session.execute(text("ANALYZE documents"))
|
||
session.commit()
|
||
# The SAME predicate the tier applies: a NULL-embedding row is in
|
||
# neither the index nor the exact reference (a zero vector would score
|
||
# as a real candidate and land in no result set).
|
||
rows = session.execute(
|
||
select(Chunk.id, Chunk.embedding).where(Chunk.embedding.is_not(None))
|
||
).all()
|
||
stored: dict[Any, list[float]] = {row[0]: row[1] or [] for row in rows}
|
||
assert len(stored) >= preexisting_embedded + CORPUS_CHUNKS, (
|
||
f"expected {CORPUS_CHUNKS} fixture rows on top of "
|
||
f"{preexisting_embedded} embedded rows, the relation holds {len(stored)}"
|
||
)
|
||
# The exact sets are the expensive half (30 × N dot products); the two
|
||
# recall pins differ only in the ef_search they ask the index for.
|
||
yield {
|
||
"queries": queries,
|
||
"exact": [_exact_top(stored, query) for query in queries],
|
||
"chunk_ids": set(stored),
|
||
"plan_sql": _vector_statement_sql(),
|
||
# The unforced-planner assertion (pin 1) is meaningful only on a
|
||
# relation this module owns — see the determinism premise above.
|
||
"pristine": preexisting == 0,
|
||
}
|
||
finally:
|
||
session.rollback()
|
||
session.execute(
|
||
text("DELETE FROM documents WHERE source = :src"), {"src": FIXTURE_SOURCE}
|
||
)
|
||
session.commit()
|
||
session.rollback()
|
||
command.upgrade(config, "head") # the dev DB never stays below head
|
||
session.close()
|
||
|
||
|
||
def _stub_settings(**kwargs: object) -> Settings:
|
||
"""A :class:`Settings` off the env file (the ``test_chunks_hnsw_index.py``
|
||
helper's shape — an operator's shell must not tune these pins)."""
|
||
return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
|
||
|
||
|
||
def _force_index_scan(session: Session) -> None:
|
||
"""Put the planner on the index, because a recall pin has to measure the
|
||
index and not the planner's preference.
|
||
|
||
Postgres costs an HNSW scan partly on ``ef_search``, so on a relation this
|
||
small it can legitimately prefer a seq scan + top-N sort — and a recall run
|
||
that did that would score the EXACT scan twice and report a perfect 1.0000
|
||
without visiting a single graph node. That is not hypothetical: the live
|
||
gate (``benchmarks/phase188_live_gate/``) watched exactly this happen on the
|
||
8,866-chunk replica, where the planner used the index at ``ef_search = 40``
|
||
and refused it at 100.
|
||
|
||
``enable_seqscan = off`` is Postgres' own "take any other plan if one
|
||
exists" lever. It is set at SESSION scope because ``_vector_candidates``
|
||
opens its own transaction, so a ``SET LOCAL`` issued here would die with the
|
||
caller's transaction instead of reaching the tier. Every caller resets it in
|
||
a ``finally`` — a leaked ``off`` would slow every later test on this pooled
|
||
connection — and the assertion beside it proves the lever took effect.
|
||
|
||
``enable_bitmapscan`` goes off with it because refusing sequential scans
|
||
alone does not refuse a scan: measured on the 2026-10-18 flake, the planner
|
||
answered ``enable_seqscan = off`` with
|
||
``Sort → Nested Loop → Index Scan on documents → Bitmap Heap Scan on chunks
|
||
(ix_chunks_document_id)`` — every chunk row read and sorted through the
|
||
btree on ``document_id``, the very full read these pins exist to rule out,
|
||
wearing an index's name. With both levers off the only access path left for
|
||
``chunks`` that can serve the ORDER BY is the HNSW graph; with the index
|
||
dropped the same arm reports ``False`` (verified), so the guard cannot pass
|
||
on a missing index.
|
||
"""
|
||
session.rollback()
|
||
session.execute(text("SET enable_seqscan = off"))
|
||
session.execute(text("SET enable_bitmapscan = off"))
|
||
|
||
|
||
def _release_index_scan(session: Session) -> None:
|
||
session.rollback()
|
||
session.execute(text("RESET enable_seqscan"))
|
||
session.execute(text("RESET enable_bitmapscan"))
|
||
session.rollback()
|
||
|
||
|
||
def _set_ef_search(monkeypatch: pytest.MonkeyPatch, value: int) -> None:
|
||
"""Drive the tier at *value* the way an operator does — the setting."""
|
||
settings = _stub_settings(retrieval_ef_search=value)
|
||
monkeypatch.setattr(retriever, "get_settings", lambda: settings)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pin 1 — the planner uses the index (with its vacuity guard)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_plan_uses_the_hnsw_index(db: Session, hnsw_corpus: dict[str, Any]) -> None:
|
||
"""The 2026-10-15 incident WAS a plan, so the pin is a plan: the tier's own
|
||
``ORDER BY embedding <=> <vector> LIMIT 100`` runs through
|
||
``ix_chunks_embedding_hnsw`` and never reads ``chunks`` sequentially.
|
||
|
||
Explaining the captured statement is also what proves D1's "no query
|
||
rewrite": the ``<=>`` the retriever already emits is exactly the operator
|
||
``vector_cosine_ops`` serves.
|
||
|
||
Two arms, because the unforced one is a cost decision this file does not
|
||
own alone (module docstring, "The determinism premise"): the DEFAULT plan is
|
||
asserted while the relation holds nothing but the fixture corpus (a clean CI
|
||
DB, and this module's own teardown), and the FORCED plan — sequential and
|
||
bitmap scans refused, so nothing that reads every row can be chosen — is
|
||
asserted always.
|
||
"""
|
||
sql = hnsw_corpus["plan_sql"]
|
||
if hnsw_corpus["pristine"]:
|
||
nodes = _plan(db, sql)
|
||
assert _uses_index(nodes, INDEX_NAME), (
|
||
f"{INDEX_NAME} is not in the plan — the incident is back "
|
||
f"(index validity: {_index_validity(db)}): [{_describe(nodes)}]"
|
||
)
|
||
assert not _seq_scans(nodes, RELATION), (
|
||
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
|
||
)
|
||
_force_index_scan(db)
|
||
try:
|
||
forced = _plan(db, sql)
|
||
assert _uses_index(forced, INDEX_NAME), (
|
||
f"{INDEX_NAME} is not in the plan even with sequential and bitmap "
|
||
f"scans refused (index validity: {_index_validity(db)}): "
|
||
f"[{_describe(forced)}]"
|
||
)
|
||
assert not _seq_scans(forced, RELATION), (
|
||
f"a sequential scan of {RELATION} survived the index: "
|
||
f"[{_describe(forced)}]"
|
||
)
|
||
finally:
|
||
_release_index_scan(db)
|
||
db.rollback()
|
||
|
||
|
||
def test_plan_without_the_index_seq_scans_the_relation(
|
||
db: Session, hnsw_corpus: dict[str, Any]
|
||
) -> None:
|
||
"""Vacuity guard: the same statement, same session, same statistics, with
|
||
the index access paths refused, MUST fall back to ``Seq Scan on chunks``.
|
||
|
||
``SET LOCAL enable_indexscan/indexonlyscan/bitmapscan = off`` rather than
|
||
dropping the index: a ``DROP INDEX CONCURRENTLY`` + rebuild would cost a
|
||
second graph build and leave the corpus unindexed if the rebuild failed,
|
||
while these GUCs are transaction-scoped (they die with the rollback) and
|
||
together remove every access path that reaches an index — including the
|
||
bitmap path into ``ix_chunks_document_id``, which alone would let this guard
|
||
answer with a disguised full read instead of the seq scan it asserts.
|
||
Without this test the plan pin would pass vacuously on any plan that merely
|
||
mentions some index.
|
||
"""
|
||
sql = hnsw_corpus["plan_sql"]
|
||
db.rollback()
|
||
db.execute(text("SET LOCAL enable_indexscan = off"))
|
||
db.execute(text("SET LOCAL enable_indexonlyscan = off"))
|
||
# Bitmap scans go too: they are the other way into ``ix_chunks_document_id``,
|
||
# so leaving them on let this guard depend on the planner's mood instead of
|
||
# on the access path it removes (the 2026-10-18 flake answer was a Bitmap
|
||
# Heap Scan of chunks and no Seq Scan at all).
|
||
db.execute(text("SET LOCAL enable_bitmapscan = off"))
|
||
nodes = _plan(db, sql)
|
||
assert _seq_scans(nodes, RELATION), (
|
||
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
|
||
)
|
||
assert not _uses_index(nodes, INDEX_NAME), (
|
||
f"{INDEX_NAME} was used with index scans disabled: [{_describe(nodes)}]"
|
||
)
|
||
db.rollback()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pin 2 — the approximate top-100 IS the exact top-100
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_top100_recall_at_the_shipped_ef_search(
|
||
db: Session,
|
||
hnsw_corpus: dict[str, Any],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""At the shipped ``ef_search`` the index returns the exact top-100 for
|
||
every query, exact to 1e-6 on the score the honesty gate reads.
|
||
|
||
Two different failures surface here: a ``top_score`` that moved — A8's
|
||
deflect/answer verdict flips on that one number, so it is pinned per row,
|
||
not per query — and an overlap below the floor, i.e. a candidate that never
|
||
reaches RRF. The failure message carries every query's overlap so a future
|
||
``ef_search`` raise has its evidence in the report instead of in a re-run.
|
||
"""
|
||
queries: list[list[float]] = hnsw_corpus["queries"]
|
||
exact: list[list[tuple[float, Any]]] = hnsw_corpus["exact"]
|
||
chunk_ids: set[Any] = hnsw_corpus["chunk_ids"]
|
||
_set_ef_search(monkeypatch, EF_PIN)
|
||
|
||
_force_index_scan(db)
|
||
try:
|
||
depth_nodes = _plan(db, hnsw_corpus["plan_sql"])
|
||
assert _uses_index(depth_nodes, INDEX_NAME), (
|
||
f"this recall run at ef_search={EF_PIN} did not use {INDEX_NAME} even "
|
||
"with sequential AND bitmap scans refused — the pin would be "
|
||
"measuring a scan and calling it parity (index validity: "
|
||
f"{_index_validity(db)}): [{_describe(depth_nodes)}]"
|
||
)
|
||
|
||
overlaps: list[float] = []
|
||
worst_delta = 0.0
|
||
for index, (query, exact_rows) in enumerate(zip(queries, exact, strict=True)):
|
||
exact_ids = {chunk_id for _, chunk_id in exact_rows}
|
||
exact_scores = {chunk_id: cosine for cosine, chunk_id in exact_rows}
|
||
candidates = _vector_candidates(db, query, TOP_K)
|
||
db.rollback() # end the turn's transaction, SET LOCAL included
|
||
assert len(candidates) == TOP_K, (
|
||
f"query {index}: the tier returned {len(candidates)} candidates, "
|
||
f"not the {TOP_K} the A7 cosine tier promises"
|
||
)
|
||
got_ids = {candidate.chunk_id for candidate in candidates}
|
||
assert got_ids <= chunk_ids, (
|
||
f"query {index}: a candidate is outside the corpus the exact "
|
||
"reference was computed over"
|
||
)
|
||
overlaps.append(len(exact_ids & got_ids) / TOP_K)
|
||
for candidate in candidates:
|
||
exact_cosine = exact_scores.get(candidate.chunk_id)
|
||
if exact_cosine is not None:
|
||
worst_delta = max(worst_delta, abs(candidate.cosine - exact_cosine))
|
||
finally:
|
||
_release_index_scan(db)
|
||
|
||
mean = sum(overlaps) / len(overlaps)
|
||
below = [(i, overlap) for i, overlap in enumerate(overlaps) if overlap < RECALL_FLOOR]
|
||
assert not below, (
|
||
f"top-{TOP_K} recall below {RECALL_FLOOR} at ef_search={EF_PIN} for "
|
||
f"{len(below)}/{len(overlaps)} queries: {below}; per-query overlap="
|
||
f"{[round(overlap, 4) for overlap in overlaps]} mean={mean:.4f} — the "
|
||
"remedy is a higher BOR_RETRIEVAL_EF_SEARCH, never this floor (LOCKED A3)"
|
||
)
|
||
assert worst_delta < COSINE_TOLERANCE, (
|
||
f"1 - distance drifted from the exact cosine by {worst_delta:.3e} "
|
||
f"(tolerance {COSINE_TOLERANCE:.0e}) — top_score would not be exact"
|
||
)
|
||
|
||
|
||
def test_recall_never_below_the_pinned_floor_when_ef_search_raised(
|
||
db: Session,
|
||
hnsw_corpus: dict[str, Any],
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Monotonicity sanity: a deeper search cannot recover FEWER of the exact
|
||
top-100 than a shallower one.
|
||
|
||
This is the property the operator leans on when the live battery (task 04)
|
||
says "raise it" — ``ef_search`` is a recall dial, not a lottery. A
|
||
regression here means the value never reaches the server (the ``SET LOCAL``
|
||
of task 02), which is exactly what the shipped 400 would then silently not
|
||
do. ``EF_RAISED`` sits ABOVE the shipped value, so the probe still measures
|
||
a deeper search after task 04 moved the default.
|
||
"""
|
||
exact: list[list[tuple[float, Any]]] = hnsw_corpus["exact"]
|
||
queries: list[list[float]] = hnsw_corpus["queries"]
|
||
|
||
def mean_overlap(ef_search: int) -> float:
|
||
_set_ef_search(monkeypatch, ef_search)
|
||
total = 0.0
|
||
for query, exact_rows in zip(queries, exact, strict=True):
|
||
exact_ids = {chunk_id for _, chunk_id in exact_rows}
|
||
candidates = _vector_candidates(db, query, TOP_K)
|
||
db.rollback()
|
||
total += len(exact_ids & {candidate.chunk_id for candidate in candidates}) / TOP_K
|
||
return total / len(exact)
|
||
|
||
pinned = mean_overlap(EF_PIN)
|
||
raised = mean_overlap(EF_RAISED)
|
||
assert raised >= pinned, (
|
||
f"ef_search={EF_RAISED} recovered less of the exact top-{TOP_K} "
|
||
f"({raised:.4f}) than ef_search={EF_PIN} ({pinned:.4f})"
|
||
)
|