CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
Milestone 416 step 6. A retrieval floor is a cosine similarity, which only means something inside one embedding model's geometry over documents cut one particular way. Change either and every floor on the install keeps applying while describing nothing — and nothing anywhere says so, because the scores simply come out different and the bar goes on cutting. `CHUNKER_VERSION` already solved this for documents: stamped per row, so the backfill re-embeds precisely what is stale. The same idea, applied to the numbers: - `calibration_stamp()` — embedding model + document shape, one definition. TWO fields, never a fused string (rule 149): a mismatch has to say WHICH half moved, because they call for different responses. - `retrieval_tuning_events` gains `embedding_model` / `shape_version` (migration 0104), stamped on every write. Nullable and NOT backfilled — "unstamped" is the honest answer for a row written before this existed, and it reports as `stale: null`, never as fine. - `current_settings` reports calibration per dial: tuned rows from their event, untouched dials from the registry default's own stamp. - `retrieval_surfaces` and the Settings panel show the mismatch. The panel renders ONLY when something is stale, so seeing it at all is the signal. - `migrate_floor` / `migrate_retrieval_floor` answers "a path for thresholds to be inherited by the next model so that they don't have to recalibrate a lot": the raw cosine cannot cross models, but the PERCENTILE it represented can. Measure what fraction of a surface's logged calls the old floor admitted, re-score those queries under the current model, take the value admitting the same fraction. Dry run by default; applying writes an ordinary tuning event with the arithmetic in its reason. Nothing auto-retunes. A stale stamp says a number is no longer a measurement; it does not say what the number should be, and #4102 measured the one case where the statistic and the correct action pointed opposite ways. The load-bearing test is an ABSENCE: no chat-model identifier may appear anywhere in the calibration path. Claude produces none of these scores, so a Claude upgrade must trigger nothing — a false alarm here teaches the operator to ignore the real one on the day bge-small becomes bge-base. Backup v17 carries both columns, unfilled on the way out and on the way back: a round trip must not turn "we don't know" into a stated fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
154 lines
6.7 KiB
Python
154 lines
6.7 KiB
Python
"""Carrying a floor across a calibration change (#4104).
|
|
|
|
WHAT THIS PINS
|
|
|
|
1. **The percentile is what transfers, not the number.** A floor's content is
|
|
a decision about selectivity; the cosine expressing it is units. So a
|
|
migration that admitted 30% before admits 30% after, whatever the new
|
|
scores look like.
|
|
2. **Nothing is written unless asked twice.** `apply` defaults to False. A
|
|
model change makes every number uncertain at once, which is the worst
|
|
moment to let a statistic move six dials unattended.
|
|
3. **Missing evidence is a refusal, not a guess.** No logged calls, or a
|
|
corpus that re-scores to nothing, returns `migrated: False` with a reason —
|
|
never a floor computed off an empty distribution, which is how an install
|
|
mid-backfill would end up with every bar at zero.
|
|
4. **Every registry surface can be migrated.** A seventh arm that nobody adds
|
|
a re-scorer for is one whose floor silently cannot survive a model change.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services import retrieval_migration as rm
|
|
from scribe.services.retrieval_surfaces import SURFACES
|
|
from tests.helpers import make_mock_session, tool_doc
|
|
|
|
|
|
def _logs(pairs):
|
|
"""Rows as `migrate_floor` reads them: (query, project_id, old score)."""
|
|
return [MagicMock(query=q, project_id=p, best_available_score=s)
|
|
for q, p, s in pairs]
|
|
|
|
|
|
def _session_with(rows):
|
|
session = make_mock_session()
|
|
session.execute.return_value.all.return_value = rows
|
|
return session
|
|
|
|
|
|
def test_every_surface_has_a_rescorer():
|
|
"""Otherwise a surface's floor cannot cross a model change at all."""
|
|
assert set(rm._RESCORERS) == set(SURFACES)
|
|
|
|
|
|
def test_the_floor_that_admits_a_fraction_is_an_observed_score():
|
|
scores = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05]
|
|
# 30% of ten is three; the third-best score is the bar that admits exactly
|
|
# those three. Exact rather than interpolated, so the answer can be checked
|
|
# against the sample it came from.
|
|
assert rm._floor_admitting(scores, 0.3) == 0.7
|
|
assert sum(1 for s in scores if s >= 0.7) == 3
|
|
|
|
|
|
def test_a_surface_that_admitted_nothing_keeps_admitting_nothing():
|
|
"""A migration must not quietly reopen an arm the operator had shut."""
|
|
scores = [0.5, 0.4, 0.3]
|
|
assert rm._floor_admitting(scores, 0.0) > max(scores)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_selectivity_is_preserved_across_a_scale_change():
|
|
"""The whole idea, on numbers that move a long way.
|
|
|
|
Old scores cluster near 0.7 with a bar at 0.72 admitting two of five. New
|
|
scores sit far lower — a different geometry — and the proposal is the value
|
|
that admits two of five there, not anything resembling 0.72.
|
|
"""
|
|
rows = _logs([
|
|
("q1", 2, 0.80), ("q2", 2, 0.75), ("q3", 2, 0.70),
|
|
("q4", 2, 0.60), ("q5", 2, 0.50),
|
|
])
|
|
new = {"q1": 0.42, "q2": 0.38, "q3": 0.31, "q4": 0.22, "q5": 0.10}
|
|
rescore = AsyncMock(side_effect=lambda u, q, p: new[q])
|
|
|
|
with patch.object(rm, "async_session", MagicMock(return_value=_session_with(rows))), \
|
|
patch.object(rm, "floor_for", AsyncMock(return_value=0.72)), \
|
|
patch.dict(rm._RESCORERS, {"prompt_rule": rescore}):
|
|
out = await rm.migrate_floor(1, "prompt_rule")
|
|
|
|
assert out["old_admit_rate"] == 0.4 # 0.80 and 0.75 cleared 0.72
|
|
assert out["proposed_floor"] == 0.38 # admits 0.42 and 0.38 — also two
|
|
assert out["migrated"] is False # dry run by default
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_dry_run_writes_nothing():
|
|
rows = _logs([("q1", None, 0.9), ("q2", None, 0.1)])
|
|
with patch.object(rm, "async_session", MagicMock(return_value=_session_with(rows))), \
|
|
patch.object(rm, "floor_for", AsyncMock(return_value=0.5)), \
|
|
patch.object(rm, "set_dial", AsyncMock()) as set_dial, \
|
|
patch.dict(rm._RESCORERS, {"prompt_rule": AsyncMock(return_value=0.4)}):
|
|
out = await rm.migrate_floor(1, "prompt_rule")
|
|
set_dial.assert_not_called()
|
|
assert "proposed_floor" in out
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_applying_writes_an_ordinary_tuning_event_with_the_arithmetic():
|
|
"""A migrated floor is reviewable and revertible like any other change."""
|
|
rows = _logs([("q1", None, 0.9), ("q2", None, 0.1)])
|
|
with patch.object(rm, "async_session", MagicMock(return_value=_session_with(rows))), \
|
|
patch.object(rm, "floor_for", AsyncMock(return_value=0.5)), \
|
|
patch.object(rm, "set_dial", AsyncMock(return_value={})) as set_dial, \
|
|
patch.dict(rm._RESCORERS, {"prompt_rule": AsyncMock(return_value=0.4)}):
|
|
out = await rm.migrate_floor(1, "prompt_rule", apply=True)
|
|
|
|
assert out["migrated"] is True
|
|
kwargs = set_dial.call_args.kwargs
|
|
reason = kwargs["reason"]
|
|
# The reason has to carry the working, not just the verdict — it is what the
|
|
# operator reads to decide whether to keep the number.
|
|
assert "0.5" in reason and "sampled" in reason
|
|
assert kwargs["actor"] == "model"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_logged_calls_refuses_rather_than_inventing_a_distribution():
|
|
with patch.object(rm, "async_session", MagicMock(return_value=_session_with([]))), \
|
|
patch.object(rm, "floor_for", AsyncMock(return_value=0.5)), \
|
|
patch.object(rm, "set_dial", AsyncMock()) as set_dial:
|
|
out = await rm.migrate_floor(1, "prompt_rule", apply=True)
|
|
assert out["migrated"] is False
|
|
assert "no logged calls" in out["why"]
|
|
set_dial.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_corpus_that_rescores_to_nothing_refuses():
|
|
"""The mid-backfill case: every bar would otherwise be set off no data."""
|
|
rows = _logs([("q1", None, 0.9), ("q2", None, 0.8)])
|
|
with patch.object(rm, "async_session", MagicMock(return_value=_session_with(rows))), \
|
|
patch.object(rm, "floor_for", AsyncMock(return_value=0.5)), \
|
|
patch.object(rm, "set_dial", AsyncMock()) as set_dial, \
|
|
patch.dict(rm._RESCORERS, {"prompt_rule": AsyncMock(return_value=None)}):
|
|
out = await rm.migrate_floor(1, "prompt_rule", apply=True)
|
|
assert out["migrated"] is False
|
|
assert "not embedded" in out["why"]
|
|
set_dial.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_surface_is_refused():
|
|
with pytest.raises(ValueError):
|
|
await rm.migrate_floor(1, "promptrule")
|
|
|
|
|
|
def test_the_tool_says_it_is_a_starting_point_and_defaults_to_a_dry_run():
|
|
doc = tool_doc("scribe.mcp.tools.retrieval_tuning", "migrate_retrieval_floor")
|
|
assert "DRY RUN BY DEFAULT" in doc
|
|
# Percentile-preserving carries the old floor's wrongness forward faithfully.
|
|
# A reader who misses that will treat a migrated number as a measured one.
|
|
assert "STARTING POINT" in doc.upper()
|
|
assert "stale" in doc
|