test(core): bring every sieval/core module over the 70% mutation bar - #81
Merged
Conversation
`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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type
(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 insieval/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.
runners/resume_gatetasks/metatasks/anomalytasks/progressAll 26
sieval/coremodules 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:reasonwas 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_messagehad 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.localanddevare independent markers. The run-side test used0.6.1.dev3+gxyz, which carries both, so it could not tell theorin that guard from anand. A build tagged only local would have fallen through to the series check and resumed.tasks/meta.task_meta_from_dictcarried 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.jsonis how every consumer outside this process learns what a task is.tasks/anomaly. Two counters over the same data (sample_detailscounts occurrences,rollout_detailscounts rollouts — a swap misreports how widespread an anomaly is);save's temp-file-then-rename atomicity, since a truncatedanomalies.jsonis worse than none (it looks present, so regeneration is suppressed); andrules_hashpinned 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.positionis how MultiTaskRunner keeps concurrent runners' bars from drawing over each other; collapsing it to a constant fails no behavioural test. Also that_enable_logis exactly "show_progress and not a TTY" — silencing progress must silence both channels, not swap one for the other.One method note worth keeping
anomalytook 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-pinnedrules_hashcould 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 #68 — not 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_copyomitssieval/__init__.py, somutants/sievalis 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
ruff check && ruff format --check)ty check)Manual
git diff --statis four test filesChecklist
Required (all PRs)
type(scope): description)AI-Generated Code - <model> (<provider>)in module docstringcore/🤖 Generated with Claude Code