Skip to content

Add tuning runs for custom metrics - #2470

Merged
akwasigroch merged 6 commits into
feat/metric-tuning-test-setsfrom
feat/tuning-run-metric
Aug 13, 2026
Merged

Add tuning runs for custom metrics#2470
akwasigroch merged 6 commits into
feat/metric-tuning-test-setsfrom
feat/tuning-run-metric

Conversation

@akwasigroch

@akwasigroch akwasigroch commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

🎯 This merges into feat/metric-tuning-test-sets, not main

Base branch is #2446, the integration branch for the whole metric tuning effort. This is ticket 02 of the tuning-runs work, cut from that branch and merging back into it. Migrations for the effort are held there so an early schema guess can be corrected in place rather than needing a corrective migration stacked on top; main sees exactly one merge at the end.

Purpose

A metric author can now write down what their metric should say — a tuning case per situation, each with an expected verdict — but has no way to find out whether it actually says it. So the labelled cases sit there being correct and inert, and checking a metric still means running something, reading the output and forming an impression. Editing an evaluation prompt stays guesswork with no feedback.

This adds the run: press a button and the metric is run over every case it has. Each case then shows what the metric said and why, beside the verdict the author expected. That side-by-side is what you edit an evaluation prompt against.

No agreement number yet — that is ticket 03. Seeing the divergence case by case is already the substance of a run, and the ratio needs the agreed/disagreed/skipped split that ticket brings.

What Changed

  • The metric is invoked as the system under test. services/metric_tuning/invoke.py unpacks the case payload into the same three arguments the metric receives in a real run — input, output, and the case's own expected output — and nothing else. This is the part of the feature that is easy to get backwards and the reason the whole thing is shaped this way; see the caveat below.
  • A run creates no rows in the execution tables. No test run, no test configuration, no endpoint, no metric row for the comparison. It is a service plus a Celery task writing into JSONB that already exists: the per-case result onto test.test_metadata["result"], the run summary onto the tuning test set's attributes["tuning_run"]. ADR-0004 records why, and the two schema constraints that rule out reusing the execution path.
  • Only the latest run is kept. A new run clears the previous per-case results before writing its own — otherwise a case the metric can no longer reach would keep showing what it said last time, beside a summary describing the current run. Trend over time deserves real rows rather than an unbounded blob in a column nothing paginates, and nothing is lost that re-running cannot recover.
  • Two routes, POST and GET /metrics/{id}/tuning/run. 400 when the metric has no cases (an empty scorecard says nothing; the refusal says what to do), 409 while a run is in flight, 400 on a non-custom metric. The POST is marked metric:update explicitly rather than left to the POST-means-create convention — running writes results onto the metric's own cases, and an explicit metric:execute would need a capability catalog migration for something still behind a flag.
  • A failed metric call is an errored case, not a wrong verdict. The run continues over the rest and the count is reported apart from the verdicts, so one unreachable provider never reads as a bad metric. This needed a fix after the first review pass: the SDK reports its own failures as a result rather than by raising, and LocalStrategy wraps that in MetricResultBuilder.success(), which carries no error key at all. Only the key was being checked, so a 401 against the judging model produced a run of "1 cases, 0 errored" with the failure stored as the metric's answer — error on a categorical metric, pass on a binary one, 0.0 on a numeric one. The score sentinel and the reason the SDK writes are now both read.
  • The judging model is resolved explicitly, and the chain ends in an error. It is the model saved on the metric, else the model configured as the default for evaluation, else the run is refused with a 400 before anything is written or queued. The evaluation path this borrows keeps going past those two — to whatever the caller passed, and then to the SDK's own built-in default, the hosted Rhesis LLM — and announces neither. That is how a tuning run reached a model nobody picked and died on a 401. get_user_evaluation_model is deliberately not used: it conflates "the user configured a model" with "the system has a default", which is the silent step being removed. A run scored by a judge nobody chose measures nothing and says nothing about it — set the metric's model afterwards and every stored verdict silently refers to a different judge.
  • The task does not retry. A run is one LLM call per case, so an autoretry is a second bill for the same work; it marks the run failed instead and the author presses again. A run that dies is recorded as failed rather than left running, which would both look like progress and block the next attempt.
  • The run commits after each case, so the tab shows real progress rather than jumping from nothing to everything, and a worker that dies mid-run leaves the cases it did finish behind.
  • A binary metric's verdict renders as pass/fail, not 1.0. The SDK returns a float that it treats as a flag; 1.0 displayed beside an expected pass reads as a disagreement to a human even when it is not.
  • Tab changes: a Run metric control, polling while a run is in flight, a line saying when the last run finished, and "Metric said" / "Because" columns sitting next to "Expected".

Additional Context

  • The caveat worth reviewing closely, and the one a reader is most likely to skip. On the normal evaluation path prompt.expected_response is passed to a metric as its expected_output. On a tuning case that column holds the expected verdict. Route the metric under test through that path and it is handed the answer key — told the expected response to "How are you?" is fail — and the resulting agreement number is meaningless. Nothing fails loudly; the numbers just come out flattering. test_the_metric_never_sees_the_expected_verdict is the only thing that fails if someone reconnects that wire, which is why it asserts the arguments the evaluator received and that neither the verdict nor the reviewer's rationale appears anywhere among them.
  • Concurrency is advisory, deliberately. The stored status is the only guard, so two requests in the same instant can both pass. The failure mode is a summary belonging to neither run rather than corruption — the trade ADR-0004 accepts for a flagged feature with one author per metric. Making it actually safe is ticket 04.
  • MetricTuningRun and MetricTuningCaseResult extend plain BaseModel, not the shared Base. Base carries id/nano_id/project_id, and a null id on a run response would advertise a handle to something ADR-0004 says is deliberately not a row.
  • Still invisible in every deployment. The tab stays behind NEXT_PUBLIC_METRIC_TUNING, which defaults to off and nothing sets it. The routes are live, which is why the custom-metric refusal is enforced server-side rather than only by hiding the button.
  • On size: ~1700 added lines, which is well past the repo's 400-line guideline. It is one ticket and one logical change — backend, interface and tests for a single capability — and roughly half of it is tests and docstrings. Reviewing per commit splits it into backend, backend tests, and frontend.

Testing

cd apps/backend && uv run pytest ../../tests/backend/routes/test_metric_tuning_runs.py ../../tests/backend/services/metric_tuning/test_invoke.py — 50 tests. The 28 route tests cover starting a run, the in-progress and finished summaries, the no-cases and non-custom refusals, the concurrent-run refusal, per-case verdict and reasoning, every flavour of errored case (the evaluator raising, the evaluator setting an error key, and the shape that actually occurs — a failure the SDK reports as a scored result with no error key, on both a categorical and a numeric metric, plus a metric that offers error as a real category keeping its verdict), a failed run surfacing as failed and not blocking the next one, only-the-latest-run-kept, a case added after a run having no result, and that a run leaves the cases themselves byte-for-byte unmodified. The 22 unit tests cover verdict rendering per score type, including the words a binary metric's judge can answer with that are not pass/failbool() on any of them is true, so they would otherwise all render as pass.

The metric invocation and the Celery dispatch are both stubbed, so the suite is deterministic and makes no LLM calls.

Neighbouring suites still pass: test_metric_tuning.py, test_metric_tuning_metadata.py, services/metric_tuning/ — 122 together with the new ones; test_test_set_update.py, test_test_test_sets.py, test_test_bulk_delete.py, test_explorer.py — 101; tasks/test_base_task.py, tasks/test_pipeline_wiring.py — 25.

Frontend: npx jest --testPathPatterns "MetricTuning|metric-tuning" — 31 tests, 10 of them new: the run control appearing only once there are cases, that loading the tab starts nothing, the in-flight state and its progress, the last-run line, the metric's verdict and reasoning on the row, the error marker, and a failed run being reported. npx tsc --noEmit and npm run lint are clean.

To exercise it by hand, add NEXT_PUBLIC_METRIC_TUNING=true to apps/frontend/.env.local, restart the frontend, open a custom metric's Tuning tab, add a case or two and press Run metric. A Celery worker must be running for the run to progress past running.

The metric is invoked as the system under test: it receives the case payload
unpacked into the same arguments it gets in a real run, and never the expected
verdict. Routing it through normal metric evaluation would hand it the answer
key and make every agreement number meaningless without anything failing.

A run creates no rows in the execution tables. Per-case results go on the case's
test_metadata, the run summary on the tuning test set's attributes, and only the
latest run is kept. See ADR-0004.

A case whose metric call fails is recorded as errored and the run continues, so
a flaky provider never reads as a bad metric.
The load-bearing one is
test_the_metric_never_sees_the_expected_verdict: it asserts the three arguments
the evaluator received and that neither the verdict nor the reviewer's rationale
appears among them. Nothing else fails loudly if someone reconnects that wire.

The metric invocation and the Celery dispatch are both stubbed, so the whole
path is deterministic and free of LLM calls.
A Run metric control, polling while a run is in flight, and the metric's own
verdict and reasoning beside the verdict the author expected — which is the
whole point of a run.

A binary metric's verdict renders as pass/fail rather than 1.0, since 1.0 beside
an expected pass reads as a disagreement to a human when it is not. A failed
call is marked as an error rather than shown as a verdict.

Nothing here starts a run except the button: a poll that could start one would
turn opening the tab into an LLM bill.

@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] Tuning run metric invocation:

  • expected_output=payload.expected_output or "" collapses None into "". If downstream logic uses None to mean “missing ground truth”, passing "" can make ground-truth-required metrics run with an empty reference.

    Fix: pass payload.expected_output through as-is (and adjust evaluator typing/handling if needed).

  • verdict_from_score() for binary metrics falls back to bool(score), which treats any non-empty string as truthy. If connectors ever return stringified scores like '0', this would incorrectly render as pass.

    Fix: for string scores (non pass/fail), try numeric coercion before bool(score).

[Improvement] Frontend polling UX:

  • When a run is already running, polling waits for the first interval tick before fetching, so UI can be stale for up to 3s.

    Fix: call poll() once immediately when starting the interval.

Overall the shape (latest-only JSONB summary + per-case result, no execution rows) is clear and the tests around “metric never sees expected verdict” are doing the right kind of guardrail.

# The case's own expected output -- what the *system under test*
# should have answered. Never prompt.expected_response, which is the
# expected verdict. See the module docstring.
expected_output=payload.expected_output or "",

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] expected_output=payload.expected_output or "" changes the “missing expected output” sentinel from None"".

Fix: consider passing through payload.expected_output as-is (allowing None) and updating MetricEvaluator.evaluate() typing/handling accordingly. Some downstream logic distinguishes None (missing) from a present-but-empty string, so "" can make ground-truth-required metrics run with an empty reference.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still think this is worth fixing. MetricEvaluator.evaluate() is typed as expected_output: str, but downstream LocalStrategy does distinguish None (missing reference → skip ground-truth-required metrics) from "" (treated as provided). With payload.expected_output or "", a missing expected_output becomes "" and can make ground-truth-required metrics run with an empty reference. Suggestion: pass payload.expected_output through as-is and relax MetricEvaluator.evaluate() typing to Optional[str] (connector strategy already normalizes falsy to "" anyway).

if lowered in BINARY_VERDICTS:
return lowered
passing, failing = BINARY_VERDICTS
return passing if bool(score) else failing

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] For binary metrics, bool(score) will treat any non-empty string as True (e.g. '0', 'false') if connectors return stringified numbers.

Fix: if score is a string and not one of pass/fail, try coercing numeric-ish strings to float/bool (e.g. '0' → fail, '1' → pass) before falling back to bool(score). This avoids misclassifying '0' as pass.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed now: verdict_from_score() treats non-pass/fail strings as the judge’s own category (returns the string) instead of running them through bool(score), so values like '0'/'false' won’t be misclassified as pass. This thread can be resolved.

}
};

const timer = setInterval(poll, RUN_POLL_INTERVAL_MS);

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] Polling starts setInterval(poll, …) but doesn’t do an initial poll() call. If the user opens the tab while a run is already running, the UI may sit stale for up to 3s.

Fix: call poll() once immediately after setting up (or use setTimeout(poll, 0)), then keep the interval.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still applicable: the polling effect sets up setInterval(poll, …) but never does an initial poll(). If the tab opens while a run is already running, the UI can be stale for up to the interval. Suggest calling poll() once immediately after defining it (or setTimeout(poll, 0)) before starting the interval.

A tuning run was reaching the SDK's built-in default, the hosted Rhesis LLM,
and dying on a 401. Seven fallbacks stood between "which model judges this?"
and an answer, and none of them announced itself.

The chain is now two steps and an error: the model saved on the metric, else
the model configured as the default for evaluation, else refuse the run with
a 400 before anything is written or queued. get_user_evaluation_model is
deliberately not used -- it conflates "the user configured a model" with "the
system has a default", which is the silent step being removed here.

A run scored by a judge nobody picked measures nothing and says nothing about
it: set the metric's model afterwards and every stored verdict silently refers
to a different judge.
invoke_metric_on_case only looked for a top-level "error" key, which the
shape it actually receives does not have. The SDK reports its own failures as
a result rather than by raising, the local strategy wraps that in
MetricResultBuilder.success(), and success() carries neither the SDK's
details["error"] nor an error key of its own.

So the failure was read as a verdict. A 401 against the judging model gave a
run of "1 cases, 0 errored": a categorical metric stored "error" as its
answer, a binary one stored "pass" -- an unreachable provider recorded as the
metric agreeing -- and a numeric one stored 0.0.

The score sentinel is now recognised, except where the metric declares
"error" as one of its own categories, and the reason the SDK writes is used
for the score types whose sentinel is an ordinary number. The tests build
their stub with the real builder, since stubbing the connector's shape
instead is what let this through.

@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.

Nice work keeping the metric-under-test wiring explicit (and adding the model-resolution refusal + SDK-failure detection) — this looks consistent with the ADR constraints and the new test coverage is solid.

I still see the 3 prior Improvement threads as the only remaining nits before merge (expected_output None"" coercion, binary bool(score) on string scores, and doing an immediate poll when opening the tab mid-run).

Two ways the metric's own answer was read as something it did not say.

A binary metric fell through to bool(score) for any string that was not
already "pass" or "fail", and every non-empty string is truthy -- so "no",
"false" and "0" all rendered as "pass". There is no binary judge in the SDK
factory, so a binary metric is backed by one that answers in categories and
this is reachable, not theoretical: a yes/no judge agreed with every case.
The word is now shown as the metric said it rather than guessed at.

The evaluation-model setting is parsed as a UUID by the settings accessor, so
a malformed stored value raises ValueError. The walk that reads it used
getattr, which only swallows AttributeError, and the router catches only
MetricModelNotConfigured -- so a broken setting escaped as a 500 from the one
function whose purpose is to refuse cleanly. It now refuses with the 400 it
was always meant to.
@akwasigroch akwasigroch reopened this Aug 13, 2026
@akwasigroch
akwasigroch merged commit 1f7d939 into feat/metric-tuning-test-sets Aug 13, 2026
4 of 6 checks passed
@akwasigroch
akwasigroch deleted the feat/tuning-run-metric branch August 13, 2026 14:55

@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] Preserve expected_output=None vs "" in tuning invocation.

invoke_metric_on_case() currently does expected_output=payload.expected_output or "", which collapses “missing reference” into “empty reference”. In this codebase, LocalStrategy distinguishes None (skip ground-truth-required metrics) from "" (treated as provided), so this can make reference-required metrics run with an empty ground truth.

Fix: pass payload.expected_output through as-is and relax MetricEvaluator.evaluate() to accept Optional[str] (connector path already normalizes falsy → "").

[Improvement] Poll once immediately when run is already running.

The polling effect sets an interval but doesn’t call poll() initially, so opening the tab mid-run can show stale state for up to the interval.

Found 2 issues (0 critical, 2 improvements).

@peqy

peqy Bot commented Aug 13, 2026

Copy link
Copy Markdown

Two follow-ups I’d still recommend (even though the binary verdict string issue is fixed now):

[Improvement] Tuning invoke turns missing expected_output into ""

In services/metric_tuning/invoke.py, expected_output=payload.expected_output or "" changes the sentinel from None"". LocalStrategy distinguishes expected_output is None (skip ground-truth-required metrics) from an empty string, so this can make ground-truth-required metrics run with an invalid empty reference.

Fix: pass payload.expected_output through as-is and relax MetricEvaluator.evaluate()/strategy typing to Optional[str] (or explicitly convert ""None only when it’s truly missing).

[Improvement] Polling effect doesn’t do an initial poll()

In MetricTuningTab.tsx, the running polling effect sets up setInterval(poll, …) but never calls poll() immediately. Opening the tab (or starting a run) can leave the UI stale for up to the interval.

Fix: call poll() once immediately before starting the interval.

Found 2 issues (0 critical, 2 improvements).

akwasigroch added a commit that referenced this pull request Aug 14, 2026
* feat(backend): run a metric over its tuning cases

The metric is invoked as the system under test: it receives the case payload
unpacked into the same arguments it gets in a real run, and never the expected
verdict. Routing it through normal metric evaluation would hand it the answer
key and make every agreement number meaningless without anything failing.

A run creates no rows in the execution tables. Per-case results go on the case's
test_metadata, the run summary on the tuning test set's attributes, and only the
latest run is kept. See ADR-0004.

A case whose metric call fails is recorded as errored and the run continues, so
a flaky provider never reads as a bad metric.

* test(backend): cover tuning runs

The load-bearing one is
test_the_metric_never_sees_the_expected_verdict: it asserts the three arguments
the evaluator received and that neither the verdict nor the reviewer's rationale
appears among them. Nothing else fails loudly if someone reconnects that wire.

The metric invocation and the Celery dispatch are both stubbed, so the whole
path is deterministic and free of LLM calls.

* feat(frontend): show what the metric said about each case

A Run metric control, polling while a run is in flight, and the metric's own
verdict and reasoning beside the verdict the author expected — which is the
whole point of a run.

A binary metric's verdict renders as pass/fail rather than 1.0, since 1.0 beside
an expected pass reads as a disagreement to a human when it is not. A failed
call is marked as an error rather than shown as a verdict.

Nothing here starts a run except the button: a poll that could start one would
turn opening the tab into an LLM bill.

* feat(backend): pick the tuning run's judge model explicitly

A tuning run was reaching the SDK's built-in default, the hosted Rhesis LLM,
and dying on a 401. Seven fallbacks stood between "which model judges this?"
and an answer, and none of them announced itself.

The chain is now two steps and an error: the model saved on the metric, else
the model configured as the default for evaluation, else refuse the run with
a 400 before anything is written or queued. get_user_evaluation_model is
deliberately not used -- it conflates "the user configured a model" with "the
system has a default", which is the silent step being removed here.

A run scored by a judge nobody picked measures nothing and says nothing about
it: set the metric's model afterwards and every stored verdict silently refers
to a different judge.

* fix(backend): count a failed metric call as an errored case

invoke_metric_on_case only looked for a top-level "error" key, which the
shape it actually receives does not have. The SDK reports its own failures as
a result rather than by raising, the local strategy wraps that in
MetricResultBuilder.success(), and success() carries neither the SDK's
details["error"] nor an error key of its own.

So the failure was read as a verdict. A 401 against the judging model gave a
run of "1 cases, 0 errored": a categorical metric stored "error" as its
answer, a binary one stored "pass" -- an unreachable provider recorded as the
metric agreeing -- and a numeric one stored 0.0.

The score sentinel is now recognised, except where the metric declares
"error" as one of its own categories, and the reason the SDK writes is used
for the score types whose sentinel is an ordinary number. The tests build
their stub with the real builder, since stubbing the connector's shape
instead is what let this through.

* fix(backend): stop a tuning verdict being invented from a string

Two ways the metric's own answer was read as something it did not say.

A binary metric fell through to bool(score) for any string that was not
already "pass" or "fail", and every non-empty string is truthy -- so "no",
"false" and "0" all rendered as "pass". There is no binary judge in the SDK
factory, so a binary metric is backed by one that answers in categories and
this is reachable, not theoretical: a yes/no judge agreed with every case.
The word is now shown as the metric said it rather than guessed at.

The evaluation-model setting is parsed as a UUID by the settings accessor, so
a malformed stored value raises ValueError. The walk that reads it used
getattr, which only swallows AttributeError, and the router catches only
MetricModelNotConfigured -- so a broken setting escaped as a 500 from the one
function whose purpose is to refuse cleanly. It now refuses with the 400 it
was always meant to.
akwasigroch added a commit that referenced this pull request Aug 19, 2026
* feat(backend): run a metric over its tuning cases

The metric is invoked as the system under test: it receives the case payload
unpacked into the same arguments it gets in a real run, and never the expected
verdict. Routing it through normal metric evaluation would hand it the answer
key and make every agreement number meaningless without anything failing.

A run creates no rows in the execution tables. Per-case results go on the case's
test_metadata, the run summary on the tuning test set's attributes, and only the
latest run is kept. See ADR-0004.

A case whose metric call fails is recorded as errored and the run continues, so
a flaky provider never reads as a bad metric.

* test(backend): cover tuning runs

The load-bearing one is
test_the_metric_never_sees_the_expected_verdict: it asserts the three arguments
the evaluator received and that neither the verdict nor the reviewer's rationale
appears among them. Nothing else fails loudly if someone reconnects that wire.

The metric invocation and the Celery dispatch are both stubbed, so the whole
path is deterministic and free of LLM calls.

* feat(frontend): show what the metric said about each case

A Run metric control, polling while a run is in flight, and the metric's own
verdict and reasoning beside the verdict the author expected — which is the
whole point of a run.

A binary metric's verdict renders as pass/fail rather than 1.0, since 1.0 beside
an expected pass reads as a disagreement to a human when it is not. A failed
call is marked as an error rather than shown as a verdict.

Nothing here starts a run except the button: a poll that could start one would
turn opening the tab into an LLM bill.

* feat(backend): pick the tuning run's judge model explicitly

A tuning run was reaching the SDK's built-in default, the hosted Rhesis LLM,
and dying on a 401. Seven fallbacks stood between "which model judges this?"
and an answer, and none of them announced itself.

The chain is now two steps and an error: the model saved on the metric, else
the model configured as the default for evaluation, else refuse the run with
a 400 before anything is written or queued. get_user_evaluation_model is
deliberately not used -- it conflates "the user configured a model" with "the
system has a default", which is the silent step being removed here.

A run scored by a judge nobody picked measures nothing and says nothing about
it: set the metric's model afterwards and every stored verdict silently refers
to a different judge.

* fix(backend): count a failed metric call as an errored case

invoke_metric_on_case only looked for a top-level "error" key, which the
shape it actually receives does not have. The SDK reports its own failures as
a result rather than by raising, the local strategy wraps that in
MetricResultBuilder.success(), and success() carries neither the SDK's
details["error"] nor an error key of its own.

So the failure was read as a verdict. A 401 against the judging model gave a
run of "1 cases, 0 errored": a categorical metric stored "error" as its
answer, a binary one stored "pass" -- an unreachable provider recorded as the
metric agreeing -- and a numeric one stored 0.0.

The score sentinel is now recognised, except where the metric declares
"error" as one of its own categories, and the reason the SDK writes is used
for the score types whose sentinel is an ordinary number. The tests build
their stub with the real builder, since stubbing the connector's shape
instead is what let this through.

* fix(backend): stop a tuning verdict being invented from a string

Two ways the metric's own answer was read as something it did not say.

A binary metric fell through to bool(score) for any string that was not
already "pass" or "fail", and every non-empty string is truthy -- so "no",
"false" and "0" all rendered as "pass". There is no binary judge in the SDK
factory, so a binary metric is backed by one that answers in categories and
this is reachable, not theoretical: a yes/no judge agreed with every case.
The word is now shown as the metric said it rather than guessed at.

The evaluation-model setting is parsed as a UUID by the settings accessor, so
a malformed stored value raises ValueError. The walk that reads it used
getattr, which only swallows AttributeError, and the router catches only
MetricModelNotConfigured -- so a broken setting escaped as a 500 from the one
function whose purpose is to refuse cleanly. It now refuses with the 400 it
was always meant to.
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