Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0623f2e
feat(lfm): publish the block-binding schema from the epoch wrap
MauroToscano Sep 8, 2026
ef86541
feat(lfm): give the epoch wrap a publish profile
MauroToscano Sep 8, 2026
390a4e8
style(lfm): cargo fmt the publish-profile gate
MauroToscano Sep 8, 2026
a9cf28d
feat(lfm): the per-table aggregation leg and its binding legs
MauroToscano Sep 8, 2026
8fb3ba8
test(lfm): the aggregation node's cost model, measured
MauroToscano Sep 8, 2026
d19b620
feat(lfm): the aggregation node, and the L2G fold that makes it compose
MauroToscano Sep 8, 2026
540c247
test(lfm): differential the node's legs against their children's own …
MauroToscano Sep 8, 2026
e198f76
docs(lfm): the query count is settled at 110, not provisional
MauroToscano Sep 8, 2026
e7081d1
fix(lfm): the publish-profile gate compared indices it meant to differ
MauroToscano Sep 8, 2026
d48f4e1
fix(lfm): the spine gate walked past the schema by a literal too
MauroToscano Sep 8, 2026
d2d3006
fix(lfm): a one-row table's query draw is legal, and it consumes
MauroToscano Sep 8, 2026
84d8cb0
feat(lfm): the inner node, and the L2G asymmetry building it found
MauroToscano Sep 8, 2026
0ccf9ae
fix(lfm): a one-leaf Merkle tree is a legal shape, and it still binds
MauroToscano Sep 8, 2026
e666d83
test(lfm): a local epoch size for the inner node, and per-phase RSS
MauroToscano Sep 8, 2026
efa4c40
test(lfm): split the node's RSS mark, and print the floor beside it
MauroToscano Sep 8, 2026
68aae7e
test(lfm): per-level RSS marks, so flatness is a within-run comparison
MauroToscano Sep 8, 2026
fdd2e0d
test(lfm): the >32 guard's expected string, after the lower bound went
MauroToscano Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 144 additions & 17 deletions prover/src/lfm/epoch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1492,7 +1492,48 @@ fn epoch_challenge_program(e: &RealEpoch) -> LfmProgram {
/// arenas and emits no verification, so the spine test's own arena-word count is
/// untouched.
pub(super) fn epoch_program(e: &RealEpoch, with_legs: bool) -> LfmProgram {
epoch_program_with(e, with_legs, false)
epoch_program_with(e, with_legs, false, Publishes::Diagnostic)
}

/// Which words [`epoch_program`] publishes.
///
/// The wrap's published words are the ONLY thing an aggregation node can read
/// about it, and a node pays for every one of them: eight hinted halves, four
/// canonicity guards, four recombinations, thirty-six bytes of statement absorb
/// and one extension-field inverse in the `LfmPublic` balance — per word, per
/// child. So what a wrap publishes is the size of the layer above it.
///
/// At the production posture the diagnostic set is 10,507 words, of which the
/// binding set is ≈80. The other ~10,400 have no consumer above: `composition`
/// is already asserted equal to the claimed Horner INSIDE
/// `epoch_verify::emit_table_verification`, the DEEP invariants are documented
/// as exposed for tests, and β/z/γ/ζ/ι are challenges the machine derives for
/// itself. They are an oracle for this crate's differentials, not a binding, and
/// a node that hinted them would be paying to re-read numbers it could recompute.
///
/// ⚠ [`Publishes::Diagnostic`] is the DEFAULT and every existing gate keeps
/// exactly the words it had. Dropping the differential surface is a choice made
/// per emission by the aggregator, never a global one: the differentials against
/// production's own replay are how this machine is known to derive production's
/// challenges, and a preset that quietly removed them everywhere would trade the
/// evidence for the saving.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Publishes {
/// The shared pair, the attestation id, the block-binding schema, then every
/// per-sub-proof diagnostic, then the bus total.
Diagnostic,
/// The shared pair, the attestation id, the block-binding schema and the bus
/// total — nothing per sub-proof. What a wrap feeding an aggregator emits.
Aggregation,
}

/// [`epoch_program`] with the publish set named — the aggregation lever.
pub(super) fn epoch_program_publishing(
e: &RealEpoch,
with_legs: bool,
publishes: Publishes,
) -> LfmProgram {
epoch_program_with(e, with_legs, false, publishes)
}

/// The epoch program, optionally with the DECODE cell SPLIT — a deliberately
Expand All @@ -1509,7 +1550,12 @@ pub(super) fn epoch_program(e: &RealEpoch, with_legs: bool) -> LfmProgram {
/// [`the_assembled_verifier_declares_exactly_the_shape_words`] is what refuses it.
///
/// The extra arena is declared LAST so no existing arena index moves.
fn epoch_program_with(e: &RealEpoch, with_legs: bool, split_decode: bool) -> LfmProgram {
fn epoch_program_with(
e: &RealEpoch,
with_legs: bool,
split_decode: bool,
publishes: Publishes,
) -> LfmProgram {
use super::statement_replay::{EpochStatementVars, PhaseATable, absorb_epoch_statement};

let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production());
Expand Down Expand Up @@ -1760,6 +1806,51 @@ fn epoch_program_with(e: &RealEpoch, with_legs: bool, split_decode: bool) -> Lfm
b.public(id[1]);
}

// ---- ★ THE BLOCK-BINDING SCHEMA, published right after the attestation id
//
// An aggregation node over these wraps sees exactly two things about a child:
// its `program_id`, which is an emit-time CONSTANT of the parent, and its
// PUBLISHED WORDS. Everything below is arena data that the epoch statement or
// Phase A already bound, and every one of them is material a node must CHECK
// rather than trust — so without these publishes the cross-wrap bindings are
// not weak, they are unbuildable.
//
// In the batched format these were the CARVED schema, published by the carve
// rather than by the program. The carve went with the format, so the program
// publishes them.
//
// Publishing costs no soundness, because each is already bound: the register
// vectors through the REGISTER preprocessed commitment DERIVED from them
// (`PrepSource::Register`), the label and the output halves through
// `absorb_epoch_statement`, and the L2G root through Phase A's absorb.
//
// ⚠ This order IS [`SchemaLayout`]'s, which the aggregator indexes by. A
// field inserted in the middle silently re-binds every field below it, so
// append here and extend `SchemaLayout` in the same commit.
for cell in reg_init.iter().chain(&reg_fini) {
b.public(cell.as_cell());
}
for half in epoch_label {
b.public(half.as_cell());
}
for half in public_output {
b.public(half.as_cell());
}
// ★ The L2G bookend is the LAST sub-proof — `EpochSession::pairs` proves the
// VM tables and then pushes `l2g_air`, and the from-proof reconstruction
// rebuilds the AIR list the same way. Asserted rather than assumed: publishing
// some other sub-proof's root would bind the aggregator's L2G compare to a
// table with nothing to do with the global memory argument, and would still
// look like a passing gate.
assert_eq!(
e.tables[n - 1].shape.index,
n - 1,
"the L2G bookend is the epoch's last sub-proof"
);
for half in main_cells[n - 1].lanes_flat() {
b.public(half.as_cell());
}

// ---- one fork per table ----
let mut contributions: Vec<super::builder::Ext> = Vec::new();
for (i, h) in e.tables.iter().enumerate() {
Expand Down Expand Up @@ -1831,20 +1922,26 @@ fn epoch_program_with(e: &RealEpoch, with_legs: bool, split_decode: bool) -> Lfm
},
leg_arenas,
);
b.public(out.composition.as_cell());
for v in &out.fri_terminal {
b.public(v.as_cell());
if publishes == Publishes::Diagnostic {
b.public(out.composition.as_cell());
for v in &out.fri_terminal {
b.public(v.as_cell());
}
}
}
b.public(ch.beta.as_cell());
b.public(ch.z.as_cell());
b.public(ch.gamma.as_cell());
for zeta in &ch.zetas {
b.public(zeta.as_cell());
}
for bits in &ch.iota_bits {
let felt = edsl::bits_to_felt(&mut b, bits);
b.public(felt.as_cell());
if publishes == Publishes::Diagnostic {
b.public(ch.beta.as_cell());
b.public(ch.z.as_cell());
b.public(ch.gamma.as_cell());
for zeta in &ch.zetas {
b.public(zeta.as_cell());
}
for bits in &ch.iota_bits {
// The recombination is emitted only to be published; under
// `Aggregation` it is dead work, so it goes with the publish.
let felt = edsl::bits_to_felt(&mut b, bits);
b.public(felt.as_cell());
}
}
}

Expand Down Expand Up @@ -1892,6 +1989,24 @@ pub(super) fn num_epoch_wide_arenas(e: &RealEpoch) -> usize {
6 + usize::from(!e.page_commitments.is_empty())
}

/// How many words [`epoch_program`] publishes for the BLOCK-BINDING SCHEMA — the
/// run that sits between the attestation id and the first sub-proof's block.
///
/// Exposed rather than recomputed at each reader for the reason
/// [`num_epoch_wide_arenas`] is: a gate that walks the published words by index
/// starts checking the WRONG field when this changes, and reports a pass. The
/// aggregator's `SchemaLayout` indexes the same run and must agree with this
/// number field for field.
///
/// The order is the emitter's: register INIT, register FINI, the two epoch-label
/// halves, the public-output halves, then the L2G re-commit root's lanes.
pub(super) fn schema_words(e: &RealEpoch) -> usize {
2 * crate::tables::register::NUM_REGISTER_ADDRESSES
+ 2
+ e.statement.public_output_len.div_ceil(4)
+ super::proof_arena::lanes_per_root()
}

/// The arenas [`epoch_program`] declares, in the same order.
pub(super) fn epoch_arena_words(e: &RealEpoch, with_legs: bool) -> Vec<Vec<LfmWord>> {
let mut stmt: Vec<FE> = Vec::new();
Expand Down Expand Up @@ -2039,7 +2154,19 @@ fn the_epoch_challenge_spine_matches_production() {
`program_id_from_digest` over the same inputs"
);

let mut cursor = 4usize;
// The pair, the two attestation-id words, then the BLOCK-BINDING SCHEMA —
// skipped by NAME, never by a literal.
//
// ⚠ `epoch_challenge_program` is `epoch_program(e, false)`, the SAME emitter
// with the legs off — the doc on `epoch_program` says so, and says why: a
// second copy of the spine would be a place for the assembled verifier's
// Fiat-Shamir to drift from the one this test checks. So every publish the
// assembled verifier gained, this spine gained too. A literal 4 here read
// register INIT slot 0 as `beta of table 0`, and since x0 is hard-wired zero
// the failure printed an all-zero challenge — which reads like a challenge
// that was never derived rather than like a cursor pointing at the wrong
// field.
let mut cursor = 4 + schema_words(&e);
let mut multi_row_ood = 0;
for (i, h) in e.tables.iter().enumerate() {
assert_eq!(pub_ext(cursor), h.beta, "beta of table {i}");
Expand Down Expand Up @@ -2295,7 +2422,7 @@ fn the_assembled_verifier_declares_exactly_the_shape_words() {

// Positive control on the guard itself: the split-cell control program DOES
// declare a surplus word, and this is the comparison that sees it.
let split = epoch_program_with(&e, false, true);
let split = epoch_program_with(&e, false, true, Publishes::Diagnostic);
let split_declared: usize = split.arena_schema.lens.iter().map(|l| *l as usize).sum();
assert_eq!(
split_declared,
Expand Down Expand Up @@ -2345,7 +2472,7 @@ fn a_split_decode_cell_forges_the_attestation() {
);

// ---- (a) the SPLIT program: the forgery runs and publishes the forged id.
let split = epoch_program_with(&e, false, true);
let split = epoch_program_with(&e, false, true, Publishes::Diagnostic);
let mut split_arenas = honest.clone();
split_arenas.push(super::proof_arena::commitments_to_arena(&[substituted]));
let exec = execute(&split, &split_arenas, &crate::hash_pin::BLOCK_HASHER).expect(
Expand Down
10 changes: 9 additions & 1 deletion prover/src/lfm/epoch_verify_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,15 @@ fn the_assembled_epoch_verifier_runs() {
// rests on. Its value is differentialled in the spine test; here it only has to
// be skipped, and skipped by NAME rather than by a literal.
let program_id_words = 2usize;
let mut cursor = 2 + program_id_words;
// ★ Then the BLOCK-BINDING SCHEMA — the register boundary vectors, the epoch
// label, the public-output halves and the L2G re-commit root. Skipped by NAME
// (`epoch_tests::schema_words`) rather than by a literal, for the same reason
// the id is: a literal here would start checking `beta of table 0` against a
// register slot the moment the schema moves, and would report a pass while
// doing it. Its VALUES are the aggregator's subject and are differentialled
// there; this gate only has to walk past them and still account for every word.
let schema_words = super::epoch_tests::schema_words(&e);
let mut cursor = 2 + program_id_words + schema_words;
let mut checked = 2usize;
for (i, (h, leg)) in e.tables.iter().zip(&e.legs).enumerate() {
// The legs publish first: the recomputed composition, then a terminal
Expand Down
11 changes: 10 additions & 1 deletion prover/src/lfm/machine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1510,8 +1510,17 @@ fn transcript_replay_cell_counts() {

// ------------------------- emitter-contract guards -------------------------

/// The UPPER bound only. There is no lower bound any more: `nbits = 0` is legal
/// (a one-row trace at blowup 2 has a two-leaf LDE, one query index, and that
/// index is 0), and that the emitter still CONSUMES a draw for it is pinned by
/// `per_table_aggregator_tests::a_zero_bit_query_draw_consumes_what_the_host_does`.
///
/// ⚠ The expected string names "at most 32" deliberately. A substring loose
/// enough to match any assert in this function would pass on a reintroduced
/// lower bound as readily as on this one, which is the failure mode this test
/// exists to catch.
#[test]
#[should_panic(expected = "nbits must be in 1..=32")]
#[should_panic(expected = "nbits must be at most 32")]
fn sample_u64_pow2_rejects_more_than_32_bits() {
use super::transcript_replay::TranscriptReplay;
let mut b = super::builder::LfmBuilder::new();
Expand Down
1 change: 1 addition & 0 deletions prover/src/lfm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub mod keccak_host;
pub mod layout;
pub mod lde;
pub mod logup;
pub mod per_table_aggregator;
pub mod poseidon;
pub mod programs;
pub mod proof;
Expand Down
Loading
Loading