A team ran full proofs per commit, each taking hours, and within a month engineers stopped waiting and stopped committing cleanly. Another team ran nothing per commit and found every breakage at release. Both failed the same way: the wrong set at the wrong cadence. The per-commit set must finish in minutes; the nightly set may take hours; both must report the same three outcomes.
Job of this lesson: split the two sets, describe the job that runs them, and build the gate between its ledger and the commit.
The per-commit set holds small bounded checks and every required cover: it answers whether the obvious still holds, fast. The nightly set holds the full proofs to their planned depths: it answers whether everything still holds, thoroughly. Both are one CI job, described in generic terms by four things: the tasks it runs, how they run in parallel, where the ledger lands, and what fails the build. A timeout is inconclusive, never a pass, and a newly inconclusive proof blocks the build like a failure until it is triaged:
Job | Per-commit | Nightly | Release |
|---|---|---|---|
Tasks | Bounded checks to the planned small depth, every required cover | Full proofs to the planned depth, mutation spot-checks | Every proof rerun on the final RTL |
Parallelism | One task per block; the job ends when the slowest task ends, in minutes (illustrative) | One task per property group, so one slow property cannot starve the rest; hours (illustrative) | As needed |
Ledger | One row per property and cover: outcome, depth, RTL revision hash | The same rows, kept per night for the diff | Pinned with the release hash |
Fails the build when | Any counterexample, any required cover not reached, any row from another revision | Any counterexample, any previously proven property now inconclusive | Anything but proven with current evidence |
The script below implements the gate exactly as specified. A property row ends proven, counterexample or inconclusive. A cover row ends reached, or inconclusive when it was not reached within its depth; it is never called unreachable without an engine proof of that. The gate fails on any counterexample, any required cover not reached, any proof that was proven before and is inconclusive now, any row that ran on another RTL revision, and any status outside the allowed ones. The clean ledger goes through; the failing ledger, with one counterexample, one cover not reached and one newly inconclusive proof, is rejected on all three counts, and each of the other rules is tested on its own:
OUTCOMES = ("proven", "counterexample", "inconclusive")
COVER_OUTCOMES = ("reached", "inconclusive")
def gate(run, previous, revision):
prev = {p["name"]: p["status"] for p in previous}
failures = []
for p in run:
allowed = COVER_OUTCOMES if p["kind"] == "cover" else OUTCOMES
if p["status"] not in allowed:
failures.append(p["name"] + ": unknown status " + p["status"])
elif p["status"] == "counterexample":
failures.append(p["name"] + ": counterexample")
elif p["kind"] == "cover" and p["status"] != "reached":
failures.append(p["name"] + ": required cover not reached within its depth")
elif p["status"] == "inconclusive" and prev.get(p["name"]) == "proven":
failures.append(p["name"] + ": newly inconclusive")
if p["rtl"] != revision:
failures.append(p["name"] + ": row from another revision")
return failures
def row(name, kind, status, rtl="r7"):
return {"name": name, "kind": kind, "status": status, "rtl": rtl}
previous = [row("a_conserve", "proof", "proven", "r6"), row("c_stall", "cover", "reached", "r6"),
row("a_data", "proof", "proven", "r6")]
good = [row("a_conserve", "proof", "proven"), row("c_stall", "cover", "reached"), row("a_data", "proof", "proven")]
assert gate(good, previous, "r7") == []
bad = [row("a_conserve", "proof", "counterexample"), row("c_stall", "cover", "inconclusive"),
row("a_data", "proof", "inconclusive")]
caught = gate(bad, previous, "r7")
assert len(caught) == 3
assert any("newly inconclusive" in f for f in caught)
assert any("cover not reached" in f for f in caught)
sneaky = [dict(r) for r in good]
sneaky[2]["status"] = "timeout-pass"
assert any("unknown status" in f for f in gate(sneaky, previous, "r7"))
unreached = [dict(r) for r in good]
unreached[1]["status"] = "unreached"
assert any("unknown status" in f for f in gate(unreached, previous, "r7"))
stale = [dict(r) for r in good]
stale[0]["rtl"] = "r6"
assert any("another revision" in f for f in gate(stale, previous, "r7"))
print("PASS: clean ledger passes; counterexample, uncovered cover, drift, stale row and odd labels all fail")The per-commit set is timed in minutes and the nightly set in hours across parallel tasks.
The gate fails on counterexamples, required covers not reached, newly inconclusive proofs and rows from another revision.
No status outside the allowed ones can pass the gate; unknown labels fail loudly.
Test the gate quarterly with a seeded failing ledger: one counterexample, one cover not reached, one timeout. If any of the three passes the gate, the reporting chain is broken upstream of the gate and no result downstream can be trusted. Keep the previous run used for comparison pinned to a revision hash; comparing against a floating baseline hides drift.
Block until triaged: a newly inconclusive proof is a regression signal, and the next lesson says how to tell drift from a real bug.
Because ignoring them repeats the oldest failure in this course: a timeout counted as a pass. Anything the gate does not understand fails loudly.