feat(retrieval): a tuned number carries the space it was measured in (#4104)
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
This commit is contained in:
2026-09-17 12:45:48 -04:00
co-authored by Claude Opus 5
parent dcf800ed65
commit aee24c9c1c
13 changed files with 1002 additions and 2 deletions
@@ -0,0 +1,51 @@
"""retrieval_tuning_events carries the space each number was measured in (#4104)
Revision ID: 0104
Revises: 0103
Create Date: 2026-09-17
Milestone 416 step 6. A retrieval floor is a cosine distance in ONE embedding
model's geometry, computed over documents cut one particular way. Change the
model and every score moves at once; change the chunker and the same record
embeds different text. Either way a number chosen before the change is a
measurement of something that no longer exists — and today nothing records
which world it was chosen in, so the staleness is unknowable rather than
merely unknown.
`CHUNKER_VERSION` already solved exactly this for documents: stored per row, so
the startup backfill re-embeds precisely what is stale instead of wiping the
table. These two columns are that idea applied to the tuned numbers.
TWO COLUMNS, NOT ONE (rule 149). A reader has to be able to say WHICH half
moved: a new embedding model and a re-cut document shape invalidate the same
numbers for different reasons, and a fused `"<model>@<n>"` could only report
that something changed.
NULLABLE, and not backfilled. The rows already in this table were written
under something, but naming it would be inventing a fact — the honest value is
"unstamped", which is a different answer from a model name that might be wrong.
`current_settings` reports an unstamped dial as exactly that.
"""
import sqlalchemy as sa
from alembic import op
revision = "0104"
down_revision = "0103"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_tuning_events",
sa.Column("embedding_model", sa.Text(), nullable=True),
)
op.add_column(
"retrieval_tuning_events",
sa.Column("shape_version", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_tuning_events", "shape_version")
op.drop_column("retrieval_tuning_events", "embedding_model")
+118
View File
@@ -178,6 +178,64 @@ interface TuningEvent {
reason: string;
}
// What a dial's number was measured against, and whether that space has moved
// (#4104). Mirrors the `calibration` block `current_settings` returns per dial.
// `stale` is nullable on purpose: a dial moved before Scribe recorded stamps is
// UNKNOWN rather than fine, and rendering the two the same would hide the case
// most likely to be wrong.
interface DialCalibration {
source: string;
embedding_model: string | null;
shape_version: number | null;
model_changed: boolean | null;
shape_changed: boolean | null;
stale: boolean | null;
}
interface SurfaceRow {
surface: string;
calibration: Record<string, DialCalibration>;
}
const surfaceRows = ref<SurfaceRow[]>([]);
// Flattened to one line per dial that needs looking at, because the operator's
// question is "which numbers do I no longer trust", not "tell me about each of
// six surfaces". Anything calibrated against the live model contributes
// nothing — the panel below renders only when this is non-empty, so a healthy
// install never sees it. A warning that is always on the screen is one nobody
// reads when it finally means something.
const staleDials = computed(() =>
surfaceRows.value.flatMap((row) =>
Object.entries(row.calibration ?? {})
.filter(([, cal]) => cal.stale !== false)
.map(([dial, cal]) => ({
key: `${row.surface}.${dial}`,
surface: row.surface,
dial,
why:
cal.source === "unstamped"
? "changed before Scribe recorded what it was measured against"
: cal.model_changed && cal.shape_changed
? `measured under ${cal.embedding_model} at document shape ${cal.shape_version} — both have changed since`
: cal.model_changed
? `measured under ${cal.embedding_model}, which is no longer the embedding model`
: `measured at document shape ${cal.shape_version}, and documents are cut differently now`,
})),
),
);
async function loadSurfaces() {
try {
const res = await apiGet<{ surfaces: SurfaceRow[] }>("/api/retrieval/surfaces");
surfaceRows.value = res.surfaces ?? [];
} catch {
// Same reasoning as the history below: unreadable calibration is not worth
// a toast on page load, and the dials above still work.
surfaceRows.value = [];
}
}
async function loadTuningHistory() {
loadingTuning.value = true;
try {
@@ -292,6 +350,7 @@ async function saveKbInject() {
// otherwise the panel shows a trail that is stale by exactly the change
// the operator is looking at it to confirm.
await loadTuningHistory();
await loadSurfaces();
} catch {
toastStore.show('Failed to save auto-inject settings', 'error');
} finally {
@@ -767,6 +826,7 @@ onMounted(async () => {
kbReportPrefTopK.value = allSettings.kb_reportpref_top_k;
}
await loadTuningHistory();
await loadSurfaces();
if (allSettings.kb_duplicate_threshold_snippet !== undefined) {
kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet;
}
@@ -1772,6 +1832,32 @@ async function deleteUser(userId: number) {
moves the dial with the argument attached. This panel is the other
half of that bargain a change made on your behalf is one you can
read, disagree with, and set back by hand above. -->
<!-- STALENESS (#4104). A bar is a cosine similarity, which only means
something inside one embedding model's geometry over documents cut
one particular way. Change either and every number above keeps
applying while describing nothing. Shown ONLY when something has
actually moved, so that seeing it at all is the signal. -->
<div v-if="staleDials.length" class="calibration-warning">
<h4 class="calibration-warning-title">
{{ staleDials.length }}
{{ staleDials.length === 1 ? 'value was' : 'values were' }}
measured against something that has changed
</h4>
<p class="field-hint">
These bars are similarity scores, and a similarity score only means
something inside the model that produced it. They still apply — they
just no longer measure what they were set to measure. Nothing is
adjusted automatically; ask Claude to migrate them and it will carry
each one across at the same selectivity, then check the result.
</p>
<ul class="calibration-list">
<li v-for="d in staleDials" :key="d.key">
<span class="tuning-surface">{{ d.surface }}</span>
<span class="tuning-dial">{{ d.dial }}</span>
<span class="calibration-why">{{ d.why }}</span>
</li>
</ul>
</div>
<div class="tuning-history">
<h4 class="tuning-history-title">What has been tuned</h4>
<p class="field-hint">
@@ -4115,4 +4201,36 @@ async function deleteUser(userId: number) {
font-size: 0.85rem;
color: var(--fs-text-secondary);
}
/* Stale calibration (#4104). Tinted rather than loud: the numbers still work,
they have merely stopped being measurements — that is a "come back to this",
not an outage. It only renders when something is actually stale, which is
what earns it the tint at all. */
.calibration-warning {
margin-top: var(--fs-space-5);
padding: var(--fs-space-3);
border: 1px solid var(--fs-warning);
border-radius: var(--fs-radius-md);
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
}
.calibration-warning-title {
margin: 0 0 var(--fs-space-2);
font-size: 0.95rem;
color: var(--fs-warning-fg);
}
.calibration-list {
list-style: none;
margin: var(--fs-space-2) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--fs-space-1);
font-size: 0.85rem;
}
.calibration-list li {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--fs-space-2);
}
.calibration-why { color: var(--fs-text-secondary); }
</style>
+1
View File
@@ -183,6 +183,7 @@ _WRITE_TOOLS = frozenset({
# retrieval tuning — a write in both senses: it moves the number the arm
# reads, and it appends the reason to the audit trail (#4102).
"tune_retrieval",
"migrate_retrieval_floor",
# trash
"restore", "purge_trash",
})
+74
View File
@@ -5,6 +5,7 @@ bar did; these say what the bar IS, and let it be changed with the argument
attached.
"""
from scribe.mcp._context import current_user_id
from scribe.services import retrieval_migration as migration_svc
from scribe.services import retrieval_tuning as tuning_svc
@@ -37,6 +38,33 @@ async def retrieval_surfaces() -> dict:
are starting points: they were measured against one corpus with one
embedding model and cannot be right for another install by construction.
That is why this tool exists rather than a better set of defaults.
`calibration` says what SPACE each number was chosen in, per dial, and
whether that space has moved. A floor is a cosine distance in one embedding
model's geometry over documents cut one particular way; swap the model and
every score shifts at once, re-cut the documents and the same record embeds
different text. Either way the number is a measurement of something that no
longer exists, and no amount of telemetry will say so — the scores simply
come out different and the bar keeps applying.
Read the three fields apart:
- `stale: true` with `model_changed` — the geometry is new. Every floor on
this install needs re-measuring, not adjusting; a number that meant "quite
similar" in the old space means nothing particular in this one.
- `stale: true` with `shape_changed` — the documents are cut differently.
The scale still holds, but what a record embeds has changed, so which
records clear a bar has.
- `stale: null`, `source: "unstamped"` — the dial was moved before Scribe
recorded this. It was measured under SOMETHING and there is no way to say
what, which is a different answer from "it is fine". Treat it as worth
re-measuring, and the next tuning call stamps it.
NOTHING IS RETUNED AUTOMATICALLY on the strength of this, here or anywhere.
A stale stamp says a number is no longer a measurement; it does not say what
the number should be. That judgement wants the same procedure as any other
tuning change — `retrieval_telemetry(near_miss_samples=5)`, open the records
it names, then `tune_retrieval` with what you read in the reason.
"""
return {"surfaces": await tuning_svc.current_settings(current_user_id())}
@@ -109,7 +137,53 @@ async def retrieval_tuning_history(surface: str = "", limit: int = 20) -> dict:
}
async def migrate_retrieval_floor(
surface: str, sample: int = 200, apply: bool = False,
) -> dict:
"""Carry one surface's floor across an embedding-model or chunker change.
Reach for this when `retrieval_surfaces` reports a dial `stale` — and only
then. A floor is a cosine similarity, so a new embedding model moves every
score on the install at once and the stored number silently stops describing
anything. Re-deriving six floors by reading telemetry is the work this
avoids.
WHAT IT TRANSFERS. Not the number — the SELECTIVITY. A floor's real content
is a decision about how much of what an arm sees is worth spending attention
on, and that decision survives a change of units. This measures what fraction
of the surface's recent calls the old floor admitted, re-scores those same
queries under the current model, and proposes the value that admits the same
fraction.
DRY RUN BY DEFAULT. With `apply=False` it returns the arithmetic and writes
nothing. Read it before applying: the sample sizes, both score ranges, and
whether the shift looks like a change of scale or like a corpus that has not
finished re-embedding. A model change is the moment every number is
uncertain at once, which is the worst moment to let a statistic move dials
unattended.
AND IT IS STILL A STARTING POINT. Percentile-preserving means the new floor
is as good as the old one was, not better — if the old floor was wrong, this
faithfully carries the wrongness onto the new scale. It is the number to
begin from while the surface collects enough calls to judge properly, which
is `retrieval_telemetry(near_miss_samples=5)` and `tune_retrieval` as usual.
Args:
surface: the surface name from `retrieval_surfaces`.
sample: how many recent logged calls to re-score, default 200. Each one
is an embedding plus a scan, so this is real work; a surface with
only a handful of logged calls gives a percentile made of noise.
apply: False (default) returns the proposal. True writes it, as an
ordinary tuning event with the arithmetic in its reason — so a
migrated floor is reviewable and revertible like any other.
"""
return await migration_svc.migrate_floor(
current_user_id(), surface, sample=sample, apply=apply,
)
def register(mcp) -> None:
mcp.tool(name="retrieval_surfaces")(retrieval_surfaces)
mcp.tool(name="migrate_retrieval_floor")(migrate_retrieval_floor)
mcp.tool(name="tune_retrieval")(tune_retrieval)
mcp.tool(name="retrieval_tuning_history")(retrieval_tuning_history)
+21
View File
@@ -75,6 +75,23 @@ class RetrievalTuningEvent(Base):
# becomes a formality; the service refuses a blank one.
reason: Mapped[str] = mapped_column(Text, nullable=False)
# WHAT SPACE THIS NUMBER WAS MEASURED IN (#4104). A floor is a cosine
# distance in one embedding model's geometry, over documents cut one
# particular way — change either and the number describes something that
# no longer exists.
#
# On the EVENT rather than beside the setting, because this table already
# holds one row per change and `current_settings` already reads the latest
# per dial. A parallel stamp row next to the value would be a second place
# to keep in sync, and the two disagreeing is worse than neither.
#
# Both NULLABLE for the rows written before this step: those were measured
# under something, but claiming to know which would be inventing a fact.
# Null here means "unstamped", which is a different and honest answer from
# naming a model that may be wrong.
embedding_model: Mapped[str | None] = mapped_column(Text, nullable=True)
shape_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
# The only read this table has: one surface's history, newest first.
Index(
@@ -94,4 +111,8 @@ class RetrievalTuningEvent(Base):
"new_value": self.new_value,
"actor": self.actor,
"reason": self.reason,
# Null for every row written before #4104, and rendered as
# "unstamped" rather than guessed at — see the column comments.
"embedding_model": self.embedding_model,
"shape_version": self.shape_version,
}
+19 -1
View File
@@ -74,8 +74,14 @@ logger = logging.getLogger(__name__)
# argument for them silently dropped — and from this step on those dials are
# moved by the model, which is exactly the case where the operator needs the
# argument to review.
# v17 (2026-09) added retrieval_tuning_events.embedding_model / shape_version
# (milestone 416 step 6): a floor is a distance in ONE embedding model's
# geometry over documents cut one particular way, so the number alone cannot
# say whether it still measures anything. Both travel NULLABLE and unfilled —
# a row written before the stamp existed restores unstamped, because inventing
# the model it was measured under would turn "unknown" into a stated fact.
# Bump when the serialized schema changes.
BACKUP_VERSION = 16
BACKUP_VERSION = 17
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -358,6 +364,12 @@ def _retrieval_tuning_event_rows(rows) -> list[dict]:
"user_id": r.user_id, "surface": r.surface, "dial": r.dial,
"old_value": r.old_value, "new_value": r.new_value,
"actor": r.actor, "reason": r.reason,
# Carried, and NOT defaulted to the current model on the way out
# (#4104): a row that was unstamped when it was written is still
# unstamped after a round trip, and a backup that quietly filled
# the gap would turn "we don't know" into a stated fact.
"embedding_model": r.embedding_model,
"shape_version": r.shape_version,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
@@ -1247,6 +1259,12 @@ async def _restore_v2(data: dict) -> dict:
new_value=t_data.get("new_value"),
actor=t_data.get("actor") or "model",
reason=t_data.get("reason", ""),
# .get with no default, deliberately (v17): an archive written
# before the stamp existed has no key here, and None is the
# right answer for it — the same "unstamped" a pre-#4104 row
# carries in place.
embedding_model=t_data.get("embedding_model"),
shape_version=t_data.get("shape_version"),
created_at=_dt(t_data.get("created_at")),
))
stats["retrieval_tuning_events"] += 1
+32
View File
@@ -217,6 +217,38 @@ def embedding_text(title: str | None, body: str | None) -> str:
# wipe migrations 0067/0077 had to do.
CHUNKER_VERSION = 1
# The public name of the space every score lives in, and the two facts that
# can invalidate a tuned number (#4104).
#
# EMBEDDING_MODEL is `_MODEL_NAME` under a name other modules may read. It was
# private until this step, which is precisely why nothing outside this file
# could state what space a threshold was measured in — a floor is a distance in
# THIS model's geometry and means nothing in another's.
#
# The pair is what a stamp is made of, and the pairing is the point: a score
# changes when the model changes (different geometry) OR when the document
# shape changes (different text embedded for the same record). Either one
# invalidates a number that was measured before it.
EMBEDDING_MODEL = _MODEL_NAME
def calibration_stamp() -> dict:
"""What a tuned retrieval number was measured against.
ONE definition, because the alternative is each reader assembling the pair
and one of them forgetting a half. Returned as a dict rather than a string
so a mismatch can say WHICH half moved — "the model changed" and "the
chunker changed" call for different responses, and a fused string can only
report that something did.
Deliberately says nothing about the CHAT model. A Claude upgrade changes no
score here and must never raise a recalibration prompt: a false alarm on
this surface teaches an operator to ignore the true one. `tests/
test_calibration_stamp.py` asserts that absence rather than trusting it.
"""
return {"embedding_model": EMBEDDING_MODEL, "shape_version": CHUNKER_VERSION}
# Character budget approximating the model window. Tokens-per-char varies by
# content — ~4 chars/token for prose, closer to 3 for code and tables — so 1400
# chars sits at roughly 350-470 tokens, leaving headroom for the title prefixed
+248
View File
@@ -0,0 +1,248 @@
"""Carrying a tuned floor across an embedding-model change (#4104).
THE PROBLEM
A floor is a cosine similarity, and a cosine similarity is a distance in one
model's geometry. `bge-small-en-v1.5` → `bge-base-en-v1.5` moves every score on
the install at once, in no direction anyone can predict per record. The numbers
in `settings` survive the swap unchanged and silently stop describing anything:
the bar keeps applying, the telemetry keeps filling, and nothing anywhere says
the arm is now cutting in a different place.
Re-deriving six floors by hand is the alternative, and it is the thing the
operator asked for a way out of — *"a path for thresholds to be inherited by
the next version or different model so that they don't have to recalibrate a
lot."*
WHAT TRANSFERS, AND WHY IT IS NOT THE NUMBER
The raw cosine does not transfer. **The percentile it represented does.** A
floor's real content is a decision about SELECTIVITY — "admit roughly the top
fifth of what this arm sees" — and that decision is about the operator's
tolerance for noise on that surface, not about the embedder. It was true before
the model changed and is still true after.
So: measure what fraction of this surface's calls the old floor admitted, using
the scores the old model actually produced; re-score the same queries under the
new one; and take the value that admits the same fraction. Same decision, new
units.
WHY `best_available_score` IS THE FIGURE ON BOTH SIDES
Because it is the one number that exists whether or not a call returned
anything — the highest score the corpus offered, before the bar was applied
(#3670). It is what `retrieval_logs` recorded under the old model, and it is
what a re-score reproduces under the new one, so the two sides are the same
measurement rather than two things that resemble each other.
It also makes the re-score cheap to get right: `best_available_score` is
computed over the whole candidate set BEFORE `limit` and before any
`exclude_ids` the arm passed, so neither has to be reproduced here. Only the
corpus FILTERS matter, which is why `_RESCORERS` below carries those and
nothing else.
WHAT THIS DOES NOT DO
It does not fire by itself, and `migrate_floor` will not write anything unless
asked twice — `apply=True` on top of having read the dry run. A model change is
exactly the moment when every number is uncertain at once, which is the worst
possible moment to let a statistic move six dials unattended. The percentile is
a starting point on the new scale, in the same sense the shipped defaults are a
starting point: better than a stale number, not a substitute for reading what
the surface actually turned away.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.retrieval_log import RetrievalLog
from scribe.services.embeddings import (
calibration_stamp,
semantic_search_notes,
semantic_search_rules,
)
from scribe.services.retrieval_surfaces import floor_for, get_surface
from scribe.services.retrieval_tuning import set_dial
logger = logging.getLogger(__name__)
# How many of a surface's recent calls to re-score. Every one is an embedding
# plus a full scan, so this is a real cost — but a percentile off twenty calls
# is noise, and the arms that matter here log hundreds a week.
DEFAULT_SAMPLE = 200
async def _rescore_rules(user_id: int, query: str, project_id: int | None,
kind: str | None) -> float | None:
rep: dict = {}
await semantic_search_rules(
user_id, query, limit=1, threshold=0.0, kind=kind,
report=rep, project_id=project_id or None,
)
return rep.get("best_available_score")
async def _rescore_notes(user_id: int, query: str, project_id: int | None,
note_type, task_kind) -> float | None:
rep: dict = {}
await semantic_search_notes(
user_id, query, limit=1, threshold=0.0,
project_id=project_id or None, note_type=note_type,
task_kind=task_kind, scope="browse", report=rep,
)
return rep.get("best_available_score")
# ONE re-scorer per surface, carrying that arm's corpus filters and nothing
# else. These filters are stated a second time here — the arms in
# `plugin_context` are where they are first declared — and that duplication is
# deliberate rather than overlooked: the alternative is calling the arms
# themselves, which build a menu, write telemetry and record records as
# surfaced. A migration that logged two hundred fake retrievals would corrupt
# the very table the next tuning decision reads.
#
# `tests/test_retrieval_migration.py` asserts every registry surface has an
# entry, so a seventh arm cannot quietly become un-migratable.
_RESCORERS = {
"auto_inject": lambda u, q, p: _rescore_notes(u, q, p, None, None),
"write_path": lambda u, q, p: _rescore_notes(
u, q, p, ("snippet", "note"), "issue"
),
"write_path_rule": lambda u, q, p: _rescore_rules(u, q, p, None),
"pre_tool_rule": lambda u, q, p: _rescore_rules(u, q, p, None),
"prompt_rule": lambda u, q, p: _rescore_rules(u, q, p, None),
"report_preference": lambda u, q, p: _rescore_rules(u, q, p, "preference"),
}
def _floor_admitting(scores: list[float], fraction: float) -> float:
"""The floor that admits `fraction` of `scores`, on this scale.
Deliberately exact rather than interpolated: with the scores sorted
highest-first, the k-th one IS the bar that admits exactly k. An
interpolated quantile would return a number no observed call sits on, which
is harder to sanity-check against the sample it came from.
A fraction rounding to zero returns a floor just above the best score seen —
an arm that admitted nothing keeps admitting nothing, rather than being
quietly reopened by a migration.
"""
ranked = sorted(scores, reverse=True)
k = int(round(fraction * len(ranked)))
if k <= 0:
return min(1.0, ranked[0] + 1e-6)
return ranked[min(k, len(ranked)) - 1]
async def migrate_floor(
user_id: int,
surface: str,
*,
sample: int = DEFAULT_SAMPLE,
apply: bool = False,
) -> dict:
"""Recompute one surface's floor on the current model, preserving selectivity.
Returns the working: how many calls were sampled, what fraction the old
floor admitted, and what value admits the same fraction now. Writes nothing
unless `apply=True`, and when it does it writes an ordinary tuning event
with the arithmetic in its reason — a migrated floor is reviewable and
revertible on exactly the same terms as one a reader chose.
"""
get_surface(surface) # refuses an unknown name
rescore = _RESCORERS.get(surface)
if rescore is None:
raise ValueError(
f"no re-scorer for surface {surface!r}. A surface that cannot be "
"re-scored cannot be migrated — add it to _RESCORERS beside the "
"arm's own corpus filters."
)
old_floor = await floor_for(user_id, surface)
async with async_session() as session:
rows = (await session.execute(
select(
RetrievalLog.query,
RetrievalLog.project_id,
RetrievalLog.best_available_score,
)
.where(
RetrievalLog.source == surface,
RetrievalLog.user_id == user_id,
RetrievalLog.best_available_score.is_not(None),
RetrievalLog.query.is_not(None),
RetrievalLog.query != "",
)
.order_by(RetrievalLog.id.desc())
.limit(max(1, int(sample)))
)).all()
if not rows:
# Not an error. A surface with no logged calls has no evidence of what
# its floor was doing, and inventing a migration for it would be the
# exact failure this module's docstring warns about.
return {
"surface": surface, "migrated": False,
"why": "no logged calls carry a best_available_score for this "
"surface, so there is no old distribution to preserve",
"sampled": 0, "old_floor": old_floor,
}
old_scores = [float(r.best_available_score) for r in rows]
admitted = sum(1 for s in old_scores if s >= old_floor)
fraction = admitted / len(old_scores)
new_scores: list[float] = []
for r in rows:
score = await rescore(user_id, r.query, r.project_id)
if score is not None:
new_scores.append(float(score))
if not new_scores:
# The corpus answered nothing for any sampled query. Almost always an
# embedder that has not finished backfilling under the new model —
# migrating from it would set every floor off an empty distribution.
return {
"surface": surface, "migrated": False,
"why": "re-scoring returned nothing for any sampled query — the "
"corpus is probably not embedded under the current model yet",
"sampled": len(rows), "old_floor": old_floor,
"old_admit_rate": round(fraction, 4),
}
proposed = round(min(1.0, max(0.0, _floor_admitting(new_scores, fraction))), 4)
stamp = calibration_stamp()
reason = (
f"Migrated across a calibration change, preserving selectivity: the old "
f"floor {old_floor} admitted {admitted} of {len(old_scores)} sampled "
f"calls ({fraction:.1%}); {proposed} admits the same share of "
f"{len(new_scores)} queries re-scored under "
f"{stamp['embedding_model']}/shape {stamp['shape_version']}. The "
f"percentile is what carried across, not the number — spot-check the "
f"arm before trusting it."
)
result = {
"surface": surface,
"migrated": False,
"sampled": len(rows),
"rescored": len(new_scores),
"old_floor": old_floor,
"old_admit_rate": round(fraction, 4),
"old_score_range": [round(min(old_scores), 4), round(max(old_scores), 4)],
"new_score_range": [round(min(new_scores), 4), round(max(new_scores), 4)],
"proposed_floor": proposed,
"calibration": stamp,
"reason": reason,
}
if not apply:
return result
result["migrated"] = True
result["applied"] = await set_dial(
user_id, surface, "floor", proposed, reason=reason, actor="model",
)
return result
+25
View File
@@ -103,6 +103,31 @@ class Surface:
asks: str
over: str
fires: str
measured_model: str = "BAAI/bge-small-en-v1.5"
measured_shape: int = 1
"""What the SHIPPED defaults above were measured against (#4104).
A floor is a distance in one embedding model's geometry, over documents cut
one particular way. Either can change, and when one does every number in
this table describes something that no longer exists.
TWO FIELDS, NEVER ONE FUSED STRING (rule 149). A mismatch has to be able to
say WHICH half moved: a new embedding model and a re-cut document shape
invalidate the same numbers for different reasons and call for different
responses. `"<model>@<n>"` could only report that something changed, which
is the answer nobody can act on. Same reason `calibration_stamp()` returns
a dict and the event table gives each half its own column.
Recorded per surface rather than once for the module because they need not
move together: a surface retuned after a model change carries the new stamp
while its untouched siblings still carry the old one, and telling those
apart is the whole job.
LITERALS, deliberately, rather than an import of the live values — a stamp
says what was true when the number was chosen, so one that tracked the
current model would always agree with it and could never report staleness.
"""
budget_falls_back_to: str = ""
"""A budget key to inherit when this surface has none of its own set.
+68
View File
@@ -47,6 +47,7 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.services.embeddings import calibration_stamp
from scribe.services.retrieval_surfaces import (
MAX_BUDGET,
budget_for,
@@ -86,6 +87,50 @@ def _clean_reason(reason: str) -> str:
return text
def _calibration(row, s, live: dict) -> dict:
"""What space one dial's number was chosen in, and whether that space moved.
Three sources, and they are not interchangeable:
- `tuned` — the dial was moved after #4104 and the event carries its
stamp. The only case where the answer is known.
- `shipped` — the dial has never been moved, so the number in force is
the registry default, and the registry records what THAT
was measured against (`Surface.measured_model`).
- `unstamped` — the dial was moved before this step existed. It was
measured under something; naming it would be inventing a
fact, so `stale` is None rather than True or False.
"Unknown" and "fine" must not render the same.
`model_changed` and `shape_changed` are reported apart (rule 149) because
they call for different responses: a new embedding model means every number
is a distance in a geometry that no longer exists, while a re-cut document
shape means the same records now embed different text. A caller that only
ever sees `stale: true` cannot tell those apart.
"""
if row is None:
model, shape, source = s.measured_model, s.measured_shape, "shipped"
elif row.embedding_model is None and row.shape_version is None:
return {
"source": "unstamped", "embedding_model": None,
"shape_version": None, "model_changed": None,
"shape_changed": None, "stale": None,
}
else:
model, shape, source = row.embedding_model, row.shape_version, "tuned"
model_changed = model != live["embedding_model"]
shape_changed = shape != live["shape_version"]
return {
"source": source,
"embedding_model": model,
"shape_version": shape,
"model_changed": model_changed,
"shape_changed": shape_changed,
"stale": model_changed or shape_changed,
}
async def current_settings(user_id: int) -> list[dict]:
"""Every tunable surface with its live pair and its last stated reason.
@@ -94,7 +139,16 @@ async def current_settings(user_id: int) -> list[dict]:
often — because a floor cannot be moved sensibly without those three — and
the reason last given, so the next change argues with the last one instead
of overwriting it blind.
From #4104 it also carries `calibration` per dial: the embedding model and
document shape the number in force was measured in, and whether either has
moved since. NOTHING AUTO-RETUNES on the strength of it. A stale stamp says
a number is a measurement of a space that no longer exists — it does not
say what the number should be now, and the one time a statistic was allowed
to answer that question it was wrong (see the module docstring). The stamp
is here so a reader knows which floors to go and re-measure.
"""
live = calibration_stamp()
out: list[dict] = []
async with async_session() as session:
for name in surface_names():
@@ -126,6 +180,13 @@ async def current_settings(user_id: int) -> list[dict]:
"last_change": {
dial: last[dial].to_dict() for dial in DIALS if dial in last
},
# Always present for BOTH dials, unlike `last_change`: an
# untouched dial still has a number in force, and that number
# was still measured in some space. Reported, never acted on —
# see the note below on why nothing auto-retunes.
"calibration": {
dial: _calibration(last.get(dial), s, live) for dial in DIALS
},
})
return out
@@ -179,9 +240,16 @@ async def set_dial(
await set_setting(user_id, key, stored)
async with async_session() as session:
# Stamped with the space this number was chosen in (#4104). Read at
# write time rather than passed in: the caller measuring a floor and
# the caller recording it are the same call, so there is no window in
# which they could disagree.
stamp = calibration_stamp()
session.add(RetrievalTuningEvent(
user_id=user_id, surface=surface, dial=dial,
old_value=old, new_value=applied, actor=actor, reason=text,
embedding_model=stamp["embedding_model"],
shape_version=stamp["shape_version"],
))
await session.commit()
+191
View File
@@ -0,0 +1,191 @@
"""A tuned number carries the space it was measured in — and only that (#4104).
WHY THIS EXISTS
Milestone 416 step 6 answers *"a path for thresholds to be inherited by the next
version or different model so that they don't have to recalibrate a lot."* The
mechanism is a stamp: every tuning event records the embedding model and
document shape the number was chosen under, and a mismatch is reported rather
than left to be noticed.
THE ONE THAT MATTERS MOST IS THE ABSENCE
`test_no_chat_model_identifier_reaches_the_calibration_path` is the load-bearing
guard here, and it is a guard against a plausible mistake rather than a
hypothetical one. These floors live in BAAI/bge-small-en-v1.5's vector space.
The CHAT model — Claude — produces none of these scores and changes none of
them, so a Claude upgrade must trigger nothing at all. If it ever did, the
operator would be asked to recalibrate six dials for no reason, learn that this
surface cries wolf, and ignore it on the day `bge-small` → `bge-base` actually
moves every score on the install. A false alarm here does not cost a
notification; it costs the real alarm.
The rest pins the honest-unknown behaviour: a row written before stamps existed
reports as `unstamped` with `stale: None`, never as fine and never as a model
name somebody guessed at.
"""
import re
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import embeddings as emb
from scribe.services import retrieval_tuning as rt
from scribe.services.retrieval_surfaces import SURFACES, get_surface
from tests.helpers import make_mock_session, tool_doc
# Names for the thing that talks, as opposed to the thing that embeds. Any of
# these appearing in the calibration path means a chat-model change could move a
# stamp — see the module docstring for why that is the expensive failure.
_CHAT_MODEL_WORDS = (
"claude", "opus", "sonnet", "haiku", "gpt", "llama", "mistral", "gemini",
"anthropic", "openai", "chat_model", "chatmodel", "model_version",
)
def test_calibration_stamp_names_the_embedder_and_the_shape():
stamp = emb.calibration_stamp()
assert stamp == {
"embedding_model": emb.EMBEDDING_MODEL,
"shape_version": emb.CHUNKER_VERSION,
}
# Two keys, never one fused string (rule 149): a mismatch has to be able to
# say WHICH half moved, because a new embedder and a re-cut document
# invalidate the same numbers for different reasons.
assert len(stamp) == 2
def test_the_stamp_tracks_the_embedding_model_constant_not_a_copy_of_it():
"""A second literal would drift, and drift here reads as a model change."""
with patch.object(emb, "EMBEDDING_MODEL", "some/other-model"):
assert emb.calibration_stamp()["embedding_model"] == "some/other-model"
with patch.object(emb, "CHUNKER_VERSION", 99):
assert emb.calibration_stamp()["shape_version"] == 99
def test_no_chat_model_identifier_reaches_the_calibration_path():
"""THE GUARD. A Claude upgrade must move nothing in this path.
Structural rather than behavioural on purpose: the failure is someone
*adding* a chat-model field in good faith ("surely the model matters"), and
no behavioural test catches a field that has not been written yet. Asserted
over the source of every module that produces or consumes a stamp.
"""
import inspect
from scribe.services import retrieval_migration as rm
for label, obj in (
("calibration_stamp", emb.calibration_stamp),
("_calibration", rt._calibration),
("migrate_floor", rm.migrate_floor),
):
src = inspect.getsource(obj).lower()
# Comments and docstrings here legitimately discuss the chat model in
# order to rule it out, so strip prose and assert on code only.
code = "\n".join(
line.split("#", 1)[0]
for line in re.sub(r'""".*?"""', "", src, flags=re.S).splitlines()
)
for word in _CHAT_MODEL_WORDS:
assert word not in code, f"{label} names the chat model: {word!r}"
@pytest.mark.asyncio
async def test_a_moved_dial_is_written_with_the_live_stamp():
session = make_mock_session()
with patch.object(rt, "async_session", MagicMock(return_value=session)), \
patch.object(rt, "set_setting", AsyncMock()), \
patch.object(rt, "floor_for", AsyncMock(return_value=0.72)), \
patch.object(rt, "budget_for", AsyncMock(return_value=3)), \
patch.object(rt, "calibration_stamp",
MagicMock(return_value={"embedding_model": "m/x",
"shape_version": 7})):
await rt.set_dial(
1, "prompt_rule", "floor", 0.66,
reason="read the five refused records; four were genuine matches",
)
event = session.add.call_args[0][0]
assert event.embedding_model == "m/x"
assert event.shape_version == 7
def _row(model="BAAI/bge-small-en-v1.5", shape=1):
return MagicMock(embedding_model=model, shape_version=shape)
def test_an_untouched_dial_reports_the_shipped_default_it_is_still_on():
s = get_surface("prompt_rule")
live = {"embedding_model": s.measured_model, "shape_version": s.measured_shape}
cal = rt._calibration(None, s, live)
assert cal["source"] == "shipped"
assert cal["stale"] is False
def test_a_dial_moved_before_stamps_existed_is_unknown_not_fine():
"""`stale: None`, because "we don't know" and "it's fine" are not the same.
Collapsing them would hide the dials MOST likely to be wrong — the ones
somebody tuned longest ago.
"""
s = get_surface("prompt_rule")
cal = rt._calibration(_row(model=None, shape=None), s, emb.calibration_stamp())
assert cal["source"] == "unstamped"
assert cal["stale"] is None
assert cal["model_changed"] is None and cal["shape_changed"] is None
def test_a_model_change_and_a_shape_change_are_reported_apart():
"""Rule 149. Two conditions, two answers — they call for different work."""
s = get_surface("prompt_rule")
live = {"embedding_model": "new/model", "shape_version": 1}
cal = rt._calibration(_row(model="old/model", shape=1), s, live)
assert (cal["model_changed"], cal["shape_changed"], cal["stale"]) == (
True, False, True,
)
live = {"embedding_model": "old/model", "shape_version": 2}
cal = rt._calibration(_row(model="old/model", shape=1), s, live)
assert (cal["model_changed"], cal["shape_changed"], cal["stale"]) == (
False, True, True,
)
def test_a_dial_tuned_under_the_live_stamp_is_not_stale():
s = get_surface("prompt_rule")
live = emb.calibration_stamp()
cal = rt._calibration(
_row(model=live["embedding_model"], shape=live["shape_version"]), s, live,
)
assert cal["source"] == "tuned"
assert cal["stale"] is False
def test_every_surface_ships_a_stamp_for_its_defaults():
"""A default with no stamp cannot be told from one measured yesterday."""
for name, s in SURFACES.items():
assert s.measured_model, f"{name} default has no measured model"
assert isinstance(s.measured_shape, int), f"{name} shape is not a version"
def test_the_registry_stamp_is_a_literal_not_the_live_value():
"""It says what WAS true, so it must not follow the current constants.
A field that tracked `EMBEDDING_MODEL` would agree with it forever and could
never report the one thing it exists to report.
"""
with patch.object(emb, "EMBEDDING_MODEL", "some/other-model"):
assert get_surface("prompt_rule").measured_model != "some/other-model"
def test_the_tool_says_nothing_auto_retunes():
"""The contract an agent reads. A stale stamp is a prompt to MEASURE.
Without this, the obvious next move on seeing `stale: true` is to move the
dial — which is tuning from a statistic, the exact failure #4102 measured
pointing the wrong way.
"""
doc = tool_doc("scribe.mcp.tools.retrieval_tuning", "retrieval_surfaces")
assert "calibration" in doc
assert "NOTHING IS RETUNED AUTOMATICALLY" in doc
assert "unstamped" in doc
+153
View File
@@ -0,0 +1,153 @@
"""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
+1 -1
View File
@@ -23,7 +23,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 16
assert backup.BACKUP_VERSION == 17
def _exportable_note(**over):