[Feature] Move hallucination detection into a response-stage signal - #3531
[Feature] Move hallucination detection into a response-stage signal#3531subin9 wants to merge 2 commits into
Conversation
✅ Deploy Preview for vllm-semantic-router ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
0a36f56 to
47c3070
Compare
Xunzhuo
left a comment
There was a problem hiding this comment.
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.
FAUST-BENCHOU
left a comment
There was a problem hiding this comment.
pls resolve conflicts
|
Working on my final check matching with jailbreak :) |
47c3070 to
7efd9fe
Compare
7efd9fe to
c95a535
Compare
Xunzhuo
left a comment
There was a problem hiding this comment.
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.
c95a535 to
a467e4b
Compare
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>
a467e4b to
7334654
Compare
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.hallucinationdeclares the observation, it is scored once per response fromthe selected recipe's rules before any plugin runs, and the
hallucinationplugin of theselected decision only enforces on it. An answer that could not be checked is unavailable
rather than clean:
hallucination_context_unavailablewithout grounding context,hallucination_evaluation_failedwhen 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=hallucinationmake agent-ci-lint AGENT_BASE_REF=origin/mainmake test-semantic-routerTest Result
Local gates pass on the rebased head, and the router unit suite reports no failures.
TestHallucinationSignalReadsTheSelectedRecipeRulesfails when the rules come from the rootconfig instead of the selected recipe, and
TestHallucinationSignalDrivesTheSelectedDecisionPluginasserts 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
hallucinationE2E profile moves fromselection: manualtoselection: pr. Its first runOOMKilled 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-onerrorneeds for theembedding 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.