Proving something¶
5. INVARIANT: a property that is proven, not tested¶
This is where zqel stops being “ST with nicer syntax” and becomes something new.
An INVARIANT is a statement that must be true in every reachable state of the
block, forever. It is not a comment and not a runtime check — it is a proof
obligation. The compiler proves it using one-step induction, which is easier
than it sounds:
Just as important: an INVARIANT does not create behaviour. The rules
still define what the block does; the invariant is only something that behaviour
must satisfy. If the proof fails, zqel rejects the block — it does not “repair”
your logic for you.
That is the key difference between verification and synthesis:
- Verification asks: “Do these rules satisfy the property?”
- Synthesis would ask: “Find me rules that satisfy the property.”
zqel in this guide is about the first question.
- Base case: the invariant holds in the initial state (all outputs at their
power-on defaults —
0/FALSE). - Inductive step: assuming the invariant holds at the start of some scan, it still holds at the end of that scan (after one update).
If both hold, then by induction it holds forever — exactly like a row of dominoes: if the first one falls (base case) and each one knocks over the next (step), then all of them fall, no matter how many there are. You never have to check them one by one.
For the counter above, Value <= 10 is proven like this: it is 0 <= 10 at
power-on (base), and if Value <= 10 now, the rules ensure it is still <= 10
after one scan (the saturate-hold rule at priority 20 catches the boundary). So it
can never exceed 10 — for any sequence of button presses, of any length.
When a proof fails, you get a counterexample¶
Suppose we forget the saturating rule:
Value:
SET Value + 1 WHEN Count PRIO 10; // just count up, no limit!
HOLD OTHERWISE;
INVARIANT
Value <= 10; // ... but still claim this
The claim is now false, and zqel refuses to compile it:
verification failed:
[invariant-step] invariant can be violated after one cycle: (Value <= 10)
counterexample: Count=1, Value=10
Read that as a precise accusation: “Start a scan with Value = 10 and Count = 1.
After one update Value becomes 11, so your invariant is false.” It is not a
vague “test failed” — it is a specific reachable situation that breaks your claim.
Fix the logic (add the saturating rule) or fix the claim; the compiler will not
let you ship a false promise.
✅ Proven vs. ✅ tested. A test runs a handful of scenarios you thought of. An invariant proof covers all scenarios, including the ones you did not think of. The counterexample above is exactly one you might have missed.
Three non-obvious bugs the compiler catches for you¶
The nice examples are the ones where a human reviewer might plausibly nod the code through — and the compiler still says no.
(a) The hidden overflow bug. In PLC land an INT is not mathematical ℤ; it is
16-bit hardware arithmetic. So this innocent-looking block is wrong:
FUNCTION_BLOCK BadCounter
VAR_INPUT Count : BOOL; END_VAR
VAR_OUTPUT Value : INT; END_VAR
IMPLEMENTATION
Value:
SET Value + 1 WHEN Count PRIO 1;
HOLD OTHERWISE;
INVARIANT
Value >= 0;
END_IMPLEMENTATION
END_FUNCTION_BLOCK
A programmer may think “it only counts up, so it stays non-negative”. The machine
knows better: at Value = 32767, adding one wraps to -32768, and the invariant
fails. The point is not that wraparound exists — everyone knows that in the
abstract — but that the compiler catches the exact moment where your intuitive,
infinite-integer reasoning diverges from the plant.
(b) The missing bound on a dynamic read. An array access with a runtime index often “looks obviously safe” because a human silently carries some intended bound in their head. The compiler does not guess:
selected := Channel[sel];
If nothing proves sel in range, zqel reports a concrete violating state such as
sel = -32768. That is a classic field bug: the logic is fine assuming the HMI or
upstream block never sends nonsense, but the assumption was never written down.
(c) The specification hole disguised as a proof success. Formal tools do not only find buggy implementations; they also expose buggy specifications. “sorted” without “is a permutation” admits “write zeros everywhere”; “Pressure < 10 bar” admits “pump off forever, valve open forever”. The proof goes through, and that is precisely the warning sign: a proof can be perfectly correct and still certify the wrong thing. zqel forces that distinction into the open early, while the block is still small.
6. A safety property worth proving: mutual exclusion¶
Consider two motors that must never run at the same time (they might drive the
same shaft from opposite directions — running both would wreck it). Motor A has
priority; Motor B must yield (examples/motor.zqel):
MotorA:
OFF WHEN StopA PRIO 10; // stop always wins over start
ON WHEN StartA PRIO 5;
HOLD OTHERWISE;
MotorB:
OFF WHEN StopB PRIO 10;
OFF WHEN MotorA PRIO 10; // yield while A was already running at scan start
OFF WHEN StartA PRIO 10; // also yield while A is being STARTED this scan
ON WHEN StartB PRIO 5;
HOLD OTHERWISE;
INVARIANT
NOT (MotorA AND MotorB); // the safety property: never both on
✅ Proven:
NOT (MotorA AND MotorB)— it is impossible, for any sequence of button presses, for both motors to be on in the same scan.
The generated ST shows the interlock baked right into Motor B's formula:
(* proven invariant: NOT (MotorA AND MotorB) *)
_MotorA_next := (NOT StopA AND (StartA OR MotorA));
_MotorB_next := (NOT StopB AND NOT MotorA AND NOT StartA AND (StartB OR MotorB));
MotorA := _MotorA_next;
MotorB := _MotorB_next;
Notice NOT MotorA AND NOT StartA inside Motor B: B can only turn on when A is
neither running nor being started. That is why the invariant holds — and the
compiler checked that the formula it generated really does guarantee it, rather
than trusting you to have gotten the interlock right by hand.
6a. The same block, said as a contract¶
Everything above sits inside IMPLEMENTATION, and there the register rules
are the block: they say what it does, and that is the end of it. There is a
second place to put them, and the difference is worth a page because it is the
one the rest of the toolchain is built on.
FUNCTION_BLOCK MotorControl
VAR_INPUT
StopA : BOOL;
StartA : BOOL;
StopB : BOOL;
StartB : BOOL;
END_VAR
VAR_OUTPUT
MotorA : BOOL;
MotorB : BOOL;
END_VAR
CONTRACT
MotorA:
OFF WHEN StopA PRIO 10;
ON WHEN StartA PRIO 5;
HOLD OTHERWISE;
MotorB:
OFF WHEN StopB PRIO 10;
OFF WHEN MotorA PRIO 10;
OFF WHEN StartA PRIO 10;
ON WHEN StartB PRIO 5;
HOLD OTHERWISE;
INVARIANT
NOT (MotorA AND MotorB);
END_CONTRACT
END_FUNCTION_BLOCK
Same rules, same proof, same generated code — and
examples/motor_contract.zqel is this file,
compiled by the same gate that compiles every other example. What changed is
the standing of those rules. In CONTRACT they no longer say what the block
does; they say what any implementation of it must do.
That only starts to pay when an IMPLEMENTATION is added underneath:
- A register there for an output the
CONTRACTdoes not name behaves exactly as in section 6 — the ordinary case, unaffected. - A register for
MotorAorMotorBbecomes a candidate. The compiler admits it only after proving that its step function and the contract's agree on every state and every input. A countermodel rejects it, and so does anunknownor a timeout: not proved is not accepted. BY <artifact>;in place of a register binds an external artifact — Rust, ST, something a vendor shipped — to the same obligation (§12.3 of the Language Reference).
So the rules you would have written by hand in section 6 become the thing that judges whatever gets written instead — hand-written, generated, or synthesised. The workflow around that is the realization ladder; its rungs are workflow states, not new syntax, and every one of them is judged against the block above.
One restriction to know before you move clauses across: the typed relational
clauses (ONLY_WHEN, IMPLIES, IFF, EXCLUDES) are not admitted in
CONTRACT. That surface is deliberately still open, and a RULE carrying one
is refused there rather than quietly accepted.
6b. PARAMETER: one block, many machines¶
The counter in section 4 saturates at ten. Every real installation wants its own limit, and copying the block to change one number is how a codebase acquires six slightly different counters.
A PARAMETER is a value fixed once when the instance is configured and
immutable for its lifetime — not an input, because it never changes during
operation, and not a constant, because each instance may have a different one:
FUNCTION_BLOCK BoundedCounter
PARAMETER
Limit : INT;
REQUIRE Limit > 0;
END_PARAMETER
VAR_INPUT
Reset : BOOL;
Count : BOOL;
END_VAR
VAR_OUTPUT
Value : INT;
END_VAR
IMPLEMENTATION
Value:
SET 0 WHEN Reset PRIO 30;
HOLD WHEN Value >= Limit PRIO 20;
SET Value + 1 WHEN Count PRIO 10;
HOLD OTHERWISE;
INVARIANT Value >= 0;
INVARIANT Value <= Limit;
END_IMPLEMENTATION
END_FUNCTION_BLOCK
Compilation succeeded: BoundedCounter
2 output properties verified
Read that verdict carefully, because it is stronger than it looks. The proof
is not about a counter that stops at ten, nor about one particular Limit.
It holds for every Limit the REQUIRE admits — one proof, every machine
in the fleet.
The REQUIRE is not decoration¶
Delete it and the same block no longer verifies:
verification failed:
[invariant-init] invariant does not hold in the power-up state,
before the first scan has run (every output and local at its default):
(Value <= Limit)
counterexample: Count=0, Limit=-1, Reset=0, Value=0
Without a constraint, Limit could be −1. Value starts at 0, and 0 is not
≤ −1, so the invariant fails before the machine has run a single scan. The
solver did not guess this case; it searched every configuration you allowed
and reported the one that breaks.
That is the working relationship: REQUIRE narrows the configurations you
promise to build, and the proof covers all of them.
What a configuration constraint may mention¶
Only other parameters and literals. A REQUIRE naming an input is rejected:
verification failed:
[parameter-require-nonconfig-ref] PARAMETER REQUIRE references 'Count'
which is not a PARAMETER — only PARAMETER names and literals are allowed
The reason is timing. Configuration constraints are checked when the instance
is set up, before any scan; an input has no value then. A property about
inputs belongs in REQUIRE at the block level or in an INVARIANT, both of
which live in scan time.
Three rules follow from what a parameter is, and each has a diagnostic
behind it: a parameter never appears on the left of an assignment or SET
(it is immutable), it has no previous value for PRE to read (it does not
evolve), and its configuration constraint sees only configuration data.