Files
FabledGrimoire/src/scribe/models/retrieval_tuning.py
T
bvandeusenandClaude Opus 5 aee24c9c1c
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
feat(retrieval): a tuned number carries the space it was measured in (#4104)
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
2026-09-17 12:45:48 -04:00

119 lines
5.7 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
class RetrievalTuningEvent(Base):
"""One row per change to a retrieval surface's floor or budget (#4102).
WHY A TABLE AND NOT JUST THE SETTING
The setting holds the number. It cannot hold the argument, and from this
step onward the argument is the interesting part: the operator's decision is
that the model moves these values, *"9 times out of 10"*, without being
asked each time. Delegation like that is only safe if it leaves a trail the
operator can read afterwards — what changed, from what to what, and on what
evidence — and can disagree with.
So `reason` is not decoration and not optional. It is the guardrail: a model
that has to state why is a model that has to have looked, and a reason that
turns out to be wrong is a reason someone can point at. A bare number in a
settings row records that something moved and nothing about whether it
should have.
WHY `actor` IS NOT JUST `user_id`
Both a person and a model act as the same user — the MCP tools run under the
operator's own id — so the id cannot distinguish them. That distinction is
exactly what an operator scanning this table wants first: "did I do this, or
did the session?" A change the operator made themselves needs no review; one
made on their behalf might.
FK-FREE ON user_id, like `RetrievalLog` and `AppLog` above it: the history of
how a surface was tuned should outlive the account that tuned it, and a
deleted user must not cascade away the record of why a number is where it is.
"""
__tablename__ = "retrieval_tuning_events"
id: Mapped[int] = mapped_column(primary_key=True)
# Declared here rather than via CreatedAtMixin because the index below
# orders on `created_at.desc()`, which needs the column object in this class
# body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# The surface's registry name, which is also its `retrieval_logs.source`.
# That identity is what lets a reader put a change next to what the change
# did; `tests/test_retrieval_surfaces.py` asserts it rather than trusting it.
surface: Mapped[str] = mapped_column(Text, nullable=False)
# "floor" or "budget". Text rather than an enum because rule 36 makes a new
# CHECK value a migration, and this is a young surface that may well grow a
# third tunable — a constraint bought nothing here except a future migration.
dial: Mapped[str] = mapped_column(Text, nullable=False)
# Both stored as float so one pair of columns serves both dials; a budget is
# a whole number and reads back as one. `old_value` is nullable because the
# first change to a surface has no stored predecessor — it moved off the
# shipped starting point, which is a different event from moving off a value
# somebody chose, and worth being able to tell apart.
old_value: Mapped[float | None] = mapped_column(Float, nullable=True)
new_value: Mapped[float] = mapped_column(Float, nullable=False)
# "model" | "human". See the class docstring: the user id cannot tell these
# apart, and the difference is the first thing a reviewer wants.
actor: Mapped[str] = mapped_column(Text, nullable=False, default="model")
# REQUIRED at the service boundary, not merely non-null here. A column that
# is technically non-null is satisfied by "", which is how a required field
# 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(
"ix_retrieval_tuning_surface_created",
"surface",
created_at.desc(),
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"surface": self.surface,
"dial": self.dial,
"old_value": self.old_value,
"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,
}