Skip to content

Review what the metric said instead of expecting a verdict - #2533

Merged
akwasigroch merged 3 commits into
feat/metric-tuning-test-setsfrom
feat/review-what-the-metric-said
Aug 20, 2026
Merged

Review what the metric said instead of expecting a verdict#2533
akwasigroch merged 3 commits into
feat/metric-tuning-test-setsfrom
feat/review-what-the-metric-said

Conversation

@akwasigroch

Copy link
Copy Markdown
Collaborator

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.2 is 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

  • Reviews replace expected verdicts. POST /metrics/{id}/tuning/cases/{case_id}/review records one judgement; a rejection without a comment is refused. POST /metrics/{id}/tuning/reviews/accept-rest accepts every case still unreviewed, so forty cases are not forty decisions.
  • Reviews accumulate on the case, in test.test_metadata beside 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.
  • A review stands until the metric's decision actually changes. material_change.py buckets the judged verdict and the current one by the metric's threshold (or its passing_categories) and compares buckets, so 0.79 → 0.81 under a 0.5 threshold is noise and the same pair under 0.8 is 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. Changing score_type invalidates every review for the metric and keeps the comments.
  • Four outcomes, never collapsed: accepted, rejected, errored, unreviewed. Unreviewed is never counted as accepted — a set nobody looked at must not report itself as perfect — and an unreviewed case says whether nobody has judged it yet or a material change took its review away.
  • Removed with the old model: services/metric_tuning/verdict.py and its per-score-type validation, the expected verdict, the case rationale, the stale and unlabelled markers, and every write to prompt.expected_response. expected_output is now reference_answer, still read under the old key so cases written earlier keep their text.
  • Frontend: the grid reads left to right as the case, what the metric output, then the review. Thumb up accepts in one click, thumb down opens the required comment box, and the thumbs are the state as well as the control — the pressed one is filled and coloured, so no chip repeats it. An amber mark flags a review a material change invalidated. Columns nothing fills are left out. 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.

Additional Context

  • Targets feat/metric-tuning-test-sets, not main — that branch is the integration branch for the whole metric tuning effort, and main sees exactly one merge when the feature is finished.
  • Supersedes the expected-verdict work from Add metric tuning for custom metrics [feature branch] #2446 and builds on the tuning runs from Add tuning runs for custom metrics #2470. Unblocks ticket 03 (the agreement number), which is deliberately not in this PR: this one produces the per-case outcome the ratio will be computed from.
  • Behind NEXT_PUBLIC_METRIC_TUNING, and the routes still refuse anything that is not a custom metric.
  • Two things reviewers should know are deliberate. The cap is soft: at ten reviews with no accept to evict the list grows rather than dropping a comment, because a cap that silently destroyed the comments would destroy what the feature produces. And accept-the-rest accepts invalidated cases too — they are "still unreviewed", which is what the ticket asks for, but it does mean one click re-accepts a verdict that just moved.
  • One fix that is not strictly in scope: get_tuning_cases ordered only by created_at, and cases created inside one transaction share it exactly, so list order was left to the planner. It now breaks ties on id.
  • The interface departs from the spec in two places, on review: the band over the run columns is labelled Metric output even though Output also 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

181 backend tests pass, run three times over to shake out order dependence, plus 45 frontend tests for the tab and the client.

cd apps/backend
uv run pytest ../../tests/backend/routes/test_metric_tuning.py \
  ../../tests/backend/routes/test_metric_tuning_runs.py \
  ../../tests/backend/routes/test_metric_tuning_reviews.py \
  ../../tests/backend/schemas/test_metric_tuning_metadata.py \
  ../../tests/backend/services/metric_tuning -v

cd apps/frontend
npx jest 'src/app/\(protected\)/metrics' src/utils/api-client/__tests__/metric-tuning-client.test.ts

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.

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.

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Improvement] reviewer_id may be None, which can make distinct anonymous reviews overwrite each other.

[Improvement] Avoid mutable default list for MetricTuningCaseMetadata.reviews.

Found 2 issues (0 critical, 2 improvements).

"""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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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_id is always set (e.g., pass current_user.id from the router) or treat None as “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] = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Improvement] reviews: List[MetricTuningReview] = [] is a mutable default.

Fix: use Field(default_factory=list) to avoid any risk of shared state between instances.

@akwasigroch
akwasigroch merged commit 3f79582 into feat/metric-tuning-test-sets Aug 20, 2026
7 of 8 checks passed
@akwasigroch
akwasigroch deleted the feat/review-what-the-metric-said branch August 20, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant