Skip to content

Commit d04f642

Browse files
committed
Fix Lagrange kernel soundness, 0-layer bus forgery, and column_claims Fiat-Shamir gap
1 parent 54dfa97 commit d04f642

3 files changed

Lines changed: 110 additions & 33 deletions

File tree

crypto/stark/src/lookup.rs

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,17 @@ pub const LOGUP_CHALLENGE_GAMMA: usize = 2;
116116
pub const LOGUP_BRIDGE_OFFSET_IDX: usize = 3;
117117

118118
/// Start index of precomputed gamma powers in the per-table rap_challenges vector.
119-
/// rap_challenges[LOGUP_GAMMA_POWERS_START + j] = γ^j for j = 0, 1, ..., K-1.
119+
/// rap_challenges[LOGUP_GAMMA_POWERS_START + j] = γ^j for j = 0, 1, ..., K.
120+
/// K+1 powers total: γ^0..γ^{K-1} for column inner products, γ^K for the l² self-check.
120121
pub const LOGUP_GAMMA_POWERS_START: usize = 4;
121122

122123
/// Start index of GKR random_point coordinates in rap_challenges.
123-
/// After gamma_powers[0..K], we append random_point[0..n].
124-
/// The actual index is LOGUP_GAMMA_POWERS_START + K where K = number of distinct column indices.
124+
/// After gamma_powers[0..K+1] (K+1 powers, one extra for the l² self-check),
125+
/// we append random_point[0..n].
125126
/// Use `logup_random_point_start(interactions)` to compute the concrete index.
126127
pub fn logup_random_point_start(interactions: &[BusInteraction]) -> usize {
127-
LOGUP_GAMMA_POWERS_START + extract_column_indices(interactions).len()
128+
// +1 for the extra γ^K power used by the Lagrange kernel l² self-check (BUG-004 fix)
129+
LOGUP_GAMMA_POWERS_START + extract_column_indices(interactions).len() + 1
128130
}
129131

130132
// =============================================================================
@@ -1036,7 +1038,8 @@ where
10361038
// where r_j are the GKR random point coordinates stored in rap_challenges.
10371039
if self.has_trace_interaction() {
10381040
let k = extract_column_indices(&self.auxiliary_trace_build_data.interactions).len();
1039-
let rp_start = LOGUP_GAMMA_POWERS_START + k;
1041+
// +1: gamma_powers now has K+1 elements (γ^0..γ^K), so random_point starts at 4+K+1
1042+
let rp_start = LOGUP_GAMMA_POWERS_START + k + 1;
10401043
if rap_challenges.len() > rp_start {
10411044
let n = rap_challenges.len() - rp_start;
10421045
let mut l0_expected = FieldElement::<E>::one();
@@ -1640,22 +1643,31 @@ where
16401643
/// Compute the bridge offset (target/N) and gamma powers from column claims.
16411644
///
16421645
/// Returns (bridge_offset, gamma_powers) where:
1643-
/// - bridge_offset = (Σ_j γ^j · c_j) / N
1644-
/// - gamma_powers = [γ^0, γ^1, ..., γ^{K-1}]
1646+
/// - bridge_offset = (Σ_j γ^j · c_j + γ^K · l_mle_claim) / N
1647+
/// - gamma_powers = [γ^0, γ^1, ..., γ^K] (K+1 powers; γ^K is for the l² self-check)
1648+
///
1649+
/// The extra γ^K · l_mle_claim term in the target implements the Lagrange kernel
1650+
/// self-evaluation check (BUG-004): the bridge constraint forces Σ_i l[i]² = l_mle_claim
1651+
/// via Schwartz-Zippel over γ, where l_mle_claim = ∏_k (r_k² + (1-r_k)²) is the
1652+
/// expected squared-ℓ₂ norm of the true Lagrange kernel.
16451653
///
16461654
/// Both prover and verifier call this to derive the same values.
16471655
pub fn compute_bridge_params<E: IsField>(
16481656
column_claims: &[(usize, FieldElement<E>)],
16491657
gamma: &FieldElement<E>,
16501658
trace_len: usize,
1659+
l_mle_claim: &FieldElement<E>,
16511660
) -> (FieldElement<E>, Vec<FieldElement<E>>) {
16521661
let k = column_claims.len();
1653-
let gamma_powers = compute_alpha_powers(gamma, k);
1662+
// K+1 powers: γ^0..γ^{K-1} for column inner products, γ^K for l² self-check
1663+
let gamma_powers = compute_alpha_powers(gamma, k + 1);
16541664

16551665
let mut target = FieldElement::<E>::zero();
16561666
for ((_, c_j), gp) in column_claims.iter().zip(gamma_powers.iter()) {
16571667
target += c_j * gp;
16581668
}
1669+
// γ^K · l_mle_claim: enforces Σ_i l[i]² = l_mle_claim via Schwartz-Zippel
1670+
target += l_mle_claim * &gamma_powers[k];
16591671

16601672
let n_inv = FieldElement::<E>::from(trace_len as u64).inv().unwrap();
16611673
let bridge_offset = &target * &n_inv;
@@ -1669,23 +1681,34 @@ pub fn compute_bridge_params<E: IsField>(
16691681
/// - [0] = z, [1] = α (original)
16701682
/// - [2] = γ
16711683
/// - [3] = bridge_offset (target/N)
1672-
/// - [4..4+K] = γ^0, γ^1, ..., γ^{K-1}
1673-
/// - [4+K..4+K+n] = random_point[0], ..., random_point[n-1]
1684+
/// - [4..4+K+1] = γ^0, γ^1, ..., γ^K (K+1 powers; γ^K is for the l² self-check)
1685+
/// - [4+K+1..4+K+1+n] = random_point[0], ..., random_point[n-1]
16741686
pub fn extend_rap_challenges_with_bridge<E: IsField>(
16751687
rap_challenges: &mut Vec<FieldElement<E>>,
16761688
column_claims: &[(usize, FieldElement<E>)],
16771689
gamma: &FieldElement<E>,
16781690
trace_len: usize,
16791691
random_point: &[FieldElement<E>],
16801692
) {
1681-
let (bridge_offset, gamma_powers) = compute_bridge_params(column_claims, gamma, trace_len);
1693+
// BUG-004: compute l_mle_claim = ∏_k (r_k² + (1-r_k)²).
1694+
// When l[i] = eq(bits(i), r), Σ_i l[i]² equals this value.
1695+
// Including it in the bridge target (via γ^K) forces the bridge constraint to
1696+
// check Σ_i l[i]² = l_mle_claim, binding the committed l column to eq(·, r).
1697+
let one = FieldElement::<E>::one();
1698+
let l_mle_claim = random_point.iter().fold(one.clone(), |acc, r_k| {
1699+
let one_minus_r = &one - r_k;
1700+
acc * (r_k.square() + one_minus_r.square())
1701+
});
1702+
1703+
let (bridge_offset, gamma_powers) =
1704+
compute_bridge_params(column_claims, gamma, trace_len, &l_mle_claim);
16821705
rap_challenges.push(gamma.clone()); // index 2
16831706
rap_challenges.push(bridge_offset); // index 3
16841707
for gp in gamma_powers {
1685-
rap_challenges.push(gp); // indices 4, 5, ...
1708+
rap_challenges.push(gp); // indices 4, 5, ..., 4+K
16861709
}
16871710
for rp in random_point {
1688-
rap_challenges.push(rp.clone()); // indices 4+K, 4+K+1, ...
1711+
rap_challenges.push(rp.clone()); // indices 4+K+1, 4+K+2, ...
16891712
}
16901713
}
16911714

@@ -1750,14 +1773,20 @@ where
17501773
// l (aux column 0)
17511774
let l_curr = step0.get_aux_evaluation_element(0, 0);
17521775

1753-
// batched_curr = Σ_j γ^j · col_j_curr using precomputed gamma powers
1776+
// batched_curr = Σ_j γ^j · col_j_curr + γ^K · l_curr
1777+
// The extra γ^K · l_curr term makes l_curr * batched include γ^K · l_curr²,
1778+
// enforcing Σ_i l[i]² = l_mle_claim via Schwartz-Zippel over γ (BUG-004 fix).
17541779
let mut batched = FieldElement::<E>::zero();
17551780
for (j, &col_idx) in self.column_indices.iter().enumerate() {
17561781
let gamma_j = &rap_challenges[LOGUP_GAMMA_POWERS_START + j];
17571782
let col_val = step0.get_main_evaluation_element(0, col_idx);
17581783
// F×E→E: base field column × extension field gamma power
17591784
batched += col_val * gamma_j;
17601785
}
1786+
// γ^K self-check term (l² contribution)
1787+
let k = self.column_indices.len();
1788+
let gamma_k = &rap_challenges[LOGUP_GAMMA_POWERS_START + k];
1789+
batched += gamma_k * l_curr;
17611790

17621791
// σ_next - σ_curr - l_curr * batched + bridge_offset
17631792
transition_evaluations[self.constraint_idx] =
@@ -1784,6 +1813,10 @@ where
17841813
let col_val = step0.get_main_evaluation_element(0, col_idx);
17851814
batched += col_val * gamma_j;
17861815
}
1816+
// γ^K self-check term (l² contribution, mirrors prover path)
1817+
let k = self.column_indices.len();
1818+
let gamma_k = &rap_challenges[LOGUP_GAMMA_POWERS_START + k];
1819+
batched += gamma_k * l_curr;
17871820

17881821
transition_evaluations[self.constraint_idx] =
17891822
sigma_next - sigma_curr - l_curr * &batched + bridge_offset;
@@ -2408,6 +2441,11 @@ pub struct LogUpGkrResult<E: IsField> {
24082441
/// * `interactions` - The AIR's bus interactions
24092442
/// * `challenges` - LogUp challenges `[z, alpha]`
24102443
///
2444+
/// # Arguments
2445+
/// * `n_layers` - number of GKR summation layers for this instance (0 = single-row table).
2446+
/// For 0-layer instances the MLE of any column at the empty point equals its single row value,
2447+
/// so the cross-multiplication check is exact and applied unconditionally.
2448+
///
24112449
/// # Returns
24122450
/// `true` if verification passes, `false` otherwise.
24132451
pub fn reconstruct_and_verify_gkr_claims<E: IsField>(
@@ -2416,6 +2454,7 @@ pub fn reconstruct_and_verify_gkr_claims<E: IsField>(
24162454
column_claims: &[(usize, FieldElement<E>)],
24172455
interactions: &[BusInteraction],
24182456
challenges: &[FieldElement<E>],
2457+
n_layers: usize,
24192458
) -> bool {
24202459
// Build a map from column index to claimed MLE value
24212460
let claim_map: std::collections::HashMap<usize, &FieldElement<E>> = column_claims
@@ -2536,19 +2575,20 @@ pub fn reconstruct_and_verify_gkr_claims<E: IsField>(
25362575
// N(i) = sign * m(i), D(i) = fp(i)
25372576
// so MLE(N)(r) and MLE(D)(r) can be exactly reconstructed from column MLEs.
25382577
//
2539-
// For multi-interaction tables, the cross-multiplication introduces nonlinear
2540-
// terms (products of fingerprints/multiplicities across interactions), so
2541-
// MLE(N)(r) != n_recon and MLE(D)(r) != d_recon in general.
2542-
// Soundness for these tables is ensured by the bridge running sum constraint.
2543-
if interactions.len() == 1 {
2578+
// For multi-interaction tables with n_layers > 0, the cross-multiplication
2579+
// introduces nonlinear terms (products of fingerprints/multiplicities across
2580+
// interactions), so MLE(N)(r) != n_recon and MLE(D)(r) != d_recon in general.
2581+
//
2582+
// For 0-layer instances (n_layers == 0, single-row tables): the MLE at the empty
2583+
// evaluation point equals the single row value exactly — no nonlinearity. The check
2584+
// is valid and applied unconditionally regardless of interaction count (BUG-011 fix).
2585+
if interactions.len() == 1 || n_layers == 0 {
25442586
// Direct check: reconstructed values must match GKR output as rational numbers.
2545-
// The GKR verifier may return (n_claim, d_claim) in a different representation
2546-
// than the prover's raw (numerator, denominator) — e.g. (claimed_sum, 1) instead
2547-
// of (root_n, root_d). So we compare as rationals: running_n / running_d == n_claim / d_claim
2587+
// Compare as rationals: running_n / running_d == n_claim / d_claim
25482588
// i.e. running_n * d_claim == n_claim * running_d.
25492589
&running_n * d_claim == n_claim * &running_d
25502590
} else {
2551-
// Multi-interaction: structural check passed above.
2591+
// Multi-interaction with n_layers > 0: structural check passed above.
25522592
// The bridge constraint (verified during STARK proof) ensures column_claims
25532593
// are consistent with the committed trace.
25542594
true

crypto/stark/src/prover.rs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,10 +1767,18 @@ pub trait IsStarkProver<
17671767
let (batch_gkr_proof_opt, gkr_results) = batch_gkr_proof;
17681768

17691769
// =====================================================================
1770-
// Round 1, Phase B'': Sample γ for bridge batching (main transcript)
1770+
// Round 1, Phase B'': Bind column_claims, then sample γ (BUG-012 fix)
17711771
// =====================================================================
1772-
// γ is sampled AFTER all GKR messages are bound to the transcript
1773-
// but BEFORE forking per table. The verifier replays the same sample.
1772+
// column_claims for multi-interaction tables were previously not transcript-bound.
1773+
// Append them before sampling γ so that γ is adaptive-prover resistant.
1774+
// Verifier must replay in the same order (same gkr_table_indices ordering).
1775+
if needs_lookup_challenges {
1776+
for result in gkr_results.iter().flatten() {
1777+
for (_, claim_val) in &result.column_claims {
1778+
transcript.append_field_element(claim_val);
1779+
}
1780+
}
1781+
}
17741782

17751783
let gamma: FieldElement<FieldExtension> = if needs_lookup_challenges {
17761784
transcript.sample_field_element()
@@ -1807,30 +1815,45 @@ pub trait IsStarkProver<
18071815

18081816
// Column 1: Bridge running sum σ
18091817
// The constraint checks: σ[i+1] - σ[i] = l[i]·batched[i] - Δ
1810-
// where Δ = bridge_offset = target/N.
1818+
// where Δ = bridge_offset = target/N and batched[i] = Σ_j γ^j·col_j[i] + γ^K·l[i].
18111819
// So σ[0] = 0 (start), σ[i+1] = σ[i] + l[i]·batched[i] - Δ.
1812-
// The circular wrap-around at row N-1 requires σ[0] = σ[N-1] + l[N-1]·batched[N-1] - Δ,
1813-
// which telescopes to: 0 = Σ l[i]·batched[i] - N·Δ = target - target.
1820+
// The circular wrap-around at row N-1 telescopes to:
1821+
// 0 = Σ l[i]·batched[i] - N·Δ = target - target.
1822+
1823+
// BUG-004 fix: compute l_mle_claim = ∏_k (r_k² + (1-r_k)²)
1824+
let one = FieldElement::<FieldExtension>::one();
1825+
let l_mle_claim = result.random_point.iter().fold(one.clone(), |acc, r_k| {
1826+
let one_minus_r = &one - r_k;
1827+
acc * (r_k.square() + one_minus_r.square())
1828+
});
1829+
18141830
let (bridge_offset, gamma_powers) = crate::lookup::compute_bridge_params(
18151831
&result.column_claims,
18161832
&gamma,
18171833
trace_len,
1834+
&l_mle_claim,
18181835
);
18191836
let main_cols = trace.columns_main();
18201837

1821-
// Pre-compute batched values in parallel: batched[i] = Σ_j main_cols[col_j][i] * γ^j
1838+
// Pre-compute batched values: batched[i] = Σ_j γ^j·col_j[i] + γ^K·l[i]
1839+
// The γ^K·l[i] term makes l[i]·batched[i] include γ^K·l[i]²,
1840+
// enforcing Σ_i l[i]² = l_mle_claim via Schwartz-Zippel (BUG-004 fix).
18221841
#[cfg(feature = "parallel")]
18231842
let batched_iter = (0..trace_len).into_par_iter();
18241843
#[cfg(not(feature = "parallel"))]
18251844
let batched_iter = 0..trace_len;
18261845

18271846
let column_claims = &result.column_claims;
1847+
let k = column_claims.len(); // γ^K index
1848+
let kernel = &result.lagrange_kernel;
18281849
let batched_values: Vec<FieldElement<FieldExtension>> = batched_iter
18291850
.map(|row| {
18301851
let mut batched = FieldElement::<FieldExtension>::zero();
18311852
for (j, (col_idx, _)) in column_claims.iter().enumerate() {
18321853
batched += &main_cols[*col_idx][row] * &gamma_powers[j];
18331854
}
1855+
// γ^K · l[row] (self-check term)
1856+
batched += &gamma_powers[k] * &kernel[row];
18341857
batched
18351858
})
18361859
.collect();

crypto/stark/src/verifier.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -862,13 +862,16 @@ pub trait IsStarkVerifier<
862862
return false;
863863
}
864864

865-
// Verify that column_claims are consistent with GKR output
865+
// Verify that column_claims are consistent with GKR output.
866+
// Pass n_layers_by_instance[i] so 0-layer multi-interaction tables
867+
// get the direct rational check (BUG-011 fix).
866868
if !crate::lookup::reconstruct_and_verify_gkr_claims(
867869
n_claim,
868870
d_claim,
869871
&gkr_proof_data.column_claims,
870872
air.bus_interactions(),
871873
&lookup_challenges,
874+
n_layers_by_instance[i],
872875
) {
873876
#[cfg(not(feature = "test_fiat_shamir"))]
874877
error!(
@@ -902,9 +905,20 @@ pub trait IsStarkVerifier<
902905
}
903906

904907
// =====================================================================
905-
// Phase B'': Sample γ for bridge batching (main transcript)
908+
// Phase B'': Bind column_claims to transcript, then sample γ (BUG-012 fix)
906909
// =====================================================================
907-
// Must match prover: γ sampled AFTER all GKR messages, BEFORE forking.
910+
// column_claims for multi-interaction tables were previously not transcript-bound,
911+
// creating an adaptive-prover gap. Append them before sampling γ so that γ
912+
// depends on the claimed column MLE values. Must match prover ordering exactly.
913+
if needs_lookup_challenges {
914+
for &table_idx in &gkr_table_indices {
915+
if let Some(p) = &multi_proof.proofs[table_idx].logup_gkr_proof {
916+
for (_, claim_val) in &p.column_claims {
917+
transcript.append_field_element(claim_val);
918+
}
919+
}
920+
}
921+
}
908922

909923
let gamma: FieldElement<FieldExtension> = if needs_lookup_challenges {
910924
transcript.sample_field_element()

0 commit comments

Comments
 (0)