Repository navigation
bug(adapters): hallucination_detection output isn't checked against its own contract #1719
Description
flag_hallucinated_content can return records that break the adapter's published output contract, with no error. Callers then have to guess what a record means.
- A missing field comes back as
None. The io.yamlprojectstep builds each record with.get()(mellea/formatters/granite/intrinsics/output.py:908-911), so a record the model wrote withoutfarrives asfaithfulness: None. The required-key check in_ListContractnever fires, because every key is always present. - Any
faithfulnessstring passes. The io.yaml declares four values (faithful,partial,unfaithful,NA); the contract checks keys only (mellea/backends/adapters/_core.py:293-326). Constrained decoding may enforce the values on some backends, but nothing in mellea checks them, and feat(adapters)!: prompt fallback for adapter functions without a trained adapter #1654's prompt fallback will parse plain prompted output through the same contract. - A bad sentence index crashes or gives wrong data.
DecodeSentences._transform(output.py:718-735) checks onlyisinstance(value, int). An index past the end raises a bareIndexErroratoutput.py:727. A negative index silently picks a sentence from the end of the list, andTrue/Falsepass the type check.
The docstring doesn't help: it gives the values as "e.g. "faithful", "hallucinated"" (mellea/stdlib/components/intrinsic/rag.py:257-259). There's no hallucinated, and partial and NA aren't mentioned.
Proposed fix
-
A dedicated contract for
hallucination_detection. Add_HallucinationDetectionContracttomellea/backends/adapters/io_contracts.pyand register it in_INTRINSIC_IO_CONTRACTSin place of the_ListContractentry (:209-220)._RequirementCheckContract(:134) is the model, since it already does value-level checks. Keep the existing required-key behaviour, including"explanation"inexpected_keys(test/stdlib/components/intrinsic/test_rag_contracts.py:141checks it). Then, in this order:- Per record, raise
AdapterSchemaMismatchErrorwhenfaithfulnessisn't one of the four io.yaml values (this also catchesNone),response_beginorresponse_endisn't anintor is abool,begin > end, orexplanationisn't a string (the io.yaml markseas required). - Duplicates. Group records by
(response_begin, response_end). If a group disagrees onfaithfulness, raise, since there's no defined way to pick one. If it agrees, keep the first and drop the rest; the io.yaml has nodrop_duplicatesand the model can repeat itself. - Overlap. Raise if any two remaining records overlap, comparing half-open ranges because adjacent sentences share a boundary. Decoded sentences are contiguous and non-overlapping (
_desplit_sentences,output.py:435), so after step 2 this is a guard rather than a common case.
What
parse()returns is what callers receive (mellea/stdlib/components/intrinsic/_util.py:255), so the contract can do the dropping. No existing contract changes the data it returns, so say so in the class docstring.Leave
_ListContractalone.citationsandcontext-attributionuse it too, and their output legitimately repeats response spans (one record per pair of response sentence and document sentence) and carries nulls. A generic check would reject valid citations, andGroundednessRequirementwould then fail grounded responses.The value set is closed on purpose: an unknown label has no defined meaning, so it raises. On the local-file and Ollama paths a new upstream label can only arrive with a catalogue pin bump, which should update the contract. On Granite Switch the io.yaml comes from the served model's own
io_configs/(mellea/backends/adapters/adapter.py:1348-1376), while the contract comes from this registry (adapter.py:1339). A Switch release with a new label would therefore raise for Switch users with no pin bump. That's the intended fail-closed behaviour, but note it in the docstring, and make the error say what to do: name the unknown label, list the four this version understands, and suggest upgrading mellea. - Per record, raise
-
Bounds-check
DecodeSentences. In_transform(output.py:718-735), raiseTypeErrorfor abool(matching the existingTypeErrorfor non-integers at:718-722), andValueErrorfor negative and out-of-range indices, naming the index and the sentence count.ValueErrormatches the direction in bug(intrinsics): malformed output raises bare Exception, not the documented ValueError (HF, Ollama, OpenAI) #1688, and keepsmellea/formatters/granite/from importing anything frommellea/backends/.- Once this is in place, the offsets come from real sentences, so no separate "offset within the response" check is needed. That check couldn't go in the contract anyway:
parse(raw)never sees the response text. - The transform is shared with
citationsandcontext-attribution. For them, an index past the end already raises, so only the exception type and message change. A negative index goes from silently wrong to an error. Forcontext-attributionthe bound is the combined documents-plus-history sentence list (output.py:608-708).
-
Fix the docstrings. In
flag_hallucinated_content, list the four values with their io.yaml meanings, update the output-contract paragraph (rag.py:261-265) for the new checks, and addValueErrorandTypeErrortoRaises:(rag.py:285-289).find_citationsandfind_context_attributionsgo through the sameDecodeSentences, so add the newValueErrorto theirRaises:too. This addsraisestatements, so the docstring quality gate in AGENTS.md §10 applies.
Tests (no GPU)
- Contract, in
test/backends/test_adapters/test_io_contracts.py. Each of these raisesAdapterSchemaMismatchError: an unknownfaithfulness,faithfulness: None, a non-intoffset, abooloffset,begin > end, two records for one sentence with differentfaithfulness, and overlapping records. Adjacent records, extra fields and an empty list pass, and a repeated record with the samefaithfulnessis dropped, keeping the first. DecodeSentences. AboolraisesTypeError; negative and out-of-range indices raiseValueError. Valid indices are unchanged, and onecitationsand onecontext-attributioncase still decode as before.- Existing tests. Everything in
test/stdlib/components/intrinsic/test_rag_contracts.pykeeps passing._GOOD_SPAN(:125-131) hasresponse_end=10against a 9-characterresponse_text, so don't add a check that the text length matches the offsets.
Out of scope
- Truncated output, which today reaches the caller as a bare
Exception(bug(intrinsics): malformed output raises bare Exception, not the documented ValueError (HF, Ollama, OpenAI) #1688). - Telemetry. A contract violation is raised in
call_intrinsic(_util.py:255), after the backend has already recorded the adapter invocation as a success (for examplemellea/backends/huggingface.py:1651-1655), so adapter telemetry won't show it. Fixing that touches the handlers feat(adapters)!: prompt fallback for adapter functions without a trained adapter #1654 rewrites. - Checking that the records cover the whole response. That only matters if
GroundednessRequirementstarts using this adapter, which feat(rag): replace groundedness judgment prompts with the hallucination_detection adapter #1669 has on hold. An empty list stays valid.
Who notices
- Callers of
flag_hallucinated_content. Output that used to come back quietly wrong (aNonelabel, an unknown label, a record pinned to the wrong sentence) now raises. Call this out in the PR description, since the changelog is generated from PR titles. GroundednessRequirement. Its first stage callsfind_citations, which sharesDecodeSentences. A citation record with a negative orboolindex used to give wrong offsets and possibly a wrong verdict; now it raises, and bug(rag): GroundednessRequirement reports its own failures as "not grounded" #1718 turns that into an errored result.
#1654 doesn't touch io_contracts.py or output.py. Its prompt fallback does register adapters with get_io_contract(name), so the new contract checks prompt-fallback output too.
Line references are against 46ad3b3a5.
Activity