An implication a |-> b passes in every cycle where a is false, without looking at b. If a is never true, the property passes forever and checks nothing. This is called a vacuous pass. It happens through typos, through assumptions that are too strong, and through depths that are too small, and it looks exactly like a real pass. This article shows a vacuous property that hides a bug in a state machine, and how to catch it.

8 min read | Intermediate

What You'll Learn

  • Explain what a vacuous pass is and why it is dangerous
  • List the common causes: unreachable triggers, over-constraining, small depth
  • Detect vacuity with a cover for the trigger, or a meant-to-fail check in the lab
  • Fix a property whose trigger can never happen

Passing Without Checking

An implication is a conditional rule: "if the trigger happens, the consequence must hold". In a cycle without the trigger, there is nothing to check, so the property holds. That is correct and necessary: req |=> ack should not fail in cycles without a request.

The trouble starts when the trigger never happens at all. Then every cycle is a cycle without the trigger, and the property passes without ever evaluating its consequence. The result is PASS, the same word as for a property that was checked a thousand times.

Common Causes

  • A trigger that cannot happen in the design: a typo in a state value, a comparison with a value wider than the signal, two conditions that exclude each other.
  • A trigger removed by assumptions: an over-constraint, as in the over-constraining article, makes the trigger impossible.
  • A trigger out of reach of the depth: a bounded run that stops before the trigger can happen, like the saturating counter in the first formal run, which never reached 12 at depth 10.
  • A trigger tied off by the setup: an input held at a constant by an assumption or a wrapper, so the feature it enables is never used.

Commercial tools detect vacuity automatically and report a vacuous pass as a separate status. With open-source tools you check it yourself.

Quick Check

Q: A 2-bit state register is checked with state == 3'd4 |=> .... What is the result, and why?

Show Answer

A vacuous pass. A 2-bit register can hold 0 to 3, so it can never equal 4. The trigger never happens and the consequence is never checked.

The Design: a Small State Machine

A controller goes from IDLE to RUN on start, to DONE on stop, and back to IDLE. This version has a bug: after DONE it goes back to RUN:


module fsm_vac (input clk, input rst, input start, input stop, output reg [1:0] state);
  localparam IDLE = 2'd0, RUN = 2'd1, DONE = 2'd2;
  always @(posedge clk)
    if (rst) state <= IDLE;
    else case (state)
      IDLE:    if (start) state <= RUN;
      RUN:     if (stop)  state <= DONE;
      DONE:               state <= RUN;     // bug: should go to IDLE
      default:            state <= IDLE;
    endcase
endmodule

The engineer writes the rule "after DONE, the machine is IDLE", but types the state value by hand and gets it wrong:


a_done: assert property (@(posedge clk) disable iff (rst) state == 2'd3 |=> state == IDLE);  // typo: DONE is 2

State 3 does not exist in this machine. The property passes on the buggy design, and it would pass on any design.

Catching It: Cover the Trigger

For every implication, cover its trigger:


c_done: cover property (@(posedge clk) disable iff (rst) state == 2'd3);

The cover is unreachable, and that is the alarm. In SymbiYosys cover mode the log says "Unreached cover statement". Once you see it, the typo is easy to find. Use the named state instead of a number:


a_done: assert property (@(posedge clk) disable iff (rst) state == DONE |=> state == IDLE);
c_done: cover  property (@(posedge clk) disable iff (rst) state == DONE);

Now the cover is reachable, and the assertion fails at step 5 with a trace in which the machine goes from DONE back to RUN. Fix the design and it passes.

Running It in ChipVerify Lab

The lab panel does not run covers, so the trigger check is written as an assertion that is meant to fail: "the trigger never happens". If it fails, the trigger is reachable. If it passes, every implication with that trigger is vacuous.


`ifdef FORMAL
  reg init = 1'b1;
  always @(posedge clk) init <= 1'b0;
  always @(*) if (init) assume (rst);
  // intended: after DONE the machine goes to IDLE
  // typo: compares with 3 instead of DONE (2), so the trigger never happens
  always @(posedge clk) if (!init && !$past(rst) && $past(state) == 2'd3) assert (state == IDLE);
  // trigger reachable? meant to fail
  always @(posedge clk) if (!init && !$past(rst)) assert (!($past(state) == 2'd3));
`endif

On the lab engine everything passes, including the check that was meant to fail. That pass is the warning: the trigger $past(state) == 2'd3 never happens within the depth.

Quick Check

Q: A trigger check passes at depth 10 and fails at depth 30. Is the property vacuous?

Show Answer

At depth 10 it is: the trigger cannot happen within 10 cycles, so the property was not checked. At depth 30 the trigger is reachable and the property is really checked. The fix is a larger depth, not a different property.

Common Mistakes

Mistake #1: Writing state values as numbers in properties

A wrong number makes a trigger impossible without any error. Use the same named constants as the RTL, so a typo becomes a compile error or a real comparison.

Mistake #2: Reporting a pass without checking the triggers

A list of passing assertions means little until each trigger is shown to be reachable. Cover every trigger and report the covers next to the assertions.

Mistake #3: Forgetting that the depth matters for vacuity

A trigger that needs 40 cycles is unreachable in a 10-cycle run. A vacuous pass due to depth looks the same as one due to a typo. The trigger check tells you which one it is when you vary the depth.

Practice Exercise

Challenge: From Vacuous to Real

Open the example and run it. Everything passes. Change both 2'd3 in the formal block to the named state DONE and run again. Read the counterexample, fix the state machine, and run a third time. What does each of the three results tell you?

Hint

After the change, two assertions refer to DONE: the real rule and the meant-to-fail trigger check. Which one should fail on a correct design?

Solution

First run: everything passes, including the trigger check, so the rule is vacuous. Second run: both assertions fail at step 5. The trigger check fails because DONE is reachable, and the rule fails because the machine goes from DONE to RUN. Third run, with DONE: state <= IDLE;: only the trigger check fails, at step 5. That is the good outcome. The trigger happens and the rule holds whenever it does.

Open the ready-made example in the lab and run the formal check from the formal panel. The free tier allows a depth of up to 10 cycles, and PRO allows up to 100.

Summary

  • An implication passes vacuously when its trigger never happens. The result looks like a real pass.
  • Causes: impossible triggers, over-constraining assumptions, too little depth, and inputs tied off by the setup.
  • Cover every trigger. In the lab, assert that the trigger never happens and expect that check to fail.
  • A typo in a state value made "after DONE, go to IDLE" pass on a buggy state machine. With the named state it failed at step 5.
  • Use named constants in properties, and report trigger reachability together with assertion results.
Did you find what you were looking for?

Quiz

No quiz questions available for this article.