Skip to content

Commit e252322

Browse files
authored
Make continuation API take epoch size log2 (#730)
1 parent 6c0616b commit e252322

6 files changed

Lines changed: 84 additions & 65 deletions

File tree

bin/cli/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ fn cmd_prove_continuation(
627627
let bundle = match prover::continuation::prove_continuation(
628628
&elf_data,
629629
&private_inputs,
630-
epoch_size,
630+
epoch_size_log2,
631631
&opts,
632632
) {
633633
Ok(b) => b,

docs/continuations_design.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,12 @@ dangle — no HALT to anchor them, and the REGISTER FINI carries the real next P
220220
not `1` — and the Memory bus would not balance. The honest prover could not produce
221221
a verifying proof.
222222

223-
Fix: **epoch size is rounded up to a power of two** (`next_power_of_two().max(4)`).
224-
An intermediate epoch runs *exactly* `epoch_size` cycles, so its CPU table already
225-
has a power-of-two row count and therefore **zero padding rows** — nothing to
226-
dangle. The final epoch keeps its remainder *and* its HALT, so its padding chain is
227-
anchored as usual. A program shorter than one epoch runs as a single final
228-
(monolithic-style) epoch.
223+
Fix: **epoch size is expressed as `epoch_size_log2`**, so the driver slices at
224+
exactly `2^epoch_size_log2` cycles. An intermediate epoch runs that exact
225+
power-of-two number of cycles, so its CPU table already has a power-of-two row
226+
count and therefore **zero padding rows** — nothing to dangle. The final epoch
227+
keeps its remainder *and* its HALT, so its padding chain is anchored as usual. A
228+
program shorter than one epoch runs as a single final (monolithic-style) epoch.
229229

230230
This is a **completeness** fix: it changes no constraint and nothing the verifier
231231
accepts — only how the driver slices cycles. A debug-assert enforces the
@@ -553,9 +553,9 @@ recursion/aggregation layer (deferred).
553553
`verify_continuation` and the `ContinuationProof` bundle; the per-epoch
554554
`prove_epoch` / `verify_epoch` with the shared `build_epoch_airs` helper; the
555555
global proof (`prove_global` / `verify_global`); the per-epoch AIRs
556-
(`l2g_memory_air` / `l2g_global_air`); the power-of-two epoch rounding
557-
(`next_power_of_two().max(4)`); the register-FINI preprocessing; the transcript
558-
seeding; and `prove_and_verify_continuation` (the thin integrated wrapper).
556+
(`l2g_memory_air` / `l2g_global_air`); the power-of-two epoch sizing from
557+
`epoch_size_log2`; the register-FINI preprocessing; the transcript seeding; and
558+
`prove_and_verify_continuation` (the thin integrated wrapper).
559559
- `prover/src/lib.rs``verify_l2g_commitment_binding` (epoch L2G root ↔ global
560560
sub-table root) and the commit-bus offset/balance helpers
561561
(`compute_commit_bus_offset`, `compute_expected_commit_bus_balance`) that take the

prover/benches/bench_continuation.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,25 @@ fn main() {
112112
println!("main prove ok ({} bytes ELF)", elf.len());
113113
}
114114
"cont" => {
115-
let epoch_size: usize = args
115+
let epoch_size_log2: u32 = args
116116
.get(3)
117-
.map(|s| s.parse().expect("bad epoch_size"))
118-
.unwrap_or(65536);
117+
.map(|s| s.parse().expect("bad epoch_size_log2"))
118+
.unwrap_or(16);
119119
// Match the monolithic `main` mode's options (blowup 2) for a fair comparison.
120120
let opts = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2)
121121
.expect("blowup=2 is always valid");
122122
let output = lambda_vm_prover::continuation::prove_and_verify_continuation(
123123
&elf,
124124
&private_inputs,
125-
epoch_size,
125+
epoch_size_log2,
126126
&opts,
127127
)
128128
.expect("continuation failed");
129129
assert!(output.is_some(), "continuation did not verify");
130-
println!("cont prove+verify ok (epoch_size={epoch_size})");
130+
println!(
131+
"cont prove+verify ok (epoch_size_log2={epoch_size_log2}, epoch_size={})",
132+
1usize << epoch_size_log2
133+
);
131134
}
132135
other => {
133136
eprintln!("unknown mode {other:?}; use count|footprint|main|cont");

prover/src/continuation.rs

Lines changed: 58 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -621,25 +621,34 @@ fn verify_global(
621621
}
622622

623623
/// Prove a full continuation and return a self-contained [`ContinuationProof`]
624-
/// (prove half only — no verification). Splits the execution into `epoch_size`-cycle
625-
/// epochs, proves each, and proves the one cross-epoch global-memory linkage.
624+
/// (prove half only — no verification). Splits the execution into `2^epoch_size_log2`
625+
/// cycle epochs, proves each, and proves the one cross-epoch global-memory linkage.
626626
///
627-
/// Epoch size is rounded up to a power of two (min 4). An intermediate epoch runs
628-
/// exactly `epoch_size` cycles, so a power-of-two size gives its CPU table a
629-
/// power-of-two row count and therefore zero padding rows — important because CPU
630-
/// padding rows participate in the inline-PC `memory` chain (carrying pc=1) which is
631-
/// only anchored by the HALT chip's emit_pc/consume_pc, and intermediate epochs
632-
/// exclude HALT. With padding rows present and no HALT their pc=1 tokens dangle and
633-
/// the Memory bus fails to balance; zero padding rows sidestep that. The final epoch
634-
/// keeps its remainder and its HALT, so its padding chain is anchored as usual. A
635-
/// program that fits in one epoch runs as a single final (monolithic-style) epoch.
627+
/// Intermediate epochs run exactly `2^epoch_size_log2` cycles, so their CPU tables
628+
/// have power-of-two row counts and therefore zero padding rows — important because
629+
/// CPU padding rows participate in the inline-PC `memory` chain (carrying pc=1)
630+
/// which is only anchored by the HALT chip's emit_pc/consume_pc, and intermediate
631+
/// epochs exclude HALT. With padding rows present and no HALT their pc=1 tokens
632+
/// dangle and the Memory bus fails to balance; zero padding rows sidestep that. The
633+
/// final epoch keeps its remainder and its HALT, so its padding chain is anchored as
634+
/// usual. A program that fits in one epoch runs as a single final (monolithic-style)
635+
/// epoch.
636636
pub fn prove_continuation(
637637
elf_bytes: &[u8],
638638
private_inputs: &[u8],
639-
epoch_size: usize,
639+
epoch_size_log2: u32,
640640
opts: &ProofOptions,
641641
) -> Result<ContinuationProof, Error> {
642-
let epoch_size = epoch_size.next_power_of_two().max(4);
642+
if epoch_size_log2 < 2 {
643+
return Err(Error::InvalidContinuationEpochSize(
644+
"epoch_size_log2 must be at least 2 (4 cycles)".to_string(),
645+
));
646+
}
647+
let epoch_size = 1usize.checked_shl(epoch_size_log2).ok_or_else(|| {
648+
Error::InvalidContinuationEpochSize(format!(
649+
"epoch_size_log2 {epoch_size_log2} is too large for this platform"
650+
))
651+
})?;
643652

644653
let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?;
645654
let mut executor = Executor::new(&elf, private_inputs.to_vec())
@@ -844,10 +853,10 @@ pub fn verify_continuation(
844853
pub fn prove_and_verify_continuation(
845854
elf_bytes: &[u8],
846855
private_inputs: &[u8],
847-
epoch_size: usize,
856+
epoch_size_log2: u32,
848857
opts: &ProofOptions,
849858
) -> Result<Option<Vec<u8>>, Error> {
850-
let bundle = prove_continuation(elf_bytes, private_inputs, epoch_size, opts)?;
859+
let bundle = prove_continuation(elf_bytes, private_inputs, epoch_size_log2, opts)?;
851860
verify_continuation(elf_bytes, &bundle, opts)
852861
}
853862

@@ -874,30 +883,26 @@ mod tests {
874883
.logs
875884
.len();
876885

877-
// Both commits in a single epoch (x254 starts at 0).
886+
// Both commits in a single 64-cycle epoch (x254 starts at 0).
878887
let single = prove_and_verify_continuation(
879888
&elf_bytes,
880889
&[],
881-
total,
890+
6,
882891
&ProofOptions::default_test_options(),
883892
)
884893
.unwrap();
885894
assert_eq!(single.as_deref(), Some(&expected_output[..]));
895+
assert!(total <= (1 << 6), "single-epoch log2 must cover the run");
886896

887897
// The late commit (only `halt` follows it) lands past the midpoint, so a
888-
// half-sized epoch forces it into a later epoch where x254 is already 2.
898+
// 16-cycle epoch forces it into a later epoch where x254 is already 2.
889899
// Prove first so we can assert the run actually split into >1 epoch — without
890900
// this the test would silently pass even if it degraded to a single epoch.
891-
let bundle = prove_continuation(
892-
&elf_bytes,
893-
&[],
894-
(total / 2).max(1),
895-
&ProofOptions::default_test_options(),
896-
)
897-
.unwrap();
901+
let bundle =
902+
prove_continuation(&elf_bytes, &[], 4, &ProofOptions::default_test_options()).unwrap();
898903
assert!(
899904
bundle.num_epochs() > 1,
900-
"a half-sized epoch must split the run into multiple epochs"
905+
"16-cycle epochs must split the run into multiple epochs"
901906
);
902907
let split = verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options())
903908
.unwrap();
@@ -909,12 +914,13 @@ mod tests {
909914
}
910915

911916
// A memory-heavy multi-epoch continuation. `all_loadstore_32` is ~34 cycles, so
912-
// a power-of-two `epoch_size` of 8 yields several intermediate epochs (each an
917+
// `epoch_size_log2 = 3` (8 cycles) yields several intermediate epochs (each an
913918
// exact power-of-two cycle count → no CPU padding rows) plus a final epoch.
914919
#[test]
915920
fn test_prove_and_verify_continuation() {
916921
let _ = env_logger::builder().is_test(true).try_init();
917922
let elf_bytes = asm_elf_bytes("all_loadstore_32");
923+
let epoch_size_log2 = 3;
918924
let epoch_size = 8;
919925
// Guard against silent degradation: the program must be longer than one
920926
// epoch, otherwise this collapses to a single final epoch and stops testing
@@ -933,7 +939,7 @@ mod tests {
933939
prove_and_verify_continuation(
934940
&elf_bytes,
935941
&[],
936-
epoch_size,
942+
epoch_size_log2,
937943
&ProofOptions::default_test_options()
938944
)
939945
.unwrap()
@@ -944,8 +950,9 @@ mod tests {
944950
// Regression for the `epoch_touched_cells` fresh-register bug. A syscall whose
945951
// operand pointers live in registers (ECSM reads a0/a1/a2) can have those
946952
// registers set in an EARLIER epoch than the call. `test_ecsm_split` sets
947-
// a0/a1/a2 at the very start and runs the ECSM ~46 cycles later; epoch_size 32
948-
// puts the pointer setup in epoch 0 and the ecall in epoch 1. The per-epoch
953+
// a0/a1/a2 at the very start and runs the ECSM ~46 cycles later;
954+
// `epoch_size_log2 = 5` (32 cycles) puts the pointer setup in epoch 0 and the
955+
// ecall in epoch 1. The per-epoch
949956
// touched-cell pass must carry registers across the boundary — otherwise it
950957
// reads the pointers as 0, mispredicts the touched cells (and the ECSM
951958
// operands), and the epoch cannot verify.
@@ -963,7 +970,7 @@ mod tests {
963970
let out = prove_and_verify_continuation(
964971
&elf_bytes,
965972
&[],
966-
32,
973+
5,
967974
&ProofOptions::default_test_options(),
968975
)
969976
.unwrap();
@@ -973,27 +980,31 @@ mod tests {
973980
);
974981
}
975982

976-
// Guards the power-of-two epoch-size rounding in `prove_and_verify_continuation`.
977-
// A non-power-of-two `epoch_size` (10) must still verify: the driver rounds it up
978-
// to 16, so intermediate epochs have no CPU padding rows. Without the rounding
979-
// this returns `Ok(None)` (dangling padding pc=1 tokens). 16-cycle epochs over
980-
// the 33-cycle `test_commit_split` also put its two commits in different epochs,
981-
// exercising the cross-epoch x254 carry; asserting the exact aggregated output
982-
// keeps this test from silently degrading to a trivial pass.
983+
// Guards that the continuation API takes `epoch_size_log2` directly. A log2 of
984+
// 4 produces 16-cycle epochs over the 33-cycle `test_commit_split`, putting its
985+
// two commits in different epochs and exercising the cross-epoch x254 carry.
983986
#[test]
984-
fn test_continuation_non_power_of_two_epoch_size() {
987+
fn test_continuation_epoch_size_log2() {
985988
let _ = env_logger::builder().is_test(true).try_init();
986989
let elf_bytes = asm_elf_bytes("test_commit_split");
987990
let out = prove_and_verify_continuation(
988991
&elf_bytes,
989992
&[],
990-
10,
993+
4,
991994
&ProofOptions::default_test_options(),
992995
)
993996
.unwrap();
994997
assert_eq!(out.as_deref(), Some(&[0xAA, 0xBB, 0xCC, 0xDD][..]));
995998
}
996999

1000+
#[test]
1001+
fn test_continuation_rejects_too_small_epoch_size_log2() {
1002+
assert!(matches!(
1003+
prove_continuation(&[], &[], 1, &ProofOptions::default_test_options()),
1004+
Err(Error::InvalidContinuationEpochSize(_))
1005+
));
1006+
}
1007+
9971008
// ---- Standalone (split) prover/verifier ----
9981009

9991010
// Round-trip: a bundle from prove_continuation verifies on its own (only the
@@ -1003,7 +1014,7 @@ mod tests {
10031014
let _ = env_logger::builder().is_test(true).try_init();
10041015
let elf_bytes = asm_elf_bytes("test_commit_split");
10051016
let bundle =
1006-
prove_continuation(&elf_bytes, &[], 10, &ProofOptions::default_test_options()).unwrap();
1017+
prove_continuation(&elf_bytes, &[], 4, &ProofOptions::default_test_options()).unwrap();
10071018
let out = verify_continuation(&elf_bytes, &bundle, &ProofOptions::default_test_options())
10081019
.unwrap();
10091020
assert_eq!(out.as_deref(), Some(&[0xAA, 0xBB, 0xCC, 0xDD][..]));
@@ -1016,7 +1027,7 @@ mod tests {
10161027
let _ = env_logger::builder().is_test(true).try_init();
10171028
let elf_bytes = asm_elf_bytes("test_commit_split");
10181029
let bundle =
1019-
prove_continuation(&elf_bytes, &[], 10, &ProofOptions::default_test_options()).unwrap();
1030+
prove_continuation(&elf_bytes, &[], 4, &ProofOptions::default_test_options()).unwrap();
10201031

10211032
let bytes = bincode::serialize(&bundle).unwrap();
10221033
let restored: ContinuationProof = bincode::deserialize(&bytes).unwrap();
@@ -1034,7 +1045,7 @@ mod tests {
10341045
let _ = env_logger::builder().is_test(true).try_init();
10351046
let elf_bytes = asm_elf_bytes("all_loadstore_32");
10361047
let mut bundle =
1037-
prove_continuation(&elf_bytes, &[], 8, &ProofOptions::default_test_options()).unwrap();
1048+
prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap();
10381049
assert!(bundle.epochs.len() >= 3, "need multiple epochs");
10391050
bundle.epochs.pop();
10401051
assert!(
@@ -1052,7 +1063,7 @@ mod tests {
10521063
let _ = env_logger::builder().is_test(true).try_init();
10531064
let elf_bytes = asm_elf_bytes("all_loadstore_32");
10541065
let mut bundle =
1055-
prove_continuation(&elf_bytes, &[], 8, &ProofOptions::default_test_options()).unwrap();
1066+
prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap();
10561067
assert!(bundle.epochs.len() >= 3, "need multiple epochs");
10571068
bundle.epochs.swap(0, 1);
10581069
assert!(
@@ -1071,7 +1082,7 @@ mod tests {
10711082
let _ = env_logger::builder().is_test(true).try_init();
10721083
let elf_bytes = asm_elf_bytes("all_loadstore_32");
10731084
let mut bundle =
1074-
prove_continuation(&elf_bytes, &[], 8, &ProofOptions::default_test_options()).unwrap();
1085+
prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap();
10751086
assert!(
10761087
bundle.epochs.len() >= 2,
10771088
"need a second epoch to chain into"
@@ -1094,7 +1105,7 @@ mod tests {
10941105
let _ = env_logger::builder().is_test(true).try_init();
10951106
let elf_bytes = asm_elf_bytes("all_loadstore_32");
10961107
let mut bundle =
1097-
prove_continuation(&elf_bytes, &[], 8, &ProofOptions::default_test_options()).unwrap();
1108+
prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap();
10981109
assert!(!bundle.epochs.is_empty());
10991110
bundle.epochs[0].reg_fini.pop();
11001111
assert!(
@@ -1182,7 +1193,7 @@ mod tests {
11821193
let _ = env_logger::builder().is_test(true).try_init();
11831194
let elf_bytes = asm_elf_bytes("all_loadstore_32");
11841195
let mut bundle =
1185-
prove_continuation(&elf_bytes, &[], 8, &ProofOptions::default_test_options()).unwrap();
1196+
prove_continuation(&elf_bytes, &[], 3, &ProofOptions::default_test_options()).unwrap();
11861197
assert!(
11871198
bundle.epochs.len() >= 2,
11881199
"need multiple epochs to exercise the binding"

prover/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ pub enum Error {
186186
Prover(String),
187187
/// Proof contains invalid table_counts (e.g. zero for a required table)
188188
InvalidTableCounts(String),
189+
/// Continuation epoch size exponent is invalid.
190+
InvalidContinuationEpochSize(String),
189191
/// A non-final continuation epoch contains the program-terminating
190192
/// instruction. The terminating instruction must be in the final epoch.
191193
HaltInNonFinalEpoch,
@@ -202,6 +204,9 @@ impl fmt::Display for Error {
202204
Error::Execution(msg) => write!(f, "execution error: {msg}"),
203205
Error::Prover(msg) => write!(f, "proving error: {msg}"),
204206
Error::InvalidTableCounts(msg) => write!(f, "invalid table_counts: {msg}"),
207+
Error::InvalidContinuationEpochSize(msg) => {
208+
write!(f, "invalid continuation epoch size: {msg}")
209+
}
205210
Error::HaltInNonFinalEpoch => {
206211
write!(
207212
f,

prover/src/tests/local_to_global_bus_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,8 +1015,8 @@ fn test_l2g_design_y_orphan_mu_zero_rejects() {
10151015
/// epoch boundaries) and the resulting multi-epoch L2G chain verifies end-to-end.
10161016
///
10171017
/// The fixture reads 16 bytes of private input from 0xFF000000, then commits
1018-
/// bytes 4..12 (8 bytes after the 4-byte length prefix). With epoch_size=4
1019-
/// the 11-cycle program spans three epochs: epoch 0 reads the private-input
1018+
/// bytes 4..12 (8 bytes after the 4-byte length prefix). With `epoch_size_log2=2`
1019+
/// (4 cycles) the 11-cycle program spans three epochs: epoch 0 reads the private-input
10201020
/// page (touching 0xFF000000..), epoch 1 performs the commit syscall, epoch 2
10211021
/// halts. The private-input page's L2G entry (epoch 0 fini → epoch 1+ init)
10221022
/// is the cross-epoch link under test.
@@ -1042,7 +1042,7 @@ fn test_continuation_private_input_spans_epochs() {
10421042
let result = crate::continuation::prove_and_verify_continuation(
10431043
&elf_bytes,
10441044
&input,
1045-
4,
1045+
2,
10461046
&ProofOptions::default_test_options(),
10471047
);
10481048

0 commit comments

Comments
 (0)