Design and Semantics
This document explains how Haze is put together and what its answers mean precisely. It's written for readers who know some typed functional programming and basic probability. If you want to learn the language rather than its internals, the tutorial and the language reference are better places to start.
Starting from Lean
Haze follows the architecture of Lean 4, so it helps to recall how Lean is organized. The file
references below point into the Lean sources that come with the pinned toolchain, under
~/.elan/toolchains/leanprover--lean4---v4.35.0/src/lean.
| Stage | In Lean | What it does |
|---|---|---|
| Syntax | Syntax (Init/Prelude.lean:5119), Lean/Parser |
An extensible parser produces untyped syntax trees, which macros can rewrite before elaboration. |
| Elaboration | Lean/Elab, elabTermAux (Lean/Elab/Term/TermElabM.lean:1851) |
The elaborator turns syntax into typed terms: it resolves names, inserts coercions and implicit arguments, solves metavariables and runs tactics. Elaborators for each kind of syntax are looked up in the termElabAttribute table (TermElabM.lean:637–654), which built-in and user-defined elaborators share. |
| Core terms | Expr (Lean/Expr.lean:298) |
Terms of a dependent type theory with universes and inductive types. Bound variables are de Bruijn indices, and binder names are kept only for printing. |
| Declarations | Declaration (Lean/Declaration.lean:185), ConstantInfo (:430) |
Axioms, definitions, theorems, opaque constants and inductive types. A theorem is a constant whose value is a proof. |
| Kernel | addDecl (Lean/AddDecl.lean:229), lean_add_decl (Lean/Environment.lean:296) |
A small type checker, written in C++, checks every declaration again before it enters the environment. |
| Driver | processCommand, processCommands (Lean/Elab/Frontend.lean:64, 79) |
Commands are parsed and elaborated one at a time, threading the environment and collecting messages. |
The important idea is the split between the elaborator and the kernel. The elaborator does the hard, heuristic work and may have bugs; the kernel is small and checks its output, so only the kernel has to be trusted.
Why Haze has its own kernel
Lean's kernel answers one question: does this term have this type? A proposition is either proved or it isn't. The questions Haze is built for are graded instead: how likely is it that a property holds, given random events and parameters that are only roughly known? Changing Lean's kernel to answer that would change the meaning of every existing Lean proof, and it would mean maintaining a fork of Lean's compiler and runtime.
So Haze keeps the architecture and replaces what sits inside it. It has its own elaborator, an environment of checked declarations, and a small kernel that checks every declaration again, but its core language and the judgement its kernel decides are different. Because Haze is written in Lean, its kernel can be read, run and partly proved correct in the same system (see Trust and testing).
How the parts correspond
| Lean | Haze | What changes |
|---|---|---|
Lean.Syntax, Lean.Parser |
Haze.Syntax |
A fixed grammar for systems, claims and fuzzy literals. |
Lean.Elab |
Haze.Elab |
An if whose condition is a Deg becomes the stochastic conditional Expr.choose. |
Lean.Expr |
Haze.Expr, Haze.Stmt |
Adds stochastic conditionals, random primitives, categorical choice, system state and bounded quantifiers, and drops universes and dependent types. |
Lean.Declaration |
Haze.Decl and the records in Haze.Env |
Adds fuzzy parameters, random nodes, transition systems, and theorems that store a belief–plausibility interval instead of a proof. |
| The kernel type checker | Haze.Kernel.Check |
Decides the graded judgement Γ ⊢ e : T @ g, described below. |
| Kernel reduction | Haze.Kernel.Eval, Haze.Kernel.Verify |
Computes exact results: a distribution monad, model checking and α-cut integration. |
Lean.Environment.addDecl |
Haze.Kernel.addDecl, Haze.Frontend.processDecl |
A declaration is added only after both the type checker and its semantic action succeed. |
Grades
Alongside its type, every term in Haze has a grade that records which kinds of uncertainty it
can carry. A grade is a subset of {prob, fuzzy, partial}: prob means the term depends on a
random choice, fuzzy that it depends on a fuzzy parameter, and partial that computing it
might run out of fuel. Grades are ordered by inclusion, and combining two terms takes the union
of their grades. The main rules are:
Γ ⊢ lit v : T @ crisp
Γ ⊢ gvar x : T @ grade(x) (a fuzzy parameter has grade fuzzy)
Γ ⊢ rand r args : T @ ⊔ grade(args) ⊔ prob
Γ ⊢ cat alts : T @ ⊔ grade(alts) ⊔ prob (weights are non-negative and sum to 1)
Γ ⊢ call f args : ret(f) @ ⊔ grade(args) ⊔ latent(f)
Γ ⊢ c : Deg @ g₀ Γ ⊢ a : T @ g₁ Γ ⊢ b : T @ g₂
──────────────────────────────────────────────────────
Γ ⊢ choose c a b : T @ g₀ ⊔ g₁ ⊔ g₂ ⊔ prob
A function call adds the function's latent grade, the uncertainty its body introduces. For a
recursive function this depends on itself, so the kernel computes it as a least fixed point,
starting from partial and iterating. The theorems Grade.le_iff_join and Grade.le_top in
Haze/Theory.lean show that grades form a finite join-semilattice with a
top element, which is why the iteration always stops, after at most four rounds.
You can also state the grade you expect, and the kernel holds you to it:
def noisy : Num @crisp := uniform(1, 3) is rejected, because uniform is random.
Semantics
Exact numbers
Numbers are exact rationals (Lean's Rat), so every probability Haze reports is exact. When a
fraction is long, messages round it and show the exact value next to it, as in
0.666667 (=2/3), and very small numbers are written in scientific notation, as in
≈1.37018e-8 (=1/72982849).
Expressions
An expression denotes a finite probability distribution over outcomes, which Haze.Kernel.M represents
as a list of outcomes with exact weights. An outcome is either a value or Res.stuck, which
means a computation ran out of fuel and its result is unknown. Runtime faults, such as division
by zero or a degree outside the range from 0 to 1, are reported as errors rather than treated
as outcomes.
Worlds
Haze processes declarations in order, and they define a distribution over worlds. Each random
node adds one value to every world, so all later references to the node see the same value.
observe keeps only the worlds in which its condition holds and rescales their probabilities by
Bayes' rule.
The worlds aren't built once for the whole file. The front end records the parameters, nodes
and observations, and each query has the kernel replay them (Haze.Kernel.buildBranches),
including only what the query or an earlier observation can read. The rest get the unknown
outcome without being evaluated. Twenty independent inputs with a hundred values each would
otherwise mean 100²⁰ worlds before the first query. A node is still evaluated once when it's
declared, with what it depends on, so its runtime errors are reported where it is written.
Fuzzy parameters
A fuzzy parameter is a normal possibility distribution π over finitely many values: every
value has a possibility between 0 and 1, and at least one has possibility 1. Haze reads it as a
random set. Drawing α uniformly from (0, 1] gives the α-cut {v | π v ≥ α}, the set of
values that are still plausible at level α.
To evaluate a property, Haze goes through every joint assignment y of the fuzzy parameters.
The possibility of an assignment is the smallest possibility among its values. With y fixed,
the model is an ordinary finite Markov chain, and the property gets an exact interval
[lo y, hi y]; the two ends differ only because of outcomes that are unknown. Haze
then combines the assignments into a belief and a plausibility:
Bel = (1 / π*) · Σⱼ (αⱼ - αⱼ₊₁) · min { lo y | π y ≥ αⱼ }
Pl = (1 / π*) · Σⱼ (αⱼ - αⱼ₊₁) · max { hi y | π y ≥ αⱼ }
Here α₁ > α₂ > … > αₙ are the distinct possibilities of the assignments, αₙ₊₁ = 0, and π*
is the largest possibility. π* is 1 unless an observation has ruled out every assignment of
possibility 1.
Two special cases make these numbers easier to read. Without fuzzy parameters, belief and
plausibility are both just the exact probability. For a true-or-false property that depends only
on fuzzy parameters, they are the necessity and possibility of possibility theory: in
tests/basics.hz, the parameter load ≈ {50: 0.5, 70: 1, 90: 0.4} gives
load > 80 a possibility of 0.4 and load <= 80 a necessity of 0.6. In general, the pair is the
belief and plausibility of Dempster–Shafer theory for the random set formed by combining the
α-cuts with the probabilistic model.
Fuzzy truth
A property of type Deg contributes its degree of truth rather than 0 or 1, so the interval
bounds its expected degree. and, or and not follow whichever fuzzy logic is active.
Systems and temporal properties
A system's configuration is a world, the values of its state variables, and whether it has
crashed. Each step runs the step block; a failed assert crashes the system, and a crashed
configuration never changes again. The kernel computes the exact distribution of
configurations one step at a time and merges configurations that are equal. On top of that:
after[k] φis the expected degree ofφat stepk.always[k] φtracks the lowest degree ofφalong each path from step 0 to stepk. Paths whose lowest degree reaches 0 are dropped.eventually[k] φtracks the highest degree along each path. Paths that reach 1 count as satisfied and aren't explored further.
If a path runs into an unknown outcome, the rest of that path is unknown too. It counts as a failure for the lower bound and as a success for the upper bound.
Unbounded properties
always φ and eventually φ without a bound, and steps until φ, are about the whole infinite
run. For these, the kernel first explores every reachable configuration breadth first
(Haze.Kernel.Checker.explore), giving a finite Markov chain, and then solves linear equations
on it.
For a Bool property, eventually φ is the probability x_s of reaching a state where φ
holds. If h_s is the probability that φ holds in state s (it can be random), then
x_s = h_s + (1 - h_s) Σ_t P(s, t) x_t. always φ is one minus the probability of reaching a
state where φ fails. For a Deg property, eventually is the expected highest degree along
the run and always the expected lowest. Both reduce to reachability by the layer-cake formula
E[X] = Σⱼ (θⱼ - θⱼ₋₁) P(X ≥ θⱼ), where the θⱼ are the degrees the property takes, so one
system is solved for each θⱼ. The expected number of steps until φ holds solves
y_s = c_s (1 + Σ_t P(s, t) y_t), where c_s is the probability that φ doesn't hold in s;
it is infinite from states where, with positive probability, φ never holds, and graph search
finds those exactly.
Haze.Kernel.Solve.solve handles these systems. States that can't reach a positive right-hand
side have value 0, found by graph search. The rest are split into strongly connected
components, which are solved from the ones that can't reach any other component to the ones
that can, so each component only needs the values of components already solved. A component of
up to 120 states is solved exactly by Gauss–Jordan elimination over the rationals.
Exact elimination on a larger component would take too long, so Haze solves it in floating
point and then certifies the answer. The floating-point solution comes from an LU factorization
of the band of I - A, which is cheap because breadth-first numbering keeps the band narrow
(and is safe without pivoting because I - A is a nonsingular M-matrix). Haze then moves the
solution down and up along a vector v with (I - A) v ≈ 1, rounds the results to multiples
of 2⁻⁶⁴, and checks in exact arithmetic that the lower vector l satisfies F l ≥ l and the
upper vector u satisfies F u ≤ u, where F x = b + A x. Since F is monotone and its
iterates converge to the unique solution x* from any starting point, these inequalities prove
l ≤ x* ≤ u. Floating-point error can only make the check fail, in which case Haze widens the
bounds and tries again, falling back to the trivial bounds. It never makes a bound wrong. This is
the idea behind optimistic value iteration (Hartmanns and Kaminski, 2020).
Exploration stops once explore states are known. States found but not expanded are treated as
unknown, exactly like outcomes that ran out of fuel: they count as failures for the lower bound
and as successes for the upper bound. This makes systems with infinitely many states usable,
with a result that is always sound and is tight when the unexplored states are unlikely.
Parallel checking
The kernel is pure, so independent work can run on separate Lean tasks without changing any
result. The parameter assignments of a query are checked in parallel. Within an assignment,
frontiers of 64 or more configurations, breadth-first levels and the property's value in each
state are computed in parallel chunks whose results are joined in their original order. When the
frontier is small, which is common for systems with few states but many random choices per step,
the first point in a step that splits into eight or more outcomes runs the rest of the step
for each outcome in parallel instead (Haze.Kernel.M.bindSplit). A query with four or more parameter assignments
doesn't split steps, since its assignments already keep the cores busy.
The benchmarks in bench cover these cases; python scripts/bench.py times them.
On a 20-thread laptop, the model with 81 parameter assignments went from 37 to 6.5 seconds and
the one with 4,096 random choices per step from 4.6 to 2.1 seconds. A model with a long horizon
on a small chain doesn't benefit, because each step depends on the one before.
Haze doesn't use the GPU. Almost all the time goes into running step programs, with branching and exact rational arithmetic, and that kind of work suits CPU cores rather than a GPU. The one GPU-friendly part, the floating-point solve, takes a small share of the time. Because its answer is certified afterwards, a GPU solver could be added later without trusting it.
Probability-weighted tests
#tests needs the runs of a system, not just its distribution of configurations, so the kernel
can record random decisions while it executes a step (ECtx.trace). Each assignment, let,
if, match and assert whose term can make a random choice appends what it decided to the
state. Recording is off for every other query, and when it is on it only keeps apart runs that
made different decisions; the probability of each configuration is unchanged.
Haze.Kernel.Checker.weightedRuns is a best-first search over partial runs, kept in a heap
ordered by probability. Extending a run multiplies its probability by a factor of at most 1, so
when a complete run is taken from the heap, no run still in the heap, and no extension of one,
can be more likely. Complete runs therefore come out in exactly decreasing order of
probability, and equally likely runs in the order they were found, which keeps the output
deterministic. The search stops after the requested number of runs and enough failing runs, or
after 200,000 expansions.
The statistical test is a one-sided binomial test. For a failure rate q, Haze finds the
smallest number of runs n, and for it the smallest allowance c, with
P(Bin(n, q) > c) ≤ 10⁻⁶ and P(Bin(n, q₂) > c) ≥ 0.99, where q₂ is twice q (or halfway to
1). These tail probabilities are computed in floating point; the test only sizes checks of
other code, so it doesn't affect any result Haze proves.
Keeping state spaces small
Before checking a property, Haze works out which global variables the property can possibly
read, directly or through node definitions, function calls and the system's initialization and
step (Haze.Kernel.relevantGlobals). Only those variables are built into the worlds, and worlds
that become equal are merged. The result doesn't change, since the property can't see the
variables that were dropped, but there are far fewer states to explore.
The same applies to fuzzy parameters. A parameter that neither the property nor an observation reads doesn't change any assignment's result, and because its possibility distribution is normal, every α-cut contains at least one of its values. The minimum and maximum over an α-cut are therefore the same with or without it, and Haze doesn't split on it.
State variables get the classic cone-of-influence reduction from model checking
(Haze.Kernel.coneOfInfluence). A state variable depends on the variables read by the
expressions assigned to it, by the local definitions those expressions use, and by the
conditions of the if, match and random choices around the assignment. The cone of a property
is the set of variables it reads, closed under these dependencies. Variables that an assert
reads are always in the cone, since a crash affects everything, and so are the variables that a
function call in an assignment depends on, since a call can run out of fuel. Assignments to the
other variables are removed from the step (Haze.Kernel.sliceStep), and those variables stay at
a placeholder value that nothing reads. A clock or a counter that a property never reads no longer splits its configurations.
Claims and theorems
A claim without a bound is always accepted, and Haze records its interval. A claim
with prob >= q is accepted when q ≤ Bel, and one with prob <= q when Pl ≤ q. A theorem is
accepted only when Bel = 1, which means it holds on every path of positive probability, for
every parameter assignment of positive possibility, with no unknown outcomes.
A property φ given ψ is a conditional probability. For each parameter assignment, Haze
computes N, the probability that ψ and φ both hold, and C, the probability that ψ
holds and φ doesn't, each as an interval because of unknown outcomes. The result is the
interval from N.lo / (N.lo + C.hi) to N.hi / (N.hi + C.lo), the least and most favorable
ratios, and these per-assignment intervals are then combined as above. Both are evaluated at the
same step, which is why given is limited to after[k] and properties without a temporal
operator.
When a theorem or a with prob >= bound fails, Haze reports the first configuration that
violates the property, taken from a parameter assignment whose own result breaks the bound. If
the property starts with forall quantifiers or conjunctions, it also reports values for the
quantified variables that make it false. When a with prob <= bound fails, a violating
configuration would be one where the property is false, which says nothing useful, so Haze
instead looks for a configuration where it holds, as a counterexample to the negated property:
after[k] ¬φ for after[k] φ, and always[k] ¬φ for eventually[k] φ.
Trust and testing
The trusted part of Haze is the kernel in Haze/Kernel, about 2,000 lines
of Lean including documentation. Floating-point code runs inside it only to propose bounds,
which the kernel then checks in exact arithmetic. Haze.Kernel.addDecl re-checks every type and grade that the
elaborator produces, so a mistake in the elaborator can't slip an ill-typed or wrongly graded
declaration past it.
Some of the kernel's foundations are proved in Lean. Haze/Theory.lean
proves, using only Lean's standard axioms, the lattice laws that latent-grade inference relies
on, and the algebraic laws of the fuzzy connectives the evaluator uses: commutativity, units,
the bound by min, and De Morgan duality.
The rest is tested. Every file in tests and examples is a golden
test (see the test suite), and many expected results were checked by hand:
2/3 for the Monty Hall problem, a posterior of 19/118 after a positive medical test, a necessity
and possibility pair, a Łukasiewicz implication, a 0.19³ chance of giving up after three retries,
a stale-read risk of 0.128, and a root-cause posterior of 567/955. The models in
examples/Gallery are also checked against independent calculations by
scripts/verify_gallery.py. The test
tests/systems_and_conditioning.hz covers the
reductions above, including a dependency chain long enough to catch a closure that stops
early.
References
- Didier Dubois and Henri Prade. Possibility Theory: An Approach to Computerized Processing of Uncertainty (1988).
- Glenn Shafer. A Mathematical Theory of Evidence (1976).
- Christel Baier and Joost-Pieter Katoen. Principles of Model Checking (2008).
- Leonardo de Moura and Sebastian Ullrich. "The Lean 4 Theorem Prover and Programming Language" (2021).