Skip to content

LocalEvalService.evaluate adds rubrics to the caller's inference results, so scoring them again raises #7874

Description

@adityasingh2400

🔴 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:

  1. Add an eval case with a case-level rubric, then run perform_inference on it.
  2. Call evaluate on the inference results, with any metric.
  3. Call evaluate on 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 main at 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 with response_match_score needs 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))]  # ValueError

How often has this issue occurred?:

  • Always (100%), when the eval case or its expected invocations have rubrics

Activity

  1. added theissue type on Nov 2, 2026
  2. added
    eval[Component] This issue is related to evaluation
    on Nov 2, 2026
  3. surajksharma07 commented on Nov 5, 2026

    @surajksharma07
    Collaborator

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

  4. adityasingh2400 commented on Nov 5, 2026

    @adityasingh2400
    ContributorAuthor

    @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 each evaluate:

    1st evaluate on a fresh deep copy: PASSED | judged ['confirms_action', 'names_device']
    2nd evaluate on a fresh deep copy: PASSED | judged ['confirms_action', 'names_device']
    the original results still carry [[]]
    

    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 evaluate and 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:

    score saved_by_v2.13.0.json (carries [['names_device', 'confirms_action']]): raised ValueError: Rubric with rubric_id 'names_device' already exists.
    

    d3abf17 fixes that. Before attaching rubrics, _drop_rubrics_left_by_earlier_evaluate removes the ones on the copy that are identical to the rubrics about to be attached. All four files now pass:

    score saved_by_v2.13.0.json (carries [['names_device', 'confirms_action']]): PASSED | judged ['confirms_action', 'names_device']
    

    These runs score with rubric_based_final_response_quality_v1 and a fake judge, so the rubrics really do reach the metric, once each.

    Only exact copies are dropped:

    • A rubric with the same id and any changed field (text, description or type) still raises.
    • An id shared by an eval case and one of its turns still raises.
    • A rubric on an inference under any other id stays and gets scored.

    Two calls a reviewer may want to make, both only for files that already carry rubrics:

    • If any field of a rubric changed since the save, it counts as different, so evaluate raises and the run aborts instead of the current version winning.
    • A rubric removed or renamed since the save can't be told apart from one the caller added, so it stays and counts toward the score. On main, that file raised unless none of its rubrics get re-attached.

    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_twice scores 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_version does 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_scenario covers a case with no expected invocations.
    • test_evaluate_scores_saved_results_when_a_rubric_was_added_since covers 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_turn and test_evaluate_still_rejects_a_rubric_id_conflict pin the rules above.

    pytest tests/unittests/evaluation tests/unittests/optimization gives 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

eval[Component] This issue is related to evaluation

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions