Skip to content

Commit 76fba72

Browse files
committed
feat(stark,lfm): per-table preprocessed binding in the batched proof — batched epochs now verify
The fused prep round REPLACED the per-table critical soundness check with one pinned mixed root, which (a) failed closed on every real LFM epoch (round PARTIAL) and (b) had no in-machine binding story for the wrap — its provenance classes (interned constant / register-derived / ELF-attested) fuse into one digest, breaking the DECODE attestation join. This is the M-8 blocker. Adopted #768's arrangement instead: each preprocessed table keeps its OWN row-pair tree — exactly the one air.precomputed_commitment() pins — and both sides absorb those roots FROM THE AIR SET, never the proof, per table in table order before main_root. The proof carries only per-query per-table standard openings, authenticated at the reduced per-table index. PinnedPrep, the fail-closed None arm, and the round-coverage refusal are gone from this path; the prover enforces the stale-constant guard unconditionally (PrecomputedCommitmentMismatch), and the process-wide precomputed-tree cache serves both paths. Soundness suite rebuilt, not weakened: honest end-to-end, wrong-root rejection, per-matrix/per-column tampers (opening-level and whole-verifier), un-reduced-index control, invented-opening rejection, stale-constant prove failure — all green (stark 346/0). The LFM refusal test executed the flip its own doc mandated: a_batched_lfm_epoch_verifies_end_to_end proves, verifies, and rejects a tampered prep opening and a moved public word. The registry's prep_root/prep_widths stay pinned but are now consumed by nothing; retiring them is a later deliberate act.
1 parent 357226e commit 76fba72

9 files changed

Lines changed: 447 additions & 583 deletions

File tree

‎crypto/stark/src/batched/proof.rs‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use math::field::traits::IsField;
2121
use crate::config::Commitment;
2222
use crate::fri::fri_decommit::FriDecommitment;
2323
use crate::fri::mmcs::MixedOpening;
24+
use crate::proof::stark::PolynomialOpenings;
2425
use crate::lookup::BusPublicInputs;
2526
use crate::table::Table;
2627

@@ -56,8 +57,20 @@ pub struct BatchedTableData<E: IsField, PI> {
5657
/// FRI layer decommitment.
5758
#[derive(Debug, Clone)]
5859
pub struct BatchedQueryOpening<F: IsField, E: IsField> {
59-
/// Preprocessed round. `None` when the epoch has no preprocessed table.
60-
pub prep: Option<MixedOpening<F>>,
60+
/// Preprocessed openings, ONE PER PREPROCESSED TABLE in AIR order — each a
61+
/// standard row-pair opening against that table's own precomputed tree.
62+
///
63+
/// ★ Deliberately NOT a round of the mixed MMCS (this is #768's
64+
/// arrangement, kept for the same reason): the per-table precomputed trees
65+
/// are exactly the ones `air.precomputed_commitment()` pins, so the
66+
/// verifier absorbs and compares roots it already owns — the per-table
67+
/// path's critical soundness check, verbatim — and a recursive verifier
68+
/// binds each root with the provenance machinery that already exists
69+
/// (interned constant / derived in-machine / ELF-attested). A fused
70+
/// mixed-height prep root has no in-machine binding story: its provenance
71+
/// classes are mixed into one digest, which is the M-8 blocker this layout
72+
/// dissolves. Empty when the epoch has no preprocessed table.
73+
pub prep: Vec<PolynomialOpenings<F>>,
6174
/// Main round — always present; every table contributes a matrix.
6275
pub main: MixedOpening<F>,
6376
/// Auxiliary round. `None` when no table has a RAP.
@@ -72,19 +85,11 @@ pub struct BatchedQueryOpening<F: IsField, E: IsField> {
7285
#[derive(Debug, Clone)]
7386
pub struct BatchedMultiProof<F: IsField, E: IsField, PI> {
7487
pub tables: Vec<BatchedTableData<E, PI>>,
75-
/// Root of the mixed-height MMCS over every preprocessed matrix. `None` when
76-
/// the epoch has none.
77-
///
78-
/// ⚠ This root REPLACES the per-table `air.precomputed_commitment()`
79-
/// comparison, which is the per-table path's critical soundness check. A
80-
/// single comparison here is only equivalent if the shape pins the parse —
81-
/// see [`crate::batched::verifier::verify_prep_round`] and the per-matrix
82-
/// tamper control beside it.
83-
///
84-
/// ⚠ Reading this field is not checking it. What decides whether the epoch
85-
/// used the preprocessed matrices the program pins is the comparison
86-
/// against `PinnedPrep::root`; this is the prover's claim.
87-
pub prep_root: Option<Commitment>,
88+
/// ★ There is deliberately NO `prep_root` here. Preprocessed matrices are
89+
/// committed per table and their roots are `air.precomputed_commitment()`
90+
/// — absorbed by both sides FROM THE AIR SET, never from the proof,
91+
/// exactly as the per-table path's Phase A does. The proof carries only
92+
/// the per-query openings ([`BatchedQueryOpening::prep`]).
8893
pub main_root: Commitment,
8994
pub aux_root: Option<Commitment>,
9095
pub parts_root: Commitment,

‎crypto/stark/src/batched/prover.rs‎

Lines changed: 64 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
//!
1515
//! ```text
1616
//! shape histogram <- bound BEFORE the first root
17-
//! per table: main LDE -> prep/main MMCS builders [barrier]
18-
//! prep_root, main_root
17+
//! per table: main LDE -> per-table prep tree + main MMCS builder [barrier]
18+
//! per-table prep roots (from the AIR set), main_root
1919
//! LogUp challenges
2020
//! per table: aux trace + aux LDE -> aux MMCS builder [barrier]
2121
//! aux_root
@@ -70,7 +70,8 @@ use crate::batched::proof::{
7070
lde_bytes,
7171
};
7272
use crate::batched::round4::commit_batched_fri;
73-
use crate::batched::shape::{EpochShape, PinnedPrep, RoundShape, ShapeError};
73+
use crate::batched::shape::{EpochShape, RoundShape, ShapeError};
74+
use crypto::merkle_tree::merkle::MerkleTree;
7475
use crate::config::StarkHash;
7576
use crate::domain::Domain;
7677
use crate::fri::batched::HeightCombiner;
@@ -116,20 +117,15 @@ struct LdePair<Field: IsField, FieldExtension: IsField> {
116117

117118
/// Prove one epoch with batched commitments.
118119
///
119-
/// `expected_prep`, when supplied, is the registry's committed preprocessed root
120-
/// and the widths it was committed over (M-6). The prover compares its own
121-
/// against them and fails fast, preserving the property the per-table path gets
122-
/// from `air.precomputed_commitment()`: a stale preprocessed constant is caught
123-
/// here rather than by every future verifier.
124-
///
125-
/// `None` is permissive here — it is how the root is generated in the first
126-
/// place. That is the opposite of the verifier's disposition; see
127-
/// [`PinnedPrep`].
120+
/// Preprocessed matrices are committed per table (the trees
121+
/// `air.precomputed_commitment()` pins), so the per-table path's stale-constant
122+
/// guard runs here unconditionally: a built prep tree that disagrees with the
123+
/// AIR's own root fails the prove with the same error the per-table prover
124+
/// raises.
128125
#[allow(clippy::too_many_arguments)]
129126
pub fn multi_prove_batched<Field, FieldExtension, PI, H, P>(
130127
mut air_trace_pairs: Vec<BatchedAirTracePair<'_, Field, FieldExtension, PI>>,
131128
transcript: &mut (impl IsStarkTranscript<FieldExtension, Field> + Clone + Send),
132-
expected_prep: Option<PinnedPrep<'_>>,
133129
#[cfg(feature = "disk-spill")] storage_mode: StorageMode,
134130
residency: ResidencyMode,
135131
) -> Result<
@@ -199,14 +195,22 @@ where
199195
// Both builders are fed from the SAME expansion: a preprocessed table's
200196
// precomputed columns and its multiplicity columns are two column ranges of
201197
// one row-major main LDE, exactly as `commit_main_trace` splits them.
202-
let mut prep_builder =
203-
(!shape.prep.is_empty()).then(|| StreamingMmcsBuilder::<Field, H>::new(&shape.prep.dims));
198+
// ★ Per-table preprocessed trees — #768's arrangement, kept for the same
199+
// reason (see `BatchedQueryOpening::prep`): each preprocessed table keeps
200+
// its OWN row-pair tree, the one `air.precomputed_commitment()` pins, and
201+
// both sides absorb that root FROM THE AIR SET, never from the proof —
202+
// the per-table path's critical soundness check, verbatim. The trees are
203+
// process-cached by root, so continuation epochs stop re-committing the
204+
// execution-independent tables (DECODE, BITWISE, ...), exactly as the
205+
// per-table prover does.
206+
let mut prep_trees: Vec<Option<std::sync::Arc<MerkleTree<H::Batched<Field>>>>> =
207+
(0..num_tables).map(|_| None).collect();
204208
let mut main_builder = StreamingMmcsBuilder::<Field, H>::new(&shape.main.dims);
205209
let mut retained_main: Vec<Option<(Vec<FieldElement<Field>>, usize)>> =
206210
(0..num_tables).map(|_| None).collect();
207211

208212
for table in 0..num_tables {
209-
let (_, trace, _) = &air_trace_pairs[table];
213+
let (air, trace, _) = &air_trace_pairs[table];
210214
let (main_data, total_cols) = P::expand_main_lde_row_major(
211215
trace,
212216
&domains[table],
@@ -221,17 +225,36 @@ where
221225
let height = shape.heights[table];
222226
let num_precomputed = total_cols - matrix_width(&shape.main, table);
223227

224-
if let Some(builder) = prep_builder.as_mut()
225-
&& num_precomputed > 0
226-
{
227-
let src = vec![BorrowedMatrix::RowMajorNatural {
228-
data: &main_data,
229-
stride: total_cols,
230-
col_start: 0,
231-
width: num_precomputed,
232-
log_height: height,
233-
}];
234-
builder.absorb(&src, 0);
228+
if num_precomputed > 0 {
229+
// The root every verifier will absorb is the AIR's own; building a
230+
// tree that disagrees with it is a stale constant or a wrong LDE,
231+
// and the per-table path's error is the honest name for both.
232+
let expected = air.precomputed_commitment();
233+
let tree = match crate::prover::precomputed_tree_cache_get::<H::Batched<Field>>(
234+
&expected,
235+
) {
236+
Some(tree) => tree,
237+
None => {
238+
let (tree, root) = P::commit_rows_bit_reversed_subset::<Field>(
239+
&main_data,
240+
total_cols,
241+
0,
242+
num_precomputed,
243+
)
244+
.ok_or(ProvingError::PrecomputedCommitmentMismatch)?;
245+
if root != expected {
246+
return Err(ProvingError::PrecomputedCommitmentMismatch);
247+
}
248+
let tree = std::sync::Arc::new(tree);
249+
crate::prover::precomputed_tree_cache_put(
250+
expected,
251+
std::sync::Arc::clone(&tree),
252+
);
253+
tree
254+
}
255+
};
256+
transcript.append_bytes(&expected);
257+
prep_trees[table] = Some(tree);
235258
}
236259
let src = vec![BorrowedMatrix::RowMajorNatural {
237260
data: &main_data,
@@ -253,24 +276,7 @@ where
253276
}
254277
}
255278

256-
let prep_mmcs = prep_builder.map(StreamingMmcsBuilder::finish);
257279
let main_mmcs = main_builder.finish();
258-
let prep_root = prep_mmcs.as_ref().map(MixedMmcs::root);
259-
// The widths are compared first because they are the more legible failure:
260-
// a registry whose entry predates a change to some AIR's precomputed column
261-
// count disagrees here in a way that names the cause, instead of surfacing
262-
// as a root mismatch that could equally be a stale constant.
263-
if let Some(expected) = expected_prep {
264-
if expected.widths != shape.prep.widths().as_slice() {
265-
return Err(ProvingError::PrecomputedCommitmentMismatch);
266-
}
267-
if prep_root.as_ref() != Some(expected.root) {
268-
return Err(ProvingError::PrecomputedCommitmentMismatch);
269-
}
270-
}
271-
if let Some(root) = prep_root {
272-
transcript.append_bytes(&root);
273-
}
274280
let main_root = main_mmcs.root();
275281
transcript.append_bytes(&main_root);
276282

@@ -603,7 +609,10 @@ where
603609
let iotas = commit.iotas.clone();
604610
let fri_decommitments = crate::fri::query_phase::<FieldExtension, H>(&commit.layers, &iotas);
605611

606-
let mut prep_openings = empty_openings::<Field>(&iotas, shape.prep.tables.len());
612+
// Per-query, per-prep-table standard openings (prep-table order =
613+
// `shape.prep.tables`, which is AIR order).
614+
let mut prep_openings: Vec<Vec<crate::proof::stark::PolynomialOpenings<Field>>> =
615+
(0..iotas.len()).map(|_| Vec::new()).collect();
607616
let mut main_openings = empty_openings::<Field>(&iotas, shape.main.tables.len());
608617
let mut aux_openings = empty_openings::<FieldExtension>(&iotas, shape.aux.tables.len());
609618
let mut parts_openings = empty_openings::<FieldExtension>(&iotas, shape.parts.tables.len());
@@ -614,9 +623,6 @@ where
614623
// (the prep round's `h_max` is below the FRI's whenever the tallest
615624
// preprocessed table is not the tallest table), and a round that reduced
616625
// again on the way out would land somewhere else entirely.
617-
let prep_iotas = prep_mmcs
618-
.as_ref()
619-
.map(|mmcs| reduced_iotas(&iotas, h_max, mmcs.h_max()));
620626
let main_iotas = reduced_iotas(&iotas, h_max, main_mmcs.h_max());
621627
let aux_iotas = aux_mmcs
622628
.as_ref()
@@ -644,16 +650,15 @@ where
644650
let (main_data, total_cols) = &ldes.main;
645651
let num_precomputed = total_cols - matrix_width(&shape.main, table);
646652

647-
if let (Some(mmcs), Some(m)) = (prep_mmcs.as_ref(), matrix_index(&shape.prep, table)) {
648-
let src = vec![BorrowedMatrix::RowMajorNatural {
649-
data: main_data,
650-
stride: *total_cols,
651-
col_start: 0,
652-
width: num_precomputed,
653-
log_height: height,
654-
}];
655-
let indices = prep_iotas.as_ref().expect("the prep MMCS exists here");
656-
fill_openings(mmcs, m, &src, indices, &mut prep_openings);
653+
if let Some(tree) = prep_trees[table].as_ref() {
654+
// The per-table tree lives in the TABLE's own index space; reduce
655+
// the shared FRI index by the height difference once, here.
656+
let table_iotas = reduced_iotas(&iotas, h_max, height);
657+
for (q, &idx) in table_iotas.iter().enumerate() {
658+
prep_openings[q].push(P::open_polys_with(&domains[table], tree, idx, |row| {
659+
main_data[row * total_cols..row * total_cols + num_precomputed].to_vec()
660+
}));
661+
}
657662
}
658663
if let Some(m) = matrix_index(&shape.main, table) {
659664
let src = vec![BorrowedMatrix::RowMajorNatural {
@@ -697,11 +702,7 @@ where
697702

698703
let queries = (0..iotas.len())
699704
.map(|q| BatchedQueryOpening {
700-
prep: prep_mmcs.as_ref().map(|mmcs| {
701-
let indices = prep_iotas.as_ref().expect("the prep MMCS exists here");
702-
assemble(mmcs, indices[q], &mut prep_openings, q)
703-
.expect("the prep round was opened at these very indices")
704-
}),
705+
prep: std::mem::take(&mut prep_openings[q]),
705706
main: assemble(&main_mmcs, main_iotas[q], &mut main_openings, q)
706707
.expect("the main round was opened at these very indices"),
707708
aux: aux_mmcs.as_ref().map(|mmcs| {
@@ -739,7 +740,6 @@ where
739740
Ok((
740741
BatchedMultiProof {
741742
tables,
742-
prep_root,
743743
main_root,
744744
aux_root,
745745
parts_root,

0 commit comments

Comments
 (0)