CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
Docstrings, section headers and assertion messages drop the record number, milestone or step that introduced the behaviour, and incident accounts are restated as the present-tense failure shape. A dead pointer to a design doc that is not in the repo is removed. Renamed to the property they check: test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note Fixture and example ids stay: "note 5" in a supersession chain, "rule 156" in an illustrative question, `#2275` as the hex-versus-citation case, `#3244` as the example of a bare number, fixed dates in date-filter tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
8.5 KiB
Python
194 lines
8.5 KiB
Python
"""Carrying a floor across a calibration change.
|
|
|
|
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.
|
|
5. **A half-migrated corpus is a refusal.** While any row the surface
|
|
searches is stamped with another model, a re-score measures a blend of two
|
|
geometries — so nothing is sampled until the backfill has finished.
|
|
"""
|
|
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):
|
|
"""`.all()` is SYNCHRONOUS on a Result, so it needs a MagicMock.
|
|
|
|
`make_mock_session` is an AsyncMock, and every child of an AsyncMock is one
|
|
too — leaving `.all` as it comes hands the service a coroutine where it
|
|
expects a list, the same trap the helper's docstring flags for `add`.
|
|
"""
|
|
session = make_mock_session()
|
|
session.execute.return_value = MagicMock(all=MagicMock(return_value=rows))
|
|
return session
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _corpus_on_the_live_model():
|
|
"""Every test below starts from a wholly re-embedded corpus; the one that
|
|
is about a half-migrated corpus patches this again."""
|
|
with patch.object(rm, "rows_off_the_live_model", AsyncMock(return_value=0)):
|
|
yield
|
|
|
|
|
|
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_every_surface_names_the_corpus_it_searches():
|
|
"""Otherwise the half-migrated check has no table to count for it."""
|
|
assert set(rm._CORPUS) == set(SURFACES)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_corpus_part_way_through_a_model_change_refuses_before_sampling():
|
|
rows = _logs([("q1", None, 0.9), ("q2", None, 0.1)])
|
|
rescore = AsyncMock(return_value=0.4)
|
|
with patch.object(rm, "rows_off_the_live_model", AsyncMock(return_value=17)) as off, \
|
|
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": rescore}):
|
|
out = await rm.migrate_floor(1, "prompt_rule", apply=True)
|
|
|
|
assert out["migrated"] is False
|
|
assert out["rows_off_model"] == 17
|
|
assert off.await_args.args[0] is rm.RuleEmbedding
|
|
rescore.assert_not_called()
|
|
set_dial.assert_not_called()
|
|
|
|
|
|
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
|