Skip to content

chore(mutants): make structural removal the policy, and close the ifd residue - #575

Merged
justin13888 merged 15 commits into
masterfrom
chore/110-mutation-policy-residue
Sep 10, 2026
Merged

justin13888 merged 15 commits into
masterfrom
chore/110-mutation-policy-residue

Conversation

@justin13888

@justin13888 justin13888 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #110 asks for two things: the mutation-survivor policy written down where the rules
already live, and the enumerated survivor residue closed. This branch does the policy and the
gamut-riff / gamut-ifd residue; the large-crate surveys are out of scope and filed as
#585.

Policy (AGENTS.md, docs/mutation-testing.md). The rule is now what this workspace has
actually been doing: remove the mutant before you exclude it. A survivor is a question about
the code before it is a question about the suite, a mutant that hangs the suite is a survivor
too, and exclusions live only in .cargo/mutants.toml (there is no #[mutants::skip] in this
tree and there should not be one). docs/mutation-testing.md gains what a reader needs to
interpret a run: exit code 3 is a timeout rather than a clean run, an allocation-runaway
mutant's verdict currently depends on the address-space cap the runner derives (filed as #613),
and the tool's whole vocabulary is seven verbs — so a missing match arm, a wrong literal in an
expression and a wrong alternative inside an or-pattern are all invisible to the gate.

The vocabulary is now derived, not listed by hand. The document carries the command and what
it returns, and the command runs verbatim out of the document:

cargo mutants --list --no-config | sed -E '…' | sort | uniq -c | sort -rn
  16082 replace operator      7212 replace body        917 delete match arm
    540 delete -               269 delete !            160 replace match guard
     35 delete field

25 215 mutants, seven verbs. The hand-written list this replaces was derived from three crates
and was two verbs short: delete - (540 workspace-wide) and delete field (35). The
literal claim is narrowed with it — a literal in an expression or a guard operand is never
mutated, but a body that is a literal is replaced wholesale, so replace SrgbIntent::code -> u8 with 0 reaches one.

The census now fails on a line it cannot classify. Its last rule used to be a catch-all
rewriting anything shaped replace X with Y to "operator swap", so a future cargo-mutants verb
of that shape would have been folded into the count and the seven-verb claim above would have
gone false with no signal — which is the failure a derivation is written to replace. Each rule
matches one verb's exact shape now, and an awk tail prints anything left over as
UNCLASSIFIED and exits non-zero. Tightening the operator rule to its own alphabet surfaced 110
swaps the catch-all had been absorbing silently: they carry no in <fn> tail because they sit
outside any function body (const/static initialisers, and one array length in a signature).
The rule admits them explicitly, so the seven counts are unchanged and the pipeline exits 0 on
the tree as it stands.

One rule is added on this pass: never narrow a contract to make a mutant assertable, and it
now carries the test that decides a case — whether any conformant input can tell the old bound
from the new. A bound no conformant input reaches is arbitrary, and tightening it while citing
the clause that says so is a correction; a bound conformant input does reach is the deliverable.
Under that test PR #520's decision 14 stands: it lowered a reader's EXIF depth bound from
16 to 2 citing EXIF 2.3 §4.6.3, under which no conformant tree is deeper, so no input that was
accepted is now refused. The forbidden case is the other one, and both are named in the rule.

Residue. Measured fresh on this base rather than taken from the (months-old) issue text.

  • gamut-riff — 165 mutants, 157 caught, 8 unviable. No survivors, no timeouts, nothing to
    change.

  • gamut-ifd — 652 mutants, 587 caught, 57 unviable: no survivors and eight TIMEOUTs.
    Every one of the eight is now removed structurally rather than excluded. Seven are
    hand-advanced cursors whose termination was a property of their own arithmetic (all_zero,
    ReadLedger::record, write_with's pool placement). The eighth and ninth both live in
    ReadLedger::subtract, and an earlier revision of this branch excluded them; that is
    reversed. The walk is now driven by a for over the sub-slice of claims that can touch the
    span, so the data bounds it, and both former hangs are ordinary comparisons the suite kills.

    The rewrite introduces exactly one mutant no test kills, and it is provably equivalent:
    relaxing the sub-slice bound r.start < end to <= admits at most one extra claim — the one
    beginning exactly at the span's end, since merged is coalesced and carries no zero-length
    claim — and that claim pushes the same trailing gap the loop's tail would have pushed and
    leaves pos == end. Argued at the entry and checked over every ledger/claim pair drawn from a
    10-cell universe (1 048 576 pairs), whose output fingerprints are identical. It is excluded
    line/column-anchored, so the == and > twins of the same comparison stay in the survey and
    are caught.

    The crate re-surveys at 637 mutants, 580 caught, 57 unviable, 0 missed and 0 TIMEOUTs,
    measured at this branch's head across all four round-robin shards.

.cargo/mutants.toml is split into two labelled groups. Its header called everything in it
"provably equivalent"; two other claims were already in there — a body of entries arguing that
the mutant moves only a choice the format leaves free, and a smaller one arguing that the mutant
is detected but reportable only as a TIMEOUT. Neither is equivalence. The array is now GROUP 1
(unkillable: equivalent, or a correctness-preserving free choice) and GROUP 2 (non-terminating).

The membership is derived, not asserted. Parsing the array with each entry attached to the
comment block directly above it gives 176 entries in GROUP 1, 24 in GROUP 2, 200 in total.
Four entries whose comments argue non-termination had been left under the UNKILLABLE heading —
build_optimal_table, both isqrt entries, and the trellis backtrack terminator — and are now
under the one that says so; the rd heading that had claimed "non-terminating and
provably-equivalent" at once is split, so each half states one claim and points at the other.
Two comments that had drifted from their entries are back beside them: ProgCoder::ac_refine's,
which sat 39 lines above the entry it explains, and code_length_count's, which sat over
decode_image_data's entry and read as an explanation of it.

No regex changed at any point in this branch's regrouping. The entry set before and after is
identical as a multiset, and cargo mutants --list selects the same 24 353 of 25 215
generated mutants at the previous head and at this one, compared line by line.

The header also states the rule the file follows rather than a stricter one it does not. "One
regex per mutant" was contradicted by the file it governs: ten entries carry an explicit
alternation over sibling mutants, four of them arguing in their own comment why one entry
deliberately covers several. The rule is stated per claim, with the condition that makes such
an entry legitimate — one statement, or one site's operator class, and a single argument that
covers all of them. And the anchoring trade is written out, since the file argues both sides of
it: file:line:column pins one mutant exactly but stops matching silently when code above it
moves, while a function signature survives movement but can reach a mutant nobody argued for.

Correction to the previously published measurement

The "after" row in an earlier revision of this body — 638 mutants, 0 missed, 0 timeouts — was
measured at 2834bce6, one commit before a425fc09 added the second exclusion, and so
never described the head it was published against (cargo mutants --list gives 638 there and
637 at the head). The row below is measured at this branch's head and is the only survey this
PR claims.

Validation

Run in the worktree, whole-workspace commands inside a memory-capped scope
(MemoryMax=16G, MemorySwapMax=0, CARGO_BUILD_JOBS=2, ulimit -v 12000000):

Command Result
cargo test -p gamut-ifd --all-features pass — 176 tests
cargo clippy -p gamut-ifd --all-targets --all-features -- -D warnings pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass — no errors in 9 commits
mise run lint pass
mise run test pass — 3 767 tests across 202 binaries
mise run mutants-crate gamut-ifd --shard 0/4 (at this head) 160 tested, 143 caught, 17 unviable, 0 missed, 0 timeouts
mise run mutants-crate gamut-ifd --shard 1/4 (at this head) 159 tested, 145 caught, 14 unviable, 0 missed, 0 timeouts
mise run mutants-crate gamut-ifd --shard 2/4 (at this head) 159 tested, 147 caught, 12 unviable, 0 missed, 0 timeouts
mise run mutants-crate gamut-ifd --shard 3/4 (at this head) 159 tested, 145 caught, 14 unviable, 0 missed, 0 timeouts
gamut-ifd total, measured at this head 637 mutants, 580 caught, 57 unviable, 0 missed, 0 TIMEOUTs
mise run mutants-crate gamut-ifd --shard i/4 (×4, on the base) 652 mutants, 0 missed, 8 timeouts
mise run mutants-crate gamut-riff --shard i/2 (×2, on the base) 165 mutants, 157 caught, 8 unviable, 0 missed, 0 timeouts
CI Incremental (PR diff) shards 0-3 (the --in-diff gate) pass, all four
CI Format & Metadata, Clippy & Doctests pass

Re-run for the third round, which touches .cargo/mutants.toml, AGENTS.md and
docs/mutation-testing.md only:

Command Result
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass — no errors in 15 commits
cargo mutants --list --no-config | wc -l 25 215, matching the figure the document publishes
the census exactly as the document prints it seven verbs, the published counts, exit 0
the same census with one synthetic replace call foo with bar in baz line appended UNCLASSIFIED: 1 replace call foo with bar in baz, exit 1
cargo mutants --list at 10505d9f vs at this head 24 353 lines each, identical after sorting
python3 -c over tomllib: the file parses, every regex compiles 200 entries, all compile
entry multiset at 10505d9f vs this head identical
entries per group, parsed from the array 176 GROUP 1, 24 GROUP 2
entries in exclude_re on origin/master vs this head 199 vs 200

No crate source changed in this round, so no mutation survey is claimed for it; the two
cargo mutants --list runs are the evidence that the exclusion file's reorganisation selects
exactly the same mutants as before.

Every shard exited 0. The four --shard k/4 selections are round-robin and disjoint, and their
counts sum to the 637 that cargo mutants -p gamut-ifd --list reports at this head — one fewer
than the 638 it reports without the single exclusion this branch adds.

The __CARGO_TEST_ROOT prefix is a known nested-worktree artefact: cargo otherwise walks past
the worktree root when loading the tooling/* manifests. No manifest was changed for it.

Risks and rollout

Documentation plus a bounded set of local rewrites in gamut-ifd. No public API change. Each
rewrite is behaviour-preserving by an argument stated at the site: stepping a range yields the
same chunk starts as a hand-advanced cursor; the spans that merge are the run whose starts still
reach end; the claims that touch a span are the run that reaches past its start; and pins is
sorted and validated disjoint immediately above the pool placement, so one forward pass settles
it. The existing byte-accounting tests and the invariants property cover all of them, and the
claim-walk rewrite is additionally pinned by an exhaustive differential over a 10-cell universe.

Against origin/master the exclusion file grows by one entry, 199 to 200. Earlier revisions
of this body read that the other way round, and this section is corrected in place because it is
the current description rather than a dated entry. The two entries described there as "removed"
were never on the default branch: this branch added them itself (d8db951a, a425fc09) and
reverted them in 2505280d once the rewrite made them unnecessary, so they net to zero against
the base. What a reviewer reads in the diff is one entry added — the equivalence claim on
ReadLedger::subtract's take_while bound, carrying both an argument and an exhaustive check.

This round changes no file under crates/, so it claims no mutation survey. The evidence that
reorganising the exclusion file is inert is the identical cargo mutants --list selection either
side of it.

The residual risk is the line/column anchor on the new entry: if code above it moves and nobody
re-runs the crate's survey, the regex stops matching. That fails loudly — the mutant
reappears and the gate goes red — rather than silently suppressing something killable.

Issue

Refs #110. The remainder — mutation surveys of gamut-jpeg, gamut-png, gamut-webp, gamut-av1,
gamut-avif, gamut-cmm, gamut-tiff, gamut-dng and gamut-heic — is filed as #585. The runner's
machine-dependent address-space cap, which makes an allocation-runaway mutant score caught on
one machine and TIMEOUT on another, is filed as #613.

Decisions taken

Issue 110  Plan: v1  Branch: chore/110-mutation-policy-residue  Base: origin/master
Touches: AGENTS.md (terse policy: prefer structural removal; skip annotations vs globs;
         the memory-capped runner note), docs/mutation-testing.md, .cargo/mutants.toml
         (review only), plus the enumerated survivors in gamut-riff and gamut-ifd
Will not: run the large-crate surveys (jpeg, png, webp, av1, avif, cmm, tiff, dng, heic)
Lane: SINGLETON with respect to E-493 — you both run mutation surveys, and two at once
      fight over the output directory and each assumes it owns the whole memory budget
Settled: S3 (tests follow docs/testing.md)

Decisions taken.
1. Boundary: the policy deliverables plus the riff and ifd residue. The large-crate
   surveys are filed as one issue listing the crates.
   Reverses: extend the lane.  Filed: yes.
2. An exclusion is retired only with a killing test or a structural rewrite. None is added
   without the per-entry rationale the file already requires.
3. The gamut-riff residue is closed by measurement, not by edit: a fresh two-shard survey
   on this base reports no survivors and no timeouts, so there is nothing to change.
   Reverses: editing gamut-riff to satisfy the issue text.
4. `.cargo/mutants.toml` is not review-only after all: the ifd residue is eight TIMEOUTs
   and no survivors, and one of them cannot be rewritten away. Bounding
   `ReadLedger::subtract`'s claim skip by a slice only trades the hang for an equivalent
   mutant at the `r.end() == span.start` boundary, so the skip is confined to its own
   function and excluded by name.
   Reverses: rewriting all eight structurally.
5. A ninth hang, whose verdict depends on the runner's address-space cap (`caught` locally,
   TIMEOUT on CI), is excluded for determinism rather than bounded by an iteration count
   whose own arithmetic would be an equivalent mutant.
   Reverses: adding a per-span iteration bound.
6. The policy text gains one rule beyond the landed version — never narrow a contract to
   make a mutant assertable — because a lane elsewhere in this run lowered a depth bound to
   make a mutant killable and thereby made its crate emit a file its own reader refuses.

Appended after review of the branch.
7. Decision 4 is REVERSED, and decision 5 with it. Bounding the claim walk by a slice does
   not trade the hang for an equivalent mutant "at the `r.end() == span.start` boundary" as
   decision 4 asserted: the equivalent mutant it introduces is at the other end of the run,
   the `take_while` bound `r.start < end`, and it is one mutant rather than two hangs. The
   rewrite is taken, both exclusions are deleted, and the single equivalent mutant is
   excluded with an argument and an exhaustive differential (1 048 576 ledger/claim pairs,
   identical output fingerprints). The whole-crate survey at the head is the evidence.
   Reverses: revert the rewrite commit and restore the two entries.
8. The false premise decision 4 rested on is corrected in the source as well. The comment
   on `next_live_claim` claimed the mutant could not produce a wrong answer; enumeration
   says it terminates with a wrong answer on 230,890 of 426,877 ledger/claim pairs and
   hangs on 59,501. That function is gone with the rewrite, but the same over-general
   claim was left in `ReadLedger::record`'s comment and in the rewritten walk's, and both
   now say which mutants stall and which merely mis-step.
   Reverses: revert the doc commit.
9. `exclude_re` is split into TWO labelled groups, not three, and the first group's heading
   names both claims it holds — provably equivalent, and correctness-preserving free
   choice. About forty entries argue the second; they are interleaved with the equivalence
   arguments of the same crate and are referenced by those arguments positionally ("same
   shape as the guard above"), so moving them would rewrite prose belonging to nine crates
   this lane does not survey. Which claim an entry makes is stated in its own comment, and
   the header now requires that.
   Reverses: split group 1 in two as well, behind a survey of each crate.
10. The twenty-one pre-existing hang entries are NOT retro-anchored to line:column. That
   instruction was written for the two entries this branch added, and both are gone.
   Re-anchoring the rest needs the current line numbers of gamut-jpeg, gamut-webp,
   gamut-bitstream and gamut-tiff, and a wrong anchor stops matching silently and turns
   another lane's gate red at an unrelated moment. The rule in `AGENTS.md` is therefore
   written as the principle the anchors serve — an entry must not be able to cover a
   killable sibling — which `file:line:column` satisfies for an operator mutation and a
   signature anchor satisfies for a whole-body one. The one entry this branch adds is
   line/column-anchored.
   Reverses: re-anchor them behind a survey of each crate.
11. #613's citation site disappeared with the exclusion it was to be cited from, so the
   issue is cited from `docs/mutation-testing.md` instead, beside the sentence that tells a
   reader how to interpret a `caught`/`TIMEOUT` split.
   Reverses: cite it from an exclusion.

Appended after the second review of the branch.
12. The four GROUP-1 entries whose comments argue non-termination move to GROUP 2, and the
   `rd` heading that asserted "non-terminating and provably-equivalent" at once is split so
   each half states one claim and cross-references the other. Decision 9's reason for leaving
   entries where they were — that moving them would rewrite prose belonging to crates this
   lane does not survey — does not reach these four: they are gamut-jpeg's own blocks, they
   move whole with their comments, and the positional references they carry ("same shape as
   the scan.rs cursor hangs") point at GROUP 2, which is where they now sit. Group membership
   is derived by parsing the array rather than restated: 176 / 24 / 200.
   Reverses: restore the previous grouping.
13. The body states the exclusion count as a net against `origin/master` — 199 to 200, one
   more — and names the two entries it had called "removed" as this branch's own additions,
   reverted before the head. Corrected in place rather than by an appended note, because a
   `## Summary` or `## Risks` a reader meets as the current description is not a dated entry.
   Reverses: restore the previous wording.
14. "One regex per mutant" is restated as one regex per *claim*, with the condition that makes
   a multi-mutant entry legitimate. Measured, not chosen: ten entries in the file the rule
   governs carry an explicit alternation over sibling mutants, four of them arguing in their
   own comment why. Marking them as named exceptions was the alternative and is rejected — ten
   exceptions to a one-line rule is the rule, written twice.
   Reverses: keep the strict form and enumerate the exceptions.
15. The two comments that had drifted from their entries are moved to them, and the webp
   heading one of them sat under no longer promises a count of what follows it.
   Reverses: leave them where they were.
16. The vocabulary census fails on a line it cannot classify. Its catch-all would have absorbed
   a future verb shaped like an operator swap, and the seven-verb claim the paragraph rests on
   would have gone false with no signal — a derivation that cannot detect a new class is a
   hand-written list wearing a script. Tightening the operator rule to its own alphabet
   surfaced 110 swaps sitting outside any function body, which the catch-all had been absorbing
   silently; the rule admits them explicitly and the seven counts are unchanged.
   Reverses: restore the catch-all.
17. `AGENTS.md` gains two clauses beyond decision 6's rule. A **reader's** loose bound is what
   carries the non-conformant files the world actually holds, so narrowing one additionally
   requires naming what it stops accepting, beside the clause that permits it — #520's decision
   14 still stands under it, since it names the clause and refuses nothing that was accepted.
   And structural removal is not free: the rewrite takes the site's killable siblings with it,
   so a survey that shrinks is not thereby a stronger one. This branch is its own example —
   gamut-ifd went from 652 mutants to 637.
   Reverses: leave both unstated.
18. No file under `crates/` changes in this round, so no mutation survey is claimed for it.
   That the reorganisation is inert is shown instead by `cargo mutants --list` selecting an
   identical 24 353 mutants at the previous head and at this one.
   Reverses: re-run the crate surveys to make the same point more expensively.

No human approved this plan. This is an unattended run; the record above is what a human reads
afterwards.

Unresolved review notes

None.

Issue #110 asked for the policy to be written down where the mutation-testing
rules already live. What this workspace has actually been doing for the last
few surveys is now the rule:

- A survivor is a question about the code before it is a question about the
  suite. gamut-tiff removed five shift-or equivalents, gamut-deflate five more,
  gamut-avif four, and gamut-webp deleted a guard its constructor already made
  -- each time because the mutant could be made not to exist. Only what
  survives that gets a test, and only what survives a test gets an exclusion.
- Exclusions live in `.cargo/mutants.toml` and nowhere else. There is no
  `#[mutants::skip]` in this tree today and there should not be one: a single
  reviewable list is what makes "is this entry still justified?" answerable.

`docs/mutation-testing.md` gains what a reader needs to interpret a run rather
than just launch one. Exit code 3 is a timeout, not a clean run. The tool's
whole vocabulary is a default-valued body, a swapped binary operator, a deleted
`!`, a forced match guard and a deleted match arm -- confirmed from
`cargo mutants --list` on gamut-ifd -- so a missing arm, a wrong literal and a
wrong alternative inside an or-pattern (`delete match arm A(v) | B(v)` removes
both) are all things the gate cannot report. And a mutant nothing can kill is
often a sign the expression is unobservable, which is a reason to delete it.

Refs #110
Three loops in the byte-accounting path advanced a cursor by hand, so whether
they terminated was a property of their own arithmetic rather than of the range
or slice they walked. A survey of gamut-ifd (652 mutants, four shards) reported
no survivors and eight TIMEOUTs, and seven of those eight are these loops:
mutate the advance and the loop stops making progress, which cargo-mutants can
only report as a timeout at 60s a scenario -- never as a wrong answer a test
could catch.

- `all_zero` chunked with `pos += n` over `while pos < end`; stepping the range
  yields the same chunk starts and removes both the advance and the comparison
  (`+= -> *=`, `< -> <=`).
- `ReadLedger::record` merged spans with a hand-advanced `j`; the spans that
  merge are the run from `i` whose starts still reach `end`, so `take_while` on
  the sub-slice bounds it (`+= -> *=`).
- `write_with`'s pool placement re-ran `pins.iter().find(...)` until nothing
  overlapped. `pins` is sorted by offset and validated disjoint immediately
  above, so a jump can never re-expose a pin already behind the cursor and one
  forward pass settles the placement. Taking the later of the two positions
  makes a pin the value already clears a no-op, which retires the second
  comparison entirely (`&& -> ||`, `< -> <=`, `+ -> *`, `+ -> -`).

Behaviour is unchanged in each case, and the surviving mutants are ordinary
killable ones: the crate re-surveys at 0 missed and 0 timeouts.

Refs #110
The eighth and last TIMEOUT in the gamut-ifd survey is `ReadLedger::subtract`'s
skip loop, and unlike the other seven it cannot be rewritten away. Dropping the
claims that end at or before `pos` is what leaves the covered arm a claim ending
*after* `pos`, and so what makes `pos` advance; relax the comparison and the walk
spins instead of producing a wrong answer. Bounding the walk by a slice
(`partition_point` per span) only moves the problem: it trades the hang for an
equivalent mutant at the `r.end() == span.start` boundary, where including or
excluding an already-settled claim cannot change the output either way.

So the skip becomes its own three-line function. `subtract` has two other `<=`
comparisons whose `>` twins are caught, and a name is a stabler anchor than a
line for the exclusion that has to document this one. The function returns the
surviving claim rather than nothing, which keeps its *body* mutant killable:
`with None` says no claim covers anything, the walk then reports every read as
unclaimed, and `subtract_reports_exactly_the_unclaimed_reads` fails.

Refs #110
One more thing a survivor is not: an argument for changing what the code
promises. A bound that exists because the format defines it -- a nesting depth,
a size cap, a table length -- is not a knob for test assertability, and lowering
one so its boundary comes within reach of the suite can leave an encoder writing
a file its own reader refuses. The contract is the deliverable; the gate is the
instrument.

Refs #110
`ReadLedger::subtract`'s `while pos < end` walks one ledger span. Relaxed to
`<=` the body runs once more at `pos == end`, where the span is exhausted and
nothing can move `pos`: the settled claims are already dropped, so the covered
arm would assign `pos` the value it holds and the fallback arm computes
`stop == end`. The walk spins and produces no output any test can check.

It also pushes a zero-length range every turn, which is what makes its verdict
depend on the runner: under a tight address-space cap the scenario aborts and
scores `caught` before the 60s test timeout fires, and under a looser one it is
a TIMEOUT. It scored `caught` in a local survey and TIMEOUT on CI from the same
tree. An exclusion is what makes the gate say the same thing on both.

Refs #110
`ReadLedger::subtract` walked one shared `Peekable` cursor across every ledger
span, with a `while pos < end` loop whose progress depended on a settled-claim
skip. Two of its operators could then be reported only as a mutation-testing
timeout, and the skip's own comparison was worse than that: relaxed, it drops
the *live* claims instead of the settled ones, so the walk terminates with a
wrong answer far more often than it hangs.

Take the claims that can touch a span as a sub-slice instead -- the run that
reaches past the span's start, up to the last that begins before its end -- and
drive the walk with a `for` over it plus one trailing push. The data bounds the
loop, so no arithmetic has to stay right for it to terminate, and both operators
become ordinary comparisons a test can pin.

Behaviour-identical: over every ledger/claim pair drawn from a 10-cell universe
(1048576 pairs, both lists in the normalised form the walk receives) the new
walk returns byte-for-byte what the old one returned.

Refs #110
Both ledger walks carried a comment claiming that the loop a hand-advanced
cursor drives "could then be reported only as a mutation-testing timeout, never
as a wrong answer". That is true of the one mutant each comment names -- the one
that stalls the cursor -- and false as the general statement it reads as: the
skip this branch removed had a comparison whose relaxed twin dropped the *live*
claims and terminated with a wrong answer on 230,890 of 426,877 enumerated
ledger/claim pairs, hanging on only 59,501 of them.

Say which mutants stall and which merely mis-step, and claim unkillability only
for the first kind.

Refs #110
The list's header called everything in it "provably equivalent". Two other
claims were already in there: about forty entries argue that the mutant moves
only a choice the format leaves free -- a different but equally valid encoding,
which is not equivalence -- and twenty-one argue that the mutant is detected but
reportable only as a TIMEOUT, which is not unkillable either. A reader could not
tell which claim an entry made without reading its prose.

Split the array into two labelled groups, UNKILLABLE and NON-TERMINATING, move
every hang entry under the second, and rewrite the header to state both claims
and what anchoring each kind needs. No regex changes: the entry set before and
after this commit is identical.

Add one entry, for `ReadLedger::subtract`'s `take_while` bound. `merged` is
coalesced and carries no zero-length claim, so at most one claim can begin
exactly at a span's end, and it contributes exactly what the trailing push would
have: relaxing `<` to `<=` is byte-identical output. Argued at the entry and
checked over all 1048576 ledger/claim pairs of a 10-cell universe, whose output
fingerprints match. Line/column-anchored, so the `==` and `>` twins of the same
comparison stay in the survey.

Refs #110
The verb list was written from three crates and was two verbs short: over the
whole tree cargo-mutants also deletes a unary `-` (540 instances) and a field
from a struct literal (35). Publish the one-line command that derives all seven
from `cargo mutants --list`, with what it returns today, so the next reader
re-derives rather than trusts. Narrow the literal claim with it: a literal in an
expression or a guard operand is never mutated, but a body that is just a
literal is replaced wholesale, so that one is reachable.

Give the "never narrow a contract" rule the test it was missing -- whether any
conformant input can tell the old bound from the new. A bound no conformant
input reaches is arbitrary, and tightening it while citing the clause that says
so is a correction; a bound conformant input does reach is the deliverable, and
lowering it to bring a boundary within the suite's reach is what makes an
encoder emit a file its own reader refuses. Both cases are named.

Scope the two documents against each other: policy is stated once, in
`AGENTS.md`, and the runner document says how to read a result -- including that
an allocation-runaway mutant's verdict currently depends on the cap the runner
derives (#613) -- and points at the policy rather than restating it.

Refs #110
Regrouping the array moved one comment line one entry too far: the sentence
naming `code_length_count`'s trim floor left with the non-terminating entries
while the rest of its paragraph stayed behind, so group 1 opened a block
mid-sentence and group 2 carried a heading for an entry it does not hold.
Comment only; the entry set is untouched.

Refs #110
The sentence introducing the derived vocabulary said the written list had been
short "twice", which is a claim about other lists. Say what is true of this
one: it was taken from three crates and missed two verbs.

Refs #110
Four entries argued non-termination from under the UNKILLABLE heading, whose
claim is that no test can distinguish the mutant -- and one block heading
asserted both at once. Move `build_optimal_table`, both `isqrt` entries and the
trellis backtrack terminator to the NON-TERMINATING group, split the `rd`
heading so each half states one claim and points at the other, and reunite the
`code_length_count` paragraph with its own entry twenty lines below, where it had
been reading as an explanation of `decode_image_data`.

The header now states the rule the file follows rather than a stricter one it
does not: one regex per claim, covering several generated mutants only where one
argument covers them all, and the two anchors written out with what each trades
-- line:column stops matching silently when code above it moves, a signature
survives movement but can reach a mutant nobody argued for.

The entry set is byte-for-byte the same 200 regexes; only their order, their
grouping and the prose around them change.

Refs #110
`.cargo/mutants.toml` holds entries that deliberately cover several generated
mutants -- one statement's three identical advances, one site's two operator
swaps -- and each says in its own comment why. "One regex per mutant" was
therefore a rule its own file contradicted; state it per claim, with the
condition that makes a multi-mutant entry legitimate, and point at the file's
header for which anchor trades what.

Also name the price of the structural removal the bullet above recommends: the
rewrite takes the site's killable siblings with it, so a survey that shrinks is
not thereby a stronger one.

Refs #110
The rule tests a proposed narrowing against conformant inputs, which is the
right floor for an encoder. A reader's loose bound is not there for conformant
files: it is what carries the malformed ones the world actually holds, so a
narrowing that passes the conformance test can still cost real decodes. Require
naming what it stops accepting, beside the clause that permits it.

Refs #110
The census ended in a catch-all that rewrote anything shaped `replace X with Y`
to "operator swap". A future cargo-mutants verb of that shape would have been
absorbed into the count silently and the seven-verb claim the paragraph rests on
would have gone false with no signal -- the exact failure the derivation replaced
a hand-written list to avoid.

Each rule now matches one verb's shape (operators against their own alphabet,
guards against `with true|false`), and an awk tail prints anything unmatched as
UNCLASSIFIED and exits non-zero. Tightening the operator rule surfaced 110 swaps
that carry no `in <fn>` tail because they sit outside any function body, which
the catch-all had been absorbing; the rule admits them explicitly, so the seven
counts are unchanged and the pipeline exits 0 on the tree as it stands.

Refs #110
@justin13888
justin13888 merged commit 98802ad into master Sep 10, 2026
8 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.

1 participant