|
| 1 | +-- SPDX-License-Identifier: MPL-2.0 |
| 2 | +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +-- |
| 4 | +||| Flagship semantic proof for Lustreiser (raises the Idris2 ABI to Layer 2). |
| 5 | +||| |
| 6 | +||| Lustre is a synchronous dataflow language: a node is a deterministic |
| 7 | +||| transition over discrete clock ticks. This module gives a faithful, |
| 8 | +||| executable model of one such node — a *saturating bounded counter*, |
| 9 | +||| the canonical pattern in real-time embedded code (watchdog ticks, |
| 10 | +||| debounce counters, retry budgets) — and proves the two safety |
| 11 | +||| properties that justify "formally verified real-time embedded code": |
| 12 | +||| |
| 13 | +||| 1. INVARIANT PRESERVATION (per tick): if the counter state satisfies |
| 14 | +||| `state <= cap` before a tick, it still satisfies `state <= cap` |
| 15 | +||| after the transition, for ANY input. Lifted to whole runs: every |
| 16 | +||| reachable state of the node respects the bound — no overflow. |
| 17 | +||| |
| 18 | +||| 2. DETERMINISM (the synchronous hypothesis): the node is a pure |
| 19 | +||| function of (state, input). Identical input streams from identical |
| 20 | +||| initial state produce identical output streams — bit-for-bit. |
| 21 | +||| |
| 22 | +||| The model is minimal but real: a true Lustre saturating counter, |
| 23 | +||| with state, a reset input, and the `pre`/`fby`-style one-tick memory. |
| 24 | +module Lustreiser.ABI.Semantics |
| 25 | + |
| 26 | +import Data.Nat |
| 27 | +import Decidable.Equality |
| 28 | + |
| 29 | +%default total |
| 30 | + |
| 31 | +-------------------------------------------------------------------------------- |
| 32 | +-- Faithful ADT model of a synchronous saturating-counter node |
| 33 | +-------------------------------------------------------------------------------- |
| 34 | + |
| 35 | +||| The per-tick input to the node. On each clock tick the environment |
| 36 | +||| either pulses `Tick` (advance the counter) or `Reset` (clear to 0). |
| 37 | +||| This is the boolean clock input that Lustre `merge`/`when` would gate on. |
| 38 | +public export |
| 39 | +data Input = Tick | Reset |
| 40 | + |
| 41 | +||| The node is parameterised by a static saturation bound `cap` — fixed at |
| 42 | +||| compile time, exactly as a Lustre constant. State is the current count. |
| 43 | +||| `cap` is part of the node identity (a field), not a free variable. |
| 44 | +public export |
| 45 | +record CounterNode where |
| 46 | + constructor MkCounter |
| 47 | + cap : Nat |
| 48 | + state : Nat |
| 49 | + |
| 50 | +||| Saturating successor: increments unless already at the cap. |
| 51 | +||| Decided on the propositional `LTE (S n) cap` (i.e. `n < cap`) so the |
| 52 | +||| bound proof can reuse exactly the witness this branch produces. |
| 53 | +public export |
| 54 | +satInc : (cap : Nat) -> (n : Nat) -> Nat |
| 55 | +satInc cap n = case isLTE (S n) cap of |
| 56 | + Yes _ => S n |
| 57 | + No _ => cap |
| 58 | + |
| 59 | +||| One synchronous transition (the node's step / output function). |
| 60 | +||| This is the ENTIRE behaviour of the node on a single clock tick. |
| 61 | +public export |
| 62 | +step : Input -> CounterNode -> CounterNode |
| 63 | +step Tick (MkCounter cap s) = MkCounter cap (satInc cap s) |
| 64 | +step Reset (MkCounter cap s) = MkCounter cap 0 |
| 65 | + |
| 66 | +||| Run the node over a finite input stream (a list of ticks), threading |
| 67 | +||| state left-to-right — this is the synchronous execution semantics. |
| 68 | +public export |
| 69 | +run : List Input -> CounterNode -> CounterNode |
| 70 | +run [] c = c |
| 71 | +run (i :: is) c = run is (step i c) |
| 72 | + |
| 73 | +-------------------------------------------------------------------------------- |
| 74 | +-- PROPERTY 1: the per-tick safety invariant (counter never exceeds cap) |
| 75 | +-------------------------------------------------------------------------------- |
| 76 | + |
| 77 | +||| The invariant: the node's state is within its static bound. |
| 78 | +||| There is deliberately NO way to build `WithinBound` for an out-of-range |
| 79 | +||| state other than via the genuine `LTE state cap` proof — the bad case |
| 80 | +||| (state > cap) is simply not constructible. |
| 81 | +public export |
| 82 | +data WithinBound : CounterNode -> Type where |
| 83 | + IsWithin : {cap, s : Nat} -> LTE s cap -> WithinBound (MkCounter cap s) |
| 84 | + |
| 85 | +-------------------------------------------------------------------------------- |
| 86 | +-- Lemmas (all genuine — no believe_me / postulate / assert) |
| 87 | +-------------------------------------------------------------------------------- |
| 88 | + |
| 89 | +||| `satInc` never exceeds the cap, for any current value. Case split on the |
| 90 | +||| same decidable `LTE (S n) cap` test that `satInc` itself uses, so each |
| 91 | +||| branch reduces and is discharged with a real `LTE` proof. |
| 92 | +public export |
| 93 | +satIncBounded : (cap : Nat) -> (n : Nat) -> LTE (satInc cap n) cap |
| 94 | +satIncBounded cap n with (isLTE (S n) cap) |
| 95 | + -- `S n <= cap`: `satInc = S n`, and that very proof is the bound. |
| 96 | + satIncBounded cap n | Yes prf = prf |
| 97 | + -- not `S n <= cap`: `satInc = cap`, and `cap <= cap` reflexively. |
| 98 | + satIncBounded cap n | No _ = reflexive |
| 99 | + |
| 100 | +||| INVARIANT PRESERVATION: if the state is within bound before a tick, it is |
| 101 | +||| within bound after the tick — for EVERY input. This is the per-tick |
| 102 | +||| safety theorem at the heart of the node. |
| 103 | +public export |
| 104 | +stepPreservesBound : (i : Input) -> (c : CounterNode) -> |
| 105 | + WithinBound c -> WithinBound (step i c) |
| 106 | +stepPreservesBound Tick (MkCounter cap s) (IsWithin _) = |
| 107 | + IsWithin (satIncBounded cap s) |
| 108 | +stepPreservesBound Reset (MkCounter cap s) (IsWithin _) = |
| 109 | + IsWithin LTEZero |
| 110 | + |
| 111 | +||| INVARIANT for whole runs: if the initial state is within bound, then after |
| 112 | +||| running ANY input stream the final state is still within bound. Proved by |
| 113 | +||| induction over the stream, reusing the per-tick theorem at each step. |
| 114 | +public export |
| 115 | +runPreservesBound : (is : List Input) -> (c : CounterNode) -> |
| 116 | + WithinBound c -> WithinBound (run is c) |
| 117 | +runPreservesBound [] c w = w |
| 118 | +runPreservesBound (i :: is) c w = |
| 119 | + runPreservesBound is (step i c) (stepPreservesBound i c w) |
| 120 | + |
| 121 | +-------------------------------------------------------------------------------- |
| 122 | +-- Sound + complete decision procedure for the invariant |
| 123 | +-------------------------------------------------------------------------------- |
| 124 | + |
| 125 | +||| Decide the invariant for a concrete node. Sound (Yes carries a real proof) |
| 126 | +||| and complete (No carries a refutation of the bad case). Built on the |
| 127 | +||| library `isLTE`, which is itself a genuine `Dec (LTE m n)`. |
| 128 | +public export |
| 129 | +decWithinBound : (c : CounterNode) -> Dec (WithinBound c) |
| 130 | +decWithinBound (MkCounter cap s) = case isLTE s cap of |
| 131 | + Yes prf => Yes (IsWithin prf) |
| 132 | + No contra => No (\(IsWithin p) => contra p) |
| 133 | + |
| 134 | +-------------------------------------------------------------------------------- |
| 135 | +-- PROPERTY 2: determinism (the synchronous hypothesis) |
| 136 | +-------------------------------------------------------------------------------- |
| 137 | + |
| 138 | +||| DETERMINISM, one tick: the transition is a function, so equal inputs and |
| 139 | +||| equal start states force equal results. This is the propositional content |
| 140 | +||| of "the node is deterministic" — there is no nondeterministic branch. |
| 141 | +public export |
| 142 | +stepDeterministic : (i : Input) -> (c1, c2 : CounterNode) -> |
| 143 | + c1 = c2 -> step i c1 = step i c2 |
| 144 | +stepDeterministic i c1 c2 eq = cong (step i) eq |
| 145 | + |
| 146 | +||| DETERMINISM, whole run: identical input streams from identical initial |
| 147 | +||| state yield identical final state — bit-for-bit. Proved by induction; |
| 148 | +||| the engine of real-time reproducibility / replayability. |
| 149 | +public export |
| 150 | +runDeterministic : (is : List Input) -> (c1, c2 : CounterNode) -> |
| 151 | + c1 = c2 -> run is c1 = run is c2 |
| 152 | +runDeterministic is c1 c2 eq = cong (run is) eq |
| 153 | + |
| 154 | +-------------------------------------------------------------------------------- |
| 155 | +-- Certifier: maps a node + invariant proof to an ABI-level status |
| 156 | +-------------------------------------------------------------------------------- |
| 157 | + |
| 158 | +||| ABI-facing verdict for a single node's bound check. |
| 159 | +public export |
| 160 | +data BoundStatus = BoundProven | BoundRefuted |
| 161 | + |
| 162 | +||| Certify the bound invariant, returning a machine status. The decision is |
| 163 | +||| the genuine `decWithinBound`, so `BoundProven` is never emitted without an |
| 164 | +||| underlying `LTE` proof existing. |
| 165 | +public export |
| 166 | +certifyBound : (c : CounterNode) -> BoundStatus |
| 167 | +certifyBound c = case decWithinBound c of |
| 168 | + Yes _ => BoundProven |
| 169 | + No _ => BoundRefuted |
| 170 | + |
| 171 | +||| SOUNDNESS of the certifier: if it says `BoundProven`, the invariant truly |
| 172 | +||| holds. We recover the witness by re-running the same decision and matching |
| 173 | +||| on its result — no axioms. |
| 174 | +public export |
| 175 | +certifyBoundSound : (c : CounterNode) -> certifyBound c = BoundProven -> |
| 176 | + WithinBound c |
| 177 | +certifyBoundSound c prf with (decWithinBound c) |
| 178 | + certifyBoundSound c prf | Yes w = w |
| 179 | + certifyBoundSound c Refl | No _ impossible |
| 180 | + |
| 181 | +-------------------------------------------------------------------------------- |
| 182 | +-- POSITIVE control: an explicit inhabited witness |
| 183 | +-------------------------------------------------------------------------------- |
| 184 | + |
| 185 | +||| A concrete watchdog counter (cap = 3, state = 2) is within bound, and |
| 186 | +||| stays within bound after a tick — a fully explicit, machine-checked |
| 187 | +||| witness that the property is inhabited (non-trivial). |
| 188 | +public export |
| 189 | +safeWatchdogWithinBound : WithinBound (MkCounter 3 2) |
| 190 | +safeWatchdogWithinBound = IsWithin (LTESucc (LTESucc LTEZero)) |
| 191 | + |
| 192 | +||| Running a real input stream from a within-bound start stays within bound. |
| 193 | +||| (cap = 3, start = 0, stream = Tick,Tick,Tick,Tick,Reset,Tick) — the |
| 194 | +||| saturation actually fires here, so this exercises the interesting path. |
| 195 | +public export |
| 196 | +safeRunWithinBound : WithinBound (run [Tick,Tick,Tick,Tick,Reset,Tick] (MkCounter 3 0)) |
| 197 | +safeRunWithinBound = |
| 198 | + runPreservesBound [Tick,Tick,Tick,Tick,Reset,Tick] (MkCounter 3 0) (IsWithin LTEZero) |
| 199 | + |
| 200 | +||| Determinism positive control: the same stream from the same state lands on |
| 201 | +||| the same concrete final node (checked by `Refl`, i.e. by reduction). |
| 202 | +public export |
| 203 | +safeRunDeterministic : |
| 204 | + run [Tick,Tick,Reset,Tick] (MkCounter 3 0) |
| 205 | + = run [Tick,Tick,Reset,Tick] (MkCounter 3 0) |
| 206 | +safeRunDeterministic = |
| 207 | + runDeterministic [Tick,Tick,Reset,Tick] (MkCounter 3 0) (MkCounter 3 0) Refl |
| 208 | + |
| 209 | +-------------------------------------------------------------------------------- |
| 210 | +-- NEGATIVE control: the bad case is genuinely refuted |
| 211 | +-------------------------------------------------------------------------------- |
| 212 | + |
| 213 | +||| An out-of-range node (cap = 2, state = 5) does NOT satisfy the invariant. |
| 214 | +||| Machine-checked: pattern-matching extracts the impossible `LTE 5 2` and |
| 215 | +||| discharges it. This is what makes the property non-vacuous. |
| 216 | +public export |
| 217 | +overflowNotWithinBound : Not (WithinBound (MkCounter 2 5)) |
| 218 | +overflowNotWithinBound (IsWithin prf) = absurd prf |
0 commit comments