A bug report arrives from the lab. The team replays the failing scenario in simulation, adds a temporary internal check, and finds that their regression had triggered the bug: in 40 different tests, thousands of times. Every one of those tests passed. The wrong value was produced, travelled one stage, and was thrown away by a mux that happened to be selecting the other input every time.
How can a test trigger a bug and still pass?
The obvious model of testing is that if the stimulus reaches the buggy logic, the test will fail. Reaching the logic is only the first of three conditions. The bug must also corrupt a value that travels to something the testbench observes, and a check must be looking at that thing at that time and know the right answer. Fail any one and the test passes with the bug fully exercised.
Condition | Meaning | Fails when |
|---|---|---|
Activation | The stimulus drives the buggy logic into the case where it is wrong | The triggering case is never reached (controllability, lesson 3.1) |
Propagation | The wrong value travels to an observed point | Later logic masks it: a mux selects another input, an AND with 0, an overwrite before use, a value never read |
Detection | A check at that point knows the right answer and compares | No check there, or the check is too weak (lesson 2.1) |
Software testing calls the same chain reachability, infection, propagation and revealability (fact V5). It is why a large number of tests can exercise a bug and none of them report it.
A saturating adder feeds a mux, y = mode ? sum : a. The adder's overflow test is written on the 8-bit result, so it can never be true: 200 + 100 gives 44 instead of 255. Test A overflows the adder with mode = 0; test B repeats the same additions with mode = 1. An output check compares y with a model; an internal check compares sum.
// Activation, propagation, detection. A saturating adder feeds a mux: y = mode ? sum : a.
// The adder has a bug: it compares the 8-bit result with 255, so an overflow wraps instead
// of saturating (200 + 100 gives 44, not 255). Test A (slots 1-3) overflows the adder with
// mode = 0: the bug fires but the mux discards sum, so y is correct. Test B (slots 5-7)
// overflows it with mode = 1: the wrong sum reaches y. An output check (black box) compares
// y with a model; an internal check (white box) compares sum with the model's sum.
module tb;
logic clk = 0;
always #5 clk = ~clk;
initial begin $dumpfile("wave.vcd"); $dumpvars(0, tb); end
logic mode = 0;
logic [7:0] a = 0, b = 0, sum, y;
logic [8:0] wide;
assign wide = a + b;
assign sum = (8'(a + b) > 8'd255) ? 8'd255 : 8'(a + b); // BUG: should test wide > 255
assign y = mode ? sum : a;
// stimulus: {mode, a, b} per slot
logic [16:0] stim [0:8] = '{17'h0_0000, 17'h0_C864, 17'h0_FA0A, 17'h0_1020,
17'h0_0000, 17'h1_C864, 17'h1_FA0A, 17'h1_1020, 17'h0_0000};
int slot = 0;
always @(posedge clk) begin
{mode, a, b} <= stim[slot];
slot <= slot + 1;
end
// checkers, sampled at the falling edge when inputs are stable
function automatic logic [7:0] sat(input logic [7:0] x, input logic [7:0] z);
return (9'(x) + 9'(z) > 9'd255) ? 8'd255 : 8'(x + z);
endfunction
int out_err_a = 0, out_err_b = 0, int_err_a = 0, int_err_b = 0;
logic out_bad, int_bad;
always @(negedge clk) begin
out_bad = (y !== (mode ? sat(a, b) : a));
int_bad = (sum !== sat(a, b));
if (slot >= 2 && slot <= 4) begin out_err_a += out_bad; int_err_a += int_bad; end
if (slot >= 6 && slot <= 8) begin out_err_b += out_bad; int_err_b += int_bad; end
end
initial begin
repeat (10) @(posedge clk);
$display("test A (mode 0): output check %0d errors, internal check %0d errors", out_err_a, int_err_a);
$display("test B (mode 1): output check %0d errors, internal check %0d errors", out_err_b, int_err_b);
if (out_err_a == 0 && int_err_a == 2 && out_err_b == 2 && int_err_b == 2)
$display("PASS: in test A the bug fired twice but was masked; only the internal check saw it. In test B it propagated to y");
else $display("FAIL: unexpected error counts");
$finish;
end
endmoduleIt prints:
test A (mode 0): output check 0 errors, internal check 2 errors
test B (mode 1): output check 2 errors, internal check 2 errors
PASS: in test A the bug fired twice but was masked; only the internal check saw it. In test B it propagated to yHow to read it: values are in hex. In slots 1 and 2, a + b overflows (c8 + 64 and fa + 0a) and sum is wrong (2c, 4 instead of ff), so the bug is activated. mode is 0, so y shows a (c8, fa): the wrong value does not propagate, and the output check sees nothing. In slots 5 and 6 the same additions run with mode = 1, the wrong sums reach y, and the output check detects both. The internal check on sum catches the bug in both tests, because it observes the value before the mux can mask it.
Selection: a mux, arbiter or priority encoder picks a different source.
Overwrite: a register is rewritten before anything reads the wrong value.
Logical masking: AND with 0, OR with 1, a saturating clamp, a comparison that hides small errors.
Unused results: a speculative or predicted value that is discarded (a mispredicted branch, a cancelled request).
Error correction: ECC or retries silently repair a wrong value, hiding the logic bug that produced it.
Performance-only effects: DCRouter's BUG_9 loses credits, which only slows the output (lesson 3.2).
To improve | Do this |
|---|---|
Activation | Reach the triggering states on purpose: bias, directed sequences, coverage of the trigger (Part 6) |
Propagation | Vary the conditions downstream (modes, selections, back-pressure) so the wrong value has a path out; read everything at the end |
Detection | Check close to the source (internal assertions), check every output, and use a complete oracle |
Practice | Purpose |
|---|---|
Assertions on intermediate values | Detect at the source, before masking |
Coverage of trigger conditions and of propagation conditions | Know whether the bug could have been activated and observed |
Mutation testing of the testbench | Plant bugs and check the testbench reports them (Part 6) |
The opening story is illustrative but common: a regression that triggered a bug thousands of times and never reported it. Coverage said the overflow case was hit, so the team believed it was tested. Coverage of activation is not evidence of detection; only a check that sees the value, or coverage that the wrong value reached an output, is.
Propagation: the bug was activated (sum was wrong) but the mux selected a, so the wrong value never reached y.
It observes sum directly, before the mux, so masking downstream cannot hide the wrong value from it.
That a check compared the overflow result with the right answer, or that the wrong result would have reached a checked output; activation alone proves nothing.
A test finds a bug only if it activates it, the wrong value propagates, and a check detects it.
Masking by selection, overwrite, logic or discard lets a test trigger a bug and pass.
Check close to the source and vary downstream conditions; coverage of the trigger is not evidence of detection.