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.

Note (2026-10-20). This issue now also covers #885 (closed as absorbed). Fix
item 2's bounds check on DecodeSentences is exactly the #885 crash site: an
out-of-range sentence index raised a bare IndexError at output.py:727, and a
negative index silently picked a sentence from the end of the list. After this ships,
both become loud ValueErrors naming the index and the sentence count.

#885's deeper idea (propagating sentence-count bounds into the JSON schema used for
constrained decoding, so the model cannot emit out-of-range indices at all) is
deliberately not in scope here. Reopen it if the new ValueError fires in practice.

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

Activity

  1. added
    bugSomething isn't working
    p2Medium/low: minor bugs, niche features, polish, docs, tests, cleanup. Scoped, lower urgency.
    area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, Core
    on Oct 8, 2026
  2. self-assigned this
    on Oct 8, 2026
  3. added
    area/formatterOutput formatters, structured output, GraniteFormatter, JSON schema formatting
    help wantedExtra attention is needed
    on Oct 20, 2026
  4. removed their assignment
    on Oct 20, 2026
  5. CG-5228 commented on Oct 21, 2026

    @CG-5228

    Hi! I'd like to work on this issue (#1719) and have prepared a verified fix with unit tests — opening a PR shortly.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, Corearea/formatterOutput formatters, structured output, GraniteFormatter, JSON schema formattingbugSomething isn't workinghelp wantedExtra attention is neededp2Medium/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