Skip to content

Aiur test pipeline: proving/execution split, unconstrained-op typing, full cross-engine coverage - #538

Merged
arthurpaulino merged 5 commits into
mainfrom
ap/tests
Aug 6, 2026
Merged

Aiur test pipeline: proving/execution split, unconstrained-op typing, full cross-engine coverage#538
arthurpaulino merged 5 commits into
mainfrom
ap/tests

Conversation

@arthurpaulino

Copy link
Copy Markdown
Member

Restructures the Aiur test suites around what each execution mode actually
checks, closes a type-level soundness hole in the unconstrained ops, and
brings every Aiur bytecode op up to 4-way engine coverage — which
immediately caught and fixed four real bugs.

Closes #454

Suite split: aiur-prove / aiur-cross

  • aiur-prove (renamed from aiur) is now the proving suite: 29
    cases, each pinning a distinct constraint, selector-gating, or
    lookup-argument configuration — the things execution never evaluates.
    Its toplevel is pruned to functions reachable from those cases, and the
    minimal covering set of paths keeps proofs while redundant path
    variants moved to aiur-cross (down from ~70 proofs originally).
  • aiur-cross is the compiler/interpreter suite: every case runs
    4-way agreementSource.Eval (reference), Aiur.Interpret
    (debug interpreter), Bytecode.Eval, and the native Rust executor —
    on both values and IOBuffer, plus negative-path agreement (all four
    engines must reject: assert mismatch, range-check failure,
    non-exhaustive match, missing IO key, OOB IO read). The toplevel is
    compiled once instead of once per case.
  • aiur-hashes proves only boundary sizes (blake3 0/1088, sha256
    0/65 — one small and one multi-chunk per distinct wide circuit); the
    other 25 sizes execute against the Rust reference. The interpreter now
    also runs the smallest hash sizes and rbtree-map.
  • Test FRI parameters drop the proof-of-work grinding (2^20 hashing per
    proof, testing nothing) and use numQueries := 64, matching the
    Rust-side unit tests.
  • AiurTestCase ergonomics: .prove / .interp / .exec constructors
    with a label parameter; executionOnly renamed to withProof.
  • The ixvm codegen-parity gate reuses the kernelChecks cases, removing
    a duplicated per-constant witness-building pass (~5.6s of setup).

Bugs found by the new agreement matrix

The two Lean evaluators had only ever been compared against each other;
the interpreter and native executor had never joined the comparison. The
4-way matrix exposed:

  1. Source.Eval: .ret evaluated as a normal value, so return
    inside a non-tail match arm fed the continuation instead of exiting
    the function. Fixed with an earlyReturn sentinel on the error track,
    unwrapped at the function boundary.
  2. Bytecode.Eval: Ctrl.return was handled identically to
    Ctrl.yield, running matchContinue continuations after a
    function-level return. Same sentinel fix, mirroring the Rust
    executor's continuation-stack truncation.
  3. Interpret.runFunction: missed the .ret catch for the entry
    function itself ("unexpected return").
  4. G.ofNat narrowed through toUInt64 before reducing mod p, so
    any field op whose intermediate exceeds 2^64 — Mul products,
    Add/Sub carries, and therefore G.pow/G.inverse — wrapped mod
    2^64 instead of mod p. All three Lean evaluators agreed with each
    other and diverged only from the native executor. Fixed by reducing in
    Nat before narrowing.

Unconstrained ops: advice must not type as u8

u8's contract is "known to be range-checked", but the unconstrained
ops returned prover advice typed as bytes, upheld by caller discipline
only:

  • unconstrained_big_uint_div_mod now has a fixed signature
    (bigUintDivModResultTyp in the checker): inputs must be a pointer to
    a list datatype instantiated at [U8; 8] (e.g. KLimbs), and each
    result is the same datatype at [G; 8]. Non-conforming inputs fail
    with a dedicated checker error. Kernel-side, klimbs_range_check
    (check-and-discard) becomes glimbs_to_klimbs: the u8_range_check
    outputs are what mint the u8 limbs — the only door from advice back
    into KLimbs.
  • unconstrained_g_to_bytes returns [G; 8] for the same reason;
    its consumers (idx_to_u64 in the IxVM ingress, gl_to_bytes in the
    MultiStark verifier) mint bytes from their range-check outputs.

to_field is an erased coercion, so the g_to_bytes change leaves the
compiled bytecode byte-identical. The div_mod kernel restructure shifted
nine FFT pins and the shard pin marginally down (re-measured and
re-pinned; codegen regenerated, parity green).

unconstrained_big_uint_div_mod implemented in the Lean evaluators

Previously stubbed with an error in all three Lean engines — the last
op without cross-engine coverage. All three now mirror
crates/aiur/src/execute.rs exactly: width-10 limb-chain walk (tag
0 = Cons / 1 = Nil, per-byte range checks, cycle-bounded), Nat
division (which gives the runtime's b = 0 → (0, a) convention for
free), and canonical rebuild in the Rust builder's allocation order with
content dedup. Shared limb codecs live in Goldilocks.lean with proven
termination. Source.Eval's store/load arms are refactored onto shared
storeValue/loadValue helpers, and the Semantics files no longer
use panicking accessors — this code is slated for formal verification.

Coverage: divmod_test (plain / unit divisor / zero divisor / two-limb
dividend) and hint_test (g_to_bytes recomposition + canonicality on
p−1, g_inverse on 7 and 0) run 4-way agreement in aiur-cross and one
proof each in aiur-prove.

Quick suites promoted to primary

aiur-prove (~11s), aiur-hashes (4s), rbtree-map (2s),
multi-stark (2s), and recursive-verifier (3s — its "~1.5 min"
comment was stale) now run by default as primaryRunners: deferred IO
actions, so their setup doesn't execute at module initialization for
unrelated invocations. Runner names work as filter args, and the primary
section gains the same unknown-name guard the ignored section has. Only
ixvm remains in the ignored Aiur set.

CI

  • aiur-cross previously never ran in CI (primary suites were
    filtered to tc-unit); it now runs in the "Aiur tests" step together
    with the promoted quick suites.
  • Separate "IxVM kernel tests" step keeps --ignored ixvm.

Validation

  • Full default lake test (all primary suites + runners): ~11.5s wall,
    green.
  • aiur-cross: 1000+ checks including 20 rejection checks and the two
    new aggregates; aiur-prove: 29 proofs; aiur-hashes, rbtree-map,
    multi-stark, recursive-verifier, ixvm (pins, codegen parity,
    arena, exploits, shard): all green.
  • cargo fmt clean, cargo clippy --release --workspace --all-targets --all-features zero warnings.

@arthurpaulino
arthurpaulino enabled auto-merge (squash) August 5, 2026 22:54
…mantics

Restructure the Aiur test pipeline around what each mode actually checks:

- `aiur-prove` (renamed from `aiur`) is now the proving suite: 27
  cases, each pinning a distinct constraint, selector-gating, or
  lookup-argument configuration. The toplevel is pruned to functions
  reachable from these cases.
- `aiur-cross` is the compiler/interpreter suite: every case runs
  4-way agreement (Source.Eval, Interpret, Bytecode.Eval, native
  execute) on values and IOBuffer, plus negative-path agreement (all
  engines must reject) for assert / range-check / non-exhaustive-match
  / IO failures. Adds fold, gadget-op matches, and full inline_test
  parity; compiles the toplevel once. New CI step runs it on PRs (it
  previously never ran in CI).
- `aiur-hashes` proves only boundary sizes (blake3 0/1088, sha256
  0/65); other sizes execute against the Rust reference. The
  interpreter now also runs the smallest hash sizes and rbtree-map.
- Test FRI params drop proof-of-work grinding and use numQueries := 64,
  matching the Rust-side unit tests in crates/aiur.
- AiurTestCase: `.prove`/`.interp`/`.exec` constructors with a
  label param; `executionOnly` renamed to `withProof`.
- `ixvm`: the codegen parity gate reuses the `kernelChecks` cases
  (`runParityCase` ignores the FFT pins), removing the duplicated
  per-constant Ixon env load and witness construction (~5.6s of setup);
  the `parityCases` builder is gone.

The 4-way agreement immediately exposed three early-`return` bugs,
fixed here: Source.Eval evaluated `.ret` as a normal value (feeding
non-tail match continuations instead of exiting the function),
Bytecode.Eval treated Ctrl.return like Ctrl.yield (running
matchContinue continuations after a function-level return), and
Interpret.runFunction missed the `.ret` catch for the entry function.
All three now match the Rust executor's semantics.
The op returned its input type verbatim, so the kernel's div/mod hints
came back typed `KLimbs = List‹U64›` — prover advice claiming u8's
"known to be range-checked" contract with no check having run. The
kernel's manual `klimbs_range_check` upheld soundness by discipline
only; the type system was satisfied before it ran.

The checker now gives the op a fixed signature (see
`bigUintDivModResultTyp`): inputs must be a pointer to a list datatype
instantiated at `[U8; 8]`, and each result is the same datatype at
`[G; 8]`. Unconstrained limbs cannot pose as bytes; consumers are
obliged to range-check their way back. Non-conforming inputs fail with
`unconstrainedBigUintDivModType`.

Kernel side, `klimbs_range_check` (check-and-discard) becomes
`glimbs_to_klimbs`: it walks the `List‹[G; 8]›` hint, range-checks
every byte, and rebuilds the limbs from the checked outputs — the only
door from advice back into `KLimbs`. `klimbs_div_mod` converts
before normalizing.

Codegen regenerated. FFT pins re-measured via
`lake test -- --ignored ixvm`: the nine div/mod-dependent constants
and the shard pipeline all shifted marginally down (ppm-level layout
change from the circuit swap); the shard pin message now prints
expected/got like the per-constant pins.
Same flaw and same fix as unconstrained_big_uint_div_mod (previous
commit): the op typed its advice `[U8; 8]`, granting u8's "known to
be range-checked" contract to unchecked prover bytes, upheld only by
caller discipline. The checker now types the result `[G; 8]`; the two
consumers (`idx_to_u64` in the IxVM ingress, `gl_to_bytes` in the
MultiStark verifier) mint their bytes from the u8_range_check outputs,
dropping the now-unnecessary to_field wrappers.

to_field is an erased coercion, so the compiled bytecode is unchanged:
`ix codegen` regenerates byte-identical kernels and every FFT pin
holds (verified via `lake test -- --ignored ixvm multi-stark`).
The op was the last cross-engine blind spot: both Lean semantic
evaluators and the debug interpreter stubbed it with an error, so
nothing could cross-check the runtime's bignum hints. All three now
mirror crates/aiur/src/execute.rs exactly: walk the width-10 limb
chains (tag 0 = Cons, 1 = Nil, LE bytes, per-byte range check,
cycle-bounded), divide as Nat (which matches the runtime's
b = 0 -> (0, a) convention), and rebuild canonical chains in the Rust
builder's allocation order (Nil first, limbs reversed, content-deduped).
Shared limb codecs live in Goldilocks.lean with proven termination.
SourceEval's store/load arms are refactored onto shared
storeValue/loadValue so hint chains allocate identically to program
stores, and the Semantics files no longer use panicking accessors
(vs[0]!, set!) — this code is slated for formal verification.

Coverage: divmod_test (plain / unit divisor / zero divisor / two-limb
dividend) and hint_test (g_to_bytes recomposition + canonicality on
p - 1, g_inverse on 7 and 0) run 4-way agreement in aiur-cross and one
proof each in aiur-prove.

hint_test immediately exposed a long-standing bug in the Lean field
model: G.ofNat narrowed through toUInt64 BEFORE reducing mod p, so any
op whose intermediate exceeds 2^64 (Mul products, Add/Sub carries, the
pow chain behind G.inverse) wrapped mod 2^64 instead of mod p — all
three Lean evaluators agreed with each other and diverged only from the
native executor. Fixed by reducing in Nat before narrowing; probed
inv(7) * 7 = 1 directly.

Validated: aiur-cross and aiur-prove (29 proofs) fully green, plus
aiur-hashes and rbtree-map.
aiur-prove, aiur-hashes, rbtree-map, multi-stark and recursive-verifier
are all seconds-scale now (measured: ~11s, 4s, 2s, 2s, 3s), so they run
by default. They stay as deferred IO runners — a new `primaryRunners`
list — rather than becoming `TestSeq` values, so their setup (Aiur
system builds, STARK proofs) does not execute at module initialization
for unrelated invocations. The primary section gains the same
unknown-name guard the ignored section has, and runner names work as
filter args (`lake test -- aiur-prove`).

Only ixvm remains in the ignored Aiur set; CI's Aiur step drops
--ignored for the migrated suites and keeps a separate ignored ixvm
step. The recursive-verifier's "~1.5 min" comment was stale — the
pipeline runs in seconds.

Full default `lake test` (all primary suites + runners): ~11.5s wall.
@arthurpaulino
arthurpaulino merged commit 706c497 into main Aug 6, 2026
11 checks passed
@arthurpaulino
arthurpaulino deleted the ap/tests branch August 6, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Solve type inconsistencies of unconstrained_big_uint_div_mod

2 participants