Files
turnstone/tests/test_admin_rerank_calibrate.py
T
Patrick Buckley 68ed0b6c15 feat(rerank): per-model calibration on detect + calibrate endpoint
Phase 3. Stores reranker calibration per-model on the model definition's
capabilities (rerank_threshold/rerank_scale/rerank_separated; a non-empty
rerank_scale is the "has been calibrated" marker) instead of a single global
threshold, populated automatically when a reranker endpoint is detected.

- ChatSession._bm25_rerank_threshold precedence: the active reranker model's
  calibrated rerank_threshold (when separated) wins; calibrated-but-not-
  separated -> 0 (no floor); else the global tools.rerank_bm25_threshold
  fallback. Reads the raw caps dict, in-memory per turn.
- Detect (admin_detect_model) calibrates a supports_rerank endpoint and
  autopopulates the three fields like context window; the create-model UI shows
  a verdict chip (calibrated / no-clean-separation / not-calibrated).
- POST /api/admin/model-definitions/{id}/calibrate backs the Re-calibrate
  button; calibration runs off the event loop (run_in_executor, bounded by
  asyncio.timeout(90)), persists the fields + refreshes the registry, and is
  graceful on a down/slow endpoint (never 500). Shared
  merge_calibration_into_caps helper used by the endpoint and the CLI.
- turnstone-admin rerank-calibrate is now per-model: --model <alias> required;
  --apply writes that model's caps (not the global setting). A no-separation
  result records the marker (calibrated, no floor) consistently across CLI and
  endpoint.

The serving lesson stays documented: Qwen3-Reranker needs vLLM --chat-template
or its scores are near-random (live-validated 0.6B + 4B: the calibrated floor
came out 0.95 vs 0.33 for the same task -- why per-model calibration exists).

Negative-tested: the floor-precedence branches (calibrated+separated -> per-
model, calibrated+!separated -> 0, uncalibrated -> global, no-alias -> global,
registry-must-not-be-consulted), endpoint persist/refresh + graceful failure,
detect-skips-non-rerankers, the CLI per-model write, and the caps-merge
preserving supports_rerank. Chip states verified via headless Chrome.
2026-06-01 15:47:14 -07:00

150 lines
5.0 KiB
Python

"""Tests for ``turnstone-admin rerank-calibrate`` (Phase 3 per-model calibration).
Drives the real ``_cmd_rerank_calibrate`` through a real sqlite storage. The CLI
is now per-model: it requires ``--model <alias>``, resolves that reranker model
definition, calibrates its endpoint, and (with ``--apply``) writes the three
calibration fields onto the model's capabilities — NOT the global
``tools.rerank_bm25_threshold``. ``calibrate_model`` is stubbed (it needs a live
endpoint). Mirrors the storage setup in test_admin_export.py.
"""
from __future__ import annotations
import argparse
import json
from typing import TYPE_CHECKING
import pytest
from turnstone.admin import _cmd_rerank_calibrate
from turnstone.core.rerank_calibrate import CalibrationResult
from turnstone.core.storage import init_storage, reset_storage
if TYPE_CHECKING:
from collections.abc import Iterator
_ALIAS = "my-reranker"
@pytest.fixture(autouse=True)
def _reset_storage_singleton() -> Iterator[None]:
reset_storage()
yield
reset_storage()
def _args(db_path: str, *, apply: bool, model: str = _ALIAS) -> argparse.Namespace:
return argparse.Namespace(
model=model,
apply=apply,
db_backend="sqlite",
db_url="",
db_path=db_path,
db_pool_size=None,
db_sslmode="",
db_sslrootcert="",
db_sslcert="",
db_sslkey="",
)
def _result(*, separated: bool, threshold: float | None) -> CalibrationResult:
return CalibrationResult(
model="m",
raw_scale="probability (0-1)",
separated=separated,
suggested_threshold=threshold,
relevant_min=0.8,
relevant_max=0.9,
irrelevant_min=0.1,
irrelevant_max=0.2,
n_relevant=7,
n_irrelevant=42,
)
def _seed_reranker(db_path: str, *, base_url: str = "http://localhost:9999/rerank") -> str:
"""Create a reranker model definition; return its db_path-backed alias."""
storage = init_storage("sqlite", path=db_path)
storage.create_model_definition(
definition_id="def-reranker",
alias=_ALIAS,
model="bge-reranker",
provider="openai-compatible",
base_url=base_url,
api_key="",
context_window=0,
capabilities=json.dumps({"supports_rerank": True}),
)
return _ALIAS
def _stub_calibrate(monkeypatch, result: CalibrationResult) -> None:
monkeypatch.setattr(
"turnstone.core.rerank_calibrate.calibrate_model",
lambda base_url, model, api_key, *, instruction="", timeout=60.0: result,
)
def _read_caps(db_path: str) -> dict:
storage = init_storage("sqlite", path=db_path)
row = storage.get_model_definition_by_alias(_ALIAS)
assert row is not None
return json.loads(row["capabilities"] or "{}")
def test_apply_writes_model_caps(tmp_path, monkeypatch, capsys):
db = str(tmp_path / "t.db")
_seed_reranker(db)
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.37))
_cmd_rerank_calibrate(_args(db, apply=True))
caps = _read_caps(db)
assert caps["rerank_threshold"] == 0.37
assert caps["rerank_scale"] == "probability (0-1)"
assert caps["rerank_separated"] is True
# The original capability is preserved (merge, not replace).
assert caps["supports_rerank"] is True
assert "Applied" in capsys.readouterr().out
def test_no_apply_recommends_only(tmp_path, monkeypatch, capsys):
db = str(tmp_path / "t.db")
_seed_reranker(db)
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.37))
_cmd_rerank_calibrate(_args(db, apply=False))
assert "rerank_threshold" not in _read_caps(db) # not written
out = capsys.readouterr().out
assert "0.37" in out and "--apply" in out
def test_no_separation_persists_marker_on_apply(tmp_path, monkeypatch, capsys):
"""A no-separation result is a valid recorded outcome: with --apply it still
persists the marker (rerank_scale + separated=False, threshold 0.0) — aligned
with the calibrate endpoint — and exits 0 (no floor)."""
db = str(tmp_path / "t.db")
_seed_reranker(db)
_stub_calibrate(monkeypatch, _result(separated=False, threshold=None))
_cmd_rerank_calibrate(_args(db, apply=True)) # no SystemExit
caps = _read_caps(db)
assert caps["rerank_scale"] == "probability (0-1)"
assert caps["rerank_separated"] is False
assert caps["rerank_threshold"] == 0.0
assert caps["supports_rerank"] is True # merge, not replace
assert "No clean separation" in capsys.readouterr().out
def test_unknown_model_errors(tmp_path, monkeypatch):
db = str(tmp_path / "t.db")
init_storage("sqlite", path=db) # schema only, no model definition
with pytest.raises(SystemExit) as ei:
_cmd_rerank_calibrate(_args(db, apply=False, model="nope"))
assert ei.value.code == 1
def test_model_without_base_url_errors(tmp_path, monkeypatch):
db = str(tmp_path / "t.db")
_seed_reranker(db, base_url="")
with pytest.raises(SystemExit) as ei:
_cmd_rerank_calibrate(_args(db, apply=False))
assert ei.value.code == 1