Skip to content

test(rag): labelled set and harness for groundedness prompts vs hallucination_detection - #1762

Open
Charansripadi wants to merge 1 commit into
generative-computing:mainfrom
Charansripadi:test/1721-groundedness-eval-set
Open

Charansripadi wants to merge 1 commit into
generative-computing:mainfrom
Charansripadi:test/1721-groundedness-eval-set

Conversation

@Charansripadi

@Charansripadi Charansripadi commented Oct 24, 2026 •

Copy link
Copy Markdown

Pull Request

Issue

Part of #1721. This adds the labelled set and the harness; the per-arm results follow once #1718 and #1719 have merged.

Description

Adds test/stdlib/requirements/groundedness_eval/, the measurement set and harness for deciding #1669.

  • labelled_set.jsonl: 128 hand-written items. Every fact is invented, so neither arm can pass a claim from the model's own knowledge, and the set has no third-party licence terms. It covers grounded (30), cross-document synthesis (20), genuinely partial (20), invented (30), needs-no-citation (10), mixed (10) and long responses of 20 to 80 sentences (8). Each sentence has a gold label in the adapter's own set (faithful, partial, unfaithful, NA). The responses are split exactly as the adapter input splits them (NLTK Punkt), and a unit test checks that for every item.
  • harness.py:
    • run validates the set with arm 1 (GroundednessRequirement as shipped) or arm 2 (find_citations for the citation output, verdict from flag_hallucinated_content with the rule in the issue), in either allow_partial_support mode, with repeats. Output is resumable JSONL, with latency per validation and tokens summed from generation.usage through a scoped plugin.

    • compare reports, per arm:

      • false fails and misses, separately;
      • partial-only misses in strict mode;
      • label use (gold vs adapter label);
      • hard failures;
      • stability across repeats.

      It then runs exact McNemar on the disagreements, computes a paired bootstrap CI, and applies the decision rule.

    • Backends other than local HF (e.g. Granite Switch) plug in with --backend-factory module:function.

  • README.md: set design, how to run, and the proposed decision rule (also posted on design(rag): measure hallucination_detection against the groundedness judgment prompts before swapping #1721 for agreement before any run).

Arm 1 is meant to run after #1718, so that an errored result counts as a hard failure rather than a "not grounded" verdict.

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code was added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

test_groundedness_eval.py has 280 unit tests, no model needed. They cover set integrity, the arm-2 verdict rule (including no-record and malformed records), McNemar and the CI, metrics, the decision rule, and run/resume. The run paths that call a real backend are not covered by unit tests; they need a GPU and the adapters.

Attribution

  • AI coding assistants used

Adding a new component, requirement, sampling strategy, or tool?

If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.

  • Component
  • Requirement
  • Sampling Strategy
  • Tool

NOTE: Please ensure you have an issue that has been acknowledged by a core contributor and routed you to open a pull request against this repository. Otherwise, please open an issue before continuing with this pull request.

…cination_detection

Adds the measurement set and harness for generative-computing#1721, ahead of the arms
themselves (which wait on generative-computing#1718 and generative-computing#1719):

- labelled_set.jsonl: 128 hand-written items with fictional facts, so
  neither arm can pass a claim from world knowledge. Covers grounded,
  cross-document synthesis, genuinely partial, invented, non-factual,
  mixed, and long (20-80 sentence) responses. Every response carries
  per-sentence gold labels in the adapter's own label set, split exactly
  as the adapter input splits it (NLTK Punkt), checked by a unit test.
- harness.py: runs arm 1 (GroundednessRequirement as shipped) or arm 2
  (find_citations for the output, verdict from flag_hallucinated_content
  with the rule in the issue) in either allow_partial_support mode, with
  repeats, per-validation latency and token counts, and resumable JSONL
  output. compare() reports false fails and misses separately, exact
  McNemar on disagreements, a paired bootstrap CI, hard failures, label
  use and stability, and applies a pre-registered decision rule.
- README.md: set design, how to run, and a proposed decision rule to be
  agreed on the issue before any run.

Refs generative-computing#1721

Signed-off-by: Sri Charan Sripadi <charansripadi@gmail.com>

@jakelorocco jakelorocco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for working on this issue!

I see Nigel opened this issue and has been working with you on it; so I will defer to him for now. Whatever the outcome, I don't think the code for determining that outcome should live in the main mellea codebase.

I do see the positives for saving harnesses like this; we should figure out where to save things like this so that we can reuse / repurpose them for future experiments.

@planetf1 planetf1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this. The set design is careful: invented facts so neither arm can lean on the model's own knowledge, gold labels that line up with the verdicts, and the Punkt split pinned by a test. The unit tests are thorough, and the pure logic (McNemar, the verdict rule, resume) checks out.

Nothing here blocks merge. The inline comments are all about making the measurement trustworthy before the runs on #1721, plus a few nits.

m1, m2 = arm_metrics(items, first), arm_metrics(items, second)
lenient = next(iter({o.allow_partial_support for o in first}))
if {o.allow_partial_support for o in second} != {lenient}:
raise ValueError("compare outcomes from the same allow_partial_support mode")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing here blocks merge, but I'd fix this before the runs: compare doesn't check its input files.

It checks that both files use the same mode (allow_partial_support), and nothing else. I tried three bad inputs:

  1. Missing items. An arm-2 file with only 10 of the 128 items still gets go: true. Nothing says the other 118 are missing.
  2. Files in the wrong order. Passing the prompts file as arm 2 prints a normal report.
  3. Two bindings in one file. run appends to --out, so a local-file run and a switch run can end up in the same file. Only the first run's verdicts are used, but errors from both are counted. I built a switch run of 128 rows: 5 errored, 6 invented items wrongly passing, the rest correct. On its own it fails (go: false, 5 of 128 = 3.9% hard failures). Appended behind a clean local-file run, the same rows give go: true, because 5 of 256 is 1.95%. The README's rule is "under 2% of validations, on each binding", so mixing runs hides the failure.

The fix is to check the inputs the same way the mode is already checked:

for outcomes in (first, second):
    if len({o.arm for o in outcomes}) != 1:
        raise ValueError(f"outcomes mix arms: {sorted({o.arm for o in outcomes})}")
    if len({o.binding for o in outcomes}) != 1:
        raise ValueError(f"outcomes mix bindings: {sorted({o.binding for o in outcomes})}")

And two small additions: error if an item is missing from either file, and check the first file really is the prompts arm.

| Primary verdict | repeat 0 of 3 |
| Margin | 5 percentage points, for false fails and for misses, in each mode |
| Paired test | exact McNemar on the items where the arms disagree, α = 0.05 |
| "No worse" | rule `mcnemar`: arm 2's rate is at most arm 1's + 5 points, and McNemar does not show arm 2 making more of that error |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth agreeing on #1721 before any run: on this set, the McNemar half of the rule can never fire. Whenever the 5-point margin passes, the p-value is above 0.05 (I checked both one-sided and two-sided). So the rule is just the margin: arm 2 may miss 1 more of the 34 invented claims, or have 3 more false fails of 74 in strict mode, 4 of 94 in lenient.

That's probably a fine rule. But the table reads as if the test adds protection, and the "Why mcnemar, not ci" note leans on it too. I'd say plainly that it's a margin rule, here and in the #1721 proposal. Growing the set until the test can fire takes roughly 120 invented-claim items, which doesn't seem worth it for this decision.

@hook("generation_post_call", mode=PluginMode.AUDIT)
async def _count(payload: Any, context: Any) -> None:
usage = getattr(payload.model_output.generation, "usage", None)
if usage:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One problem for the cost numbers: on LocalHFBackend, the token counts will be empty for arm 2 and too low for arm 1.

The counter reads generation.usage. On HF, adapter calls never fill it in. The intrinsic path passes a decoded string to processing (huggingface.py:1639), and usage only gets set from a GenerateDecoderOnlyOutput (huggingface.py:2460). The judge prompts use the normal chat path, so they do report.

So arm 2 shows None, and arm 1's total misses its find_citations call. The Switch path does report usage for adapter calls, so the two bindings won't be comparable either. The README says tokens are "left empty if a backend does not report usage", but this is about the call path, not the backend.

Easy fix: count the generation calls the plugin sees, and how many reported usage. If the numbers differ, store None. That way a partial total never looks like a real one.

t0 = time.perf_counter()
try:
raw = await fn(item, backend, allow_partial_support)
except Exception as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One more thing before the runs: an errored result is written to the file like any other, and resume treats it as done. I checked: if a run errors on an item, rerunning with a fixed setup skips it. The only way to retry is to delete the file.

Why it matters: this except Exception catches everything. An OOM on the 80-sentence items, a failed adapter download, or a bad --backend-factory all become "hard failures" counted against the 2% gate. Those say nothing about the adapter. The unit test even uses RuntimeError("adapter not loaded") as its hard-failure example, and that's an environment problem, not an adapter one.

Two small fixes, either or both:

  • Retry errored rows on resume, so they're not in the done set.
  • Record the exception type, and count only the adapter's own errors (the ValueError / AdapterSchemaMismatchError the wrappers raise) as hard failures. Anything else should stop the run.

completion_tokens: Summed over generation calls, if reported.
"""

item_id: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Last of the main points: the result rows don't say what ran. Outcome records no model and no adapter revision. The resume key skips the model too, so rerunning with a different --model into the same file silently skips everything.

The fix is close to free. mellea already fires ADAPTER_FUNCTION_INVOCATION_COMPLETE on every adapter call, with the adapter name, revision, and binding type. The same scoped plugin that counts tokens could write those onto each row.

Why it's worth it: the README plans to re-run the set after an adapter bump and compare. Without the revision in the results, there's nothing to compare against. And knowing the binding type on each row would confirm the adapter arm actually ran the adapter on both bindings.

scope = plugin_scope(counter.as_plugin())
await scope.__aenter__()
return scope
except Exception:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this except Exception hides real breakage. If the hooks extra is missing, token counting silently turns off, and it looks exactly like a backend that reports no usage. Catching just ImportError would do.

def test_gold_sentences_match_the_adapter_split(item):
"""Gold labels line up one-to-one with the sentences the adapter sees."""
spans = sentence_spans(item.response)
assert [item.response[b:e] for b, e in spans] == [s["text"] for s in item.sentences]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: worth pinning the join too. The adapter's character offsets assume the response is the sentences joined with single spaces. All 128 items satisfy " ".join(s["text"] for s in item.sentences) == item.response today, but a future item with a newline or a double space would still pass this test while the offsets drift. One more assert covers it.

## Running (GPU)

```bash
H="python -m test.stdlib.requirements.groundedness_eval.harness"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this fails under zsh, which doesn't word-split $H. uv run python -m test.stdlib.requirements.groundedness_eval.harness run ... works everywhere.

from typing import Any

SET_PATH = Path(__file__).with_name("labelled_set.jsonl")
LABELS = ("faithful", "partial", "unfaithful", "NA")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this label set matches the adapter's enum, which is the right reference. One related thing, not for this PR: the flag_hallucinated_content docstring gives "hallucinated" as an example faithfulness value, and that isn't in the enum. That docstring is already in #1719's scope, so I've left it alone here.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants