REAL and Numerical Assurance — Chain Walkthrough¶
A first-principles walkthrough of the numerical assurance chain using a first-order lowpass filter as the running example.
All numbers in this document are produced by the tool itself and pinned by
golden tests in tests/test_examples_t2_golden.py. If a number here ever
disagrees with the test, the test is authoritative.
Supporting files:
examples/t2/lowpass.lola — main example program
examples/t2/lowpass_report.py — runs the full numerical assurance chain, prints all warrants
examples/t2/external_pid_contract.py — PID external real contract + two implementation warrants
examples/t2/pid_rust.rs — illustrative Rust f64 implementation
examples/t2/pid_st.st — illustrative ST LREAL implementation
Part I Why REAL means ℝ¶
LoLa's REAL type is the mathematical real numbers ℝ — not IEEE 754 floats.
This is not a naming convention. It is a proof-theoretic choice with consequences throughout the compiler:
-
Z3 is the prover. When LoLa proves
INVARIANT s >= -1.0, it calls Z3 on the mathematical statement∀ s,u ∈ ℝ : s∈[-1,1] ∧ u∈[-1,1] ⟹ 0.98s + 0.02u ∈ [-1,1]. Z3 reasons in ℝ, not in Float64. -
Float64 is a representation, not a type definition. Choosing
profile=FLOAT64_REPRtells the compiler "the hardware computes this state variable in 64-bit floating-point". This is a representation boundary, not a redefinition of what REAL means. -
Error is the gap between ℝ and Float64. The entire numerical assurance chain exists to bound this gap with a traceable, certified number.
The principle: a Float implementation can only approximate a mathematically-defined REAL program. It can never redefine it.
Part II A first REAL program¶
The running example is a first-order lowpass filter derived from the continuous ODE
ẋ = −2s + 2u
with pole at −2 rad/s. Forward Euler discretisation at T_s = 10 ms gives
s[k+1] = (1 + h·(−2))·s[k] + h·2·u[k] = 0.98·s[k] + 0.02·u[k]
The LoLa source is in examples/t2/lowpass.lola:
FUNCTION_BLOCK LowpassFilter
(*
Discrete first-order lowpass filter derived from
the continuous ODE ẋ = -2·s + 2·u (pole at -2 rad/s)
via Forward Euler with step h = 1/100 s:
s[k+1] = (1 + h·(-2))·s[k] + h·2·u[k]
= 0.98·s[k] + 0.02·u[k]
T2-claim: for all k, |s_k^machine - s_k^exact| ≤ Δ
where Δ is the CombinedImplementationTrajectoryBound computed
from Float64 roundoff + physical sensor error + discretisation
error + timing jitter. All bounds are proved or declared;
none are left implicit.
*)
VAR_INPUT
u : REAL; (* normalised input; sensor reading in [-1, 1] *)
END_VAR
VAR_OUTPUT
s : REAL; (* filter state / output *)
END_VAR
IMPLEMENTATION
s: SET 0.98 * s + 0.02 * u OTHERWISE;
REQUIRE u >= -1.0;
REQUIRE u <= 1.0;
INVARIANT s >= -1.0;
INVARIANT s <= 1.0;
END_IMPLEMENTATION
END_FUNCTION_BLOCK
FUNCTION_BLOCK declares a stateful component with inputs, outputs, and an update rule.
s: SET expr OTHERWISE is a conditional update: s takes the value of
expr at every step (the OTHERWISE clause covers the "no condition
restricts this" case). In LoLa, SET expressions use REAL = ℝ arithmetic.
REQUIRE declares an input constraint. Z3 treats it as a precondition: "assume u ∈ [-1, 1] when proving the INVARIANT".
INVARIANT is a state invariant. Z3 proves it inductively: if s ∈ [-1, 1]
and u ∈ [-1, 1], then 0.98·s + 0.02·u ∈ [-1, 1] in ℝ. The proof is exact
and needs no margin — it works in ℝ.
Compiling this alone (no numerical assurance options) already runs the Z3 proof:
from lola.compiler import compile_source
from lola import profiles
result = compile_source(src, profile=profiles.FLOAT64_REPR)
iw = result.program.assurance.invariant_warrants
# iw["s"].status == "proved"
Part III From exact mathematics to Float64¶
Z3 proved the INVARIANT in ℝ. But the PLC runs in Float64. Do the Float64 computations stay close to the ℝ result?
profile=FLOAT64_REPR is the answer. It does two things:
-
Declares the representation. Every state variable
sis mapped to a 64-bit IEEE 754 double. This is stored in the assurance report as a first-class annotation. -
Triggers the Gappa roundoff proof. For each state variable, the compiler produces a Gappa script that asks: "given that the Float64 arithmetic computes
fl(0.98)·fl(s) + fl(0.02)·fl(u)instead of the exact0.98·s + 0.02·u, how large is the per-step error?"
The Lipschitz bound is computed algebraically:
L = |∂SET/∂s| = 0.98 = 49/50
proved, because Z3 can differentiate the linear SET expression and Z3 arithmetic is exact. L < 1 means the discrete map is contractive: errors shrink geometrically rather than accumulate.
Part IV Roundoff and state accumulation¶
Gappa proves the per-step machine roundoff (StateTransitionBound):
ε_m = 281 / 112589990684262400 ≈ 2.50 × 10⁻¹⁵ per step
This is a verified, rigorous upper bound — not a heuristic estimate. Gappa constructs a formal certificate of the bound.
Error recurrence. If E_k is the accumulated error after k steps, then
E_{k+1} ≤ L · E_k + ε_m
Starting at E_0 = 0 (at-rest initial condition), this recurrence gives:
E_k ≤ ε_m · (1 + L + L² + … + L^{k-1}) = ε_m · (1 − L^k) / (1 − L)
For L = 49/50 and k → ∞ (the asymptotic, worst-case bound):
E_∞ = ε_m / (1 − L) = ε_m / (1/50) = 50 · ε_m
The TubeClosure + TrajectoryBound gives the proved value:
E_∞ (machine only) = 345 / 2251799813685248 ≈ 1.53 × 10⁻¹³
(Exact values are pinned to the toolchain's prover, Gappa 1.8.0: the adaptive search certifies the first bound the prover accepts, so a different Gappa version yields a slightly different — equally sound — number.) The machine-only bound sits around 10⁻¹³ — far tighter than anything a physical sensor can provide. Parts V–VII add the real engineering uncertainties.
Part V Physical uncertainty¶
The sensor reading of u is not perfect. The physical measurement error is
declared as:
physical_errors={"u": Fraction(1, 100)} # |δu| ≤ 0.01 (1 % of range)
LoLa creates a PhysicalInputBound: a declared, assumed warrant that the physical sensor error on u is at most 1/100.
The InputSensitivityBound is then proved algebraically:
|∂SET/∂u| = 0.02 = 1/50 [proved]
so a 1/100 error on u contributes at most
ε_p = 1/100 · 1/50 = 1/5000 = 0.0002 per step
The StepErrorBudget adds machine roundoff and physical input error:
machine_roundoff ≈ 1.17 × 10⁻¹⁵ [proved — inside the wider tube]
physical_input_error = 1/5000 = 0.0002 [proved-under-assumptions]
───────────────────────────────────────────
SEB total ≈ 0.0002
Physical uncertainty dominates machine roundoff by seven orders of magnitude. The machine is not the problem; the sensor is.
Part VI Sampling, discretization, and jitter¶
The model s[k+1] = 0.98·s[k] + 0.02·u[k] is a discrete approximation of
the continuous ODE ẋ = −2s + 2u. LoLa formalises this relationship with a
SamplingWarrant:
sampling_models={"s": SamplingSpec(
sample_period=Fraction(1, 100), # T_s = 10 ms
method="forward-euler",
continuous_rhs=RealBin("+",
RealBin("*", RealConst(Fraction(-2)), Ref("s")),
RealBin("*", RealConst(Fraction(2)), Ref("u")),
),
)}
This warrant says: "the discrete LoLa program is the Forward Euler approximation of this specific continuous ODE". It is declared, not proved — LoLa does not prove that the ODE models the physical plant. That is the engineer's responsibility.
Discretisation error. Forward Euler has a local truncation error of h²/2 · |ẍ|_max. Given the input derivative bound |u̇| ≤ 0 (piecewise constant, declared):
ε_trunc = 121 / 244500 ≈ 4.95 × 10⁻⁴ [proved-under-assumptions]
Timing jitter. Real PLCs do not sample at exactly T_s = 10 ms. Declare a jitter bound J = 1 ms:
jitter_bounds={"s": Fraction(1, 1000)}
The jitter step error bounds the effect of timing variation on one step:
ε_jitter = 2 / 489 ≈ 4.09 × 10⁻³ [proved-under-assumptions]
The effective single-step timing+discretisation error is:
ε_eff = ε_trunc + ε_jitter = 1121 / 244500 ≈ 4.58 × 10⁻³
The worst-case discrete Lipschitz at the extremes of the jitter interval h ∈ [9 ms, 11 ms] is:
L_Φ^max = max(|1 − 2·0.009|, |1 − 2·0.011|) = 491 / 500 = 0.982
Part VII The unified engineering error report¶
With physical errors, sampling, and jitter all declared, the compiler assembles the CombinedImplementationTrajectoryBound:
ε_combined = SEB.total + ε_eff ≈ 0.004785 [proved-under-assumptions]
L_eff = max(L_machine, L_Φ^max) = 491/500 = 0.982
Because L_eff < 1, the combined system is still contractive. The asymptotic trajectory bound is:
E_∞ = ε_combined / (1 − L_eff) ≈ 0.2658
E_100 ≈ 0.2226 (after 100 steps from rest)
Interpretation: starting from an exact initial condition, the Float64 PLC implementation running with 1 % sensor noise and 1 ms jitter will have an accumulated state error of at most 0.266 — in normalised units, about 26.6 % of the full scale. This is the price of a 1 ms jitter with a 10 ms sample period (10 % relative jitter); reducing J tightens the bound sharply.
The EngineeringErrorReport assembles all of this into one
machine-readable artefact with a stable digest. Running
PYTHONPATH=. python examples/t2/lowpass_report.py prints:
══════════════════════════════════════════════════════════════════════════════
Engineering Error Budget — s
══════════════════════════════════════════════════════════════════════════════
Representation : float64
Overall status : proved-under-assumptions
Warrant ceiling: proved-under-assumptions
┌─ Semantics ──────────────────────────────────────────────────────────────────
LoLa SET (discrete) : ((0.98 * s) + (0.02 * u))
Continuous ODE rhs : Add(Mul(Integer(-1), Integer(2), Symbol('s', real=True)),
Mul(Integer(2), Symbol('u', real=True)))
┌─ Timing ─────────────────────────────────────────────────────────────────────
Sample period T_s : 0.010000 (1/100) s
Jitter bound J : 0.001000 (1/1000) s
h ∈ [0.009000 (9/1000), 0.011000 (11/1000)] s
┌─ Per-step error budget ──────────────────────────────────────────────────────
Machine roundoff ε_m 0.000000 (3297/2814749767106560000) [proved]
Physical input ε_p 0.000200 (1/5000) [proved-under-assumptions]
Discretization ε_d 0.000495 (121/244500) [proved-under-assumptions]
Jitter ε_j 0.004090 (2/489) [proved-under-assumptions]
────────────────────────────────────────────────────────────────────────────
Combined ε_combined 0.004785
┌─ Trajectory bound ───────────────────────────────────────────────────────────
L_machine : 0.980000 (49/50)
L_Φ^max : 0.982000 (491/500)
L_eff : 0.982000 (491/500) (max of above)
Regime : contractive
Horizon N : 100
E_N : 0.222600
E_∞ : 0.265826
┌─ Declared assumptions ───────────────────────────────────────────────────────
[SamplingWarrant] Nominal sample period T_s = 1/100 s
[InputDerivativeBound] |u_dot| <= 0 (declared)
[InputRangeWarrant] u in [-1, 1] (from REQUIRE)
[JitterBound] Timing jitter |T_k - T_s| <= J = 1/1000 s
[PhysicalInputBound] |delta_u| <= 1/100 (declared)
┌─ Proof chain ────────────────────────────────────────────────────────────────
[CombinedStepBudget] proved ceil=proved-under-assumptions
[SemanticCompositionWarrant] proved ceil=proved-under-assumptions
[LipschitzBound] proved
[CombinedImplementationTrajectoryBound] proved ceil=proved-under-assumptions
[JitterStepBound] proved ceil=proved-under-assumptions
[StepErrorBudget] proved ceil=proved-under-assumptions
┌─ Report digest ──────────────────────────────────────────────────────────────
77d8ebfba73e73748ae444c8ff35b17836aa48211d52fc053296e05d7b03a265
══════════════════════════════════════════════════════════════════════════════
The digest 77d8ebf... is a SHA-256 over the complete report payload. It
changes if any input (source, SamplingSpec, jitter bound, sensor error, or
horizon) changes. This makes the report a traceable artefact: a CI log entry
or a datasheet footnote can reference this digest to pin the exact claim.
Part VIII External implementations¶
Everything in Parts I–VII concerns REAL programs that LoLa's compiler can directly analyse — the FUNCTION_BLOCK source is present and the SET expressions are known.
Some components cannot be expressed in LoLa: a hardware codec, a vendor-supplied PID library, a DSP filter running in firmware. For these, LoLa provides ExternalRealContract and ExternalImplementationWarrant.
The principle¶
A Float implementation can never define the mathematical meaning of a LoLa REAL component. It can only approximate a mathematically-defined ExternalRealContract.
An ExternalRealContract holds the exact REAL (= ℝ) semantics of a
component as LoLa IR expressions — the same RealBin/RealConst/Ref nodes
used inside FUNCTION_BLOCKs. The contract is always status="assumed": the
engineer declares the intended mathematical behaviour. LoLa does not prove
that the hardware or library actually computes it.
An ExternalImplementationWarrant binds one concrete implementation
(e.g. a specific Rust crate at a specific version) to an ExternalRealContract.
It declares the per-output and per-state error bounds relative to the
contract's exact REAL values, and the representation (float64 or lreal64).
It is status="assumed" by default — the error bounds are declared, not
proved by Gappa (though a future extension could add Gappa or Kani proofs).
The proof DAG is:
ExternalRealContract[assumed]
↑ (assumption_dep)
ExternalImplementationWarrant[assumed]
The ERC is a leaf: it has no LoLa source to verify against. The EIW's
assumption_dep_warrant_keys records the ERC's digest, so the ProofRegistry
can enforce that the warrant references a specific, sealed contract.
Part IX PID preview: one mathematical contract, two implementations¶
The PID controller is a natural example for ExternalRealContract because:
- It is stateful (integral accumulator, previous error).
- Its mathematical semantics are well-defined and compact.
- Different implementations (Rust f64 on an embedded target vs ST LREAL on an OpenPLC runtime) can both be certified against the same sealed contract.
The contract¶
The controller computes (with Kp = 2, Ki = 1/10, Kd = 1/100):
e[k] = setpoint − measurement
integral[k+1] = integral[k] + (Ki · dt) · e[k]
control[k] = Kp·e[k] + integral[k] + Kd·(e[k] − e_prev[k]) / dt
e_prev[k+1] = e[k]
In examples/t2/external_pid_contract.py, these are written as exact REAL
IR expressions and sealed into an ExternalRealContract:
from fractions import Fraction
from lola.ir import RealBin, RealConst, Ref
from lola.contract_ranges import Range
from lola.external_real_contract import make_external_real_contract
_e = RealBin("-", Ref("setpoint"), Ref("measurement"))
_i_next = RealBin("+", Ref("integral"),
RealBin("*", RealBin("*", RealConst(Fraction(1,10)), Ref("dt")), _e))
_deriv = RealBin("*", RealConst(Fraction(1,100)),
RealBin("/", RealBin("-", _e, Ref("e_prev")), Ref("dt")))
_control = RealBin("+",
RealBin("+", RealBin("*", RealConst(Fraction(2)), _e), Ref("integral")),
_deriv)
contract = make_external_real_contract(
component_name="PidController",
kind="stateful-block",
input_names=["setpoint", "measurement", "dt"],
output_names=["control"],
state_names=["integral", "e_prev"],
exact_outputs={"control": _control},
exact_next_state={"integral": _i_next, "e_prev": _e},
input_ranges={
"setpoint": Range(Fraction(-10), Fraction(10)),
"measurement": Range(Fraction(-10), Fraction(10)),
"dt": Range(Fraction(1, 1000), Fraction(1, 10)),
},
state_invariants={
"integral": Range(Fraction(-100), Fraction(100)),
"e_prev": Range(Fraction(-20), Fraction(20)),
},
)
# contract.digest == "a16f5dcb5d7fcb970d7ca8b12e2601e628936f2edb9b2e18e930999fdfffd8bc"
The digest a16f5dcb... identifies this exact mathematical specification.
Any change to Kp, Ki, Kd, or the formula changes the digest.
Two implementations, one contract¶
examples/t2/pid_rust.rs implements the controller in Rust using f64. The
ExternalImplementationWarrant declares:
w_rust = make_external_implementation_warrant(
contract=contract,
backend="rust",
implementation_id="pid_rust_f64_v1",
representation="float64",
output_error_bounds={"control": Fraction(1, 10000)}, # ε ≤ 0.0001
state_transition_error_bounds={
"integral": Fraction(1, 100000),
"e_prev": Fraction(0),
},
state_lipschitz_bounds={"integral": Fraction(1, 5), "e_prev": Fraction(1)},
status="assumed",
)
# w_rust.digest == "7f6d5374babd4db989cdbde1ba058464902907e743eecbb883edc1e3a6b44e9a"
examples/t2/pid_st.st implements the same controller as an IEC 61131-3
FUNCTION_BLOCK using LREAL. A separate warrant declares looser bounds:
w_st = make_external_implementation_warrant(
contract=contract,
backend="structured-text",
implementation_id="pid_st_lreal64_v1",
representation="lreal64",
output_error_bounds={"control": Fraction(1, 1000)}, # ε ≤ 0.001
state_transition_error_bounds={
"integral": Fraction(1, 10000),
"e_prev": Fraction(0),
},
state_lipschitz_bounds={"integral": Fraction(1, 5), "e_prev": Fraction(1)},
status="assumed",
)
# w_st.digest == "391eae6f573652eeb4f1130339db3c0811c0ea099615729cace1a128cdb1ba66"
Both warrants reference the same sealed contract (mathematical_contract_digest
== "a16f5dcb..."), but with different implementation-specific bounds:
| Backend | Representation | ε_out[control] |
|---|---|---|
| Rust f64 | float64 | 1/10000 = 0.0001 |
| ST LREAL | lreal64 | 1/1000 = 0.001 |
The Rust bound is 10× tighter because the Rust implementation avoids intermediate LREAL rounding modes and accumulates fewer inter-operation conversions.
What this buys¶
The contract digest is proof that both implementations commit to the same mathematical PID definition. A system integrator can:
- Choose the implementation that meets their error budget.
- Verify that the bound declared in the EIW is consistent with their requirements.
- Record the contract digest in the system's assurance case to pin exactly which mathematical PID definition was certified.
The error bounds in the EIW are declared, not Gappa-proved. A future
status="proved" path (using Kani or Gappa with Rust or LREAL backends)
would replace the assumption with a verified bound — without changing the
mathematical contract.
Part X What LoLa proves — and what it does not¶
Proved (Z3, Gappa, or algebraic chain)¶
| Claim | Method | Status |
|---|---|---|
| INVARIANT s ∈ [−1, 1] holds at every step | Z3 (ℝ) | proved |
| Lipschitz constant L = 49/50 | algebraic | proved |
| Per-step machine roundoff ε_m ≈ 5.06 × 10⁻¹⁶ | Gappa | proved |
| Input sensitivity | ∂SET/∂u | = 1/50 |
| Discretisation correspondence: SET = s + h·f(s,u) exactly | algebraic | proved |
| Discretisation step error ε_trunc = 121/244500 | algebraic | proved-under-assumptions |
| Jitter step error ε_jitter = 2/489 | algebraic | proved-under-assumptions |
| Combined trajectory bound E_∞ ≈ 0.2658 | algebraic chain | proved-under-assumptions |
Assumed (declared by the engineer)¶
| Claim | Warrant | Impact |
|---|---|---|
| Sensor error | δu | ≤ 1/100 |
| Sample period T_s = 1/100 s | SamplingWarrant | drives ε_trunc and L_Φ^max |
| Continuous ODE is ẋ = −2s + 2u | SamplingWarrant | defines the reference |
| Input is piecewise constant ( | u̇ | = 0) |
| Timing jitter | T_k − T_s | ≤ 1/1000 s |
| PID ExternalRealContract semantics | ExternalRealContract | pins the contract |
| PID implementation error bounds | ExternalImplementationWarrant | per-implementation claim |
Not in scope (by design)¶
Functional correctness of external implementations. The
ExternalImplementationWarrant says "our Rust PID outputs are within 0.0001
of the contract's ℝ value". It does not say "the Rust code has no bugs".
A Kani proof of the Rust code would upgrade the warrant to status="proved".
Physical plant stability. LoLa proved that the discrete filter is contractive (L < 1). It did not prove that the continuous closed-loop system is stable. Continuous stability analysis is a separate activity (e.g. Lyapunov, Nyquist) that takes the LoLa DiscreteMapLipschitz as one input.
Scheduling and OS timing. The JitterBound is declared. LoLa does not prove that the scheduler guarantees |T_k − T_s| ≤ J. That is a realtime systems concern, properly addressed by WCET analysis or measurement.
Numerical conditioning of sequences. LoLa bounds the error after N steps from a single exact initial condition. It does not address ensemble behaviour, noise spectral density, or aliasing — those are signal-processing concerns that use the LoLa bounds as inputs, not outputs.
End of LoLa by Example.
Next: docs/architecture.md gives the full proof-registry design.
docs/adr-numerical-semantics.md records the decision to use ℝ rather than
a Float semantics as the primary type.