Skip to content

test(core): bring every sieval/core module over the 70% mutation bar - #81

Merged
ethan-scitix merged 4 commits into
mainfrom
worktree-resume-gate-mutation
Aug 7, 2026
Merged

test(core): bring every sieval/core module over the 70% mutation bar#81
ethan-scitix merged 4 commits into
mainfrom
worktree-resume-gate-mutation

Conversation

@ethan-scitix

@ethan-scitix ethan-scitix commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Type

  • refactor — code restructuring, no behavior change

(tests only — no source file is touched)

Summary

The mutation-score gate in sieval/core/CLAUDE.md (≥70% per modified module) has never been runnable — see #68 for the two config bugs. The first sweep it has ever been possible to run covered all 5,381 mutants in sieval/core: overall 76.7%, so the package is broadly healthy, but four modules sat under the bar.

This PR brings all four over it. Tests only; no source file changes.

module before after
runners/resume_gate 41.9% 87.8%
tasks/meta 61.4% 92.9%
tasks/anomaly 64.6% 70.8%
tasks/progress 66.7% 71.5%

All 26 sieval/core modules are now at or above the bar — the first time that requirement has actually been met rather than merely stated.

What was actually missing

Not coverage — every one of these modules was already exercised. What was missing were assertions with discriminating power.

resume_gate (the worst, and the one guarding the firmest contract). The repo states it plainly: "Precise reproducibility is a product contract, not a nicety. Safety guards ship strict-only." Yet:

  • reason was asserted as != "". The four reject reasons lead an operator to four different fixes, so swapping any two passed every test — including a swap that sends them somewhere that cannot work.
  • format_identity_reject_message had no tests at all — 18 mutants with nothing to observe them, and the function was not even imported. It is the message for resuming into a directory another task produced, where the stated stake is handing back someone else's report without running a sample.
  • local and dev are independent markers. The run-side test used 0.6.1.dev3+gxyz, which carries both, so it could not tell the or in that guard from an and. A build tagged only local would have fallen through to the series check and resumed.

tasks/meta. task_meta_from_dict carried 84 survivors: the whole deserializer was unverified, so any field could be read from the wrong key, dropped, or swapped with its neighbour. meta/index.json is how every consumer outside this process learns what a task is.

tasks/anomaly. Two counters over the same data (sample_details counts occurrences, rollout_details counts rollouts — a swap misreports how widespread an anomaly is); save's temp-file-then-rename atomicity, since a truncated anomalies.json is worse than none (it looks present, so regeneration is suppressed); and rules_hash pinned to its current value, because editing any rule's prose rotates every stored report fleet-wide and belongs in a diff rather than in production.

tasks/progress. position is how MultiTaskRunner keeps concurrent runners' bars from drawing over each other; collapsing it to a constant fails no behavioural test. Also that _enable_log is exactly "show_progress and not a TTY" — silencing progress must silence both channels, not swap one for the other.

One method note worth keeping

anomaly took four rounds. The first three guessed where the survivors were — read the source, judge which contracts matter, write tests. The last of those rounds killed exactly zero mutants.

Dumping the actual diff of all 134 survivors changed the answer completely: they were in @sieval_detection_rule's own derivation logic, not in the detection or reporting code. And the already-pinned rules_hash could not reach them, because the built-in rules register at import — a test reading the resulting registry sees what the derivation produced once, never the derivation itself. Killing them required registering a rule through the decorator inside the test.

Read the survivors before writing the tests.

Related Issues

Refs #68not a hard dependency. The two PRs touch no file in common: this one changes four test files, #68 changes none of them, and these tests pass with or without it. What #68 carries is the fix that makes mutmut runnable at all ([tool.mutmut].also_copy omits sieval/__init__.py, so mutants/sieval is not an importable package and the run dies before any mutant executes). So #68 is needed to reproduce the scores in this PR, not to merge it.

Merging #68 first is still the tidier order — it means the numbers here are verifiable the moment this lands — but either order is safe.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check)
  • Type check clean (ty check)
  • Unit tests pass — 3268 passed, 0 failed (about 90 new)

Manual

  • Mutation score measured before and after for each module, scoped per module, numbers in the table above
  • No source file modified — git diff --stat is four test files

Checklist

Required (all PRs)

  • PR title follows conventional format (type(scope): description)
  • No internal paths, credentials, or personal info in committed files
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring
  • No new upper-layer dependencies added to core/
  • Deleted code verified — nothing deleted

🤖 Generated with Claude Code

ethan-scitix and others added 3 commits August 7, 2026 20:00
`resume_gate` scored lowest of all 26 `sieval/core` modules — 31 of its 74
mutants killed. It guards the contract this repo states most firmly:

    Precise reproducibility is a product contract, not a nicety.
    Safety guards (e.g. `--resume` strict match) ship strict-only.

Nobody knew, because the mutation gate in `sieval/core/CLAUDE.md` has never been
runnable (see #68). Tests only, no behaviour change.

The existing suite covered the ladder's `action` thoroughly and its `reason`
with `!= ""`. That is the gap: the four reject reasons lead an operator to four
*different* fixes — repair the version string, reinstall a released build, pin a
non-dev build, match the series — so swapping any of them for another passed
every test, including a swap that sends the operator somewhere that cannot work.
Each reason is now asserted exactly.

`format_identity_reject_message` had no tests at all: 18 mutants with nothing to
observe them, and the function was not even imported. It is the message for
resuming into a directory another task produced, and its own text states the
stake — a finished run is matched by path alone, so resuming would hand back the
persisted task's report as this task's result without running a sample. Now
asserted on both task names, the persisted/current labelling, the explanation,
and both recovery paths — including that the two builders do *not* share a
second option, since "reinstall sieval" and "give this task its own result_dir"
are not interchangeable advice.

Two contract holes the old tests could not see:

* **Ladder precedence.** Rule order is the contract, not just membership. Exact
  match outranks every reject rule; unparseable outranks the 0.0.0 check (an
  unparseable string cannot be compared to 0.0.0 at all); 0.0.0 outranks the dev
  check; dev outranks the series check, because matching the series would still
  not make a dev build resumable.
* **`local` and `dev` are independent markers.** The run-side test used
  `0.6.1.dev3+gxyz`, which carries *both*, so it could not distinguish the `or`
  in that guard from an `and`. A build tagged only local would have fallen
  through to the series check and resumed. Now covered on both sides, each
  marker alone.

Also pins the break axis directly: minor under 1.0, major from 1.0 on, the
crossing incompatible in both directions, and pre-releases falling through to
the series check rather than being rejected as unpinnable like dev/local.

Residual 9 survivors are mutmut's `XX`-sentinel string mutations on formatting
this suite deliberately does not assert; killing them needs exact equality on
multi-line operator text, which trades discrimination for brittleness.

Measuring this requires the `[tool.mutmut].also_copy` fix in #68 — without it
mutmut cannot assemble an importable `mutants/sieval` and dies before any mutant
runs. Land that first, or apply it locally to reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of the three remaining `sieval/core` modules under the ≥70% bar. Tests
only; no source file is touched.

`tasks/meta.task_meta_from_dict` carried 84 survivors — the whole deserializer
was unverified. Any field could be read from the wrong key, dropped, or swapped
with its neighbour and nothing would notice, and `meta/index.json` is how every
consumer outside this process learns what a task is. Now round-tripped against
`task_meta_to_dict` with every field distinct, read back individually (a
symmetric swap survives round-trip equality alone), and each documented default
pinned — including that an omitted `status` means `stable` rather than
downgrading a task that consumers gate on.

`tasks/progress` had its three pbar gating conditions covered but not the
arguments. `position` is how MultiTaskRunner keeps concurrent runners' bars from
drawing over each other; collapsing it to a constant fails no behavioural test.
Also pins `leave=True` (the final counts survive the moment they become worth
reading), that `_enable_log` is exactly "show_progress and not a TTY" — silencing
progress must silence both channels rather than swapping one for the other — and
that the dump file needs both a directory and the flag.

`tasks/anomaly` moved 64.6% -> 68.2% and is **still under the bar**. What landed
here is real: the two counters in `generate_and_save_from_results`
(`sample_details` counts occurrences, `rollout_details` counts rollouts — a swap
misreports how widespread an anomaly is), `save`'s temp-file-then-rename
atomicity (a truncated `anomalies.json` is worse than none, since it looks
present and suppresses regeneration), the backup path's negative branches, and
`rules_hash` pinned to its current value — editing any rule's *prose* rotates
every stored report fleet-wide, so that is an event which belongs in a diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t the registry

The last module under the bar, and the one that took four tries. The first
three rounds were spent guessing where the survivors were; the last round of six
`detect` tests killed exactly zero mutants. Dumping the actual diff of all 134
survivors first changed the answer completely.

They were concentrated in `@sieval_detection_rule`'s own registration logic:

    - rule_name = func_name.removeprefix("_detect_").removeprefix("detect_")
    + rule_name = func_name.removesuffix("_detect_").removeprefix("detect_")

    - rule_tags = tags if tags is not None else [rule_name.replace("_", " ")]
    + rule_tags = tags if tags is None else [...]

The pinned `rules_hash` could not reach these. The built-in rules register at
*import*, so a test that reads the resulting registry never exercises the
decorator's derivation — it only sees what the derivation produced once. Killing
them requires registering a rule through the decorator inside the test.

The derived name is not cosmetic: it is what `applies_to` matches on and what
every persisted report keys by, so a changed derivation renames rules across
every stored `anomalies.json`. Now covered: `detect_` and `_detect_` prefixes
stripped, a name without either kept whole, default tags derived from the rule
name, explicit tags winning, and an explicitly empty tag list respected rather
than replaced — `is not None`, not truthiness, since "no tags" and "derive some
for me" are different instructions. Plus the default severity and each field of
the definition, which are serialized into `rules_schema` and hashed.

All 26 `sieval/core` modules are now at or above the ≥70% bar in
`sieval/core/CLAUDE.md`. Tests only; no source file is touched in this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ethan-scitix ethan-scitix changed the title test(core): raise resume_gate's mutation score 41.9% -> 87.8% test(core): bring every sieval/core module over the 70% mutation bar Aug 7, 2026
…ameter

CI caught a typecheck failure I introduced after running `ty` and before
pushing — the second time in this session I have made that exact sequencing
mistake.

`DetectFunc` is a Protocol declaring `__call__(self, ctx: TaskContext) ->
set[int]`. Its parameter is positional *and* keyword, so renaming to `_ctx` to
silence ruff's ARG001 stopped the test double from satisfying it:

    error[invalid-argument-type]: Expected `DetectFunc`,
    found `def rule(_ctx) -> Unknown`

Fixed by matching the idiom this file already uses for its other test rules — a
named, fully annotated `ctx` — and declaring the parameter intentionally unused
with `del ctx`, which satisfies ARG001 without a suppression comment.

Verified this time with `ty`, `ruff check`, `ruff format --check` and the suite
in one invocation rather than in sequence, since running them apart is what let
the last two edits through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ethan-scitix
ethan-scitix merged commit bba57b7 into main Aug 7, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the worktree-resume-gate-mutation branch August 7, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant