Skip to content

Mutation testing: the harness, nineteen vectors, the triage record - #64

Open
EvanWinget wants to merge 7 commits into
mainfrom
mutation-testing
Open

Mutation testing: the harness, nineteen vectors, the triage record#64
EvanWinget wants to merge 7 commits into
mainfrom
mutation-testing

Conversation

@EvanWinget

Copy link
Copy Markdown
Owner

What changed

Three commits, in review order:

  1. tools: the mutation harness. tools/mutate.py generates small semantic mutants of every module in python/bitlisp/ (flipped comparisons, swapped operators, off-by-one constants, negated if tests, deleted raise, exchanged break/continue) and runs the vector corpus against each in a private mirror of the tree. Survivors are reported with diffs. --tests runs the pytest suite over corpus survivors. python/tests/test_mutate.py covers the generator, the mirror, and one end-to-end kill. CLAUDE.md gains the command, ci/lint/codespell-ignore-words.txt gains NotIn (the ast class).
  2. vectors: the gaps the first mutation pass found. Nineteen cases across eight files, each a behavior the spec states that no vector exercised. The commit message cites the spec section for each. vectors/README.md gains a mutation-coverage section.
  3. docs: the mutation triage record. docs/mutation-triage.md with the method, the survivor classes and why each is accepted, the pass numbers, the gap table, and the representative accepted survivors. docs/README.md entry.

No spec change. No implementation change in python/bitlisp/: every gap was in the corpus, not the reference.

Numbers

On main at PR 60 with the new vectors: 1,733 mutants, 1,551 killed (89.5%), 3 timeouts (kills), 179 corpus survivors. The pytest suite kills 55 of those, 124 survive both. Before the nineteen vectors the pass had 195 survivors. Every remaining survivor is in one of five accepted classes: equivalent mutants, guards no input reaches, checks that share an error code with the one that then fires, the transaction model's own preconditions in tx.py, and boundaries no vector can carry (the 2^20 and 2^27 length forms, a 12 GB atom, a signature scalar above the group order, a 65,536-byte script).

What the gaps were

The ones worth a reviewer's attention:

  • A path atom with an interior zero byte was costed as if the zero were leading (vm/paths.json). Cross-checked against chia-rs.
  • The one-byte atom 0x7f in the long form, a lone 0xfc prefix, and a length of 8,191 in the three-byte form were all unpinned non-minimal encodings (vm/serialize.json). The corpus pinned the 0x40 boundary and not the 0x2000 one.
  • ASSERT_SEQUENCE_HEIGHT had no zero and no negative case where its three siblings had both.
  • A reserved declared cost of exactly -1 fell through to reserved_cost_too_low.
  • ASSERT_MY_SCRIPTPUBKEY and ASSERT_MY_AMOUNT had no arity cases.
  • No specifier composed an amount with an identity field, so the operand order after an amount was unpinned.
  • No amount specifier addressed a zero-amount prevout.
  • The compact-size boundary at 253 in the txid and outputs hash was unexercised by any seal vector (validation/seals.json, cross-checked against the vendored Core framework).

Review guide

  • Read docs/mutation-triage.md first: it is the argument for what was and was not turned into a vector.
  • Spot-check accepted survivors: .venv/bin/python tools/mutate.py --only tx:0 --list prints a diff for any id from the report. Ids are ordinal within a module and shift when the module changes, which is why the record names sites rather than ids.
  • Verify: .venv/bin/python tools/run_vectors.py (1,151 cases), .venv/bin/pytest python/tests, ci/lint/lint.sh. The full mutation pass is .venv/bin/python tools/mutate.py --tests, about two hours on 8 cores. --module costs runs one module in a couple of minutes.

A note on the harness itself

The first --tests column reported every survivor killed. The mirror lacked puzzles/, which two test modules read at collection, so pytest failed before running a test. The harness now links puzzles/ and refuses to start unless the unmutated mirror passes the same oracles the mutants face.

tools/mutate.py breaks the reference in one small way at a time and
asks whether the vector corpus notices. It generates every mutant of
every module in python/bitlisp from a fixed edit set (comparison
operators flipped to their neighbor or negation, arithmetic and
bitwise operators swapped, and/or exchanged, integer constants moved
by one, booleans flipped, if tests negated, not removed, raise
deleted, break/continue exchanged), runs the corpus against each
mutant in a private mirror of the tree, and reports the survivors
with their diffs. With --tests, survivors also run the pytest suite,
separating what nothing catches from what the tests catch and the
corpus does not.

Mirrors copy python/ and tools/ and link vectors/ and puzzles/, so
mutants never touch the checkout and parallel workers never see each
other's edits. The unmutated tree must pass every oracle the mutants
face before any mutant runs, otherwise a broken mirror would report
every mutant killed (which is how the first pass's --tests column
went wrong before puzzles/ was linked). Timeouts count as kills.

The unit test checks that every module yields parseable, distinct
mutants, that a constant mutant changes exactly its constant, that
the mirror shares data and copies code, and that a broken error-code
table dies in the corpus. CLAUDE.md gains the command.
Nineteen cases, each a behavior the spec states that no vector
exercised, found by tools/mutate.py as mutants the corpus let
survive. Every case passes the reference. The two path cases were
cross-checked against the consensus oracle (chia-rs, flags 0), the
three seal cases against the vendored Bitcoin Core framework.

VM.md section 3.1: an interior zero byte in a path atom is not a
leading zero byte and is not costed (path_interior_zero_byte,
path_leading_and_interior_zero_bytes). The surviving mutant counted
every zero byte.

VM.md section 2 (D5): the one-byte atom 0x7f in the long form is
non-minimal (nonminimal_one_byte_atom_7f), a lone 0xfc prefix byte
is bad_encoding (lone_prefix_fc), and a length of 8,191 in the
three-byte form is non-minimal (nonminimal_length_e0_at_8191). The
corpus pinned the 0x40 boundary but not the 0x2000 one.

CONDITIONS.md time asserts: ASSERT_SEQUENCE_HEIGHT accepts 0 and
rejects -1 (seqheight_zero, seqheight_negative). The other three
time asserts had both cases, this one had neither.

CONDITIONS.md section 1, VALIDATION.md rule 6: a reserved declared
cost of exactly -1 is bad_condition_arg, not reserved_cost_too_low
(reserved_cost_minus_one). The corpus had -500.

CONDITIONS.md self asserts: ASSERT_MY_SCRIPTPUBKEY and
ASSERT_MY_AMOUNT take exactly one operand (four arity cases). Only
the outpoint and taproot asserts had arity cases.

CONDITIONS.md message family: a pair in a non-amount specifier
field is bad_condition_arg (assure_script_specifier_pair), an empty
ASSERT_ANNOUNCEMENT is bad_condition_arity
(assert_announcement_arity_zero), and a specifier composing amount
with tapleaf parses its fields in operand order
(assure_amount_tapleaf_specifier_parses). No composed case had
carried an amount before an identity field.

VALIDATION.md rule 3 (C9): an amount specifier over a zero-amount
prevout balances (amount_specifier_over_zero_amount_input_balances).
The amount domain is 0 to MAX_MONEY and the corpus started at 1.

CONDITIONS.md seals: the compact-size boundary at 253 in the txid
and outputs hash, a 252-byte and a 253-byte output script under
SEAL_OUTPUTS and 253 outputs under SEAL. The one-byte form was the
only one any seal vector exercised.
docs/mutation-triage.md records what tools/mutate.py found on its
first pass and how each survivor was judged: the method, the six
survivor classes (equivalent, unreachable guard, same code, model
precondition, beyond reach, gap) with the reason each of the first
five is accepted without a vector, the per-module numbers on main at
PR 60 (1,733 mutants, 1,551 killed, 179 corpus survivors of which
the pytest suite kills 55, 3 timeouts), the nineteen gaps with the
mutant that exposed each and the vector that closes it, and the
representative sites of every accepted survivor. Line numbers are
left out on purpose: the classes are what a later pass compares
against, and the re-running section says how.

docs/README.md gains the entry.
A mutant that makes the reference raise outside its error taxonomy
stops the corpus runner on an escaping exception before any vector
reaches a verdict. That is detection, but by Python rather than by
the corpus, and counting it as a kill overstates what the corpus
guards. The corpus driver now exits with its own code on a vector's
verdict, so any other nonzero exit is classified crashed, and the
pytest pass treats only exit 1 (failing tests) as a kill. The
summary gains the column and the JSON report carries the verdict.
The unit test pins the mapping.
Six findings from the multi-agent review of PR 64, each reproduced
before fixing.

The runner's malformed-case catch turned ValueError and KeyError
escaping a mutated reference into vector verdicts, inflating kill
counts. run_vectors gains MalformedCase, a VectorError subclass
raised on that path, and the corpus driver classifies it as a crash.

The driver stopped at the first failing file in alphabetical order,
so a mutant's killed-versus-crashed verdict was an artifact of file
ordering and every VM kill first paid the condition and validation
files. It now continues past crashing files, lets a vector verdict
win over a crash, and visits files cheapest first, in an order the
baseline run measures.

Under --tests the pytest oracle collected the mirror's own copy of
the harness test, which mutated the already-mutated package, built
nested mirrors, and manufactured false kills. That test and the
corpus-runner test (a repeat of the run every survivor just passed)
are excluded, and a generated conftest disables Hypothesis deadlines
so parallel suites on a loaded machine cannot fail a latency check
and report a false kill. The report keeps each run's output tail so
a test kill names its failing test.

Negating an if test whose condition is a bare equality-class
comparison or a "not" duplicated the comparison swap or the "not"
removal, one semantic mutant under two ids. Those negation sites
are skipped, and AugAssign, IfExp, and While now get the operator
swaps and test negations the docstring already claimed, closing the
untouched augmented-assignment and expression-test sites the review
counted, the message ledger's "+= 1" among them.

Worker mirrors are removed at exit instead of accumulating in the
temporary directory. Crashed and timed-out mutants are listed by
site like survivors. __init__ is excluded from the inventory: its
mutants would measure the export list, not the reference.
The two 253-byte seal cases are removed. The review ran the mutation
both ways: the pre-existing seal boundary case and the seal unit
suite already pin the 253 side of the compact-size boundary, and
with both cases deleted every _compact_size mutant is still killed,
so only the 252 case, which is kept, closes a gap. The largest case
in the corpus pinned nothing.

The two path-cost cases are renamed from interior to trailing zero
bytes: programs 0100 and 000100 place the zero byte at the tail,
and the old names claimed a position the bytes do not have. Case
content is unchanged.
The full re-run after the review fold-ins counts 1,651 mutants:
1,241 killed by the corpus, 225 crashed (detected by Python before
any verdict, no kill credit), 180 survived, 5 timed out. The pass
table gains the crashed column, and the numbers paragraph is
rewritten from the new run: the pytest suite kills 52 of the 180
survivors and 128 survive both oracles.

Three former test kills were artifacts of the mirror's own harness
test mutating the already-mutated package and now survive
genuinely: the error-code guard in errors.py, a frozen flag in
conditions.py, and sha256tree's argument index in operators.py.
Each already sits in an accepted class.

The one new survivor, from the newly mutated expression tests, is
the taproot-versus-scriptpubkey name choice in validation.py's
unsatisfied-scriptpubkey message. It only names a condition in a
message and joins the same-code class beside the name table index.
@EvanWinget

Copy link
Copy Markdown
Owner Author

Review pass (/code-review 64 max, Fable finders, adversarial verify)

Ten confirmed findings. Six are folded into 19a725e, two are vector corrections in dac86ba, and two were dropped at the finding cap but resolved anyway, outcomes below.

  1. Escaping ValueError/KeyError from a mutated reference became vector verdicts, inflating kill counts. Fixed: run_vectors gains MalformedCase and the driver classifies that path as a crash, not a kill.
  2. The driver stopped at the first failing file in alphabetical order, so killed-versus-crashed was an artifact of file ordering. Fixed: the driver continues past crashing files, a vector verdict wins over a crash, and files run cheapest first per the baseline timing.
  3. Under --tests the pytest oracle collected the mirror's own test_mutate.py, which mutated the already-mutated package, built nested mirrors, and manufactured false kills. Fixed: that test and the corpus-runner test are excluded, a generated conftest disables Hypothesis deadlines, and the report keeps each run's output tail so a test kill names its failing test. Three former test kills now survive genuinely, each already in an accepted class.
  4. Negating an if test that is a bare comparison or a not duplicated another mutant, one semantic change under two ids. Fixed: those negation sites are skipped.
  5. AugAssign, IfExp, and While sites went unmutated despite the docstring's claim. Fixed: all three now get the operator swaps and test negations, and the one new survivor they produced (the name choice in the unsatisfied-scriptpubkey message) is triaged into the same-code class.
  6. Worker mirrors accumulated in the temporary directory, and crashed or timed-out mutants went unlisted. Fixed: mirrors are removed at exit, both listings print by site.
  7. The two 253-byte seal cases pinned nothing. The review ran the mutation both ways: the existing boundary case and the seal unit suite already pin the 253 side. Fixed: both cases removed, the 252 case kept.
  8. The path-cost case names claimed interior zero bytes where programs 0100 and 000100 place them at the tail. Fixed: renamed to trailing, content unchanged.
  9. __init__ was inconsistently included in the inventory. Its mutants would measure the export list, not the reference. Fixed: excluded.
  10. Pytest exit code 2 counted as a kill. No change: a collection error means the mutant broke the suite before it ran, and the harness now reports that as crashed through the driver exit codes, which is the intended verdict.

Final pass after the fold-ins: 1,651 mutants, 1,241 killed by the corpus, 225 crashed, 180 survived (52 killed by the pytest suite, 128 survive both, all 180 in the accepted classes), 5 timeouts. Corpus at 1,149 cases with this PR's 17 vectors. Lint clean, CI green.

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