How to configure MATHREAL representation and run the numerical assurance chain¶
Audience: engineer using MATHREAL/LREAL variables and wanting to emit runnable
code or obtain a quantitative error bound.\
Prerequisites: familiarity with zqel's MATHREAL, INVARIANT, and ASSUME as
shown in the Introduction by Example §8.\
Language Reference: §13 MATHREAL semantics,
§17 Profiles.\
CLI Reference: --project, --profile.
The problem: MATHREAL is ℝ, not a machine type¶
zqel's MATHREAL is mathematical ℝ. A proof that INVARIANT s >= -1.0 holds is a
theorem about real numbers, valid for any value the inputs can take. This is precise
and strong — but it says nothing about IEEE 754.
To run a MATHREAL programme you must choose a Representation Boundary: the machine arithmetic that will approximate ℝ. Without one, the compiler verifies the programme but refuses to emit code:
$ python -m zqel examples/t2/lowpass.zqel
verification failed:
[overflow] REAL/LREAL overflow gate (default): REAL/LREAL programs require a
Representation Boundary before overflow analysis (T2.1+). ...
The mathematical proof succeeded; only code emission is gated.
Step 1 — verify the mathematical programme¶
Even before choosing a representation, verify that the programme is mathematically correct. This is the cheapest check and the foundation everything else rests on:
$ python -m zqel examples/t2/lowpass.zqel --profile float64
Compilation succeeded: LowpassFilter
G2: unrated
Claims: 2
REAL overflow-safety (DT <= 1s): ST ✓, Rust ✓
--profile float64 enables Float64 representation and lifts the overflow gate. The
programme passes all claims over ℝ and passes the overflow analysis for the Float64
representation.
Step 2 — emit code¶
With Float64 representation active, both backends are available:
$ python -m zqel examples/t2/lowpass.zqel --profile float64 --target st
$ python -m zqel examples/t2/lowpass.zqel --profile float64 --target rust
MATHREAL lowers to LREAL in Structured Text and f64 in Rust. The proven invariants
appear as comments in the generated output; DT becomes the cycle_dt runtime
argument; CLAMP becomes nested SEL calls.
Both targets are generated from the same proven ℝ model, so they compute the same mathematical function.
Step 3 — quantify the error with a project file¶
--profile float64 is a shorthand for code emission. For a quantitative error bound
— how far can the machine result deviate from the ℝ result? — you need a project
configuration file that declares the deployment context.
The canonical example is
examples/t2/lowpass.toml:
[representation]
real = "float64"
[sampling.s]
period = "10ms"
method = "forward-euler"
jitter = "1ms"
horizon = 100
[sampling.s.input_derivatives]
u = "0"
[physical_errors]
u = "1/100"
[rewrite]
policy = "min_abs_roundoff"
Run with the project file:
$ python -m zqel examples/t2/lowpass.zqel --project examples/t2/lowpass.toml
The compiler prints the Engineering Error Report — a bound on how far the deployed implementation can deviate from the exact mathematical model across an N-step trajectory:
── Engineering Error Report (s) ─────────────────────────────
Per-step error budget:
Machine roundoff ε_m 0.000000 [proved]
Physical input ε_p 0.000200 [proved-under-assumptions]
Discretization ε_d 0.000495 [proved-under-assumptions]
Jitter ε_j 0.004090 [proved-under-assumptions]
─────────────────────────────────────────────────────────
Combined ε_combined 0.004785
Trajectory bound:
E_N (100 steps) : 0.222600
E_∞ : 0.265826
E_∞ is the steady-state bound on |s_machine − s_exact| under the declared
assumptions. Items marked proved-under-assumptions depend on the declared
[physical_errors], [sampling.*], and [jitter] values — these are engineering
assumptions about the deployment, not mathematical proofs.
What each TOML field contributes¶
| Field | What it declares | Assurance effect |
|---|---|---|
[representation] real |
Machine arithmetic for MATHREAL/LREAL | assumed gap between ℝ and Float64 |
[sampling.*] period |
Nominal scan period | assumed deployment fact |
[sampling.*] method |
Discretisation method | assumed engineering choice |
[sampling.*] jitter |
Timing uncertainty bound | assumed deployment fact |
[sampling.*] input_derivatives] |
Rate of change of inputs between samples | assumed physical fact |
[physical_errors] |
Sensor/actuator error bound | assumed sensor specification |
[rewrite] policy |
Algebraic expression selection | codegen choice; no assurance effect |
Every field introducing an assumption is labelled in the Engineering Error Report's
"Declared assumptions" section. The overall status is the weakest warrant in the
chain: proved < audited < proved-under-assumptions < assumed.
For the full field reference, see the Project Configuration Reference.
What the boundary obliges you to prove¶
Choosing Float64 says the machine values are IEEE 754 binary64, and that carries an obligation the exact-ℝ semantics does not have: every value the programme actually evaluates has to fit. In ℝ there is no such thing as overflow, so this obligation does not exist under the default profile and is not imposed there.
The compiler discharges it in two halves, because a register carries state across cycles and a single-step argument cannot bound an accumulator:
| base | the power-up state is finite |
| step | the profile premise, the proved invariants and the inductive hypothesis together bound every evaluated intermediate — and the next state |
Only proved invariants may be premises. An invariant that failed or came back unknown would be assuming the conclusion, and a clamped integrator would then be indistinguishable from an unbounded one.
The verdict has four states, not two:
| Verdict | Emits? | Grounds acceptance? |
|---|---|---|
PROVED |
yes | yes |
CONDITIONAL |
yes | no |
VIOLATED |
no | no |
UNDECIDED |
no | no |
CONDITIONAL is the common one, and it is not a weaker PROVED. It means the
obligation discharged under declared assumptions that nothing enforces — every
ASSUME the block makes, and every ASSUME it inherited through composition. Those
are listed with the block that declared them and the instance path they arrived
through, and they are exactly what a deployment has to honour for the verdict to
mean anything. See Language Reference §17 and issue #168
for why a condition that is printed but enforced by nothing is a hazard rather than
a reassurance.
If the block reads DT, the proof also leans on the profile's scan-time window
— 0 <= DT <= dt_max, or with a declared floor DT = 0 OR dt_min <= DT <= dt_max.
That window is enforced by nothing, so it is listed as an obligation of its own and
the stamp prints the window itself rather than a count: a reader has to be able to
check it against the PLC the code will run on. A block that never reads DT
carries no such condition.
VIOLATED and UNDECIDED both refuse, and they refuse before any backend runs,
so a refused programme leaves no artefact behind. They keep separate codes on
purpose: float64-range-violated means a counterexample exists, and
float64-range-undecided means nobody found out. Those are different facts, and
only the first one is about your programme.
Common failure modes¶
MATHREAL/LREAL programs require a Representation Boundary
Add --profile float64 or [representation] real = "float64" in a project file.
INVARIANT fails with --profile float64 but not before
The mathematical proof passed, but overflow analysis found a counterexample. The
programme is correct over ℝ but not over Float64. Common causes: unbounded
accumulation without a CLAMP, or arithmetic that goes outside the safe Float64
range before CLAMP takes effect. Add tighter ASSUMEs on inputs or restructure the
update rule.
float64-range-violated on a block that looks bounded
Three causes account for nearly all of them.
A register inherits no bound. The inductive hypothesis says only finite; the
tighter bound has to stand as an INVARIANT and be proved. An accumulator that is
"obviously" bounded by how it is used is not bounded by anything the compiler can
see. Compare examples/analog/integrator.zqel with integrator_bounded.zqel.
CLAMP bounds its result, not its argument. In u := CLAMP(-4.0 * x, -10.0,
10.0) the product is evaluated before the clamp sees it, so an unconstrained
x leaves the range on the way in. examples/control/PController.zqel is the
smallest illustration.
A quotient by elapsed time needs a floor, not just <> 0. IF DT <> 0.0 proves
definedness, not magnitude: a quotient by an arbitrarily small DT leaves
binary64 however tightly its numerator is bounded. FLOAT64_REPR deliberately
leaves dt_min_ns unset, because a minimum cycle time is not a property of the
language. A profile that wants it discharged declares one — see
profiles.FLOAT64_DT_MIN_1US, under which pid_bounded.zqel and
differentiator_bounded.zqel do discharge. Declaring a floor does not make the
quotient safe; it makes the condition explicit and visible as a deployment
obligation.
float64-range-undecided
Not a softer violated. The solver did not decide, or the node is not encodable.
"Nobody could tell" and "it is safe" are different sentences, and a tool that says
the second while meaning the first is the defect this state exists to prevent.
Engineering Error Report shows E_∞ is large
The dominant term is usually jitter (high ε_j) or a weak physical error spec (high
ε_p). To tighten: reduce jitter, reduce [physical_errors] values (if your
hardware supports it), or add a CONTINUOUS_REFERENCE block to let the ODE-based
discretisation analysis produce tighter ε_d.
CONTINUOUS_REFERENCE block required for sampling analysis
Some numerical assurance steps require a DER(<var>) = <expr>; ODE declaration in the .zqel file.
The lowpass.zqel example shows the pattern. The ODE does not affect the programme's
execution semantics; it is engineering knowledge used only by the numerical assurance analysis.
See also¶
- Language Reference §13 — MATHREAL semantics
- Language Reference §17 — Profiles
- Project Configuration Reference
- CLI Reference —
--project,--profile - Introduction by Example §8 and §19 for the conceptual foundation