File size: 13,812 Bytes
63f41a6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | # %% [markdown]
# # 0307 - Aeneas semantics: Rust (safe subset) -> LLBC -> pure Lean, the Result monad, and what a proof of `not P` certifies
# Companion to `notes/0307 - Aeneas Semantics.pdf`. Pure Python, exhaustive enumeration over `u8`; no Lean needed.
# The "Rust abstract machine" below is a small stateful interpreter (store, borrows, panics, fuel). The "translation" is a pure function
# into `Result` with explicit threading of borrowed values (backward functions). Every check compares them on the **whole** finite domain.
# This is a finite test of the refinement statement, not a proof of Aeneas' soundness theorem (that is a meta-theorem about the tool, see the note).
# %%
import os
os.environ["OMP_NUM_THREADS"] = "1"
import itertools, math
from dataclasses import dataclass
CHECKS = []
def check(name, ok, detail=""):
CHECKS.append((name, bool(ok), detail))
assert ok, (name, detail)
print(f"[ok] {name} {detail}")
# %% [markdown]
# ## 1. The Result monad: ok / fail(e) / div
# `Result a = ok a | fail Error | div`. `bind` propagates `fail` and `div`. Errors we use: `integerOverflow`, `arrayOutOfBounds`, `panic`.
# Monad laws are checked on a finite carrier (all values of `u8`, three error tags, `div`).
# %%
@dataclass(frozen=True)
class R:
tag: str # 'ok' | 'fail' | 'div'
v: object = None # value for ok, error name for fail
def ok(v): return R('ok', v)
def fail(e): return R('fail', e)
DIV = R('div')
def bind(m, f):
return f(m.v) if m.tag == 'ok' else m
U8 = range(256)
carrier = [ok(v) for v in U8] + [fail('integerOverflow'), fail('arrayOutOfBounds'), fail('panic'), DIV]
f = lambda x: ok(x + 1) if x < 255 else fail('integerOverflow') # a fallible step
g = lambda x: ok(x // 2) if x % 3 else fail('panic')
left = all(bind(ok(v), f) == f(v) for v in U8)
right = all(bind(m, ok) == m for m in carrier)
assoc = all(bind(bind(m, f), g) == bind(m, lambda x: bind(f(x), g)) for m in carrier)
check("1. monad laws on 260 values: left identity, right identity, associativity", left and right and assoc)
# %% [markdown]
# ## 2. Naive translation fails: integers without overflow
# Rust (debug / `overflow-checks`): `fn avg(a: u8, b: u8) -> u8 { (a + b) / 2 }`. The abstract machine panics iff `a + b > 255`.
# NAIVE translation: `a, b` as mathematical integers, no failure case. CORRECT translation: `let s <- a + b; s / 2` with checked `+` in `Result`.
# %%
def rust_avg(a, b): # abstract machine, checked arithmetic
s = a + b
if s > 255: return ('panic', 'integerOverflow')
return ('ret', s // 2)
def tr_naive_avg(a, b): return ok((a + b) // 2) # no Result
def u8_add(a, b): return ok(a + b) if a + b <= 255 else fail('integerOverflow')
def tr_avg(a, b): return bind(u8_add(a, b), lambda s: ok(s // 2))
def obs(r): return ('ret', r.v) if r.tag == 'ok' else (('panic', r.v) if r.tag == 'fail' else ('div', None))
DOM = [(a, b) for a in U8 for b in U8]
bad_naive = sum(1 for a, b in DOM if obs(tr_naive_avg(a, b)) != rust_avg(a, b))
bad_good = sum(1 for a, b in DOM if obs(tr_avg(a, b)) != rust_avg(a, b))
panics = sum(1 for a, b in DOM if rust_avg(a, b)[0] == 'panic')
check("2. naive translation disagrees with the machine on exactly the panicking inputs", bad_naive == panics == 255 * 256 // 2, f"disagree={bad_naive}, panics={panics}, 255*256/2={255*256//2}")
check("2. Result translation agrees with the machine on all 65536 inputs", bad_good == 0, f"disagree={bad_good}")
# %% [markdown]
# ## 3. Borrow elimination: `&mut` becomes "return the new value" (forward) and, when a borrow is returned, a backward function
# Machine: a store maps addresses to values; `&mut x` is an address. `fn bump(x: &mut u8) { *x += 1 }`.
# Translation: `bump (x : U8) : Result U8` returns the new value and the caller rebinds.
# %%
def machine_bump(store, addr): # returns (outcome, store')
v = store[addr] + 1
if v > 255: return ('panic', 'integerOverflow'), store # store unchanged when the panic happens before the write
s2 = dict(store); s2[addr] = v
return ('ret', None), s2
def tr_bump(x): return u8_add(x, 1)
good = 0
for x in U8:
out, st = machine_bump({0: x}, 0)
r = tr_bump(x)
if out[0] == 'ret': good += (r.tag == 'ok' and r.v == st[0])
else: good += (r.tag == 'fail' and r.v == out[1])
check("3a. bump: machine (store, outcome) = translation (returned value, Result) for all u8", good == 256)
# Returned mutable borrow: fn get_mut(v: &mut Vec<u8>, i: usize) -> &mut u8 ; caller: *get_mut(&mut v, i) += k
def machine_incr_at(vec, i, k):
heap = {j: x for j, x in enumerate(vec)} # elements are addressable places
if i >= len(vec): return ('panic', 'arrayOutOfBounds'), vec
v = heap[i] + k
if v > 255: return ('panic', 'integerOverflow'), vec
heap[i] = v
return ('ret', None), [heap[j] for j in range(len(vec))]
def tr_get_mut(v, i): # forward value and BACKWARD function back : u8 -> Vec
if i >= len(v): return fail('arrayOutOfBounds')
back = lambda new, v=v, i=i: tuple(new if j == i else x for j, x in enumerate(v))
return ok((v[i], back))
def tr_incr_at(v, i, k):
return bind(tr_get_mut(v, i), lambda p: bind(u8_add(p[0], k), lambda new: ok(p[1](new))))
cases = ties = 0
for n in range(0, 4):
for vec in itertools.product([0, 1, 254, 255], repeat=n):
for i in range(0, 5):
for k in (0, 1, 2):
out, st = machine_incr_at(list(vec), i, k)
r = tr_incr_at(vec, i, k)
cases += 1
if out[0] == 'ret': ties += (r.tag == 'ok' and list(r.v) == st)
else: ties += (r.tag == 'fail' and r.v == out[1])
check("3b. returned &mut: machine vs forward value + backward function, vec len<=3, i<=4, k<=2", ties == cases, f"{ties}/{cases} cases")
# %% [markdown]
# ## 4. Loops become recursion; non-termination is `div`
# `fn spin(x: u8) { while x != 0 { } }`. The machine is run with a fuel bound F; `div` is what survives every F.
# `fn sum_to(n: u8) -> u8 { let mut s = 0; let mut i = 0; while i < n { i += 1; s += i } s }` : the accumulator overflows once n(n+1)/2 > 255, i.e. n >= 23.
# %%
def machine_spin(x, fuel):
for _ in range(fuel):
if x == 0: return ('ret', None)
return ('fuel', None)
def tr_spin(x): return ok(None) if x == 0 else DIV
agree = all(((machine_spin(x, F)[0] == 'ret') == (tr_spin(x).tag == 'ok')) and ((machine_spin(x, F)[0] == 'fuel') == (tr_spin(x).tag == 'div')) for x in U8 for F in (1, 10, 1000))
check("4a. spin: 'div' corresponds to fuel exhaustion for every tested fuel", agree)
def machine_sum_to(n):
s = i = 0
while i < n:
i += 1; s += i
if s > 255: return ('panic', 'integerOverflow')
return ('ret', s)
def tr_sum_loop(n, i=0, s=0): # the recursive function Aeneas would emit
if i < n:
i2 = i + 1
r = u8_add(s, i2)
return bind(r, lambda s2: tr_sum_loop(n, i2, s2))
return ok(s)
m = [machine_sum_to(n) for n in U8]
check("4b. sum_to: loop = recursive translation for all n in u8", all(obs(tr_sum_loop(n)) == m[n] for n in U8))
first_bad = min(n for n in U8 if m[n][0] == 'panic')
check("4b. first panicking n is 23 (n(n+1)/2 = 276 > 255, 22*23/2 = 253 <= 255)", first_bad == 23 and 22 * 23 // 2 == 253 and 23 * 24 // 2 == 276, f"first_bad={first_bad}")
# %% [markdown]
# ## 5. A proof of `not P`: closed proposition versus negation under binders
# Property of `avg`: `P(a,b,r)`: `min(a,b) <= r <= max(a,b)`, evaluated on the translated function, with `Q(a,b) := exists r, avg a b = ok r /\ P(a,b,r)`.
# Closed statement S := forall a b, H a b -> Q a b (H trivially true here). Its negation not S <=> exists a b, H a b /\ not Q a b.
# Different, much stronger statement S' := forall a b, H a b -> not Q a b. Under binders, `not Q a b` for a free (a,b) is a claim about *every* instance.
# %%
def Q(a, b):
r = tr_avg(a, b)
return r.tag == 'ok' and min(a, b) <= r.v <= max(a, b)
H = lambda a, b: True
S = all((not H(a, b)) or Q(a, b) for a, b in DOM)
notS = any(H(a, b) and not Q(a, b) for a, b in DOM)
Sprime = all((not H(a, b)) or (not Q(a, b)) for a, b in DOM)
witnesses = [(a, b) for a, b in DOM if H(a, b) and not Q(a, b)]
check("5a. S is false and (not S) holds, certified by one witness", (not S) and notS and witnesses[0] == (1, 255), f"|W|={len(witnesses)}, first witness {witnesses[0]}")
check("5a. the 'under binders' strengthening S' (forall x, not Q x) is false: Q holds at (0,0)", (not Sprime) and Q(0, 0), f"|{{Q}}|={len(DOM)-len(witnesses)}")
check("5a. |W| = number of panicking inputs = 32640", len(witnesses) == 32640)
# With a precondition H a b := a + b <= 255 the closed statement is TRUE and its negation is not provable
H2 = lambda a, b: a + b <= 255
check("5b. with H := a+b<=255, S_H holds (so no certificate for not S_H exists)", all((not H2(a, b)) or Q(a, b) for a, b in DOM))
# Vacuity: if H is unsatisfiable, the under-binders form is provable and yet there is no witness at all
H3 = lambda a, b: a + b > 255 and a + b < 256
check("5c. unsatisfiable H: S'_H = forall x, H x -> not Q x holds vacuously, but the witness set is empty (no bug evidence)",
all((not H3(a, b)) or (not Q(a, b)) for a, b in DOM) and not any(H3(a, b) for a, b in DOM))
# And with H satisfiable and S'_H true (every H-input fails): S'_H implies the closed negation
H4 = lambda a, b: a + b >= 256
check("5c. satisfiable H := a+b>=256: S'_H holds (every such input fails) and so implies not S_H", all((not H4(a, b)) or (not Q(a, b)) for a, b in DOM) and any(H4(a, b) for a, b in DOM))
# %% [markdown]
# ## 6. Reachability: the certificate is about `avg`, not about the program that calls it
# Public caller: `fn mean_pct(a: u8, b: u8) -> u8 { avg(min(a, c), min(b, c)) }` for a clamp `c` (a property of the *caller*).
# Reach_c = {(a', b'): a', b' <= c}. Witnesses W from section 5 are real bugs of the program only if W intersect Reach_c is non-empty.
# %%
def count_formula(c): # pairs in [0,c]^2 with a+b >= 256
return sum(max(0, min(c, 255) - max(0, 256 - a) + 1) for a in range(0, min(c, 255) + 1))
rows = []
for c in (50, 100, 127, 128, 150, 200, 255):
brute = sum(1 for a, b in witnesses if a <= c and b <= c)
rows.append((c, brute, count_formula(c)))
assert brute == count_formula(c)
print(f" clamp c={c:3d}: |W ∩ Reach_c| = {brute:6d} (closed form {count_formula(c)})")
check("6. |W ∩ Reach_c| equals the closed form for all tested c", all(b == f for _, b, f in rows))
check("6. c <= 127: certificate holds for avg yet the program mean_pct never panics (false alarm)", dict((c, b) for c, b, _ in rows)[127] == 0 and dict((c, b) for c, b, _ in rows)[128] == 1, "c=127 -> 0, c=128 -> 1 (128+128=256)")
# end-to-end: run the caller on its whole input domain
def mean_pct(a, b, c): return tr_avg(min(a, c), min(b, c))
for c in (100, 127, 128, 200):
panics_c = sum(1 for a, b in DOM if mean_pct(a, b, c).tag == 'fail')
print(f" mean_pct with clamp {c}: panicking inputs over all 65536 raw (a,b): {panics_c}")
check("6. the caller with c=127 never fails on any of 65536 raw inputs; with c=128 it fails (a,b >= 128)", all(mean_pct(a, b, 127).tag == 'ok' for a, b in DOM) and sum(1 for a, b in DOM if mean_pct(a, b, 128).tag == 'fail') == 128 * 128)
# %% [markdown]
# ## 7. Translation soundness: wrong translations give wrong certificates in both directions
# * U1 (too lax): unbounded integers. Then `S` is *provable*, the real program violates it: a missed bug.
# * U2 (wrapping, release-mode semantics): `Q` fails on the same inputs but as a wrong value, and panic-freedom becomes provable. "The bug" depends on which Rust semantics is modelled.
# * U3 (too strict): checks overflow on the *widened* sum `(a as u16 + b as u16)/2`, which is fine in Rust, but translates the u16 add as a u8 add: spurious `fail`, a false certificate.
# %%
def Q_with(trf): return lambda a, b: (lambda r: r.tag == 'ok' and min(a, b) <= r.v <= max(a, b))(trf(a, b))
trU1 = tr_naive_avg
trU2 = lambda a, b: ok(((a + b) % 256) // 2)
def rust_wide(a, b): return ('ret', (a + b) // 2) # `((a as u16 + b as u16) / 2) as u8` never panics
trU3 = lambda a, b: bind(u8_add(a, b), lambda s: ok(s // 2)) # wrong: widened add checked at u8
Wset = lambda q: sum(1 for a, b in DOM if not q(a, b))
print(" failing sets |{not Q}|: truth(avg, checked) =", Wset(Q), ", U1 (unbounded) =", Wset(Q_with(trU1)), ", U2 (wrapping) =", Wset(Q_with(trU2)))
check("7a. U1: S is provable under the unbounded translation (no witness), but the program panics on 32640 inputs", Wset(Q_with(trU1)) == 0 and Wset(Q) == 32640)
pf_checked = all(tr_avg(a, b).tag == 'ok' for a, b in DOM); pf_wrap = all(trU2(a, b).tag == 'ok' for a, b in DOM)
same_set = {(a, b) for a, b in DOM if not Q_with(trU2)(a, b)} == set(witnesses)
check("7b. U2: wrapping gives the SAME 32640 witnesses for P but for a different reason (wrong value, not panic); panic-freedom is provable under U2 and false under checked semantics",
same_set and pf_wrap and not pf_checked, f"|W_wrap|={Wset(Q_with(trU2))}, panic-free: wrap={pf_wrap}, checked={pf_checked}")
# wide version of the program: truth never panics, so S_wide holds; the strict translation certifies a bug
Qwide_true = lambda a, b: min(a, b) <= rust_wide(a, b)[1] <= max(a, b)
check("7c. U3: program `avg_wide` is correct on all inputs; the over-strict translation yields 32640 spurious witnesses", all(Qwide_true(a, b) for a, b in DOM) and Wset(Q_with(trU3)) == 32640)
# %% [markdown]
# ## Summary
# %%
for n, ok_, d in CHECKS: print(("PASS " if ok_ else "FAIL ") + n)
assert all(c[1] for c in CHECKS)
print(f"\n{len(CHECKS)} checks, all passed")
|