Skip to content

fix(scorer): mark model_graded grade-parse failure as unscored (not INCORRECT)#4048

Merged
dragonstyle merged 9 commits into
UKGovernmentBEIS:mainfrom
vladmesh:fix/scorer-model-graded-noanswer
Jul 7, 2026
Merged

fix(scorer): mark model_graded grade-parse failure as unscored (not INCORRECT)#4048
dragonstyle merged 9 commits into
UKGovernmentBEIS:mainfrom
vladmesh:fix/scorer-model-graded-noanswer

Conversation

@vladmesh

@vladmesh vladmesh commented May 26, 2026

Copy link
Copy Markdown
Contributor

This PR contains:

  • New features
  • Changes to dev-tools e.g. CI config / github tooling
  • Docs
  • Bug fixes
  • Code refactor

What is the current behavior? (You can also link to an open issue here)

Closes #4026.

When the judge's output does not match DEFAULT_GRADE_PATTERN (or a user-supplied grade_pattern), the else branch in model_graded_qa / model_graded_fact (src/inspect_ai/scorer/_model.py) returns Score(value=INCORRECT, ...). This conflates "the judge said the answer was incorrect" with "we couldn't extract a verdict from the judge", making the two indistinguishable in the score distribution. When small judge models emit variants like GRID: C, ANSWER: C or **Answer: C** instead of GRADE: C, those samples are silently scored INCORRECT even when the judge clearly decided the answer was correct, so accuracy is understated with no signal that anything went wrong.

What is the new behavior?

The parse-failure branch now returns Score.unscored(...) instead of Score(value=INCORRECT, ...), tagged with metadata["unscored_reason"] = "grade_parse_failure".

This follows the layering distinction @MattFisher raised in the issue and PR threads. pattern() and math() score the output of the Model Under Test, so NOANSWER is the right sentinel there. model_graded_* is different: the grader model is scoring instrumentation, not the thing under test. A grade-parse failure is the instrument failing, not the subject declining to answer, so recording it as NOANSWER would still attribute a "no answer" to the subject that it may not have produced. Score.unscored() (NaN) is the framework's existing sentinel for "a scorer cannot produce a value for this sample": the sample is dropped from aggregate metrics and surfaced separately as an unscored sample, with unscored_reason available for downstream filtering and for reporting the judge-parse-failure rate.

This supersedes the original approach in this PR, which returned NOANSWER. See the #4026 thread for the discussion that moved it to unscored().

Does this PR introduce a breaking change? (What changes might users need to make in their application due to this PR?)

Yes, a metric-level one. Previously judge-parse failures were counted as INCORRECT (mapped to 0.0 by value_to_float), so they stayed in the denominator. They are now unscored (NaN), excluded from aggregate metrics and reported separately as unscored samples. Evals that previously hit judge-parse failures will see those samples leave the denominator, so reported accuracy can move (typically up, since genuinely-correct-but-unparseable grades are no longer counted as wrong). Any code that inspects the raw Score.value for these samples needs to handle NaN rather than "I".

Tests

  • tests/scorer/test_model_graded.py::test_grade_parse_failure_is_unscored — parametrised over four representative malformed grader outputs (GRID: C, ANSWER: C, **Answer: C**, and a completion with no grade marker); asserts Score.value is NaN and metadata["unscored_reason"] == "grade_parse_failure".
  • tests/scorer/test_model_graded.py::test_matched_grade_resolves_to_value — symmetry check that a parseable GRADE: C / GRADE: I / GRADE: P still resolves to CORRECT / INCORRECT / PARTIAL, so the matched-grade branch is undisturbed.
  • test_model_graded_answer_set_on_grade_parse_failure (from model_graded_fact: answer field empty when judge GRADE parsing fails despite correct completion #4025) updated to assert the parse-failure sample is unscored (NaN) while Score.answer still carries the subject completion.

Other information:

@avalyset avalyset left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Change looks right to me. Returning NOANSWER from the parse-failure branch lines up with the existing pattern() convention in _pattern.py:101, and the distinction between "judge couldn't be parsed" and "judge said incorrect" is exactly what the aggregate needs to stay visible — losing it silently understates accuracy in a way that's hard to notice downstream.

One non-blocking observation on the test side: the parse-failure path is well covered by the four parametrised cases, but there's no companion assertion that a valid GRADE: I still resolves to INCORRECT. Adding that would make the suite symmetric and catch any future drift in the matched-grade branch. Happy either way — fine to leave as-is.

…parser fails

When the judge's output does not match DEFAULT_GRADE_PATTERN (or a
user-supplied grade_pattern), model_graded_qa / model_graded_fact
previously returned value=INCORRECT in the else-branch of _model.py.
"Couldn't extract a verdict" and "verdict is incorrect" then became
indistinguishable in the score distribution, silently inflating the
INCORRECT count in aggregate metrics when judges flubbed the grade
prefix word (e.g. GRID:, GRAND, ANSWER:, **Answer: C**).

This matches the convention already used by pattern() in _pattern.py:
when its regex fails to match, it returns NOANSWER rather than
INCORRECT. value_to_float() maps both NOANSWER and INCORRECT to 0.0,
so accuracy aggregations are unchanged; the difference is that
judge-parse failures are now distinguishable from real INCORRECTs.

Closes UKGovernmentBEIS#4026.

Co-authored-by: Yana Bodrasheva <bodraski@gmail.com>
Co-authored-by: Elena Arseneva <ea.arseneva@gmail.com>
Co-authored-by: Anton Gornakov <gornakovanton@gmail.com>
@vladmesh
vladmesh force-pushed the fix/scorer-model-graded-noanswer branch from 6ba5fcc to 7cf102a Compare May 28, 2026 18:30

@avalyset avalyset left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the parametrized test cases and the symmetry assertion — that closes the observability gap cleanly. Good call flagging _math.py for a follow-up; the pattern is identical and antnewman's audit on #4026 backs that scope. This looks ready from my end.

@MattFisher

Copy link
Copy Markdown
Contributor

We're currently addressing similar circumstances in inspect_evals, and I'd suggest this could be a good place to use Score.unscored().

Use this when a scorer cannot produce a value for a sample but you still want to record context (answer, explanation, metadata).

@avalyset

Copy link
Copy Markdown
Contributor

The unscored() suggestion is worth weighing carefully here, because it and NOANSWER resolve two different situations and the distinction matters for what the metric reports.

unscored() sets value to NaN, which is filtered out upstream (in _eval/task/results.py, before the metric runs) and reported separately as unscored_samples — so it's excluded from both numerator and denominator. NOANSWER maps to 0 in value_to_float and stays in the denominator as a counted, non-answered outcome.

For a grader that fails to emit a parseable verdict, that difference is the whole question: unscored() removes the observation from the aggregate, which hides the grader-failure rate; NOANSWER keeps it visible as a counted miss. In a safety-eval setting, surfacing how often the grader itself fails seems like the property you want to preserve, not drop. That's also consistent with _pattern.py, which already returns NOANSWER on a non-match rather than excluding the sample.

unscored() looks like the right tool for the case where no score is meaningfully possible (e.g. _multi.py when all sub-scorers abstain), rather than for a grader parse failure where the sample did get graded and the grader simply returned something unparseable.

@vladmesh

vladmesh commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @MattFisher. unscored() is the right call when no score is possible. Here I still lean NOANSWER: it keeps the parse-failed sample in the denominator as a non-correct outcome, distinguishable from a real INCORRECT, whereas unscored() drops it from the rate. For a safety eval I'd rather a judge-parse-failure rate stay visible than have samples quietly leave the denominator. It also keeps the scorers aligned: pattern() already returns NOANSWER _pattern.py:101) and #4091 did the same for math(). Also, this doesn't move headline accuracy, since value_to_float maps both to 0.0; the win is they stop being indistinguishable downstream.
I'd really love to hear the maintainers' take here

@MattFisher

MattFisher commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

We need to be a bit careful here - the pattern scorer returns NO_ANSWER when the output of the Model Under Test (MUT) doesn't conform to the requested pattern, whereas this PR is dealing with the output of the Grader Model not conforming to the requested pattern.

In this case, the Grader model is effectively part of the scoring instrumentation, and a failure by the instrumentation should not be recorded as "No answer" from the MUT - the MUT may have returned a correct or incorrect answer or no answer at all, but the scoring instrumentation failed.

4091's proposed change to math() is also around the MUT output, not the grader (and is not yet accepted).

If the crucial use case here is the judge failure rate not being sufficiently obvious when unscored is used, perhaps we can find a way to surface that information more prominently? E.g. the --score-on-error option changes how samples that raise errors are scored, maybe something analogous could be done for judge failures?

@MattFisher

Copy link
Copy Markdown
Contributor

For reference, we're doing a bunch of analysis on this issue for inspect_evals that was prompted by a number of PRs that had different opinions - (#1681 swe_bench, #1679 kernelbench/osworld, #1655 vimgolf/agentharm, #1671 cybergym).

Use Score.unscored() instead of NOANSWER on grade-regex miss: a judge
parse failure is an instrumentation failure, not a model-under-test
outcome, so it leaves the rate and is surfaced via unscored_samples.
Tag unscored_reason=grade_parse_failure to keep it distinguishable.
@vladmesh

Copy link
Copy Markdown
Contributor Author

Thanks @MattFisher, agreed. Pushed a variant Score.unscored() tagged with unscored_reason="grade_parse_failure", so it leaves the rate but stays visible. Heads up that this shifts the accuracy denominator vs today.

@dragonstyle dragonstyle self-assigned this Jun 25, 2026
@vladmesh vladmesh changed the title fix(scorer): return NOANSWER (not INCORRECT) when model_graded grade parser fails fix(scorer): mark model_graded grade-parse failure as unscored (not INCORRECT) Jul 5, 2026
@dragonstyle
dragonstyle enabled auto-merge (squash) July 7, 2026 19:38
auto-merge was automatically disabled July 7, 2026 19:53

Head branch was pushed to by a user without write access

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.

model_graded_qa / model_graded_fact silently default to INCORRECT when the judge's verdict doesn't match the grade regex

4 participants