BMC solving, witnesses, and replay boundaries
Bounded model checking (BMC) turns one finite execution horizon into a Z3 query. The solver result, however, is only the first of three distinct claims:
solving says whether a bounded objective has a model;
decoding projects a SAT model into a public macro-step trace; and
replay checks that the projected observations agree with
SimulationRuntime.
Keeping those claims separate is essential. A SAT result can carry a useful witness without proving anything beyond the selected bound. A successful replay can expose agreement between the SMT encoding and the runtime without proving that either implementation is complete for every possible trace.
The claim ladder is deliberately one-way:
Layer |
Input |
Claim it can make |
Claim it cannot make |
|---|---|---|---|
Solve |
\(C_N\), the property objective, and optional tail observation |
The bounded SMT formula is SAT, UNSAT, unknown, or timed out. |
It does not expose a public trace or prove runtime agreement. |
Decode |
A SAT model from the main solve |
The model can be projected into a public macro-step trace. |
It does not decide whether the trace is a desired behavior or a violation; polarity does that. |
Replay |
The decoded public trace |
The decoded observations agree with |
It does not prove all models decode, all cases are encoded correctly, or the property holds beyond \(N\). |
One incremental solver, staged feasibility
Let \(D_N\) be the bounded domain, \(I_0\) the retained initializer, \(T_N\) the macro-step transition relation, and \(ENV_N\) the query environment constraints. The solver keeps the following cumulative spaces:
For a compiled property objective \(Obj_q\), the primary query is \(\Phi_q = S_{\mathrm{assume}} \land Obj_q\).
The primary result is interpreted first. If it is UNSAT, the solver checks
\(S_{\mathrm{assume}}\) and, only when necessary, \(S_{\mathrm{init}}\)
and \(K_N\) to distinguish an objective-only UNSAT from an infeasible
scenario. These checks are staged on one incremental solver; SAT prefix
evidence may be marked inferred rather than being solved again.
For a response property, \(\Omega_q\) denotes the observation that an obligation remains beyond the bound, and the optional suffix query is \(\Psi_q = S_{\mathrm{assume}} \land \Omega_q\).
It is evaluated only after \(S_{\mathrm{assume}}\) is known SAT and only
when check_incomplete is enabled and the suffix formula is non-trivial.
The suffix model is an incomplete_suffix role; it does not turn an
incomplete response into a property verdict.
pyfcstm.bmc.witness.solve_bmc_property() creates one incremental solver
and one shared budget per public solve. timeout_ms=None does not install a
Z3 timeout. A finite timeout_ms is a monotonic total budget shared by the
primary, feasibility, and applicable suffix checks; each check receives only
the remaining milliseconds. When the budget is exhausted, later checks are
not called and their evidence remains not_checked.
Z3’s unknown result is split by reason_unknown(): the exact reason
"timeout" becomes public status timeout; other reasons remain
unknown. Neither status carries a model. Main elapsed time is stored in
elapsed_ms; suffix elapsed time is retained as
incomplete_elapsed_ms=.... Disabling the suffix check is observable as
incomplete_check=disabled rather than being treated as a proof that no
incomplete suffix exists.
Verdicts are polarity-aware
SAT has opposite meanings for the two property families. reach,
exists_always, and cover use witness polarity: SAT finds the behavior
requested by the property. forbid, invariant, must_reach, and
response use counterexample polarity: SAT finds a violation.
Write \(p \in \{W,C\}\) for witness or counterexample polarity,
\(q\) for the property kind, \(s\) for the main solver status, and
\(t\) for the response-tail solver status. The incomplete condition is
deliberately narrow: only counterexample-polarity response with a main
UNSAT result and a bad tail status is incomplete. A tail result cannot weaken a
main SAT response counterexample and cannot affect any other property kind. The
public three-valued property verdict is:
This is the implementation behind BmcSolveResult.property_satisfied. The
stable outcome strings refine the same map:
Polarity / property |
Main status |
Tail condition |
|
|---|---|---|---|
witness |
|
irrelevant |
|
witness |
|
irrelevant |
|
counterexample |
|
irrelevant |
|
counterexample |
|
absent, irrelevant, or tail proved UNSAT |
|
counterexample |
|
tail bad: unchecked, SAT, unknown, or timed out |
|
either |
|
irrelevant |
|
A response counterexample is decisive as soon as the main formula is SAT. A simultaneously satisfiable tail observation does not weaken that concrete violation. The asymmetric special case exists only for main UNSAT: before claiming satisfaction, the implementation must exclude a trigger whose full response window falls beyond frame \(N\).
Generic witnesses and counterexamples
The witness schema is generic: it records a SAT model for the main objective.
For witness-polarity properties, that generic witness is the behavior the user
asked to find. For counterexample-polarity properties, the same decoded schema
records a counterexample because SAT means the violation objective was
satisfied. The word counterexample therefore names the interpretation of a
primary SAT result, not a separate trace format.
A tail SAT model for response incompleteness is different. It supports the
incomplete horizon diagnostic, but it is not decoded and replayed as the
primary user witness because the main objective was UNSAT. Conversely, when the
main response objective is SAT, the decoded primary trace remains a decisive
counterexample even if a separate tail observation is also satisfiable.
From a model to a public witness
The raw Z3 model contains solver symbols and implementation details. It is not
the public witness schema. pyfcstm.bmc.witness.decode_bmc_witness()
projects the model onto \(N+1\) frame observations and \(N\) macro-step
observations:
Here \(q_i\) and \(\mathbf{x}_i\) are the public state path and persistent variables; \(\iota_i\) and \(\tau_i\) mark the initial and terminated sentinels. Each step records the selected case \(c_i\), delta and gamma progress flags, sparse replay inputs \(I_i\), ordered event accounting \(U_i\) (consumed and derived unconsumed events), and abstract call records \(A_i\).
The projection is deliberately sparse. True event Booleans are included in
input_events only when the selected case, an explicit true assumption, or
response-property support needs them for replay. Negative assumptions and
other inspected event values may appear in event_reads as debugging data,
but they are not passed to runtime.cycle(). Case labels, gamma, and
progress remain witness-side explanations; delta is also emitted as a
public runtime-step observation and is checked during replay.
Decoding therefore has a strict caller boundary: it accepts a compiled formula
and a z3.ModelRef that the caller obtained from the SAT main solve. It does
not perform a third satisfiability check. Invalid model values, a missing or
multiply selected case, and inconsistent internal event support fail loudly as
BmcBuildError because silently manufacturing a partial trace would make
replay evidence meaningless.
Replay agreement and its limits
Replay initializes SimulationRuntime from the witness’s public initial
metadata, calls cycle() with only each step’s sparse input-event paths, and
records runtime frames, event accounting, and abstract handler contexts. Let
\(W\) be the decoded trace and \(R(W)\) that captured runtime trace.
The success flag is the conjunction of the public comparisons:
where frame equality covers state, termination, persistent-variable keys and
values, and step equality covers input, consumed and unconsumed events, the
delta result, plus ordered abstract-call metadata and snapshots.
Floating-point values use the
explicit replay tolerance rather than bitwise equality. The initial sentinel
is compared against the runtime state produced by cold initialization, not
mistaken for an ordinary state path.
The following trace shows the ownership boundary for a one-step transition:
Stage |
Input |
Observable result |
|---|---|---|
Solve |
\(C_1 \land Q_1\) |
|
Decode |
model symbols |
two frames; selected transition; sparse input event; event accounting |
Replay |
initial metadata plus the sparse input event |
two runtime frames and one captured runtime step |
Compare |
decoded and runtime observations |
|
Case labels and solver-only progress flags are intentionally absent from
\(\operatorname{eq}_S\). A runtime cannot disagree about information it
does not publish. Conversely, delta, event consumption, and abstract-call snapshots
are included because matching only the final state would miss behaviorally
important divergence.
Counterexample: replay is not a proof of the encoder
Suppose a decoded witness says that frame 1 has x=2, while the runtime
reaches the same state with x=1. Replay returns structured evidence such
as:
ok: false
path: frames[1].vars.x
expected: 2
actual: 1
message: value mismatch
This falsifies alignment for that witness; matching state names alone cannot
hide the variable-effect error. The converse is weaker: ok=True proves
agreement only for the decoded public observations on this finite trace. It
does not prove that unselected cases are encoded correctly, that all SAT models
decode, that the query is true beyond \(N\), or that BMC and the runtime do
not share the same modeling mistake.
Where a conflict lies, and why the answer is exclusive
When the scenario is infeasible, the first useful question is not which clause
but which part. The staged solve already answers it: the kernel, the
initialization, and the assumptions enter the solver in that order, so the stage
at which satisfiability is lost identifies the family. A kernel that is already
unsatisfiable is a kernel_conflict and implicates the model rather than the
query. A kernel that holds until init arrives gives an
initialization_* family; one that holds until assume arrives gives an
assumptions_* family.
Within a family the three members are distinguished by what the new clauses
disagree with. *_self_conflict means the new clauses contradict each other
and would fail with nothing else present. *_domain_conflict means each is
individually consistent but together they leave a frame with no legal value –
they exceed what the frame domain allows. assumptions_prefix_conflict (and
its initialization counterpart) means the clauses are consistent with the domain
too, and only the transition relation rules them out: nothing the machine can do
reaches the required combination.
The seven values are exclusive because each is decided by the first stage or sub-check that fails, and the checks are ordered. That is why the report prints one classification rather than a set, and why a reader can act on it: the classification names the file they should open.
Sufficient is not minimal
The solver’s own unsat core is sufficient: removing all of it makes the formula satisfiable. It is not minimal: it may contain clauses that play no part in the conflict, because the solver stops as soon as it has enough. A reader handed a sufficient core has to guess which members matter.
Minimization removes the guesswork by testing each member: drop it, re-solve,
and keep the drop only if the remainder is still unsatisfiable. A core that
survives this for every member is subset_minimal – every member is load
bearing, so every member is worth reading. The published claim distinguishes
the two states honestly: raw when no member was tested,
partial_minimized when the budget ran out mid-way, subset_minimal when
all of them were.
This matters because minimality is what makes the next stage possible at all. A proof step has to say which core member it restates, and a sufficient-only core has members that restate nothing.
What the proof is trusted on
A published proof is a claim that each step was checked. The interesting design question is by whom, because a checker that shares code with the constructor agrees with it by construction and proves nothing.
Four methods divide the work. Input nodes use core_binding: the normalized
fact is re-encoded from scratch and the solver is asked to refute both
group => fact and fact => group. Both directions are required. One
direction alone would allow a fact that is merely implied by the core member,
which is a summary rather than a restatement – and a summary can drop exactly
the detail a reader needed. If either direction comes back satisfiable, unknown,
or times out, the proof does not reach complete.
Derived and root nodes use rule_checker: an independent checker takes the
premises and the claimed conclusion and re-derives it, without calling the code
that produced it. It compares whole conclusion mappings rather than selected
fields, and refuses a conclusion carrying a field it does not recognize, so a
constructor that quietly adds information cannot slip it past.
solver_entailment covers derived and root steps the solver discharges instead.
case_condition_entailment is one: whether the core members establish a case’s
condition is a question about their constraints, and a rule checker sees only the
published facts, which do not contain it. The node names the members that entail
the condition, and those members are asserted to be a subset of the published core
– a step resting on something outside it would break the minimality the proof
claims for its own leaves.
A group that holds one requirement per case is a conjunction, and no single fact
can imply the whole of it – so an input restating one of those requirements uses
core_binding_unit instead. The same two directions are refuted, against that
one requirement rather than against the group, and the node names which one it was
through unit_index beside unit_count. The pair is what lets a reader see
the proportion covered: “requirement 5 of 12” says something that “the transition
relation” does not. A fact equivalent to two requirements identifies neither, so
the binding is refused rather than resolved – an index a reader cannot rely on is
worse than no index.
The step relations are the only groups that decompose this way, so a query whose
core rests on one of their cases is where the pair is published: such an input
carries core_binding_unit while the other members of the same core carry
core_binding, and the node names which requirement of the relation the case
restated. The pair reaches a consumer of the JSON result; the terminal report
carries each node’s sentence rather than how it was checked. For a while the pair
was defined and never published, because the
attribution stopped at the binding check and never reached the node – a gap that
read from the outside exactly like a method no query could produce.
The boundary is therefore: a reader may trust that each sentence follows from the core members named beside it, and may not trust that the encoding faithfully models their intent. The proof is about the constraints as encoded. That is the same boundary replay draws for a witness, for the same reason.
Why some conflicts have no proof
The proof depth degrades rather than fabricating, and the reason is structural rather than incidental.
An input node stands for one core member, and the core is the set of authored
clauses plus generated support groups. A fact about an intermediate frame – what
a variable holds after two steps, for instance – is not authored anywhere; it is
derived from a transition rule. So a conflict that only becomes visible after
accumulating across steps has no core member to attribute its key facts to, and
the closure has nowhere to start. Such a query reports achieved_mode as
formal.
For those conflicts the formal explanation is not a lesser answer. It names the
classification, the subset-minimal core and every source location, and its
narrative can name the initial state and the conflicting values – which a proof
built without the intermediate facts would lose. A reader chasing a cross-step
conflict is better served by formal today, and the report says so in its
reason line rather than leaving them to wonder.
Three rules were out of reach for one shared reason until recently, and the account
is kept here because the shape it describes is still what a reader meets. A case
publishes the assignment it makes, but the assignment holds where the case
applies, and the evaluation rule refuses an expression carrying a condition –
rightly, since “x increases by one under C” together with “x is 0” does not
give “x is 1” unless C is established. Nothing established it, so
transition_assignment had no usable premise, and equality_substitution and
arithmetic_evaluation waited one step further back on the
arithmetic_expression it produces.
case_condition_entailment establishes it. The condition is proved from the core
members themselves rather than from their published facts – the members that put the
machine in the state a case names include the step relation that got it there, and a
step relation publishes as structural_constraint, content no reader sees. So the
solver does that step, the node cites the members it used, and it records
solver_entailment rather than rule_checker because no predicate over the
premises could have settled it. The translation from core members to proof facts
emits seven kinds and none of them is an arithmetic_expression, so that fact
still has exactly one producer and the chain still starts where it always would
have – what changed is that the first link now carries no condition. Zero of its
twelve rules never fire, and the
paragraphs above still describe the conflicts that have no proof: those are the ones
whose facts no core member states, which is a different shortage from the one this
rule filled.
A second boundary is narrower. An event assumption is published as a
structural_constraint fact: the core member is known and located, but its
content is not read, so no rule applies to it. The narrative then reports
structural_only and says only that the constraints cannot hold together –
true, and unhelpfully thin for a reader who wanted to know which two event
requirements collided. Both boundaries are consequences of decisions recorded
in the contract, not defects in the checker, and both are visible to the caller
through achieved_mode and derivation_status rather than silent.
Why the bounded structure grows
Let \(V\) be the number of persistent variables, \(E\) the number of
events, and \(K_i\) the number of allocated macro-step case selectors at
step \(i\). BmcTraceSymbols.allocate creates one state and \(V\)
variable symbols per frame, \(E\) input-event symbols plus delta and gamma
per step, and one selector per step/case pair. The exact count of these public
trace symbols is:
The second equality uses \(N>0\); the first equality is the exact count for every admitted bound. For a fixed expanded case set, symbol count is linear in the bound. That does not make solving cost linear: the relation also repeats guards, updates, definedness conditions, call snapshots, and case implications, while the solver searches their combinations. Macro expansion can increase \(K_i\) before the bound is unrolled, so reducing \(N\) does not repair a case explosion inside one step. Equation (5) counts allocated trace variables, not Z3 expression nodes or solver search states.
Working traces and formula ledger
The five equations can be audited with one minimal model and two queries. The model is intentionally small so the solver boundary remains visible:
state Root;
The response query exercises the staged primary and suffix paths described by (1):
check response <= 1: trigger true -> within 2 false;
Its trace summary is main=unsat, tail=sat, outcome=incomplete.
There is no primary SAT model and therefore no bounded property verdict. The
SAT suffix is nevertheless decoded and replayed as an incomplete_suffix
role-aware witness for the executable finite prefix; it must not be mistaken
for a complete witness or counterexample. The second query exercises the
positive witness path:
check reach <= 1: active("Root");
It produces main=sat, outcome=witness_found, two decoded frames, one
decoded step, and replay.ok=true. For the same bound-1 query,
\(V=0\), \(E=0\), and the sole step has \(K_0=2\) selectors.
Equation (5) therefore gives
\(|X_1|=2+2+2=6\): two frame-state symbols, delta and gamma, and two case
selectors.
The list below is the forward audit map for the labelled equations in this page. Literal LaTeX is the labelled block at each labelled equation target; the English and Chinese files carry identical blocks.
Each equation below names its implementation, its tests, and the query whose trace exercises it.
- (1) – staged feasibility and response suffix
compile_bmc_property,solve_bmc_propertyand_SolveBudget. Covered bytest_compile_response_strict_successor_and_incomplete_suffixandtest_solver_unknown_and_timeout_paths_are_structured. The response query above gives UNSAT on the main objective and SAT on the tail.- (2) – polarity-aware three-valued verdict
BmcSolveResult.property_satisfiedandoutcome. The response query givesincomplete; the reach query giveswitness_found. Covered by:test_solve_result_public_verdict_truth_tabletest_response_violation_verdict_stays_decisive_with_suffix
- (3) – SAT model to sparse public trace
decode_bmc_witness,_decode_stepand_event_inputs_for_step. Covered by the witness decoder and event-policy tests intest/bmc/test_witness.py. The reach query decodes two frames and one step.- (4) – public observation equality
replay_bmc_witness,_compare_frameand_compare_step. The reach query reportsreplay.ok=true, and a trace with a tamperedxfails. Covered by:test_replay_reports_structured_var_mismatchtest_bmc_witness_replay_matches_full_semantic_fixture_trace
- (5) – exact allocated trace-symbol count
BmcTraceSymbols.allocate. Covered by shape assertions intest/bmc/test_domain.pyandtest/bmc/test_relation_public_api.py. The reach query has \(N=1,V=0,E=0,K_0=2\), hence six symbols.
The semantic-fixture replay suite is especially important: it checks complete runtime traces for the registered hard-pass scenarios, not merely that a witness object can be serialized. The tampering tests provide the opposite evidence by changing a public observation and requiring a precise mismatch.
What the explanation claims, formally
The four statements below are what the optional explanation asserts about its own output. They are separate from the solve equations above because they constrain a report, not a search: each one is a property the published object either has or is refused for lacking.
Let \(C = \{c_1, \dots, c_n\}\) be the published conflict core, each \(c_i\) the encoding of one authored clause or generated support group, and let \(\Phi\) denote conjunction.
Soundness is the weakest claim, and every core makes it. A core is sound when its members alone already admit no assignment:
This is what the solver’s own core gives, and it says nothing about whether every member is needed. Subset-minimality is the stronger claim, and it is made only when every member was tested by removing it and re-solving:
A core satisfying (7) reports subset_minimal
with subset_minimality as proven. One satisfying only
(6) reports raw, and one whose testing was cut short
reports partial_minimized. The distinction is what tells a reader whether
every listed line is worth editing.
At proof depth each input node restates one core member as a normalized fact \(f\). Restatement is stronger than implication in both directions, and both are required:
The left conjunct says the member forces the fact; the right says the fact forces
the member. Checking only the left would admit an \(f\) weaker than
\(c\) – a summary, which may have dropped the detail the reader needed. Any
of these checks returning satisfiable, unknown, or timing out keeps the proof out
of complete.
Finally the inputs and the core stand in bijection, so that every member is read exactly once and no node speaks for two:
A missing, extra, or duplicated input violates (9) and is refused rather than published – including the case of two distinct members stating the same fact, which has no place to go: merging them would give one node two attributions and dropping one would leave a member unread.
Each claim below names its implementation, its test, and a query that produces
it. All four share the two-line query
assume at 1: var("x") == 1; assume at 1: var("x") == 2;, so one run
reproduces the whole ledger.
- (6) – the core alone is unsatisfiable
Built by
extract_source_coreinpyfcstm/bmc/infeasibility.py; covered bytest/bmc/test_infeasibility.py. The query reportsCore size: 2with the scenario UNSAT.- (7) – every member is load bearing
Built by the minimization loop in the same function; covered by
test_reduction_and_minimality_stay_coupledintest/bmc/test_explanation.py. The query reportsReduction: subset_minimalandSubset minimality: proven.- (8) – both directions are refuted
Checked by
check_core_bindingsinpyfcstm/bmc/infeasibility.py; covered bytest/bmc/test_proof_wiring.py. The query publishes both inputs withverification_methodascore_binding.- (9) – one node per member
Enforced by
build_domain_proofinpyfcstm/bmc/proof.py. The query publishes two input nodes for a two-member core, each with one entry initem_ids. Its two tests take a line each, so that a long identifier is not clipped in a narrow column:test_an_input_node_restates_one_member_and_says_sotest_two_members_stating_one_fact_are_refused_rather_than_merged
The ledger is worth reading against the boundary above: these four claims are about the constraints as encoded. None of them says the encoding matches what the author meant, which is why the trust boundary is stated separately.