Property Objectives, Definedness, and Bounds
A bounded property is not merely a Boolean condition appended to a trace. pyfcstm first lowers each predicate into a value and the side conditions under which that value is defined, then chooses an objective whose satisfiability has the polarity of the property kind. This page explains that construction and the separate observation used when a response window extends beyond the bound.
The implementation sources are pyfcstm/bmc/properties.py and the expression
lowering in pyfcstm/bmc/relation.py. The primary executable specification
is test/bmc/test_properties.py; call-filter guards are additionally covered
by test/bmc/test_call_predicate_guards.py. This is an explanation of the
current bounded semantics, not a claim that UNSAT proves an unbounded temporal
property.
One door-latch example for all seven properties
Use one bounded door-latch story to separate user intent from solver polarity.
Assume the trace starts in Door.Locked. The event Door.Unlock may move
the latch to Door.Unlocked in the next macro step, and the public transition
case is Door.Locked::transition::Door.Unlocked::0. The examples below are
short property shapes, not a full model listing.
Kind |
Finite quantifier in the main objective |
SAT polarity |
User intent |
Door-latch shape |
|---|---|---|---|---|
|
\(\exists\) trace, \(\exists\) frame with \(G_i(p)\) |
witness |
Show that a desired state can occur. |
|
|
\(\exists\) trace, \(\exists\) forbidden or undefined frame |
counterexample |
Reject any trace that visits a forbidden state. |
|
|
\(\exists\) trace, \(\exists\) false or undefined frame |
counterexample |
Require every visible frame to satisfy a condition. |
|
|
\(\exists\) complete trace with no good matching frame |
counterexample |
Require every bounded trace to reach the target. |
|
|
\(\exists\) trace where every frame is good |
witness |
Show that some behavior can keep a condition true throughout. |
|
|
\(\exists\) trace, \(\exists\) public case selector |
witness |
Show that a named transition or fallback case can be selected. |
|
|
\(\exists\) trigger step with a complete missing-response window |
counterexample |
Require a future response after each trigger within the bound. |
|
The table uses property fragments to keep the example readable. In a complete
.fbmcq query each fragment appears after check <kind> <= N: and any
needed initial clause, for example init state("Door.Locked");. The
important point is the polarity: the decoded SAT model is a desired witness for
reach, exists_always, and cover, but a violation trace for
forbid, invariant, must_reach, and response.
Notation and three recurring traces
Let \(F_0,\ldots,F_N\) be the frames of a trace satisfying \(Core_N\), and let \(E_i\) be the event input for the macro step from \(F_i\) to \(F_{i+1}\). For a frame predicate \(p\), \(P_i(p)\) is its lowered Boolean value and \(D_i(p)\) is its runtime definedness. A response property uses trigger \(t\), response \(r\), and positive window \(W\).
Most examples use this two-state machine:
state Root {
event Go;
state A;
state B;
[*] -> A;
A -> B : Go;
}
With init state("Root.A"), bound 1, and Go true at step 0, its
distinguishing trace is
\(F_0=\texttt{Root.A}, E_0=\texttt{Go}, F_1=\texttt{Root.B}\).
Turning Go off leaves both frames in Root.A. The definedness
counterexample uses x = 1 and y = 0: evaluating x / y > 0 makes its
value irrelevant because division requires \(y\ne0\).
For call counting, the tests use a state whose during actions call
Before, increment x, and call After. One selected macro step then
contains a Before record with snapshot x == 0 and an After record
with snapshot x == 1. Filters inspect those call-time snapshots, not the
post-step frame.
Predicate definedness
The predicate-definedness equation collects every side condition produced
while lowering \(p\).
The empty conjunction is true, so atoms such as active("Root.A") are
defined without inventing an extra failure condition.
_PredicateFormula.definedness stores (1), and
_lower_predicate builds it from definedness_constraints. In
check reach <= 1: x / y > 0; with
y == 0, both observed frames have \(D_i(p)=\bot\); the query is UNSAT
rather than gaining a witness from an arbitrary division value. With
y == 1, the same trace is defined and the comparison is evaluated normally.
The behavior is frozen by
test_compile_liveness_definedness_failures_are_not_witnesses.
The good-predicate equation names the only predicate state that can support a witness: the expression must be defined and true.
For active("Root.A"), \(G_0\) is true on the event trace and
\(G_1\) is false. For x / y > 0 with y == 0, \(G_i\) is false
even if a solver representation happens to assign a value to the division.
_PredicateFormula.good implements (2); the reach,
must-reach, exists-always, and response tests exercise both sides.
Safety-style counterexample searches need two distinct notions of badness. (3) is bad when a forbidden predicate is undefined or true.
Thus check forbid <= 1: active("Root.A"); is SAT at \(F_0\), while
check forbid <= 1: terminated(); is UNSAT on the same trace. Replacing the
predicate with x / y > 0 and setting y == 0 is also a counterexample:
undefined does not prove that the forbidden condition stayed absent.
_PredicateFormula.bad_true and
test_compile_definedness_failures_are_safety_counterexamples are the code
and test anchors.
(4) is bad when a required invariant is undefined or false.
check invariant <= 1: active("Root.A"); finds \(F_1\) as a
counterexample on the event trace; active("Root") has no such frame.
Again, x / y > 0 with y == 0 is a counterexample rather than a vacuous
success. This is _PredicateFormula.bad_false and is tested by
test_compile_forbid_and_invariant_are_counterexample_objectives plus the
definedness regression above.
Six frame and case objectives
The objective \(\Phi_q\) is the part conjoined with \(Core_N\) for the
main solver check. SAT means a desired witness for reach,
exists_always, and cover. SAT means a counterexample for forbid,
invariant, must_reach, and response. An UNSAT result reverses that
interpretation, but only for this finite bound and these assumptions.
(5) searches all \(N+1\) frames, including \(F_0\) and \(F_N\), for one good reach predicate.
On the event trace, check reach <= 1: active("Root.B"); is SAT because of
\(F_1\); active("Root.A") is already SAT because of \(F_0\).
terminated() is an UNSAT counterexample to the expectation that every reach
query finds something. The implementation/test pair is _compile_reach and
test_compile_reach_witness_covers_frame_zero_and_final_frame.
(6) asks whether any frame is undefined or makes the forbidden predicate true.
active("Root.A") produces a SAT counterexample at \(F_0\);
terminated() produces no counterexample. The division-by-zero trace shows
why (6) uses \(B^{\top}\) rather than simply
\(P_i\). See _compile_forbid and
test_compile_forbid_and_invariant_are_counterexample_objectives.
(7) asks whether any frame is undefined or makes the invariant false.
On the event trace, active("Root.A") yields a SAT counterexample at
\(F_1\), while active("Root") makes the objective UNSAT. An undefined
numeric invariant is also SAT. These cases map directly to
_compile_invariant and
test_compile_forbid_and_invariant_are_counterexample_objectives.
(8) searches for a complete bounded trace on which no frame is a good match. It is therefore a counterexample objective, despite the positive English phrase “must reach”.
With Go false, check must_reach <= 1: active("Root.B"); is SAT and its
trace remains in Root.A. active("Root.A") makes the objective UNSAT
because \(F_0\) already reaches it. Division by zero also prevents a good
match and therefore supports the miss. See _compile_must_reach and
test_compile_must_reach_and_exists_always_polarities.
(9) searches for one bounded trace whose predicate is good at every frame. This existential path objective is not a universal invariant proof.
active("Root") is SAT on the event trace. If Go is forced true,
active("Root.A") is UNSAT because \(F_1\) is Root.B; an undefined
predicate is likewise not a witness. The source/test anchors are
_compile_exists_always and
test_compile_must_reach_and_exists_always_polarities.
Calls and cover objectives
Call predicates are step observations. For an anchor \(a\), a filter
\(f\) selects in-bound steps \(S_f(a)\). \(K_i\) is the set of
case relations at step \(i\), \(R_{i,k}\) is the abstract-call record
sequence for one case, \(C_{i,k}\) is its selector, and \(M_f(r)\) is
the conjunction of action, stage, role, state, active-leaf, named-reference,
and call-snapshot where filters.
_lower_call_count implements (10); _call_match_expr
evaluates a where clause on the record snapshot. The tested query counts one
Before call where x == 0 and one After call where x == 1. The
counterexample call_count("Root.A.After", step=*, where x == 0) >= 1 is
UNSAT. See test_compile_call_count_filters_use_call_time_snapshots and the
guard cases in test/bmc/test_call_predicate_guards.py. An omitted step
selector is anchored at the current predicate step; * spans
\(0\le i<N\), and out-of-bound relative points are clipped. An undefined
where expression cannot match a record because its definedness is conjoined
with its value.
cover does not lower an arbitrary frame predicate. It validates a naked
case("label") atom, accepts only public transition and fallback
case kinds, and disjoins matching selectors across the bounded steps.
For the event machine, forcing Go true makes
case("Root.A::transition::Root.B::0") SAT; forcing it false makes the same
cover objective UNSAT. An initial, delta, or absorb label is a
query error rather than a cover witness. (11) maps to _compile_cover
and _cover_selectors and is tested by
test_compile_cover_accepts_transition_and_fallback_but_not_internal_cases.
Response is a strict-successor property
A trigger is evaluated at each executable step \(0\le i<N\). A response is evaluated on frames. The window after step \(i\) starts at \(F_{i+1}\): truth in \(F_i\) never satisfies that trigger. A window is complete only when \(i+W\le N\). A response counts only through \(G_j(r)\), so an undefined response is not a successful response.
(12) is the ordinary missing-response counterexample over complete windows. The upper endpoint \(F_{i+W}\) is included.
With Go true at step 0,
trigger event("Root.Go", current) -> within 1 active("Root.B") has no
counterexample. Replacing the response with active("Root.A") is SAT:
although Root.A is true at the trigger frame, strict succession examines
only \(F_1\). With bound 2 and window 2, a response first true at
\(F_2\) satisfies the property. These traces are implemented by
_compile_response and tested by
test_compile_response_honors_strict_successor_window_boundaries.
Undefined triggers are not treated as “not triggered”. (13) adds them directly to the main counterexample objective and then combines the two causes. The current result and witness protocols do not classify which disjunct made the formula SAT.
The query trigger x / y > 0 -> within 1 active("Root") with y == 0 is
SAT and reports a property violation. In contrast, a defined false trigger
contributes neither a violation nor incompleteness. This distinction is fixed
by test_compile_response_treats_trigger_undefined_as_counterexample.
The response-incompleteness equation is separate from the main objective. It
observes a good trigger
whose full window lies beyond \(F_N\) and for which no response has appeared
in the visible suffix. solve_bmc_property solves this observation
separately from the main objective; it determines an incomplete outcome
when the primary check has not already found a response counterexample.
At bound 1, Go true at step 0, window 2, and response
active("Root.A"), the visible suffix contains only \(F_1=Root.B\).
The main response objective is UNSAT because the window is not complete, while
(14) is SAT and the outcome is incomplete. A
response already visible in the suffix makes \(\Omega\) false. This is
covered by test_compile_response_strict_successor_and_incomplete_suffix and
the solver-level incomplete tests in test/bmc/test_witness.py.
The response branches are intentionally non-interchangeable:
Trigger |
Window |
Main objective |
Incomplete formula |
Result |
|---|---|---|---|---|
Undefined |
Any |
SAT counterexample |
Does not decide the main result |
|
Defined and true |
Complete, no response |
SAT counterexample |
False or irrelevant |
|
Defined and true |
Truncated, no visible response |
UNSAT for this trigger |
SAT |
|
Defined and false |
Any |
No contribution |
No contribution |
Other steps decide |
What these objectives establish
Every main query is solved as \(Core_N\land\Phi_q\); response incompleteness is checked as \(Core_N\land\Omega_q\). The seven objectives differ both in quantification over the finite frames and in SAT polarity. Definedness is part of that semantics: it cannot be erased without turning runtime errors into false proofs or false witnesses. Conversely, response horizon incompleteness is not a runtime-definedness error and not a counterexample. Increasing the bound may complete the window and change that bounded result.
These formulas explain the current compiler and its tests. They do not elevate bounded UNSAT to an unbounded theorem, and they do not make replay a proof of the encoding. Solver-result interpretation, witness decoding, and runtime replay are covered by the sibling solving explanation.