Skip to content

Commit a6e9a0a

Browse files
authored
Close real checkpoint proof trust gaps
Closes #188. Closes #189. Closes #190.\n\nImplements export-bound predictor weight commitments, calibrated real activation tables, bundle-carried float reference/tolerance, CLI faithfulness gating, parity tests, and updated trust-boundary docs.
1 parent a118ffe commit a6e9a0a

19 files changed

Lines changed: 2226 additions & 261 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ lean/.lake/
2424
# Local demo/showcase scratch outputs (.artifacts/ already ignored above)
2525
/public-demo.html
2626
/demo.html
27+
uv.lock

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ non-reproducible attention. There is no proving circuit and no arithmetization.
3131
implemented and tested. P3 (full CEM planner) and P4 (pixel-to-plan, the ViT
3232
encoder inside the proof) are deferred. The image encoder is the trusted offline
3333
step today.
34-
- The proof attests the exact integer (quantized) relation, not float or PyTorch
35-
equivalence. Per-tensor activation-scale calibration for float-faithful outputs
36-
is a further refinement; activations stay int8 throughout the current scheme.
34+
- The proof attests the exact integer (quantized) relation, not the full PyTorch
35+
program. Predictor bundles carry calibrated SiLU, GELU, inverse-sqrt, and
36+
softmax-exp tables plus an offline float reference output and measured
37+
tolerance; the CLI checks the verified integer `z_next` against that bound.
38+
Activations stay int8 throughout the proved relation.
3739
- The argmin uniqueness check and the Freivalds probability bound are formally
3840
verified in Lean 4 (no `sorry`), under `lean/`.
3941

@@ -71,7 +73,7 @@ cargo run -p pwm-testkit --bin pwm --release -- prove-predictor <bundle> # real
7173
cargo test --workspace # accept + reject suites
7274
```
7375

74-
The `pwm` CLI prints a four-stage pipeline (EXPORT, PROVE, VERIFY, TAMPER); it
76+
The `pwm` CLI prints a five-stage pipeline (LOAD, INFER, COMMIT, VERIFY, TAMPER); it
7577
honors `NO_COLOR` and `--json`.
7678

7779
## Conventions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ entry.
1313

1414
## [Unreleased]
1515

16+
### Security
17+
18+
- **The predictor bundle is now commitment-chained to the export** (#188): the
19+
Python export emits, in `lewm_predictor.json`, a predictor-scoped
20+
`weights_root` computed over exactly the 30 proven block tensors in the Rust
21+
prover's canonical `tensor_id` order (`pwm_export.export.predictor_weight_dicts`),
22+
and the prover binds that *carried* value into the model commitment
23+
(`prove_predictor_with_weights_root`), so `verify_predictor` rejects with
24+
`CommitmentMismatch(Model)` unless the proven weights reproduce the export's
25+
commitment bit-for-bit. The artifact alone now certifies which weight set was
26+
proven (bundle ⇄ export bound; checkpoint ⇄ export remains trusted
27+
preprocessing — documented in README and specs §13). The Python↔Rust
28+
canonical-encoding parity gate is now two-way: the multi-leaf `weights_root`
29+
vector and the full predictor weight scheme are pinned on both sides.
30+
1631
### Changed
1732

1833
- **The `prove-predictor` demo path is now commitment-bound** (#187): the full

README.md

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ one honest hole CommitLLM leaves open: non-reproducible attention.
2424

2525
### What it does not claim
2626

27-
It proves an exact arithmetic relation. It does not claim floating-point or PyTorch
28-
equivalence, the physical truth of the predictions, or zero knowledge. Public
29-
claims must be a subset of the proven statement.
27+
It proves an exact integer arithmetic relation. For predictor bundles, the export
28+
also carries a float reference output and a measured tolerance, and the demo CLI
29+
checks the verified integer output against that bound. The verifier still does
30+
not prove the full PyTorch program, the physical truth of the predictions, zero
31+
knowledge, or that the checkpoint bytes were exported honestly. Public claims
32+
must be a subset of the proven statement.
3033

3134
## Quickstart
3235

@@ -64,11 +67,13 @@ ProvableWorldModel commit-and-audit over the le-wm world model
6467
├ witness 689,760 claimed op outputs (5.26 MiB) trace_root 56bc38fc...
6568
└ bind absorbed model + inputs + trace, then squeezed the Freivalds r (non-adaptive)
6669
67-
[stage 4/5] VERIFY no_std, float-free, never re-runs the model
70+
[stage 4/5] VERIFY no_std, float-free, commitment-bound, never re-runs the model
71+
├ binding recomputed model, quantization, planner, input + output commitments
6872
├ challenge derived the Freivalds r for 30 linear projections
6973
├ checks Freivalds v·x == r·z (soundness ≤ 1/p, p = 2⁶¹−1; union over the checks ~2⁻⁴⁴)
7074
│ exact recompute of attention, softmax, GELU, LayerNorm, residuals
71-
└ verdict ACCEPT in 28.6 ms (1.7x faster than proving; audits arithmetic only)
75+
├ faith max |int - float| <= bundle tolerance (offline reference from the export bundle)
76+
└ verdict ACCEPT in 28.6 ms (1.7x faster than proving; commitments + arithmetic audited)
7277
7378
[stage 5/5] TAMPER forge one matmul output
7479
└ forged matmul op 2 -> REJECT FreivaldsCheckFailed { op_id: 2 } (caught)
@@ -131,6 +136,7 @@ ProvableWorldModel export the real le-wm checkpoint into the prover
131136
[QUANTIZE] 34 matrices -> 11,705,856 int8 params (float32 44.7 MiB -> int8 11.2 MiB, 4.0x smaller)
132137
[COMMIT] model_commitment / quantization_commitment / graph_commitment (Blake2s-256)
133138
[ENCODE] real PushT expert episode (lerobot/pusht): 3 frames @ frameskip 5 -> ViT encoder; real 2D action
139+
[CALIBRATE] real SiLU/GELU/inverse-sqrt/softmax-exp tables + measured predictor tolerance
134140
```
135141

136142
The prover then runs the exact-integer inference on those real weights and audits it:
@@ -148,8 +154,9 @@ The prover then runs the exact-integer inference on those real weights and audit
148154
├ latency forward pass in 49.0 ms (49.7 Kop/s, 661 MMAC/s)
149155
└ z_next [11, 55, 32, -73, -57, 13] (predicted next-latent head, from the real forward pass)
150156
151-
[stage 4/5] VERIFY no_std, float-free, never re-runs the model
152-
└ verdict ACCEPT in 28.6 ms (1.7x faster than proving; audits arithmetic only)
157+
[stage 4/5] VERIFY no_std, float-free, commitment-bound, never re-runs the model
158+
├ faith max |int - float| <= bundle tolerance (offline reference from the export bundle)
159+
└ verdict ACCEPT in 28.6 ms (1.7x faster than proving; commitments + arithmetic audited)
153160
154161
[stage 5/5] TAMPER forge one matmul output
155162
└ forged matmul op 2 -> REJECT FreivaldsCheckFailed { op_id: 2 } (caught)
@@ -161,7 +168,14 @@ A real expert episode (consistent observation and action) goes through the
161168
checkpoint's own encoders to produce the latent history and action embedding the
162169
predictor consumes. The encode is the trusted offline step (the image encoder is
163170
P4-deferred for proving), and the predictor step is what the no_std verifier
164-
audits. The action is the real 2D expert control plus the agent state; the full
171+
audits. The bundle also carries the export-computed `weights_root` over the 30
172+
proven block tensors; the prover binds that carried value into the model
173+
commitment, so the verifier rejects unless the proven weights reproduce the
174+
export's commitment bit-for-bit (bundle ⇄ export bound; checkpoint ⇄ export
175+
trusted). The same bundle carries the calibrated SiLU, GELU, inverse-sqrt, and
176+
softmax-exp tables plus the float predictor output on the same encoded input; the
177+
CLI checks the verified integer `z_next` against the bundle's measured tolerance.
178+
The action is the real 2D expert control plus the agent state; the full
165179
le-wm 10-dim action layout lives in the 13 GB lewm-pusht dataset.
166180

167181
The predictor is built as the real le-wm architecture over the named-buffer block
@@ -208,7 +222,7 @@ and exactly recomputes the attention, softmax, GELU, LayerNorm, and residuals.
208222
+============================+===============================================+
209223
| v VERIFIER (no_std / float-free) |
210224
| +------------------------------------------------------------+ |
211-
| | VERIFY (NEVER re-runs the model; audits arithmetic only) | |
225+
| | VERIFY (NEVER re-runs the model; audits bindings + trace) | |
212226
| | replay transcript; Freivalds-check every fixed matmul: | |
213227
| | v.x == r.z because rT(W x) = (rT W) x | |
214228
| | soundness err <= 1/p, p = 2^61-1; | |
@@ -227,7 +241,8 @@ and exactly recomputes the attention, softmax, GELU, LayerNorm, and residuals.
227241
+============================================================================+
228242
229243
exact integer fixed point makes attention reproducible: the one hole
230-
CommitLLM leaves open is closed here. The verifier audits arithmetic only.
244+
CommitLLM leaves open is closed here. The verifier audits the committed
245+
statement and the exact arithmetic trace.
231246
```
232247

233248
The prover runs the model normally and commits to its execution trace. The
@@ -278,9 +293,10 @@ and reject tests. The exporter ingests the real `quentinll/lewm-pusht` checkpoin
278293
and quantizes the full 192-dim V0 subgraph, and the Rust prover proves and
279294
verifies the full 6-block, 16-head, 192-dim predictor with the real quantized
280295
weights (`pwm prove-predictor`), plus the `pred_proj` head on its own
281-
(`pwm prove-lewm`). The proof attests the exact integer (quantized) relation;
282-
per-tensor activation-scale calibration for float-faithful outputs is a further
283-
refinement. For P2, all `S` candidate costs must be proven, not only the winner:
296+
(`pwm prove-lewm`). The proof attests the exact integer (quantized) relation, and
297+
predictor bundles now include calibrated activation tables plus an offline
298+
float-reference tolerance that the CLI enforces after verification. For P2, all
299+
`S` candidate costs must be proven, not only the winner:
284300
proving only the selected candidate would be unsound.
285301

286302
Binding status, precisely: both the P0 feed-forward statement (`AuditArtifact`) and
@@ -295,6 +311,19 @@ claimed accumulator) before any challenge is squeezed, so the challenge is
295311
statement-bound and non-adaptive. A bare `verify_block` still exists for standalone
296312
arithmetic audits; the committed relation is the one to use for a real statement.
297313

314+
The trust boundary, exactly: **bundle ⇄ export is cryptographically bound;
315+
checkpoint ⇄ export is trusted preprocessing.** The export computes a
316+
predictor-scoped `weights_root` over exactly the proven block tensors (in the
317+
prover's canonical `tensor_id` order) and carries it in the bundle; the prover
318+
binds that carried value, not a recomputed one, into the model commitment, so
319+
`verify_predictor` accepts only if the proven weights reproduce the export's
320+
commitment bit-for-bit (`CommitmentMismatch(Model)` otherwise). The artifact alone
321+
therefore certifies *which* weight set was proven, with no out-of-band weights.
322+
What remains trusted is the export step itself: that the quantized tensors
323+
faithfully derive from the `quentinll/lewm-pusht` checkpoint bytes (download,
324+
float-to-int8 quantization, and input encoding are offline preprocessing, outside
325+
the proven relation).
326+
298327
## Architecture
299328

300329
Five small crates, one trust anchor. The verifier depends on neither the exporter

crates/pwm-core/tests/commit.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,36 @@ fn weights_root_changes_with_a_leaf_and_handles_edges() {
178178
assert_ne!(single, base);
179179
}
180180

181+
#[test]
182+
fn weights_root_multi_leaf_parity_vector_is_pinned() {
183+
// The cross-language parity vector: predictor-style tensors (ids 1000..,
184+
// scale_id 0, int8 bounds; three leaves exercise the odd-level node
185+
// duplication). The Python exporter pins the same hex in
186+
// crates/pwm-export/python/tests/test_canonical_parity.py
187+
// (test_predictor_weights_root_matches_rust), so the gate is two-way: a
188+
// change to either side's encoding breaks its own pin.
189+
let shaped = |id: u32, shape: &[u32], vals: &[i64]| {
190+
let data = vals
191+
.iter()
192+
.map(|&v| BoundedInt::new(v, -128, 127).unwrap())
193+
.collect();
194+
Tensor::new(id, shape.to_vec(), 0, data).unwrap()
195+
};
196+
let w = [
197+
shaped(1000, &[1, 2], &[1, -2]),
198+
shaped(1001, &[2, 1], &[3, 4]),
199+
shaped(1002, &[2, 2], &[-5, 6, -7, 8]),
200+
];
201+
let hex: String = weights_root(&w)
202+
.iter()
203+
.map(|b| format!("{b:02x}"))
204+
.collect();
205+
assert_eq!(
206+
hex,
207+
"c666f637a30f38d653f7c6a3280e87b705777df25bae4644148b534992b03120"
208+
);
209+
}
210+
181211
#[test]
182212
fn output_commitment_binds_outputs() {
183213
let o1 = tensor(0, &[1, 2]);

crates/pwm-export/python/pwm_export/export.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@
1313
"""
1414
from __future__ import annotations
1515

16-
import numpy as np
16+
from typing import Any
1717

1818
from . import canonical as c
19-
from .fold import fold_linear_bn
20-
from .quantize import mac_fits_int32, quantize_array
2119

2220

2321
def tensor_dict(tensor_id: int, scale_id: int, q_list: list[int], shape: list[int]) -> dict:
@@ -30,15 +28,50 @@ def tensor_dict(tensor_id: int, scale_id: int, q_list: list[int], shape: list[in
3028
}
3129

3230

31+
def predictor_weight_dicts(
32+
blocks: list[dict], d: int, heads: int, dim_head: int, mlp: int
33+
) -> list[dict]:
34+
"""The predictor-bundle weight tensors in the Rust prover's canonical scheme.
35+
36+
Mirrors `lewm_predictor.rs` exactly: per block `i` the tensor ids are
37+
`1000 + 5*i ..` in registration order `adaln, qkv, out, fc1, fc2`.
38+
Weight tensor `k` uses `scale_id = 10 + k`, matching the Rust builder and
39+
binding the per-tensor `log2` interpretation into the quantization
40+
commitment. `weights_root` over this list is the commitment the bundle
41+
carries and the prover must reproduce bit-for-bit (pinned cross-language in
42+
the canonical-parity tests).
43+
"""
44+
inner = heads * dim_head
45+
out = []
46+
for i, bk in enumerate(blocks):
47+
base = 1000 + 5 * i
48+
for off, (key, shape) in enumerate((
49+
("adaln", [6 * d, d]),
50+
("qkv", [3 * inner, d]),
51+
("out", [d, inner]),
52+
("fc1", [mlp, d]),
53+
("fc2", [d, mlp]),
54+
)):
55+
k = 5 * i + off
56+
out.append(tensor_dict(base + off, 10 + k, bk[key], shape))
57+
return out
58+
59+
3360
def quantize_torch(w, qmax: int = 127) -> tuple[list[int], int]:
3461
"""Torch entry point for E-202: convert a tensor to an array and quantize."""
62+
import numpy as np
63+
64+
from .quantize import quantize_array
65+
3566
return quantize_array(np.asarray(w.detach().cpu().numpy()), qmax)
3667

3768

3869
def fold_torch(weight, bias, bn):
3970
"""Torch entry point for E-203: pull frozen BN stats and fold (NumPy core)."""
4071
import torch
4172

73+
from .fold import fold_linear_bn
74+
4275
affine = getattr(bn, "affine", False)
4376
gamma = bn.weight if affine else torch.ones_like(bn.running_mean)
4477
beta = bn.bias if affine else torch.zeros_like(bn.running_mean)
@@ -58,6 +91,10 @@ def quantize_linear(tensor_id: int, scale_id: int, weight) -> tuple[dict, dict]:
5891
5992
Asserts the int8 MAC for this layer fits the int32 accumulator (spec §3).
6093
"""
94+
import numpy as np
95+
96+
from .quantize import mac_fits_int32, quantize_array
97+
6198
weight = np.asarray(weight, dtype=np.float64)
6299
out, inner = weight.shape
63100
if not mac_fits_int32(inner):
@@ -78,12 +115,14 @@ def build_manifest(ops: list[dict], weights: list[dict], tables: list[dict], sca
78115
}
79116

80117

81-
def export_graph(named_weights: list[tuple[int, str, np.ndarray]], tables: list[dict]) -> dict:
118+
def export_graph(named_weights: list[tuple[int, str, Any]], tables: list[dict]) -> dict:
82119
"""Quantize a list of `(op_id, name, weight)` Linears into a committed manifest.
83120
84121
Returns `{"manifest", "weights", "ops", "scales"}` — the manifest carries the
85122
same commitments the Rust prover/verifier reproduce (E-205/E-207).
86123
"""
124+
import numpy as np
125+
87126
ops: list[dict] = []
88127
weights: list[dict] = []
89128
scales: list[dict] = []

0 commit comments

Comments
 (0)