Mutation Coverage with MCY
Ask whether a property set is good enough by changing the design on purpose, and read the result as a statement about the properties.
- Lesson
- F6.5
- Time
- 20 min
- Runs on
- SymbiYosys
- Check
- 4 questions
A FIFO's formal bench had six properties, all proven, and the sign-off review asked how anyone knew six was enough. Someone corrupted one bit of the write data by hand and all six still passed: they checked the count and the flags, never the data. Mutation coverage asks that question forty times, with a script.
| Property set | Killed | Holes | Equivalent |
|---|---|---|---|
| Bookkeeping: count, flags, no push when full, no pop when empty | 24 | 14 | 1 |
| The same plus end-to-end order | 38 | 0 | 1 |
39 mutations of fifo16 and one unmutated control, measured with MCY from the pinned suite.
Change the Design, Watch the Properties
| # | Line | Mutation | Bookkeeping set | Plus order property |
|---|---|---|---|---|
| 1 | - | no change (control) | equivalent | equivalent |
| 2 | 7 | logic_and A[0] (rd[0]) forced to 0 | survived | killed by a_order |
| 3 | 12 | add Y[2] inverted when bit 3 is 0 | killed by a_level | killed by a_level |
| 4 | 6 | eq B[2] forced to 1 | killed by a_full | killed by a_full |
| 5 | 12 | mux B[0] (wdata[0]) inverted when bit 5 is 0 | survived | killed by a_order |
| 6 | 10 | mux Y[0] forced to 1 | survived | killed by a_order |
| 7 | 12 | mux B[0] (wdata[0]) inverted when bit 7 is 1 | survived | killed by a_order |
| 8 | 5 | eq Y[0] (empty[0]) forced to 1 | killed by a_empty | killed by a_empty |
| 9 | 13 | mux Y[1] forced to 0 | killed by a_level | killed by a_level |
| 10 | 14 | add Y[0] forced to 1 | killed by a_level | killed by a_level |
| 11 | 12 | mux B[2] (wdata[2]) forced to 1 | survived | killed by a_order |
| 12 | 14 | sub Y[1] inverted | killed by a_level | killed by a_level |
| 13 | 7 | logic_not Y[0] forced to 1 | killed by a_bound | killed by a_bound |
| 14 | 13 | add Y[1] inverted | killed by a_level | killed by a_level |
| 15 | 7 | logic_not A[0] (full[0]) forced to 1 | survived | killed by a_ahead |
| 16 | 10 | mux Y[0] inverted when bit 3 is 1 | survived | killed by a_order |
| 17 | 7 | logic_and A[0] (wr[0]) forced to 0 | survived | killed by a_ahead |
| 18 | 7 | logic_and Y[0] (do_wr[0]) forced to 1 | killed by a_nopop | killed by a_nopop |
| 19 | 7 | logic_and Y[0] (do_rd[0]) forced to 1 | killed by a_bound | killed by a_bound |
| 20 | 7 | logic_not A[0] (empty[0]) inverted | killed by a_bound | killed by a_bound |
| 21 | 13 | add A[0] forced to 1 | killed by a_level | killed by a_level |
| 22 | 7 | logic_and A[0] (rd[0]) forced to 1 | survived | killed by a_ahead |
| 23 | 12 | mux B[5] (wdata[5]) forced to 0 | survived | killed by a_order |
| 24 | 12 | add A[4] inverted when bit 0 is 1 | killed by a_level | killed by a_level |
| 25 | 7 | logic_not A[0] (empty[0]) forced to 1 | survived | killed by a_order |
| 26 | 7 | logic_not A[0] (empty[0]) forced to 0 | killed by a_bound | killed by a_bound |
| 27 | 7 | logic_and Y[0] (do_rd[0]) forced to 0 | survived | killed by a_order |
| 28 | 13 | mux Y[1] inverted when bit 2 is 0 | killed by a_level | killed by a_level |
| 29 | 6 | eq B[0] forced to 0 | equivalent | equivalent |
| 30 | 14 | add Y[4] inverted | killed by a_level | killed by a_level |
| 31 | 7 | logic_and A[0] (wr[0]) inverted | killed by a_nopop | killed by a_ahead |
| 32 | 5 | eq A[0] forced to 0 | killed by a_empty | killed by a_empty |
| 33 | 12 | add Y[1] forced to 1 | killed by a_level | killed by a_level |
| 34 | 7 | logic_not Y[0] inverted | killed by a_bound | killed by a_bound |
| 35 | 5 | eq A[3] inverted | killed by a_empty | killed by a_empty |
| 36 | 14 | sub Y[4] forced to 0 | killed by a_level | killed by a_ahead |
| 37 | 5 | eq A[1] inverted | killed by a_empty | killed by a_empty |
| 38 | 7 | logic_not Y[0] forced to 0 | survived | killed by a_ahead |
| 39 | 12 | mux B[3] (wdata[3]) inverted when bit 0 is 0 | survived | killed by a_order |
| 40 | 7 | logic_and A[0] (rd[0]) inverted | killed by a_nopush | killed by a_ahead |
Measured: 40 entries, MCY seed 1, flops and memory excluded. Each run is bmc depth 20 on the mutated design; "killed" names the first assertion to fail. Survivors were then checked against the original with abc pdr.
A mutation is the design with one small change: a bit forced, an operator's output inverted, a mux input flipped. Run the whole property set against it. If some property fails, the mutation is killed. If all pass, it survives.
Most kills here come from a_level (11) and a_bound (5). Count and flags see every mutation of the bookkeeping logic.
Harmless or a Hole
Read the diagram as text
- 39 mutations: one small change each
- 24 killed: some property failed
- 15 not caught: every property passed
- 1 equivalent: the outputs never change: discard
- 14 holes: the design changed and nothing noticed
- 39 mutations → 24 killed
- 39 mutations → 15 not caught
- 15 not caught → 1 equivalent
- 15 not caught → 14 holes
The check tries matched-flop LEC first, since the mutation leaves every flop in place. Then a two-copy miter from reset runs under PDR. It compares the flags always and the data whenever the FIFO holds an entry.
A survivor is one of two things. Either the change makes no difference to the outputs, or the properties cannot see a real change. A second test on each survivor, an equivalence check against the original, sorts them.
Here 1 survivor was equivalent: #29 forces bit 0 of the constant 16 in full = count == 16 to 0, which it already is. A matched-flop LEC run proved it in seconds. The other 14 are holes.
Of the holes, 5 corrupt the write data (#5, #7, #11, #23, #39). Most of the rest stop writes or reads from happening. In #17 the write request is tied to 0 inside the accept logic, and a FIFO that never accepts a write breaks none of the six properties.
Name the Missing Property
assert property (@(posedge clk) disable iff (rst)
tracking && do_rd && t_ahead == 0 |-> rdata == t_data);With it added, all 14 holes are killed, by a_order or its helper a_ahead (the followed entry is still inside the count). The rate goes from 24 of 38 (63 %) to 38 of 38 (100 %) of the mutations that change the design. This is the end-to-end property F3.4 argued for.
Cost and Practice
The campaign above took 49.4 s and 30.3 s on a laptop: one full formal run per mutation, plus an equivalence check per survivor. That is why teams build a fixed list once, rerun it when the properties change, and leave it out of every commit's regression.
Mutating a flop changes the clocking the engine can model, so flops and memories were excluded here. Commercial tools offer the same idea as a coverage view beside cone-of-influence and proof-core coverage (F7.1).
Asked in Interviews
| Question | Answer |
|---|---|
| What is mutation coverage, and what does it say? | Small changes to the design, each run against the property set. A non-equivalent survivor is a behaviour the properties cannot see. The rate measures the properties, not the design. |
| Why must it include an equivalence test? | A change that never alters the outputs cannot be caught by any correct property; counting it would blame the properties for nothing. |
| Why not run it on every commit? | Each mutation is a full formal run. Fix the list once and rerun it when the properties change. |
Try It
always @(posedge clk) begin
if ($initstate) begin assume(rst); end else begin assume(!rst); end
end
// registered copies: primary-input history is kept in flops
reg past = 0, wr_q = 0, rd_q = 0, full_q = 0, empty_q = 0;
reg [4:0] count_q = 0;
always @(posedge clk) begin
past <= !rst; wr_q <= wr; rd_q <= rd; full_q <= full; empty_q <= empty; count_q <= count;
end
always @(posedge clk) if (!rst) begin
a_bound: assert(count <= 5'd16);
a_level: assert(count == wr_ptr - rd_ptr);
a_full: assert(full == (count == 5'd16));
a_empty: assert(empty == (count == 5'd0));
if (past && wr_q && full_q && !rd_q) a_nopush: assert(count == count_q);
if (past && rd_q && empty_q && !wr_q) a_nopop: assert(count == count_q);
endtest_props runs the properties, test_eq the equivalence check.[options]
size 40
seed 1
select t:$dff t:$mem_v2 %u %n
tags KILLED SURVIVED EQUIV
[script]
read_verilog -sv fifo16.sv
prep -top fifo16
[files]
fifo16.sv
[logic]
if result("test_props") == "FAIL":
tag("KILLED")
elif result("test_eq") == "PASS":
tag("EQUIV")
else:
tag("SURVIVED")
[report]
print("Killed %d, survived %d, equivalent %d" % (tags("KILLED"), tags("SURVIVED"), tags("EQUIV")))
[test test_props]
expect PASS FAIL
run bash $PRJDIR/test_props.sh
[test test_eq]
expect PASS FAIL
run bash $PRJDIR/test_eq.shRun the sample
As loaded, the runner draws four entries (an unmutated control and three mutations) and proves each: COMPLETED: 2 killed, 2 survived, in 2.9 s. The control is one of the survivors.
Read the full table
Find the survivors on the write-data path. Which property would have to fail for each?
Change the file, change the sample
Add the order property (the
orderblock in the shared source) and the runner draws a different sample, because it samples the cells of the file you give it: COMPLETED: 2 killed, 2 survived. Compare rates only on a fixed list.
design.sv
props.sv
Use assert property (@(posedge clk) ...);. Leave blank for immediate assertions in the design.
Inputs are free unless constrained. Run to find a counterexample or check a proof.
Model: one positive-edge clock, binary data. Sampled history requires declaration-initialized variables; primary-input history is unsupported.
SBY summary and log
Check Yourself
Remember
- A mutation is killed when some property fails on it, and survives when all pass.
- Survivors are either equivalent (harmless) or holes; an equivalence check sorts them.
- The kill rate measures the property set on a fixed mutation list, not the design.
- Each survivor names a missing property: here, end-to-end data order.