Skip to content

[Feature] Move hallucination detection into a response-stage signal - #3531

Open
subin9 wants to merge 2 commits into
vllm-project:mainfrom
subin9:feat/response-stage-hallucination
Open

[Feature] Move hallucination detection into a response-stage signal#3531
subin9 wants to merge 2 commits into
vllm-project:mainfrom
subin9:feat/response-stage-hallucination

Conversation

@subin9

@subin9 subin9 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Related #3272

#3335 has landed, so this is one commit rebased onto main.

Purpose

The hallucination half of #3272, on the machinery #3335 introduced.
routing.signals.hallucination declares the observation, it is scored once per response from
the selected recipe's rules before any plugin runs, and the hallucination plugin of the
selected decision only enforces on it. An answer that could not be checked is unavailable
rather than clean: hallucination_context_unavailable without grounding context,
hallucination_evaluation_failed when the detector failed or was never provisioned.

Two things a reviewer should weigh. Declaring a rule provisions the detector even when no
decision enables the plugin, so an eligible response pays one inference wherever a rule is
declared. A configuration with no rule keeps the plugin-owned path, reported at load.

Test Plan

  • make e2e-test E2E_PROFILE=hallucination
  • unit and lint through make agent-ci-lint AGENT_BASE_REF=origin/main
  • make test-semantic-router

Test Result

Local gates pass on the rebased head, and the router unit suite reports no failures.
TestHallucinationSignalReadsTheSelectedRecipeRules fails when the rules come from the root
config instead of the selected recipe, and TestHallucinationSignalDrivesTheSelectedDecisionPlugin
asserts the detector runs exactly once against a real endpoint server. The hallucination E2E
profile was not run locally, so its lane on this head is the first evidence for it.

Not covered here

Streaming responses are still not scored, for this signal or for the response jailbreak signal
in #3335.

The hallucination E2E profile moves from selection: manual to selection: pr. Its first run
OOMKilled the router during fact-check initialization under the 2Gi limit the profile carried
while it was manual, so it now asks for 8Gi, next to the 6Gi jailbreak-onerror needs for the
embedding load alone. The detector under test is remote, but the router still loads the startup
embedding model and the mmBERT-32K fact-check classifier.

The detector reads an answer only as far as its 512-token window reaches, and a longer answer is
reported as clean rather than unverified. That is the behaviour on main, where the plugin owns
detection, and this PR moves the call without changing it. Measured on main with the real model:
with the false sentence at the end of the answer, detection holds at 433 words and is lost at 553. It is a per-surface fix in the shape of #3206, #3334 and #3369, so it belongs in its
own issue rather than here.

@netlify

netlify Bot commented Sep 6, 2026

Copy link
Copy Markdown

Deploy Preview for vllm-semantic-router ready!

Name Link
🔨 Latest commit a340a16
🔍 Latest deploy log https://app.netlify.com/projects/vllm-semantic-router/deploys/6a9ee860de2f66000896921d
😎 Deploy Preview https://deploy-preview-3531--vllm-semantic-router.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov-commenter

codecov-commenter commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 34.17%. Comparing base (aff932e) to head (c95a535).
⚠️ Report is 233 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3531      +/-   ##
==========================================
+ Coverage   33.93%   34.17%   +0.24%     
==========================================
  Files          20       20              
  Lines        2959     2888      -71     
==========================================
- Hits         1004      987      -17     
+ Misses       1849     1795      -54     
  Partials      106      106              
Flag Coverage Δ
operator 34.17% <ø> (+0.24%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@subin9
subin9 force-pushed the feat/response-stage-hallucination branch from 0a36f56 to 47c3070 Compare September 6, 2026 16:36

@Xunzhuo Xunzhuo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the hallucination-only commit on top of #3335. The response observation is recipe-scoped, runs once before the selected plugin enforces, and keeps missing context or detector failure distinct from a clean result; the focused unit and E2E coverage matches that lifecycle. I do not see a new blocker in this delta. This remains gated on #3335 landing, a rebase to current main, and the remaining exact-head hallucination and operator lanes completing.

@github-actions github-actions Bot added pr/needs-rebase Needs rebase or conflict resolution. wg/mom-routing Owned by the MoM and Routing Workgroup. pr/blocked Blocked on a named decision, dependency, or required check. and removed pr/needs-rebase Needs rebase or conflict resolution. labels Sep 6, 2026

@FAUST-BENCHOU FAUST-BENCHOU left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls resolve conflicts

@subin9

subin9 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Working on my final check matching with jailbreak :)

@subin9
subin9 force-pushed the feat/response-stage-hallucination branch from 47c3070 to 7efd9fe Compare September 7, 2026 03:31
@github-actions github-actions Bot added pr/needs-review Ready for reviewer attention. and removed pr/blocked Blocked on a named decision, dependency, or required check. labels Sep 7, 2026
@subin9
subin9 requested a review from Xunzhuo September 7, 2026 04:38
@subin9
subin9 force-pushed the feat/response-stage-hallucination branch from 7efd9fe to c95a535 Compare September 7, 2026 05:02
@subin9
subin9 requested a review from FAUST-BENCHOU September 7, 2026 06:19
@github-actions github-actions Bot added pr/blocked Blocked on a named decision, dependency, or required check. and removed pr/needs-review Ready for reviewer attention. labels Sep 7, 2026

@Xunzhuo Xunzhuo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we send this request through MoM or auto here? openai/gpt-oss-20b is a concrete backend model, so it intentionally bypasses recipe-local signals, decisions, and plugins. The exact-head hallucination lane demonstrates the result: all five cases return empty decision, warning, and matched-hallucination headers. As written, the newly required E2E cannot exercise the signal/plugin path. Please switch it to a routing entrypoint and get this lane green.

@subin9
subin9 force-pushed the feat/response-stage-hallucination branch from c95a535 to a467e4b Compare September 7, 2026 13:58
@github-actions github-actions Bot removed the pr/blocked Blocked on a named decision, dependency, or required check. label Sep 7, 2026
@github-actions github-actions Bot added the pr/needs-author Waiting for author changes or response. label Sep 7, 2026
The hallucination plugin owned detection: the answer was only checked where
the plugin was enabled, the result was visible to that plugin alone, and a
check that failed or could not run looked the same as a clean answer. This is
the hallucination half of vllm-project#3272, on the stage machinery the response-direction
jailbreak rule introduced.

- routing.signals.hallucination declares the observation (name, description,
  use_nli). It is response-stage by type, so a decision that reads it,
  directly or through a projection, is rejected at load by the same validator
  that rejects a response-direction jailbreak rule. The detector's threshold
  and span filters stay on hallucination_model; a rule matches when the
  detector found an unsupported span, and use_nli decides whether the spans
  come with NLI explanations.
- The signal is scored once per response from the selected recipe's rules,
  before any plugin runs, under hallucination:<name>: detected or
  not_detected with the detector's confidence; unavailable as
  hallucination_context_unavailable when the answer had no grounding context
  to be checked against (the unverified-factual case) and as
  hallucination_evaluation_failed when the detector failed, was never
  provisioned, or the response carried no text. When the request-stage
  fact-check signal said the prompt needs no grounding the rule is left
  unpublished.
- The hallucination plugin of the decision selected for the request consumes
  it: hallucination_action on a match, unverified_factual_action on the
  context code, nothing otherwise, and only when the plugin is enabled. A
  configuration with no rule keeps the plugin-owned path and is reported at
  load, as is a plugin use_nli that a declared rule now overrides. Each
  detector runs once.
- Declaring a rule provisions the detector for the recipe, and use_nli the
  explainer, even when no decision enables the plugin.
- Router Replay gets one outcome per rule (verdict, confidence, span count,
  plugin action, or not_applicable with its reason), recorded before a block
  returns. With x-vsr-debug, x-vsr-matched-hallucination names the matched
  rules in the body phase.
- The CLI schema, DSL compiler and decompiler, canonical config, dashboard
  (signal type, form, topology, DSL schema), reference config and fragment,
  and the tutorial carry the family. The hallucination E2E profile declares
  the rule, counts a warning only when the rule produced it, and moves to the
  PR selection.

The NLI span shaping that the plugin path had inline is now
hallucinationEvidenceFromNLI, shared with the signal path so the two cannot
report the same detection differently.

Behaviour-preserving extractions keep the TypeScript structure ratchet flat:
buildPIIEntry, buildDomainEntry, piiFormFields, PII_SIGNAL_FIELDS,
addGroundingSignals, and removeSignalByName hoisted to module level.

Tests: TestHallucinationSignalDrivesTheSelectedDecisionPlugin (detector runs
once), CleanAnswerTakesNoAction, BackendFailureIsNotHidden,
WithoutContextIsUnavailable, NotApplicableWithoutFactCheck,
IsPublishedWithoutAPlugin, ReadsTheSelectedRecipeRules (named-entrypoint
regression), RecordsReplayOutcome, MatchedHeaderIsWrittenInTheBodyPhase;
TestValidateRejectsDecisionReadingHallucinationRule (direct and through a
projection), TestValidateHallucinationSignalContracts,
TestNeedsHallucinationDetectorForDeclaredRule; the
EvaluateResponseHallucinationSignal cases; the three hallucination DSL
round-trips; the dashboard signal-type list.

The profile's request asks for the auto model. A concrete model name bypasses
every signal, decision and plugin by design, so the case selected no decision
and had nothing to detect with. Its Great Wall question is also reworded: the
fact-check classifier scores the old phrasing 0.450 against the profile's 0.65
threshold, so that case alone never reached the detector. The five questions
now score 0.869, 0.734, 0.998, 0.999 and 0.996 on the model the profile loads.

The profile's request sends the grounding context as a linked tool call and
tool result. A tool result with no call ID has been rejected before routing
since the protocol translation landed, so the case that never ran in CI was
answering 400 rather than exercising detection.

The profile moves to the PR lane with 8Gi rather than the 2Gi it carried while
it was manual. The detector under test is remote, but the router still loads
the startup embedding model and the mmBERT-32K fact-check classifier, and at
2Gi the pod is OOMKilled during fact-check initialization before the startup
probe passes.

Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
@subin9
subin9 force-pushed the feat/response-stage-hallucination branch from a467e4b to 7334654 Compare September 7, 2026 14:27
@subin9
subin9 requested a review from Xunzhuo September 7, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr/needs-author Waiting for author changes or response. wg/mom-routing Owned by the MoM and Routing Workgroup.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants