Skip to content

Backport tupferl's mutation-harness improvements - #321

Merged
martinus merged 8 commits into
mainfrom
claude/tupferl-mutation-backport-42ki73
Aug 25, 2026
Merged

martinus merged 8 commits into
mainfrom
claude/tupferl-mutation-backport-42ki73

Conversation

@martinus

@martinus martinus commented Aug 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #320.

tupferl ported this repository's mutation harness, improved it, and measured the improvements; this brings them back. Everything below was measured here unless attributed; numbers measured in tupferl say so, in the code as well as in this description.

CI is green on all 12 checks. The mutation evidence is partial and the "Mutation sweep" section says exactly how — read that before merging.

What arrived

  1. The spec-file path honours its flags (_run_spec). The dispatch was run(mutations) with every CLI flag silently dropped, survivors never confirmed, --json never written. It now honours all of them — including --killers, --baseline-only and the --json marker/pidfile lifecycle, which the issue's item 1 did not list and which the review found still inert after the first fix.
  2. Hypothesis profiles in one module (tests/profiles.py), selected by WOSWOAR_HYPOTHESIS_PROFILE; tools/mutate.py pins a derandomised 20-example mutation profile for every probe; CI now sets ci explicitly. Measured here: the three heaviest property modules drop from ~7 s to 0.44 s under mutation, and a typo'd profile name fails loudly.
  3. One pool per sweep instead of a pool per file; recorded per file for crash-resume, as before. tupferl measured the batch design serialising two hung mutants at ~600 s of a 913 s run.
  4. A per-test alarm (EACH_TEST, --each-test): a hung test is interrupted, named, and filed BROKE — never caught.
  5. The killers cache (sweeps/killers.json, gitignored): the test that caught each mutation runs first next time, in front of the row's selection, never in place of it; rows with nothing remembered run a cheap greedy prefix. With items 3–4 this was tupferl's 2.07× (15m13s → 7m20s, 274 mutants).
  6. Red-baseline diagnostics: the failing traceback is printed on both collection paths, and --baseline-only asks just the untouched-suite question.
  7. The dedicated-machine budget: dedicated()/_confined()/WOSWOAR_MUTATE_TOTAL, and _why() prints which rule set the budget. On this 16 GiB/4-core container the unconditional halving gave 8037 MiB and 3 lanes where 7 fit.
  8. Small: watch.py --pidfile-wait (its two deadline tests were the suite's slowest at 10.09 s + 10.08 s of a 249.7 s serial run; now sub-second), one usable_cpus in tools/cpus.py, and tests/test_verdict.py (30 direct tests for the module that grades everything else), tests/test_profiles.py, tests/test_cpus.py.

Where the fix diverged from the issue (and why)

  • EACH_TEST is 150, not tupferl's 30. The issue's own constraint, confirmed by measurement: tests here bound the subprocesses they drive at 60 s and 120 s, and a hang those guards would catch (TimeoutExpired is a test noticing) must reach the guard before the alarm files it as BROKE — tupferl lost seven mutants to the opposite ordering. The slowest honest test measured 10.09 s, so 150 is 25% above the tallest guard and half of TIMEOUT.
  • usable_cpus went to a new tools/cpus.py, not into sandbox.py as the issue suggested. The premise failed on inspection: sandbox.py imports woswoar.store, so pointing the test runner at it would make a broken woswoar package crash the runner instead of being reported per test.
  • One example budget (400) instead of the per-module 300/400s. The per-module profiles were illusory: load_profile sets one process-wide default resolved when a test runs, so under discover every property module already ran at whichever profile the last import loaded. 400 is the highest of the old numbers, so nothing got weaker.
  • Fixture rows are woswoar's own and were measured before being trusted: woswoar/entry.py's clamp guard is caught by tests.test_entry, the one-character cap nudge survived.

The review (rule 1, top row — the killers cache caches a fact about the world)

Full /simplify, four angles, applied before the PR opened. The substantive findings:

  • A real defect in the ported design (present upstream too): under the one-pool sweep, rows from a red-baseline run reached --json through the incremental writer, _recorded drops the red flag, and sweep skips recorded files by name — so a resume treated void rows as final answers, feeding confirm and killers.learn. landed now stops firing the moment the baseline comes back red.
  • Row timings could overwrite the baseline's; merged with setdefault so the complete measurements win.
  • _baseline_is_green re-implemented run's shard list and lane sizing and had already diverged from its own "the same sizing run does" comment — both now come from shared _shards/_wanted.
  • Killers.head/dropped were dead state feeding dead re-announcement blocks (upstream too); deleted. _why and _budget parsed _TOTAL differently, so _TOTAL=0 printed a reason that did not fire; one _told() reader now. _confined is the single cgroup reader. verdict.addError had re-grown a copy of _answered's body. Stale --batch help and scope docstring fixed.
  • Test-side: two differing copies of the memory-cap probe became one cached support.memory_caps_apply; the fifo class moved beside test_verdict.py's probe driver; duplicated real-harness runs now run once per class; test_a_widened_row_carries_no_remembered_test could not fail (it asserted on its own construction) and now drives confirm.

Not applied, per rule 1's "say so rather than silently dropping": deduplicating a first test out of the probe's combined suite (≤0.5 s per surviving row, but it adds filtering to verdict.collect, the one function whose failure modes this file exists to prevent), and full unification of _run_spec with the generated path's tail (the lifecycle and killers wiring are now shared; the control flow is not — a shared executor exceeds this PR's scope).

Worth sending upstream to tupferl: the red-baseline resume hole, the timing-precedence flip, the dead Killers.head/dropped pair, and the _baseline_is_green sizing divergence.

What CI and the sweeps caught in this branch itself

Three defects, all mine, all found by the machinery rather than by reading:

  1. The macOS leg went red: 6 discovered tests never ran. Deferring the RLIMIT_AS probe out of import time (a review finding) had moved the gate into setUpClass, and a setUpClass skip reports once for the class — so the ids run_tests discovered never reported back. Invisible on Linux, where the limit is enforced and the classes run. Gating in setUp fixes it, and tests/test_run_tests.py now refuses the shape for the whole suite with ast.
  2. Two sweeps died on a red baseline before the real cause was found. _run handed the probe **os.environ, so WOSWOAR_MUTATE_TOTAL rode into every lane — and since _budget reads it before _visible_memory, a nested harness sized itself for the outer machine and ignored the per-lane _BUDGET beside it. _budget's own docstring already called that wrong; nothing stopped it. My first fix pinned the tests, which was treating the symptom; the fix is one line in _run, with a test that drives a real probe and asserts both names.
  3. A test of the headline fix that could not fail. The sweep flagged the landed/finished callback as surviving on lines no test executes, and it was right: every sweep test stubbed mutate.run, so the callback never ran. Three tests now drive it for real. The mid-run one earned its own lesson — written with the watcher's window equal to the held row's timeout, sweep's final write raced into the assertion and the deleted incremental write passed anyway.

Mutation sweep

WOSWOAR_MUTATE_TOTAL=… python -m tools.mutate --base main --limit 0 --batch --json sweeps/r.json, run after the review was applied. Verbatim tail:

6 file(s), 1137 changed lines -> 395 mutants

395 mutant(s) across 5 file(s), in one pool
7 lane(s) at 2150 MiB each, from 15051 MiB of usable memory (WOSWOAR_MUTATE_TOTAL) -- see tools.mutate._share.

Report totals, from sweeps/r.json:

Counter({'caught': 267, 'survived': 127, 'timeout': 1})  baseline_red: False  widened: False

Crossed with coverage (python -m tools.reached sweeps/r.json coverage.json):

ALL                                              395
  caught                                         267
  timeout (asked nothing)                          1
  SURVIVED                                       127
    on a line NO test executes  (missing test)    44
    on a line tests DO execute  (weak/equiv)      83

Three things make this evidence partial, and I would rather say so than round up.

  • Confirmation never ran. widened: false is honest: every survivor re-run timed out. verdict.collect runs a WHOLE_SUITE selection serially, the serial suite is 249.7 s, TIMEOUT is 300 s, and the re-runs are concurrent — so confirmation cannot fit here by construction. That is true on main too; this branch only made it visible by producing 127 survivors instead of a handful. Filed as mutate: the whole-suite confirmation pass cannot fit in --timeout, so survivors silently become unanswered rows #322 with the arithmetic and three candidate fixes. Until it is fixed, a survivor list from this repo cannot be trusted to be free of narrow-selection false positives.
  • The list predates the last three commits. Fixes 2 and 3 above landed after the sweep, so line numbers and some verdicts are stale. "The generated sweep goes last" exists for exactly this; I could not honour it, because running the sweep is what surfaced the bugs that then had to be fixed.
  • I did not account for all 127 individually. What I did instead: the survivors cluster in run (16), main (15), sweep.finished (11), sweep (9), _baseline_is_green (9) — the functions that are hard to test because testing them means running the harness. I wrote tests for the load-bearing ones and proved them with a hand table, which is the part of the evidence that is against the current tree:
  caught    a red baseline still hands its void rows to `landed`
  caught    nothing is ever handed over while the run is going
  caught    the file's remaining count never comes down
  caught    a finished file is never written out
  caught    `--baseline-only` runs the table anyway

One genuine weak fixture from the survivor list is also fixed: ahead_of's reachability any() survived because every fixture named a single module, where any and all agree — the two-symmetric-inputs shape. A two-module selection tells them apart.

The remainder are dominated by diagnostics (print calls and their guards), equivalent mutants (return "" vs None at call sites that only test falsiness; setitimer(…, 0) when the alarm is already off; err[0]/err[1]/err[-1] all None in the same tuple), and lines only reachable through a subprocess that in-process coverage cannot see — reached.py folded 41 of those back in and says so. A full accounting wants a clean sweep once #322 is fixed, and I would not claim this branch has one.

Notes

  • Rule 8: nothing here touches the record format, repo layout, state.json, cache or day keys. The one change an existing checkout sees is tests/profiles.py; no data moves. sweeps/ joins .gitignore.
  • CLAUDE.md and CONTRIBUTING.md were updated in the same commits as the behaviour they describe.
  • The suite passes the CI spelling locally (CI=true WOSWOAR_HYPOTHESIS_PROFILE=ci python -m tools.run_tests), and with and without an ambient WOSWOAR_MUTATE_TOTAL.

claude added 7 commits August 25, 2026 14:46
The macOS leg went red with '6 discovered tests never ran'. A setUpClass
that raises SkipTest reports once for the class, so the ids run_tests
discovered never report back -- the accounting check working correctly,
against a shape this branch introduced when it deferred the RLIMIT_AS
probe out of import time.

Invisible on Linux, where the limit is enforced and the classes run.
tests/test_run_tests.py now refuses the shape for the whole suite.
_run handed the probe **os.environ, so WOSWOAR_MUTATE_TOTAL rode into
every lane -- and _budget reads it before _visible_memory, so a nested
harness sized itself for the outer machine and ignored the per-lane
WOSWOAR_MUTATE_BUDGET beside it. _budget's own docstring already called
that wrong; nothing stopped it.

Two sweeps found it the expensive way: with the variable exported every
probe answered _budget() with the whole machine, the suites that patch
_visible_memory went red, and the baseline they broke voided all 394
rows. The first fix pinned the tests, which was treating the symptom.
The generated sweep reported the landed/finished callback as survivors on
lines no test executes, and it was right: every sweep test stubbed
mutate.run, so the callback that carries a row out to --json never ran.
That is the mechanism this branch's headline fix turns on.

Three tests now drive it for real, and a spec table proves all five
mutations of it fail without them. The mid-run one earned its own
lesson: written with the watcher's window equal to the held row's
timeout, sweep's final write raced into the assertion and the deleted
incremental write passed anyway -- SURVIVED narrowly, caught only under
the whole suite, which is what a fixture that decides by timing looks
like.
The sweep reported ahead_of's reachability any() as a survivor. Every
fixture for it named a single module, where any and all agree -- the
two-symmetric-inputs weakness, invisible in the test's own text.
@martinus
martinus marked this pull request as ready for review August 25, 2026 17:57
@martinus
martinus merged commit 939675a into main Aug 25, 2026
12 checks passed
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.

tools: backport tupferl's mutation-harness improvements (2× sweeps, per-test alarm, remembered killers, and the flags the spec path drops)

2 participants