Repository navigation
Description
🔴 Required Information
Describe the Bug:
HallucinationsV1Evaluator._evaluate_nl_response (hallucinations_v1.py) averages whatever labels _parse_validation_results returns, without checking them against the segmented sentences, and drops unknown labels. A sentence left without a usable label vanishes from the score. Three cases:
- the validator omits a sentence;
- it writes
`contradictory`(the prompt shows labels in backticks); - it omits one block's
contradicting_excerpt:line, and theDOTALLregex folds the next block into it, losing its label.
rubric_based_evaluator.py already refuses partial parses for this reason.
Steps to Reproduce:
- Check out
main(766275863d1e80489344f0fbe5b4bfa3b933a9a9). - Run the script below. It uses a scripted
BaseLlmjudge registered throughLLMRegistry, with no network calls. The segmenter returns 2 sentences, and the validator labels S1supportedand S2contradictory.
Expected Behavior:
Score 0.5, FAILED at threshold 0.8 (what the complete reply gives), or NOT_EVALUATED when a sentence has no usable label.
Observed Behavior:
Score 1.0, PASSED in all three cases.
Environment Details:
- ADK Library Version: 2.13.0 (source at the SHA above)
- Desktop OS: Windows 11
- Python Version: 3.13.5
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A (scripted judge)
🟡 Optional Information
Minimal Reproduction Code: repro_hallucinations_partial_parse.py:
"""Offline repro: HallucinationsV1Evaluator scores a validator response that
leaves sentences without a usable label as if those sentences did not exist.
A scripted judge (BaseLlm subclass, registered via LLMRegistry) answers the
segmenter with two sentences and the validator with a fixed text. No network.
"""
from __future__ import annotations
import asyncio
import re
from typing import AsyncGenerator, ClassVar
import google.adk
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric, HallucinationsCriterion, JudgeModelOptions
from google.adk.evaluation.hallucinations_v1 import HallucinationsV1Evaluator
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.models.registry import LLMRegistry
from google.genai import types
print("google.adk from:", google.adk.__file__)
S1 = "Paris is the capital of France."
S2 = "The Eiffel Tower is 50 meters tall."
SEGMENTS = f"<sentence>{S1}</sentence>\n<sentence>{S2}</sentence>"
VALIDATOR_REPLIES = {
# Control: both blocks complete -> expected 0.5.
"complete (control)": (
f"sentence: {S1}\nlabel: supported\nrationale: in context\n"
"supporting_excerpt: Paris is the capital\ncontradicting_excerpt: null\n"
f"sentence: {S2}\nlabel: contradictory\nrationale: context says 330 m\n"
"supporting_excerpt: null\ncontradicting_excerpt: 330 meters tall"
),
# Judge leaves out the contradicting_excerpt line on a `supported` block.
"S1 block lacks contradicting_excerpt line": (
f"sentence: {S1}\nlabel: supported\nrationale: in context\n"
"supporting_excerpt: Paris is the capital\n"
f"sentence: {S2}\nlabel: contradictory\nrationale: context says 330 m\n"
"supporting_excerpt: null\ncontradicting_excerpt: 330 meters tall"
),
# Judge reports only one of the two sentences.
"S2 omitted by validator": (
f"sentence: {S1}\nlabel: supported\nrationale: in context\n"
"supporting_excerpt: Paris is the capital\ncontradicting_excerpt: null"
),
# Judge copies the backticks the prompt itself puts around label names.
"S2 label written as `contradictory`": (
f"sentence: {S1}\nlabel: supported\nrationale: in context\n"
"supporting_excerpt: Paris is the capital\ncontradicting_excerpt: null\n"
f"sentence: {S2}\nlabel: `contradictory`\nrationale: context says 330 m\n"
"supporting_excerpt: null\ncontradicting_excerpt: 330 meters tall"
),
}
class ScriptedJudge(BaseLlm):
validator_reply: ClassVar[str] = ""
@classmethod
def supported_models(cls) -> list[str]:
return [r"scripted-judge"]
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
prompt = llm_request.contents[-1].parts[0].text
text = SEGMENTS if "segment the response into sentences" in prompt else self.validator_reply
yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text=text)]))
LLMRegistry.register(ScriptedJudge)
invocation = Invocation(
user_content=types.Content(role="user", parts=[types.Part(text="Tell me about Paris.")]),
final_response=types.Content(role="model", parts=[types.Part(text=f"{S1} {S2}")]),
)
metric = EvalMetric(
metric_name="hallucinations_v1",
criterion=HallucinationsCriterion(
threshold=0.8, judge_model_options=JudgeModelOptions(judge_model="scripted-judge")
),
)
async def main() -> None:
for name, reply in VALIDATOR_REPLIES.items():
ScriptedJudge.validator_reply = reply
result = await HallucinationsV1Evaluator(metric).evaluate_invocations([invocation])
print(f"{name:45} score={result.overall_score} status={result.overall_eval_status.name}")
asyncio.run(main())Output:
complete (control) score=0.5 status=FAILED
S1 block lacks contradicting_excerpt line score=1.0 status=PASSED
S2 omitted by validator score=1.0 status=PASSED
S2 label written as `contradictory` score=1.0 status=PASSED
How often has this issue occurred?: Always (100%), for validator replies of these shapes.
Activity
shwetangsinha0509 commentedon Nov 3, 2026
Fix in #7967: a response is no longer scored when the validator leaves any segmented sentence without a usable label (same approach as rubric_based_evaluator's partial-parse check), and backticked labels are now accepted.
surajksharma07 commentedon Nov 4, 2026
@gaurav-gandhi-2411 Reproduced all three cases on 2.13.0: each one scores 1.0 and passes when it should fail.
As a workaround until #7967 lands, try wrapping _parse_validation_results so it strips backticks from labels and returns nothing when the labelled sentences don’t match the segmented ones. That way the response shows as NOT_EVALUATED instead of being scored on a subset. In my local run this gave 0.5 FAILED for the backtick case and NOT_EVALUATED for the other two, and a fully supported reply still passed.
One gap I noticed: an unscored step is still dropped when steps are averaged, so with multiple steps the remaining ones can still decide the result. @shwetangsinha0509 might be worth covering that in #7967 too.
Could you check whether this behaves as expected with a real judge on your evals and test it thoroughly before anything further goes into a PR?
shwetangsinha0509 commentedon Nov 4, 2026
@surajksharma07 thanks, good catch. Covered in #7967: if any step of an invocation can't be scored, the invocation is now NOT_EVALUATED instead of being averaged over the remaining steps, and the rest of its steps aren't sent to the judge. This also applies when a step fails because of a judge error, so I updated
test_evaluate_invocations_partial_failure, which expected the old behaviour, and noted the change in the PR description.