[SDK] fix(evaluation): don't mutate the caller's scoring_metrics list - #7495
Merged
petrotiurin merged 1 commit intoJul 23, 2026
Merged
Conversation
_wrap_scoring_functions appended the wrapped scorer functions into the list the caller passed in. Reusing one scoring_metrics list across evaluate*() calls therefore accumulated a duplicate wrapper per call, so run N executed every scorer function N times. The else branch already rebinds rather than mutating; this makes the if branch consistent with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines
+2989
to
+2993
| assert [metric.name for metric in scoring_metrics] == ["equals_metric"] | ||
| assert [score.name for score in result.test_results[0].score_results] == [ | ||
| "equals_metric", | ||
| "custom_scorer", | ||
| ] |
Contributor
There was a problem hiding this comment.
Missing score-value assertions
evaluate_on_dict_items() is only checked for result names and scoring_metrics immutability, so regressions in metrics.Equals() or custom_scorer() would still pass as long as the shape matches — should we assert the ScoreResult.value fields too, e.g. both 1.0 for the matching output/expected_output case?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/unit/evaluation/test_evaluate.py around lines 2964-2993 in
`test_evaluate_on_dict_items__scoring_metrics_list_is_not_mutated`, extend the
assertions after the `evaluation.evaluate_on_dict_items(...)` call to also verify the
`ScoreResult.value` fields. Specifically, assert that the equals metric score value is
`1.0` for the matching `output`/`expected_output` and that the `custom_scorer` score
value is also `1.0` (or the exact value your scorer returns). Implement the assertions
by locating score results by `score.name` (or otherwise ensuring the correct pairing) so
the test validates scoring correctness rather than only result shape/name ordering.
petrotiurin
approved these changes
Jul 23, 2026
petrotiurin
left a comment
Contributor
There was a problem hiding this comment.
Thank you for spotting this bug and for fixing it @chuenchen309 !
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.
Details
_wrap_scoring_functions(evaluator.py:1726) appends the wrapped scorer functions into the list the caller passed in:When a user passes both
scoring_metricsandscoring_functionsand reuses the metrics list across calls — the natural way to write it — each call appends another wrapper. Run N executes every scorer function N times:That means N duplicate
ScoreResults per item, N duplicate feedback scores written to the backend, N× the LLM cost for an LLM-backed scorer function, and a skewedaggregate_evaluation_scores()(thevalueslist is padded with duplicates, so the count andstdare wrong). The user's own list is left mutated afterwards too.The
elsebranch already rebinds instead of mutating, so this just makes theifbranch consistent with it.Reachable from every public entry point that takes both arguments:
evaluate(),evaluate_experiment(),evaluate_prompt(),evaluate_optimization_trial(),evaluate_resume(),evaluate_on_dict_items().Change checklist
Issues
No existing issue — happy to open one if the team prefers that order for small SDK fixes.
Testing
Added
test_evaluate_on_dict_items__scoring_metrics_list_is_not_mutated, which reuses onescoring_metricslist across twoevaluate_on_dict_itemscalls and asserts both that the list is untouched and that the second run scores each metric once.Verified it fails before the fix (
assert ['equals_metric', 'custom_scorer', 'custom_scorer'] == ['equals_metric']) and passes after.Existing
scoring_functionscoverage attest_evaluate.py:2905only passesscoring_functionswithout a concurrent non-emptyscoring_metrics, so it takes the safeelsebranch and never exercised this path.tests/unit/evaluation/— 744 passed, matching the pre-change baseline of 743 plus the new test. The 48 failures in that directory are identical before and after (diffed the failure lists item by item); they're pre-existing litellm-related environment failures, unrelated to this change.ruff checkandruff format --checkclean.Documentation
No documentation changes needed — this restores the documented behaviour rather than changing it.
AI-WATERMARK: yes
Tools: Claude Code
Model(s): Claude Opus 4.8
Scope: Bug located with AI assistance and the fix drafted with it. I reproduced the duplication myself with a standalone script before writing anything, traced the mechanism to
evaluator.py:1726by reading the source, confirmed no open PR touches this function, and ran the tests and baseline comparison above myself.