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, }