Repository navigation
Description
🔴 Required Information
Describe the Bug:
LocalEvalService.evaluate modifies the inference results it is given. If you score the same results twice, the second call raises and aborts the whole run.
_evaluate_single_inference_result copies the eval case's rubrics, and each expected invocation's rubrics, onto inference_result.inferences. Those are the caller's own Invocation objects. A second evaluate over them finds the rubric ids already present, and _add_rubrics_to_invocation raises ValueError: Rubric with rubric_id '...' already exists.
That happens outside the per-metric error handling, so nothing is scored or saved. It happens for any metric, even ones that ignore rubrics such as response_match_score. Results serialized after a first evaluate carry the rubrics too, so they fail the same way.
This breaks the flow in the eval service guide's "Score a run again without rerunning it" section: keep a run and score it again later against a stricter threshold or an extra metric.
Steps to Reproduce:
- Add an eval case with a case-level rubric, then run
perform_inferenceon it. - Call
evaluateon the inference results, with any metric. - Call
evaluateon the same results again, e.g. with a stricter threshold.
Expected Behavior:
Both evaluate calls score the run, and the caller's inference results come back unchanged.
Observed Behavior:
1st evaluate: PASSED | caller's inference now carries rubrics: ['names_device']
2nd evaluate (same objects) raised: ValueError Rubric with rubric_id 'names_device' already exists.
evaluate on results serialized BEFORE first evaluate: PASSED
evaluate on results serialized AFTER first evaluate raised: ValueError Rubric with rubric_id 'names_device' already exists.
Environment Details:
- ADK Library Version (pip show google-adk): 2.13.0 (also on
mainat e9e8f61) - Desktop OS: macOS
- Python Version (python -V): 3.11.16
Model Information:
- Are you using LiteLLM: No
- Which model is being used: any (the repro uses a fake
BaseLlm; scoring withresponse_match_scoreneeds no model)
🟡 Optional Information
Minimal Reproduction Code:
mgr = InMemoryEvalSetsManager()
mgr.create_eval_set(app_name="app", eval_set_id="s1")
mgr.add_eval_case(app_name="app", eval_set_id="s1", eval_case=EvalCase(
eval_id="c1",
conversation=[Invocation(
user_content=types.Content(role="user", parts=[types.Part(text="Turn off the bedroom light.")]),
final_response=types.Content(role="model", parts=[types.Part(text="The bedroom light is off.")]))],
rubrics=[Rubric(rubric_id="names_device", rubric_content=RubricContent(text_property="Names the device."))]))
svc = LocalEvalService(root_agent=agent, eval_sets_manager=mgr)
infs = [r async for r in svc.perform_inference(
InferenceRequest(app_name="app", eval_set_id="s1", inference_config=InferenceConfig()))]
loose = EvaluateConfig(eval_metrics=[EvalMetric(metric_name="response_match_score", threshold=0.5)])
strict = EvaluateConfig(eval_metrics=[EvalMetric(metric_name="response_match_score", threshold=0.9)])
[r async for r in svc.evaluate(EvaluateRequest(inference_results=infs, evaluate_config=loose))]
[r async for r in svc.evaluate(EvaluateRequest(inference_results=infs, evaluate_config=strict))] # ValueErrorHow often has this issue occurred?:
- Always (100%), when the eval case or its expected invocations have rubrics
Activity
surajksharma07 commentedon Nov 5, 2026
@adityasingh2400 Reproduced it on current main (2.13.0): the first evaluate passes and the second one fails with “Rubric with rubric_id ‘names_device’ already exists”, because the rubrics end up on the caller’s own invocations.
Copying the inferences before the rubrics get attached, like you did in #7875, looks like the right direction. Until it lands, passing [r.model_copy(deep=True) for r in inference_results] into each evaluate works as a workaround on my side, so could you try that once on your setup too?
One case that might still fail: results already saved with rubrics by an older version would still raise on the copy. Could you check that, and maybe add a test that goes through the public evaluate() twice? Then I’ll flag the PR for review.
adityasingh2400 commentedon Nov 5, 2026
@surajksharma07 Thanks for checking this. You were right about the saved results. Here's what I found on each question.
Workaround. It works here too, on current main, as long as the results don't carry rubrics yet. With a fresh
r.model_copy(deep=True)for eachevaluate:A deep copy of results that already carry rubrics still raises on main.
Results saved by an older version. These did still raise on the PR. I ran
evaluateand then saved the results on v2.0.0, v2.12.0, v2.13.0 and current main, then scored each file on the branch. All four raised on 823bd23:d3abf17 fixes that. Before attaching rubrics,
_drop_rubrics_left_by_earlier_evaluateremoves the ones on the copy that are identical to the rubrics about to be attached. All four files now pass:These runs score with
rubric_based_final_response_quality_v1and a fake judge, so the rubrics really do reach the metric, once each.Only exact copies are dropped:
Two calls a reviewer may want to make, both only for files that already carry rubrics:
evaluateraises and the run aborts instead of the current version winning.Inference results saved after this fix carry no rubrics, so neither case applies to them. The guide already says the eval sets manager must still hold the same cases, so I kept this narrow. Clearing every rubric off the copy would be simpler and would always use the current eval case, but it would also silently drop rubrics a caller set by hand. It's a small change if you'd prefer that.
Tests. They now go through the public
evaluate():test_evaluate_can_score_the_same_inference_results_twicescores the same objects twice. It checks that the caller's results are unchanged and that the metric itself sees the rubrics.test_evaluate_scores_results_saved_with_rubrics_by_an_older_versiondoes a JSON round trip, with rubrics at case level, turn level, both, and a different rubric on each turn.test_evaluate_scores_saved_results_of_a_conversation_scenariocovers a case with no expected invocations.test_evaluate_scores_saved_results_when_a_rubric_was_added_sincecovers a file that holds only some of the rubrics.test_evaluate_keeps_an_inference_rubric_the_eval_case_does_not_add,test_evaluate_keeps_a_rubric_that_belongs_to_another_turnandtest_evaluate_still_rejects_a_rubric_id_conflictpin the rules above.pytest tests/unittests/evaluation tests/unittests/optimizationgives 1226 passed on the branch and 1255 passed merged onto current main. I also tried 22 mutations of the new code, such as removing a guard or matching rubrics by id only, and each one made a test fail.