fix(metrics): guard reward reads against a non-dict rewards field - #1096
fix(metrics): guard reward reads against a non-dict rewards field#1096Galius5136 wants to merge 1 commit into
Conversation
result.json is read back as a raw json.loads payload with no shape validation, so `rewards` can be any JSON value. benchflow-ai#1054 fixed the read in evaluation.py; metrics.py and skill_eval/_core.py were the last two sites in src/ that still did member access on that field unguarded. - metrics.py:364 raised AttributeError outside the enclosing try, so one malformed artifact killed `bench eval metrics` for the whole results directory. Now routed through _utils.scoring.extract_reward, the canonical total accessor: a non-mapping reads as no reward and the task lands in errored, the same bucket benchflow-ai#1054 gave the identical payload in `bench eval run`. - _safe_reward raised the same AttributeError inside the selection loop's `except Exception`, which dropped the well-formed retry being compared and let the malformed artifact win the best-result pick. - skill_eval/_core.py:741 aborted `bench skills eval` the same way; its handler catches only (json.JSONDecodeError, KeyError). Guarded with isinstance rather than extract_reward so the existing next(iter(rewards.values()), None) fallback keeps working. 11 regression cases, red on 3b9dd06 and green with the fix; each source hunk is individually pinned by mutation.
There was a problem hiding this comment.
Devin Review found 1 potential issue.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| val = rewards.get("reward") if isinstance(rewards, dict) else None | ||
| return val if isinstance(val, (int, float)) else 0.0 |
There was a problem hiding this comment.
🟡 Zero-score retries remain hidden
When malformed data precedes a valid zero-score retry, _safe_reward ties them. Metrics retain the malformed attempt and report an error.
Prompt for agents
Update collect_metrics best-result selection in src/benchflow/metrics.py so malformed non-mapping rewards rank as unscored, while a valid numeric reward, including 0.0, replaces them. Preserve deterministic tie handling for equivalent valid results and decide explicitly how empty or nonnumeric reward mappings rank. Add regression coverage where the malformed artifact sorts first and the valid retry has reward 0.0.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is a known follow-up and is intentionally out of scope for this crash fix.
Handling the malformed-first / valid-0.0 retry case requires changing the best-result selection semantics, including an explicit policy for empty and non-numeric reward mappings. This PR deliberately preserves the existing deterministic tie behavior for well-formed inputs and only makes persisted non-mapping rewards safe to read.
I've documented this case in the Known follow-up section of the PR description; I'd prefer to address the ranking semantics separately rather than bundle that policy change into the shape-guard fix.
|
Human validation completed on the PR head ( Validated manually:
The artifact was restored afterward. So the
|
#1054 fixed
_classify_completed_outcomes, sobench eval runnow survives aresult.jsonwhoserewardsis not a mapping. The same payload still killsbench eval metrics, and it abortsbench skills evaltoo. This closes both.Reproduced on
3b9dd067(main with #1054 merged), with #1054's own repro file:Root cause
result.jsonis read back as a rawjson.loadspayload with no shapevalidation, so
rewardscan be any JSON value.r.get("rewards", {})suppliesthe
{}default only when the key is absent; when the key is present with anon-mapping value, the
.getchain is applied to that value. The trailingif r.get("rewards")guards falsiness, not shape.The repo already has the total accessor for this --
_utils/scoring.py:111extract_reward, which returnsNonefor any non-mappingrewards. Everyreader of a persisted
rewardsinsrc/is isinstance-guarded already(
review/runner.py:169,eval_lift.py:303,export_prime_sft.py:212,viewer/payload.py:424,eval_artifacts.py:170,traj_capture.py:789,loop_strategies.py:251, and since #1054evaluation.py:435). These two werethe last that were not.
What changes
metrics.py:364-- the crash.reward = extract_reward(r). A non-mappingrewardsnow reads as no reward, soTaskMetrics.score_outcomeroutes the taskto
erroredthroughclassify_score_outcome-- the same classifier, and thesame bucket, that #1054 gave the identical payload in
bench eval run. Thefailing line sits outside the enclosing
try, which is why one malformedartifact took down the aggregation for the whole results directory rather than
just that file.
metrics.py_safe_reward-- the same unguarded access, one level up in thebest-result selection, where it is swallowed rather than raised: it throws
inside the enclosing
except Exception, which then logs "Skipping corruptresult file" for the file being compared -- i.e. drops the well-formed retry
and lets the malformed artifact win the pick. Guarding it restores
tests/test_scoring.py::test_malformed_rewards_shape_tolerated's invariant("a malformed entry must not shadow a well-formed one") for
collect_metrics.skill_eval/_core.py:741--if rewards:->if isinstance(rewards, dict):.Same failure mode, reached from
bench skills eval; the enclosing handlercatches only
(json.JSONDecodeError, KeyError), so theAttributeErrorpropagates out of
_run_job.extract_rewardis deliberately not used herebecause it would drop the existing
next(iter(rewards.values()), None)fallbackand change
{"score": 0.7}from0.7toNone. The isinstance guard keepsthat behaviour exactly.
What does not change
Measured A/B over every well-formed shape, both modules:
rewardscollect_metrics_run_job{"reward": 1.0}1.0, passed1.0{"reward": 0.0}0.0, failed0.0{"score": 0.7}0.7(fallback preserved)nullNone, erroredNone{}None, erroredNoneIdentical before and after. The only inputs whose behaviour changes are the ones
that used to raise.
Tests
11 new cases, all red on
3b9dd067with the source change reverted and greenwith it:
test_metrics.py::test_collect_metrics_non_dict_rewards_counts_as_errored-- 5 payloads (
1.0,1,true,[1.0],"1.0")test_metrics.py::test_collect_metrics_non_dict_rewards_does_not_shadow_a_scored_retry-- malformed + well-formed for one task; asserts the well-formed artifact is
the one kept, not just its reward
test_skill_eval.py::TestSkillEvaluatorResultCollection::test_non_dict_rewards_does_not_crash_collection-- same 5 payloads, using the class's existing
monkeypatch.setattr("benchflow.evaluation.Evaluation.run", ...)idiom, so nodocker and no provider key
Per-hunk mutation: each of the three source hunks reverted on its own is caught
by its own test, disjointly (3/3), so no hunk is unpinned and no test is
redundant.
Verification
uv run ruff check src tests tools: All checks passed!uv run ruff format --check src tests tools: 617 files already formatteduv run ty check: All checks passed!1 failed, 5975 passedwith the change vs1 failed, 5964 passedon the untouched baseline -- +11 is exactly the new cases, and there are no
head-only failures. The single failure,
test_integration_check_results.py::test_check_results_accepts_symlinked_current_repo_inferred_source,is pre-existing and reproduces identically on the untouched baseline tree.
bench eval metricson a jobs dir whoseresult.jsoncarries"rewards": 1.0-- traceback and exit 1 before, normal table withErrored 1and exit 0 after.
Known follow-up, deliberately not in this PR
With the guard in place a malformed artifact scores
0.0in the selection, soit can still tie out a well-formed artifact whose reward is
0.0-- andcollect_metricskeeps the first-seen artifact on a tie, which is the module'sown determinism rule (
test_collect_metrics_best_result_picking: "Both errored(no rewards): first seen is kept"). Fixing that means making clause 2 of the
selection predicate (
r.get("rewards") is not None) shape-aware, which is asemantic change rather than a guard:
is not Noneandisinstance(..., dict)differ on
rewards: {}, and truthiness differs again in clause 3, so a naiveswap would change which artifact wins for well-formed inputs. Happy to send it
separately if you want it.
This finishes the read path #1054 started: same payload, same classification,
the two sites it did not touch.