Mutation testing (mutation)¶
mutation checks that your tests catch bugs. It plants small bugs (Mutants) in
the RTL, runs the Target's tests against each one, and reports how many Mutants
the tests detected. A passing simulation only shows the tests accept the
design; a high mutation score shows they would notice if it were wrong. No LLM
is involved: the same RTL, --count, and --seed always give the same
Mutants, so scores are reproducible and comparable across runs. For what all
Flows share (choosing Targets, common options, logs, exit codes), see
Running Flows.
Mutation drives a Simulation Target and runs the whole suite of that Target's Tests, so the Tests must already pass.
Running mutation testing¶
- Check that the Project has a simulation Target (
booley targets --for simlists them). There is no--for mutation: mutation runs on any simulation Target. -
Ask the Developer Agent:
"Check whether the FIFO tests catch injected bugs."
"Run mutation testing on
sim_fifowith 20 Mutants." -
The agent reports how many Mutants were detected, which survived, and the path to the report.
To reproduce a run yourself inside the Sandbox:
booley flow mutation --target sim_fifo --count 20 --seed 1
Mutation takes exactly one Target; --target is required and a list is
refused.
| Option | Effect |
|---|---|
--scope <file,...> |
RTL files to plant Mutants in. Default: the Target's own RTL, without testbench, vendored, and generated files. |
--count <N\|auto> |
Number of Mutants (default 10). auto derives it from the size of the scope, capped by the time limit (about one Mutant per 150 seconds, at least 3). |
--seed <N> |
Non-negative integer that selects the Mutants (default 0). Change it to try a different sample. |
--min-detected <N> |
Detected Mutants needed to pass. Default: 80 % of --count, rounded up. |
--dry-run |
Validate the Target and scope and print the count, threshold, and seed. Nothing is built or simulated. |
The shared options (-C/--project, --timeout, --report-dir, --diagnostic,
-q/--quiet) work as for every Flow.
How a run works¶
- Booley selects the Mutants for the seed from the scope files.
- It builds the unmodified RTL and runs every test of the Target. All must
pass; otherwise the run ends with
ERROR, because a failing test could not tell a Mutant apart from the original. - Each Mutant is applied to a private copy of the sources, built, and the Target's tests run on it. Your RTL is never modified.
- A Mutant is detected when a test fails on it, or when a test runs past
its time limit (reported as
detected (timeout)): ten times the test's baseline duration, at least 30 seconds and at most 300 seconds. A Mutant that no test fails on survives. A Mutant that does not compile isinvalid: it is reported, not scored, and can never be detected, so it uses up part of the threshold's margin.
Every run rebuilds the baseline and every Mutant, so a result never depends on stale builds. Some survivors may be equivalent Mutants that change no observable behavior, which is why the default threshold is 80 %, not all.
Which Mutants it plants¶
Booley parses each scope file (Verilog or SystemVerilog) and finds every spot where one of six operators applies. Each operator makes small changes of the kinds below, one change per Mutant; a spot with several possible changes yields one candidate Mutant per change.
| Operator | What it changes | Example |
|---|---|---|
relational |
Swaps a comparison for its neighbour. | a < b -> a <= b, a == b -> a != b |
arithmetic-logical |
Swaps an arithmetic, bitwise, logical, or shift operator. | a + b -> a - b, a & b -> a \| b, a << 2 -> a >> 2 |
arithmetic-logical |
Removes a unary ~, -, or !; !x becomes x != 0, one bit wide. |
~a -> (a), !b -> ((b) != 0) |
arithmetic-logical |
Swaps a reduction operator or drops its negation. | &a -> (\|(a)), ~^a -> (^(a)) |
arithmetic-logical |
Drops one operand of &&, \|\|, &, or \|, so the other passes through at the same width. |
p && q -> 1'b1 && q, a & b -> ((a) \| ~(a)) & b |
arithmetic-logical |
Exchanges >>> and >> where the shifted operand is signed. |
s >>> 1 -> s >> 1 |
arithmetic-logical |
Turns a signed cast into an unsigned one. | $signed(x) -> $unsigned(x), signed'(x) -> unsigned'(x) |
condition |
Replaces the condition of an if or ?: with its negation, 1'b1, or 1'b0. A while, do-while, or for condition is only forced to 1'b0. |
if (valid) -> if (!(valid)) |
constant |
Changes a numeric literal's value: lowest bit flipped, plus one, or minus one. Size and base stay. | 4'd5 -> 4'd4, '0 -> '1 |
constant |
Changes a literal in the initializer of a module-level variable. | reg [7:0] cnt = 8'd3; -> 8'd2 |
constant |
Replaces an enum member with the previous or next member of its enum. | state == IDLE -> state == DONE |
bit-select |
Moves a literal bit index or slice bound up or down by one, within the declared range. | data[3] -> data[2], data[7:4] -> data[6:3] |
bit-select |
Swaps two neighbouring operands of a concatenation. | {hi, lo} -> {lo, hi} |
deletion |
Removes one assignment, net initializer, or case item. |
q <= d; removed, case branch removed |
/, %, and ** are never swapped, so Mutants do not divide by zero.
Mutations also reach the arguments of $signed and $unsigned and the
expressions in the port connections of module and interface instances.
Table details: a !x that is a whole if or ?: condition is left to the
condition operator. Dropping an operand puts the operator's identity there
(1'b1 for &&, all ones for &), and an operand that calls a function or
changes a variable is never dropped. A reduction swap trades & and \|,
turns ^ into \|, and removes the ~ of ~&, ~\|, and ~^. A sign cast
is never turned the other way. An enum member wraps around its enum, which
must be declared in the same file; a two-member enum yields one Mutant. A
concatenation swap skips equal neighbours, replication counts, streaming
concatenations, and assignment patterns.
Only design code is mutated. Booley skips:
- testbench, vendored, and generated files (by default);
- classes, programs, checkers, covergroups, and every assertion, because a mutated assertion fires and would count as a detection;
- parameter defaults and overrides, widths, delays, attributes, and the parts
of a
forloop that decide how long it runs; - the arguments of system calls other than
$signedand$unsigned; - port connections of gate primitives,
binddirectives, and checker and program instances, connections that are a bare name, and every bit-select move and deletion in a connection; - code a macro or include supplies, and the inactive branch of an
ifdef(Booley applies the build's defines); - spots that could make a loop run forever or drive a variable from two
places, and
caselabels (moving one can overlap another item).
How it chooses which Mutants¶
- Booley collects the candidates from all scope files. Identical spots count once.
- Within each operator, candidates are ranked by a hash of
--seedand the Mutant's ID. The ID comes from the operator, file, and position inside its module, so editing code elsewhere in the file does not change it. - The six operators are then taken in turn, one candidate each, in the order
relational, arithmetic-logical, condition, constant, bit-select, deletion,
until
--countMutants are chosen. A small--counttherefore covers different kinds of bugs instead of many of one kind.
If fewer candidates exist than --count, all of them run and the report
says so. The knobs are --scope (where to look), --count (how many), and
--seed (which sample); there is no filter by operator. The same RTL, scope,
count, and seed always choose the same Mutants.
Reading results¶
The report starts with what reproduces the run, then the tally and the verdict:
[mutation_test] 10 Mutants (seed 0, count 10, generator 3, pyslang 9.0.0)
[mutation_test] sim sweep: 10/10 tested 4m
8 detected (1 timeout), 0 invalid, 2 not detected
detected: 8/10 (required: 8/10, default 80% of --count)
NOT DETECTED [arithmetic-logical] rtl/alu.sv:42: 'a + b' -> 'a - b'
…
RESULT: PASS (8/10 detected)
Survivors are listed with file, line, and the original and mutated code: each is a spot your tests do not check.
| Verdict | Meaning | Exit |
|---|---|---|
PASS |
Detected Mutants reach the threshold. | 0 |
FAIL |
Fewer Mutants were detected than required. | 1 |
ERROR |
No verdict: a failing baseline, a Target that cannot run, an unusable --scope entry, an invalid option, or a build or infrastructure failure. |
2 |
Each run's files are in its numbered run directory, under flow-reports/ in
the Booley Directory or --report-dir
(Reports):
| File | What it holds |
|---|---|
mutation/<N>/campaign/mutation-results.md |
The Mutants and the outcome of each. |
mutation/<N>/campaign/mutation-specs.md |
Every Mutant: operator, file and line, original and mutated code. |
mutation/<N>/campaign/manifest.json |
The run's identity (seed, count, generator and pyslang versions, scope hashes) and each Mutant's status. |
mutation/<N>/campaign/variants/ |
The mutated source of each Mutant. |
mutation/<N>/campaign/baseline.log, mutant-logs/ |
The simulator output of the baseline and of each Mutant. |
Configuration ([flows.mutation])¶
[flows.mutation]
enabled = true # false turns the Flow off
timeout_ms = 1800000 # default: 30 minutes for the whole run
timeout_ms (or --timeout) sizes --count auto: the budget is divided to cap
the number of Mutants, so a larger limit allows more of them. Over MCP it also
raises the wait before the call hands back a run id. The command line does not
stop a run when the budget passes. See
Flow and Specialist availability.
Goals¶
A MUTATION:{target} Goal is met by this Flow's result: the Flow records the
mutation_score criterion. Your own runs never count as Goal evidence. A Goal
sets the campaign with total: N, min_detected: K, or auto: true in place of
--count and --min-detected; without them it requires 8 of 10 Mutants.
Source: docs/user/FLOW-MUTATION.md on GitHub