Skip to content

bug(adapters): hallucination_detection output isn't checked against its own contract #1719

Open

Description

@planetf1

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.yaml project step builds each record with .get() (mellea/formatters/granite/intrinsics/output.py:908-911), so a record the model wrote without f arrives as faithfulness: None. The required-key check in _ListContract never fires, because every key is always present.
  • Any faithfulness string 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 only isinstance(value, int). An index past the end raises a bare IndexError at output.py:727. A negative index silently picks a sentence from the end of the list, and True/False pass 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

  1. A dedicated contract for hallucination_detection. Add _HallucinationDetectionContract to mellea/backends/adapters/io_contracts.py and register it in _INTRINSIC_IO_CONTRACTS in place of the _ListContract entry (:209-220). _RequirementCheckContract (:134) is the model, since it already does value-level checks. Keep the existing required-key behaviour, including "explanation" in expected_keys (test/stdlib/components/intrinsic/test_rag_contracts.py:141 checks it). Then, in this order:

    1. Per record, raise AdapterSchemaMismatchError when faithfulness isn't one of the four io.yaml values (this also catches None), response_begin or response_end isn't an int or is a bool, begin > end, or explanation isn't a string (the io.yaml marks e as required).
    2. Duplicates. Group records by (response_begin, response_end). If a group disagrees on faithfulness, raise, since there's no defined way to pick one. If it agrees, keep the first and drop the rest; the io.yaml has no drop_duplicates and the model can repeat itself.
    3. 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 _ListContract alone. citations and context-attribution use 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, and GroundednessRequirement would 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.

  2. Bounds-check DecodeSentences. In _transform (output.py:718-735), raise TypeError for a bool (matching the existing TypeError for non-integers at :718-722), and ValueError for negative and out-of-range indices, naming the index and the sentence count.

    • ValueError matches the direction in bug(intrinsics): malformed output raises bare Exception, not the documented ValueError (HF, Ollama, OpenAI) #1688, and keeps mellea/formatters/granite/ from importing anything from mellea/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 citations and context-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. For context-attribution the bound is the combined documents-plus-history sentence list (output.py:608-708).
  3. 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 add ValueError and TypeError to Raises: (rag.py:285-289). find_citations and find_context_attributions go through the same DecodeSentences, so add the new ValueError to their Raises: too. This adds raise statements, 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 raises AdapterSchemaMismatchError: an unknown faithfulness, faithfulness: None, a non-int offset, a bool offset, begin > end, two records for one sentence with different faithfulness, and overlapping records. Adjacent records, extra fields and an empty list pass, and a repeated record with the same faithfulness is dropped, keeping the first.
  • DecodeSentences. A bool raises TypeError; negative and out-of-range indices raise ValueError. Valid indices are unchanged, and one citations and one context-attribution case still decode as before.
  • Existing tests. Everything in test/stdlib/components/intrinsic/test_rag_contracts.py keeps passing. _GOOD_SPAN (:125-131) has response_end=10 against a 9-character response_text, so don't add a check that the text length matches the offsets.

Out of scope

Who notices

  • Callers of flag_hallucinated_content. Output that used to come back quietly wrong (a None label, 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 calls find_citations, which shares DecodeSentences. A citation record with a negative or bool index 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.

Metadata

Metadata

Assignees

Labels

area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, CorebugSomething isn't workingp2Medium/low: minor bugs, niche features, polish, docs, tests, cleanup. Scoped, lower urgency.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions