@@ -116,15 +116,17 @@ pub const LOGUP_CHALLENGE_GAMMA: usize = 2;
116116pub 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.
120121pub 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.
126127pub 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.
16471655pub 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]
16741686pub 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.
24132451pub 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
0 commit comments