Goal
Enable let x = match y { 0 => return (0, 0), 1 => z, }; use(x) — match
expressions in non-tail position, including branches with early returns.
Key constraint: avoid selector blowup
A pure Term-level hoist (pushing continuations into branches) turns independent
matches into a nested match tree: two sequential 3-armed matches produce 9 leaf
selectors instead of 6. With k sequential n-armed matches this is n^k instead
of k·n. Unacceptable for a circuit DSL.
The fix: a bytecode-level non-tail match that allocates its own selectors,
merges branch results via conditional select, and continues with a shared
continuation. Each match contributes its selectors independently.
Circuit semantics of non-tail match
For let x = match tag { 0 => a, 1 => b }; use(x):
- Evaluate
tag
- Allocate 2 selectors (sel₀, sel₁) for the match
- Evaluate
a under sel₀, evaluate b under sel₁
- Merge:
x = sel₀ · a + sel₁ · b (conditional select)
- Continue with
use(x) in the outer selector context
Each branch's constraints are gated by its selector. The merge produces values
usable by the continuation without duplication. Operations with global effects
(store/load, I/O, assertions) are already selector-gated.
Early returns: if a branch is return v, it exits the function directly (gets
its own leaf selector via Ctrl::Return). Non-returning branches yield values
for the merge. The continuation only runs when a yielding branch is active.
Implementation plan
Step 1: Bytecode (Ix/Aiur/Bytecode.lean + src/aiur/bytecode.rs)
Add two new Ctrl variants:
-- Lean
| yield : SelIdx → Array ValIdx → Ctrl
| matchContinue : ValIdx → Array (G × Block) → Option Block
→ (outputSize : Nat) → Block → Ctrl
// Rust
Yield(SelIdx, Vec<ValIdx>),
MatchContinue(ValIdx, FxIndexMap<G, Block>, Option<Box<Block>>, usize, Box<Block>),
yield sel vals: terminates a non-returning branch of a matchContinue. Like
return it assigns a selector, but instead of exiting the function it provides
values for the merge.
matchContinue tag cases default outputSize continuation: like match but
non-returning branches end in yield instead of return. After the match, the
yielded values are merged (conditional select) and the continuation block
executes with the merged values available as the next outputSize value indices.
Also update Block.for_each_op and Toplevel.needs_circuit (Rust) to recurse
into matchContinue branches and continuation.
Step 2: Execution (src/aiur/execute.rs)
Ctrl::Yield during execution is straightforward — it means "this branch
produced its values, now the continuation runs." The execution model already
dispatches exactly one branch. For the taken branch:
- If it ends in
Yield: the branch values are the match result. Continue
executing the continuation block.
- If it ends in
Return: exit the function (same as today).
Implementation: when processing MatchContinue, dispatch to the matched branch
(same logic as Match). Then:
- If the branch's ctrl is
Yield: push the continuation block's entries onto
the execution stack, with the yielded values in the map.
- If the branch's ctrl is
Return: handle as a normal function return.
The push_block_exec_entries! macro already handles pushing a block. For
MatchContinue, after the branch block finishes (hitting a Yield), push the
continuation block.
Step 3: Constraints (src/aiur/constraints.rs)
Ctrl::Yield constraint generation:
- Same as
Return for selector assignment: slice.selectors[sel] = 1.
- But NO function return lookup (the function hasn't returned yet).
- Just record that this selector gates this branch's constraints.
Ctrl::MatchContinue constraint generation:
Ctrl::MatchContinue(var, cases, def, output_size, continuation) => {
let (var, _) = state.map[*var].clone();
let init = state.save();
// Process branches exactly like Ctrl::Match:
// - For each case: branch_sel * (var - value) = 0
// - For default: branch_sel * ((var - value) * inverse - 1) = 0
// - Collect branch constraints with branch_sel gating
// After all branches, emit merge constraints:
// For each output slot i (0..output_size):
// merged_i = Σⱼ (yield_sel_j · yield_val_j_i)
// This is implemented as auxiliary values with constraints:
// sel * (merged_i - Σⱼ (yield_sel_j · yield_val_j_i)) = 0
// Push merged values to state.map
// Collect constraints from continuation block
continuation.collect_constraints(sel, state);
}
The branch_sel gating works exactly like Ctrl::Match. The new part is the
merge: for each yielding branch j and output slot i, emit
yield_sel_j * yield_val_j_i and sum them. The sum is the merged value for
slot i. The continuation's constraints use these merged values.
Key detail: the state.save()/restore() pattern used for branches means each
branch starts from the same auxiliary column index. The merge happens after
restoring to the post-branch state, using fresh auxiliaries for the merged
values.
Step 4: Trace (src/aiur/trace.rs)
Ctrl::Yield trace generation:
- Set
slice.selectors[sel] = G::ONE (same as Return).
- Do NOT emit a function return lookup (unlike Return).
Ctrl::MatchContinue trace generation:
Ctrl::MatchContinue(var, cases, def, output_size, continuation) => {
let val = map[*var].0;
let branch = /* dispatch same as Ctrl::Match, with default inverse witnesses */;
// Populate branch row (sets selector, computes auxiliaries)
branch.populate_row(map, index, slice, context, io_buffer);
// After branch: the branch ended with Yield, so map now has the yielded values.
// The merge in the trace is trivial: only one branch ran, so the merged values
// ARE the yielded values (the conditional select is identity for the active branch).
// Push output_size auxiliaries for the merged values:
for i in 0..*output_size {
let merged = /* yielded value i from the active branch */;
slice.push_auxiliary(index, merged);
}
// Continue with continuation block
continuation.populate_row(map, index, slice, context, io_buffer);
}
The trace is simpler than constraints because only one branch executes. The
merge auxiliary columns take the actual yielded values (no conditional select
needed at trace time — the constraint system ensures the algebra is correct).
Step 5: Lowering (Ix/Aiur/Compiler/Lower.lean)
In toIndex: replace the | .match .. => throw "Non-tail ..." error with:
- Evaluate scrutinee via
toIndex
- Extract accumulated ops
- For each branch:
- If
term.escapes: compile as a block ending in Ctrl.return (early exit)
- Else: evaluate branch body via
toIndex, create block ending in
Ctrl.yield sel vals
- Emit merge ops after the match:
- For each output slot i:
merged_i = Σⱼ (sel_j * val_j_i)
- This is
outputSize values, each a sum of products
- Return the merged
Array ValIdx
In TypedTerm.compile: handle .let pat val bod where val.inner is
.match:
- Compile the match as above (yields merged values)
- Bind merged values to
pat in the bindings map
- Compile
bod as the continuation → produces a Block
- Package as
Ctrl.matchContinue tag cases default outputSize continuation
Step 6: Layout (Ix/Aiur/Compiler/Layout.lean)
Add cases in blockLayout:
Ctrl.yield: bump selectors (+1), bump lookups (+1), same as return.
Ctrl.matchContinue: process branch blocks (save/restore + maximals, same as
match), then account for merge auxiliaries (outputSize extra), then
process the continuation block.
Step 7: Dedup (Ix/Aiur/Compiler/Dedup.lean)
skeletonBlock/skeletonCtrl: recurse into matchContinue branches and
continuation; handle yield like return.
collectCalleesBlock/collectCalleesCtrl: recurse into continuation.
rewriteBlock/rewriteCtrl: rewrite FunIdx in continuation and branches.
Step 8: Lean FFI / Bytecode serialization
The Lean-to-Rust bridge must serialize the new Ctrl variants. Check how
Ctrl.match and Ctrl.return are currently serialized and add cases for
yield and matchContinue.
Step 9: Validation test (Tests/Aiur/Aiur.lean)
- Basic non-tail match:
let x = match y { 0 => a, 1 => b }; use(x)
- Early return:
let x = match y { 0 => return v, 1 => z }; use(x)
- Sequential non-tail matches (verifies selector count is additive not
multiplicative)
- Nested non-tail matches
- Non-tail match with store/load in branches (verifies lookup gating)
Success criteria: lake test -- --ignored aiur passes.
Goal
Enable
let x = match y { 0 => return (0, 0), 1 => z, }; use(x)— matchexpressions in non-tail position, including branches with early returns.
Key constraint: avoid selector blowup
A pure Term-level hoist (pushing continuations into branches) turns independent
matches into a nested match tree: two sequential 3-armed matches produce 9 leaf
selectors instead of 6. With k sequential n-armed matches this is n^k instead
of k·n. Unacceptable for a circuit DSL.
The fix: a bytecode-level non-tail match that allocates its own selectors,
merges branch results via conditional select, and continues with a shared
continuation. Each match contributes its selectors independently.
Circuit semantics of non-tail match
For
let x = match tag { 0 => a, 1 => b }; use(x):tagaunder sel₀, evaluatebunder sel₁x = sel₀ · a + sel₁ · b(conditional select)use(x)in the outer selector contextEach branch's constraints are gated by its selector. The merge produces values
usable by the continuation without duplication. Operations with global effects
(store/load, I/O, assertions) are already selector-gated.
Early returns: if a branch is
return v, it exits the function directly (getsits own leaf selector via
Ctrl::Return). Non-returning branches yield valuesfor the merge. The continuation only runs when a yielding branch is active.
Implementation plan
Step 1: Bytecode (
Ix/Aiur/Bytecode.lean+src/aiur/bytecode.rs)Add two new
Ctrlvariants:yield sel vals: terminates a non-returning branch of amatchContinue. Likereturnit assigns a selector, but instead of exiting the function it providesvalues for the merge.
matchContinue tag cases default outputSize continuation: likematchbutnon-returning branches end in
yieldinstead ofreturn. After the match, theyielded values are merged (conditional select) and the
continuationblockexecutes with the merged values available as the next
outputSizevalue indices.Also update
Block.for_each_opandToplevel.needs_circuit(Rust) to recurseinto
matchContinuebranches and continuation.Step 2: Execution (
src/aiur/execute.rs)Ctrl::Yieldduring execution is straightforward — it means "this branchproduced its values, now the continuation runs." The execution model already
dispatches exactly one branch. For the taken branch:
Yield: the branch values are the match result. Continueexecuting the continuation block.
Return: exit the function (same as today).Implementation: when processing
MatchContinue, dispatch to the matched branch(same logic as
Match). Then:Yield: push the continuation block's entries ontothe execution stack, with the yielded values in the map.
Return: handle as a normal function return.The
push_block_exec_entries!macro already handles pushing a block. ForMatchContinue, after the branch block finishes (hitting a Yield), push thecontinuation block.
Step 3: Constraints (
src/aiur/constraints.rs)Ctrl::Yieldconstraint generation:Returnfor selector assignment:slice.selectors[sel] = 1.Ctrl::MatchContinueconstraint generation:The branch_sel gating works exactly like
Ctrl::Match. The new part is themerge: for each yielding branch j and output slot i, emit
yield_sel_j * yield_val_j_iand sum them. The sum is the merged value forslot i. The continuation's constraints use these merged values.
Key detail: the
state.save()/restore()pattern used for branches means eachbranch starts from the same auxiliary column index. The merge happens after
restoring to the post-branch state, using fresh auxiliaries for the merged
values.
Step 4: Trace (
src/aiur/trace.rs)Ctrl::Yieldtrace generation:slice.selectors[sel] = G::ONE(same as Return).Ctrl::MatchContinuetrace generation:The trace is simpler than constraints because only one branch executes. The
merge auxiliary columns take the actual yielded values (no conditional select
needed at trace time — the constraint system ensures the algebra is correct).
Step 5: Lowering (
Ix/Aiur/Compiler/Lower.lean)In
toIndex: replace the| .match .. => throw "Non-tail ..."error with:toIndexterm.escapes: compile as a block ending inCtrl.return(early exit)toIndex, create block ending inCtrl.yield sel valsmerged_i = Σⱼ (sel_j * val_j_i)outputSizevalues, each a sum of productsArray ValIdxIn
TypedTerm.compile: handle.let pat val bodwhereval.inneris.match:patin the bindings mapbodas the continuation → produces aBlockCtrl.matchContinue tag cases default outputSize continuationStep 6: Layout (
Ix/Aiur/Compiler/Layout.lean)Add cases in
blockLayout:Ctrl.yield: bump selectors (+1), bump lookups (+1), same asreturn.Ctrl.matchContinue: process branch blocks (save/restore + maximals, same asmatch), then account for merge auxiliaries (outputSizeextra), thenprocess the continuation block.
Step 7: Dedup (
Ix/Aiur/Compiler/Dedup.lean)skeletonBlock/skeletonCtrl: recurse intomatchContinuebranches andcontinuation; handle
yieldlikereturn.collectCalleesBlock/collectCalleesCtrl: recurse into continuation.rewriteBlock/rewriteCtrl: rewriteFunIdxin continuation and branches.Step 8: Lean FFI / Bytecode serialization
The Lean-to-Rust bridge must serialize the new
Ctrlvariants. Check howCtrl.matchandCtrl.returnare currently serialized and add cases foryieldandmatchContinue.Step 9: Validation test (
Tests/Aiur/Aiur.lean)let x = match y { 0 => a, 1 => b }; use(x)let x = match y { 0 => return v, 1 => z }; use(x)multiplicative)
Success criteria:
lake test -- --ignored aiurpasses.