Review what the metric said instead of expecting a verdict - #2533
Merged
akwasigroch merged 3 commits intoAug 20, 2026
Merged
Conversation
A tuning case no longer records what the metric should say. Nobody can honestly author the number a numeric metric ought to return, while saying whether 0.2 is wrong is easy, so the judgement moves after the run: a reviewer accepts what the metric said or rejects it with a comment, and those comments are what someone reads when rewriting an evaluation prompt. Reviews live beside the run's result in test.test_metadata, accumulate across runs, and are capped at ten per case with eviction taking an accept and never a comment. Re-judging a verdict that has not moved replaces the reviewer's last review, so a corrected mis-click does not spend two slots. A review stands while the metric's decision has not changed: material_change buckets both verdicts by the metric's current threshold or passing categories, derived on read so moving a threshold re-evaluates the reviews that exist rather than freezing yesterday's arithmetic. A score_type change invalidates every review and keeps the comments. Gone with the old model: verdict.py and its per-score-type validation, the expected verdict, the rationale, the stale and unlabelled markers, and any write to prompt.expected_response. expected_output becomes reference_answer, still read under the old key so existing cases keep their text. Refs domain.local/adr/0005
Routes are the seam: the four outcomes, a rejection needing a comment, accept-the-rest skipping what has no verdict to judge, replace-not-append, the cap evicting an accept but never a comment, and a review surviving 0.79 to 0.81 while crossing the threshold sends the case back to unreviewed. The material-change rule gets its own unit tests -- every threshold operator, either side and exactly on it, a categorical move across passing_categories, and each fallback where no bucket can be derived. Two harness notes worth keeping. Reviews are written by the request session, so a run driven from the test session expires it first or rewrites metadata it cached before that commit. And every request in a test shares one transaction, which makes Postgres now() identical for every case, so results are keyed on the case's input and assertions on its id rather than on a list position.
The grid reads left to right as the case, then what the metric output, then the review. A thumb up accepts in one click; a thumb down opens the comment box the rejection requires. The thumbs are the state as well as the control -- the pressed one is filled and coloured -- because a chip saying "Accepted" next to a green thumb says the same thing twice. An amber warning marks a review a material change took away, which is not the same as never having had one, and says so on hover. A case whose metric call failed offers no buttons: there is no verdict there to judge. "Accept the rest" sits beside Run metric and covers every case still unreviewed, so forty cases are not forty decisions. The case drawer loses the verdict control and the rationale, and asks for the reference answer only when the metric needs one. BaseDataGrid gains an optional columnGroupingModel, which is what the bands are built from.
| """Place a new review in the history, replacing or appending as the rules say.""" | ||
| for index in range(len(reviews) - 1, -1, -1): | ||
| existing = reviews[index] | ||
| if existing.reviewer_id != review.reviewer_id: |
There was a problem hiding this comment.
[Improvement] reviewer_id can be None (see get_tenant_context), and then existing.reviewer_id != review.reviewer_id will treat all anonymous reviews as the same reviewer, causing later reviews to overwrite earlier ones.
Fix: ensure
reviewer_idis always set (e.g., passcurrent_user.idfrom the router) or treatNoneas “always append” (never replace).
| # What the metric said last time it was run over this case. | ||
| result: Optional[MetricTuningCaseResult] = None | ||
| # Oldest first. Accumulates across runs, capped at REVIEW_HISTORY_LIMIT. | ||
| reviews: List[MetricTuningReview] = [] |
There was a problem hiding this comment.
[Improvement] reviews: List[MetricTuningReview] = [] is a mutable default.
Fix: use
Field(default_factory=list)to avoid any risk of shared state between instances.
akwasigroch
merged commit Aug 20, 2026
3f79582
into
feat/metric-tuning-test-sets
7 of 8 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
A tuning case used to record the verdict the metric should return, and a run compared for equality. That model does not survive contact with this deployment: 45 numeric metrics, 2 categorical, 0 binary. Authoring the expected verdict for a numeric metric means answering "what number would you have given?", which nobody can answer honestly — while judging whether
0.2is wrong is easy.So the judgement moves to after the run. A reviewer looks at what the metric actually said and either accepts it or rejects it with a written comment, and those comments are the point of the whole feature: they are what someone reads when rewriting an evaluation prompt. Ticket 05 of the tuning-runs effort; the model is fixed by ADR-0005, which supersedes ADR-0002.
What Changed
POST /metrics/{id}/tuning/cases/{case_id}/reviewrecords one judgement; a rejection without a comment is refused.POST /metrics/{id}/tuning/reviews/accept-restaccepts every case still unreviewed, so forty cases are not forty decisions.test.test_metadatabeside the run's result — no new table, same reasoning as ADR-0004. Capped at ten per case, and eviction takes an accept, never a review carrying a comment. Re-judging a verdict that has not moved replaces that reviewer's last review rather than appending, so a corrected mis-click does not spend two slots.material_change.pybuckets the judged verdict and the current one by the metric's threshold (or itspassing_categories) and compares buckets, so0.79 → 0.81under a0.5threshold is noise and the same pair under0.8is a reversal. The bucket is derived on read and never stored, so moving a threshold re-evaluates existing reviews instead of freezing yesterday's arithmetic. Changingscore_typeinvalidates every review for the metric and keeps the comments.services/metric_tuning/verdict.pyand its per-score-type validation, the expected verdict, the case rationale, the stale and unlabelled markers, and every write toprompt.expected_response.expected_outputis nowreference_answer, still read under the old key so cases written earlier keep their text.BaseDataGridgains an optionalcolumnGroupingModel.Additional Context
feat/metric-tuning-test-sets, notmain— that branch is the integration branch for the whole metric tuning effort, andmainsees exactly one merge when the feature is finished.NEXT_PUBLIC_METRIC_TUNING, and the routes still refuse anything that is not a custom metric.get_tuning_casesordered only bycreated_at, and cases created inside one transaction share it exactly, so list order was left to the planner. It now breaks ties onid.Metric outputeven thoughOutputalso names the answer being judged one band to the left, and the review column carries no band and no chip. Both are recorded in the ticket.Testing
181backend tests pass, run three times over to shake out order dependence, plus45frontend tests for the tab and the client.By hand, with
NEXT_PUBLIC_METRIC_TUNING=true: add two cases to a custom metric, press Run metric, then reject one with a comment and accept the other from its row. Edit the evaluation prompt so one verdict crosses the metric's threshold and run again — that case comes back unreviewed with the amber mark and its old comment still in storage, while the case that only drifted keeps its judgement. Accept the rest then clears whatever is left.The test that matters most is
test_the_metric_sees_the_case_and_nothing_else: route the metric under test through the normal evaluation path and nothing raises, the scorecard just comes out flattering. That test is what fails loudly instead.