Skip to content

hallucinations_v1 scores only the sentences it could parse, so one unparsed block hides a contradiction #7963

Description

@gaurav-gandhi-2411

🔴 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 the DOTALL regex folds the next block into it, losing its label.

rubric_based_evaluator.py already refuses partial parses for this reason.

Steps to Reproduce:

  1. Check out main (766275863d1e80489344f0fbe5b4bfa3b933a9a9).
  2. Run the script below. It uses a scripted BaseLlm judge registered through LLMRegistry, with no network calls. The segmenter returns 2 sentences, and the validator labels S1 supported and S2 contradictory.

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

  1. shwetangsinha0509 commented on Nov 3, 2026

    @shwetangsinha0509

    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.

  2. surajksharma07 commented on Nov 4, 2026

    @surajksharma07
    Collaborator

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

  3. shwetangsinha0509 commented on Nov 4, 2026

    @shwetangsinha0509

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

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

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions