Coverage tells you a line ran. It does not tell you a test would notice if that
line were wrong. Mutation testing checks the second thing: it makes small edits
to the code (a > becomes >=, an and becomes or, a flag is dropped) and
runs the tests. If the tests still pass, that mutant "survived" and marks a gap
in the assertions, not just the coverage.
This repo runs mutation testing on the two modules where a silent bug would corrupt an eval score or let a bad answer pass:
evals/checks.py— the deterministic per-case gate (citation resolvable, correct agency, no-determination language, required facts, refusal + redirect, language match). Every check's boolean decides pass or fail for that case.evals/judges.py— the LLM-as-judge verdict parsing. The load-bearing property is that an unparseable or malformed judge output is recorded aspassed=None(errored/skipped), never a silentTrue.
The cross-domain safety guards in src/assistant/guards.py (PII, scope,
injection, determination language) are exercised transitively here but are not
the mutation target; they have their own dedicated suites.
Mutation score is a diagnostic, not a threshold to enforce per PR. Enforcing it would make an unrelated change fail because it added a branch, and would push people toward assertion-padding instead of meaningful tests. So:
- It is not part of
make check/make verify. - It never runs on
pull_requestorpush. - The
.github/workflows/mutation.ymljob runs weekly and on demand only, and its mutmut step iscontinue-on-error. The per-PR merge gate stays the coverage-gated suite inci.yml.
make mutation
This runs mutmut (installed via the isolated mutation dependency group, so the
default dev install stays lean) against the two fast, offline unit suites named
in [tool.mutmut] — no network, no model calls, no coverage gate. The scoped
run finishes in well under a minute.
Useful follow-ups:
uv run --group mutation mutmut results # list survivors
uv run --group mutation mutmut show <mutant-id> # see the exact surviving edit
uv run --group mutation mutmut browse # interactive TUI
mutmut copies the sources into a mutants/ sandbox and caches results in
.mutmut-cache; both are regenerated each run and are gitignored.
Scoped to evals/checks.py + evals/judges.py, run against
tests/test_checks.py + tests/test_judges.py:
| Metric | Value |
|---|---|
| Mutants generated | 334 |
| Killed | 249 |
| Survived | 85 (checks.py 33, judges.py 52) |
| Timeout / suspicious | 0 |
| Mutation score | ~75% (249/334) |
The survivors cluster in two low-risk categories, which is the useful finding:
- Diagnostic-string mutations. Most survivors edit the
detailmessage or the check/verdictnamelabel (for example"redirect_present"→"REDIRECT_PRESENT", or adetailstring replaced withNone). These fields are for humans reading a failed trace; they do not change any pass or fail decision, so the tests correctly do not assert them. Pinning every one would be assertion-padding. - Judge prompt text invisible to a scripted judge. The judge tests inject a
canned-response model, so mutations to the exact wording sent to the judge
(separators, default reasoning strings) are not observable in the verdict.
The safety-critical behavior — a parse failure becomes
passed=None, and the verdict tracksbool(data["grounded"])— is covered, and those mutants die.
Four notable survivors from the first pass were real assertion gaps and now have tests (they are killed as of this baseline):
evals/checks.py: droppingre.Ion the forbidden-content match made it case-sensitive, so a banned phrase could slip past by capitalizing it.evals/checks.py: droppingre.Ion the required-facts match would fail a correct answer that stated the fact in different casing.evals/checks.py:scope and result.kind == "answered"→scope or ...emitted acorrect_agency_citedcheck even when the case named no agency, failing answers that never claimed one.evals/judges.py: setting the groundedness judge'suserprompt toNonemeant the judge scored without seeing the passages or the answer — a silently corrupt measurement.
Run mutmut results, then mutmut show <id> on anything in checks.py or the
verdict logic of judges.py. Ask: would this edit change a pass/fail decision or
a recorded verdict? If yes, add the smallest test that asserts the real
behavior. If it only changes a diagnostic string or unobservable prompt text,
leave it — a surviving mutant is a prompt to think, not a number to force to 100.
Mutation testing asks "would a unit test notice if the check code were wrong?"
The self-test (evals/selftest.py, make eval-selftest) asks the question a
skeptic actually cares about: "given a deliberately wrong answer, does the
gate fail the right case?"
It takes an otherwise-clean, grounded answer and plants one known defect, then asserts that the specific check meant to catch it flips from pass to fail:
| Planted defect | Check that must catch it |
|---|---|
| a fare that contradicts the corpus fact table | fare_facts_consistent |
| a determination phrase ("you qualify") | no_determination_language |
| a dropped / unresolvable citation | citation_present_and_resolvable |
| a missing "as of" date | as_of_disclosure |
| the wrong agency cited | correct_agency_cited |
| an asserted forbidden over-claim | forbidden_content_absent |
| a missing required fact | required_facts_present |
The clean answer must also pass each check, so a scenario cannot pass by being
broken in two directions at once. Unlike mutation testing, this is a merge
gate — it makes no model calls, and tests/test_selftest.py runs it in CI. A
defect that survives (the mutated answer still passes its check) is a hole in
the harness, and it turns the run red.
This is the "we test our tests" evidence the evaluation story leans on: the scoreboard says the assistant passes; the self-test says the scoreboard would have caught it if it hadn't.