feat(retrieval): the model moves its own floors, and says why (#4102)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped

Milestone 416 step 4's write half. `retrieval_surfaces.py` made the six
push arms describe their `{floor, budget}` the same way; this adds the
three MCP tools that let the model READ that and change it, and the
backup sections that carry the reasons.

The operator's decision, which this implements:

    "the floor should be chosen and adjusted by the model using it… the
    user should be able to touch it but the model should be the thing
    handling it 9 times out of 10."

WHY A REASON IS REQUIRED, AND WHY THE TOOL ARGUES AGAINST PERCENTILES

The milestone originally listed self-tuning as a non-goal on one
measured case, and that case is now the tool's docstring rather than a
prohibition: `report_preference` logged 69 consecutive declines with
the refused record 0.0006 under the bar, and every percentile said
"lower it". The refused record was rule 77 "Extract intent from loose
phrasing" matched against a query about report layout — a false
positive. Lowering would have attached that rule to every completion
report ever written.

What separated the statistic from the correct action was OPENING the
record. So `tune_retrieval` refuses a blank or perfunctory reason,
tells the caller to read `retrieval_telemetry(near_miss_samples=5)`
and the record ids it names, and carries that 69-decline example — an
abstract warning loses to a number. The non-goal that survives is
*statistical* auto-tuning; nothing here reads a percentile and picks a
value.

BACKUP (v16), which is what CI caught

`retrieval_tuning_events` was neither backed up nor excluded, and
#2293's guard said so. It is backed up: `settings` already carried the
numbers, so dropping this would restore an install with six moved
dials and no argument for any of them — precisely the state the table
exists to prevent, and worse now that the model is the one moving
them. One `_retrieval_tuning_event_rows` builder called from both
exporters (snippet #2851); `user_id` travels because a restore has to
remap it, which is why the row builder is not the model's `to_dict()`.
`surface` is a registry name rather than a foreign key, so the history
survives a restore into an install whose ids all differ.

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 11:28:17 -04:00
co-authored by Claude Opus 5
parent 003bfd7a0a
commit ca49a46c23
5 changed files with 408 additions and 5 deletions
+3 -2
View File
@@ -5,14 +5,15 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, shapes,
snippets, systems, tags, tasks, trash,
design_systems, milestones, notes, processes, projects, recent, repos, retrieval_tuning,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
)
def register_all(mcp) -> None:
"""Register every tool module's tools on the given FastMCP instance."""
search.register(mcp)
retrieval_tuning.register(mcp)
notes.register(mcp)
tasks.register(mcp)
projects.register(mcp)
+115
View File
@@ -0,0 +1,115 @@
"""Reading and moving a retrieval surface's floor and budget (#4102).
The write half of the loop `retrieval_telemetry` opens. That tool says what a
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_tuning as tuning_svc
async def retrieval_surfaces() -> dict:
"""Every retrieval surface Scribe pushes on, with its floor and its budget.
Read this before changing either, and read it beside
`retrieval_telemetry(days=…, near_miss_samples=5)` — this tool says what is
in force, that one says what it did.
Each surface carries what it ASKS, what corpus it asks OVER, and how often
it FIRES, because a floor cannot be moved sensibly without all three. An arm
firing before every Bash call is spending attention on a scale an arm firing
once a turn is not, and the same number means different things to a query
that is a shell command, a code payload, or an operator's sentence.
`floor` is a COST floor: is this worth ranking at all. It is not a relevance
judgement — relevance is decided by the reader, which is the only
participant that can read a record's trigger against the actual situation,
and every injected line says so out loud ("read it before deciding it does
not apply").
`budget` is what actually binds. It is how many lines this surface may spend
on one injection, and it is the control to reach for when a surface feels
noisy — lowering a budget removes the weakest candidates, while raising a
floor removes whichever candidates happen to sit under a number.
`last_change` carries the reason last given for each dial, or is absent when
the surface is still on its shipped starting point. Those starting points
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.
"""
return {"surfaces": await tuning_svc.current_settings(current_user_id())}
async def tune_retrieval(
surface: str, dial: str, value: float, reason: str, actor: str = "model",
) -> dict:
"""Move one surface's floor or budget, recording why.
DO NOT CALL THIS FROM A PERCENTILE ALONE. `retrieval_telemetry` gives
`near_misses.p90` — the mass sitting just under the bar — and that number
says nothing about whether the mass is RELEVANT. The two have been measured
disagreeing: one surface logged 69 consecutive declines with the refused
record 0.0006 under its bar, and every percentile said "lower it". The
refused record turned out to be a rule about interpreting a REQUEST, matched
against a query about report layout — a false positive. Lowering would have
attached that rule to every completion report ever written. The statistic
and the correct action pointed in opposite directions, and only opening the
record could tell.
So the procedure is: `retrieval_telemetry(near_miss_samples=5)`, then
`get_rule` / `get_note` the `record_id`s it names, and decide whether those
records SHOULD have surfaced for those queries. Then move the dial, and say
in `reason` what you read and what it showed.
`reason` is required and must be substantive. It is the guardrail, not
bookkeeping: it is what lets the operator review a change they did not make,
disagree with it, and revert it. A number that moved with no stated basis is
one nobody can audit — including you, next week.
WHICH DIAL. Reach for `budget` when the complaint is volume, and `floor`
when the complaint is quality. Raising a floor to quieten a surface throws
away its best candidates along with its worst, because a floor cannot tell
rank from relevance; lowering a budget keeps the top of the ranking and
drops the tail, which is usually what was wanted.
Args:
surface: the surface name from `retrieval_surfaces` — also its
`retrieval_telemetry` source, so the two always name the same arm.
dial: "floor" or "budget".
value: the new value. A floor is clamped to [0, 1], a budget to a whole
number in [1, 10]. The result says whether a clamp bit, which
matters: believing you set a value the registry corrected means
reading the next telemetry as evidence about a bar never in force.
reason: what you read and what it showed. Required.
actor: "model" (default) or "human" — pass "human" only when relaying a
value the operator chose themselves, so the audit trail can tell
a change they made from one made on their behalf.
"""
return await tuning_svc.set_dial(
current_user_id(), surface, dial, value, reason=reason, actor=actor,
)
async def retrieval_tuning_history(surface: str = "", limit: int = 20) -> dict:
"""What has been changed about retrieval on this install, newest first.
Reach for it before moving a dial that has been moved before — the previous
reason is the argument the next change has to answer, and a surface that has
been walked up and down repeatedly is evidence the floor is not the problem.
Args:
surface: restrict to one surface; omit for everything.
limit: how many events, default 20, capped at 200.
"""
return {
"events": await tuning_svc.tuning_history(
current_user_id(), surface=surface or None, limit=limit,
)
}
def register(mcp) -> None:
mcp.tool(name="retrieval_surfaces")(retrieval_surfaces)
mcp.tool(name="tune_retrieval")(tune_retrieval)
mcp.tool(name="retrieval_tuning_history")(retrieval_tuning_history)