Skip to main content

p3_security/
logup.rs

1//! LogUp (logarithmic-derivative lookup) argument soundness.
2//!
3//! The `p3-lookup` crate fingerprints each bus message into a single
4//! extension-field denominator
5//!
6//! ```text
7//!     denom = alpha + (bus + 1)·beta^W − Σ_k beta^k · payload_k
8//! ```
9//!
10//! and enforces a running sum `Σ ± multiplicity / denom = 0` over the trace.
11//! Soundness is a Schwartz–Zippel argument in the Fiat–Shamir pair
12//! `(alpha, beta)`, sampled after the main commitment: an unbalanced multiset
13//! stays unbalanced unless `(alpha, beta)` is a root of the cleared
14//! fingerprint polynomial.
15//!
16//! Cleared of denominators, that polynomial has degree ≤ `N` in `alpha` and
17//! ≤ `N·W` in `beta`, where `N = num_interactions · trace_length` is the
18//! number of denominator factors (interactions summed over all buses) and
19//! `W = max_message_width` is the widest payload. Sampling both challenges
20//! independently and uniformly from the challenge field `EF`, the per-variable
21//! union bound puts a numerator root at `N·(W + 1) / |EF|`.
22//!
23//! A root of the numerator is not the only way through. A *vanishing
24//! denominator* degenerates the batched transition identity: with `denom_j = 0`
25//! the common denominator itself is `0`, so the constraint
26//! `common_denominator · frac_local − numerator = 0` no longer pins the
27//! fraction column — `frac_local` is multiplied away and left completely
28//! unconstrained on that row, letting a prover write any value into it and
29//! have the accumulator absorb it. Each denominator is monic in `alpha`, so
30//! for any `beta` exactly one `alpha` annihilates it — that event costs
31//! `N / |EF|`, not `N·W / |EF|`.
32//!
33//! Together
34//!
35//! ```text
36//!     ε_logup ≤ N·(W + 1) / |EF| + N / |EF| = N·(W + 2) / |EF|.
37//! ```
38//!
39//! Bus batching is subsumed: `N` already sums interactions over every bus, and
40//! the `beta^W` bus offset is injective by construction (`p3_lookup`'s
41//! `Challenges`), so it contributes no separate error term. The multiplicity
42//! height bound (`Σ wᵢ·hᵢ < p`) is enforced in-circuit and is not modeled here.
43//!
44//! The resulting term is passed to [`crate::stark::proven_security_report`] or
45//! [`crate::stark::conjectured_security_report`] as an `extras`
46//! [`SecurityTerm`] by the protocol call site; it is not wired into the
47//! composite automatically, since not every STARK uses lookups. In the
48//! conjectured composite it is often the binding term at large trace lengths:
49//! `N` grows linearly in the trace while the query-phase error does not.
50//!
51//! # References
52//! - Haböck, *Multivariate lookups based on logarithmic derivatives*
53//!   ([2022/1530](https://eprint.iacr.org/2022/1530)).
54
55use libm::log2;
56
57use crate::error::ErrorBits;
58use crate::grinding::{GrindingSites, boost};
59use crate::report::SecurityTerm;
60use crate::shape::InstanceShape;
61
62/// Label for the LogUp fingerprint term in a [`crate::report::SecurityReport`].
63pub const LOGUP_LABEL: &str = "logup-fingerprint";
64
65/// Shape of a LogUp lookup argument, as seen by its soundness bound.
66#[derive(Copy, Clone, Debug)]
67pub struct LogUpAir {
68    /// Number of bus messages emitted per trace row, summed across all buses
69    /// (global + local interactions). Each is one denominator factor.
70    pub num_interactions: usize,
71    /// Widest message payload tuple (`max_message_width`) — the degree of the
72    /// `beta`-compression.
73    pub max_message_width: usize,
74}
75
76/// `-log2(ε_logup)` in bits, following `ε_logup ≤ N·(W + 2) / |EF|` with
77/// `N = num_interactions · 2^log_trace_length` and `W = max_message_width`.
78///
79/// The `W + 2` covers both ways a sampled `(alpha, beta)` breaks the argument: a root of the
80/// cleared numerator, and a vanishing denominator that lets a message be dropped.
81///
82/// Returns 0 bits for degenerate inputs (no interactions, or unknown field
83/// size). Prefer [`security_term`], which omits the term entirely when there
84/// are no interactions rather than reporting a binding 0-bit bound.
85pub fn fingerprint_error(air: &LogUpAir, shape: &InstanceShape) -> ErrorBits {
86    if air.num_interactions == 0 || shape.modulus_bits == 0 {
87        return ErrorBits::from_log2(0.0);
88    }
89    let log_n = shape.log_trace_length as f64 + log2(air.num_interactions as f64);
90    let width = air.max_message_width.max(1) as f64;
91    let bits = shape.modulus_bits as f64 - log_n - log2(width + 2.0);
92    ErrorBits::from_log2(bits.max(0.0))
93}
94
95/// The LogUp fingerprint term for use in `extras`, or `None` when the AIR has
96/// no interactions (no lookup argument, hence no term).
97///
98/// `grinding.lookup_challenge` is the grind sited immediately before
99/// `(alpha, beta)` are sampled; it boosts this term and nothing else.
100pub fn security_term(
101    air: &LogUpAir,
102    shape: &InstanceShape,
103    grinding: &GrindingSites,
104) -> Option<SecurityTerm> {
105    if air.num_interactions == 0 {
106        return None;
107    }
108    Some(SecurityTerm::new(
109        LOGUP_LABEL,
110        boost(fingerprint_error(air, shape), grinding.lookup_challenge),
111    ))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn shape(modulus_bits: usize) -> InstanceShape {
119        InstanceShape {
120            log_trace_length: 20,
121            modulus_bits,
122            collision_resistance: 128,
123            num_batched_functions: 1,
124        }
125    }
126
127    /// `ε ≤ N·(W+2)/|EF|` → bits = |EF| − log2(N) − log2(W+2). For
128    /// N = 2^20 · 16 = 2^24 and W = 3: 128 − 24 − log2(5).
129    #[test]
130    fn fingerprint_error_regression() {
131        let air = LogUpAir {
132            num_interactions: 16,
133            max_message_width: 3,
134        };
135        let bits = fingerprint_error(&air, &shape(128)).bits();
136        let expected = 128.0 - 24.0 - 5f64.log2();
137        assert!((bits - expected).abs() < 1e-9, "got {bits}");
138    }
139
140    /// More interactions and wider messages both tighten (lower) the bound.
141    #[test]
142    fn fingerprint_error_is_monotone() {
143        let base = LogUpAir {
144            num_interactions: 16,
145            max_message_width: 3,
146        };
147        let more_interactions = LogUpAir {
148            num_interactions: 256,
149            ..base
150        };
151        let wider = LogUpAir {
152            max_message_width: 31,
153            ..base
154        };
155        let s = shape(128);
156
157        let b0 = fingerprint_error(&base, &s).bits();
158        assert!(fingerprint_error(&more_interactions, &s).bits() < b0);
159        assert!(fingerprint_error(&wider, &s).bits() < b0);
160    }
161
162    #[test]
163    fn security_term_absent_without_interactions() {
164        let none = LogUpAir {
165            num_interactions: 0,
166            max_message_width: 4,
167        };
168        assert!(security_term(&none, &shape(128), &GrindingSites::NONE).is_none());
169
170        let some = LogUpAir {
171            num_interactions: 4,
172            max_message_width: 4,
173        };
174        let term =
175            security_term(&some, &shape(128), &GrindingSites::NONE).expect("has interactions");
176        assert_eq!(term.label, LOGUP_LABEL);
177        assert_eq!(term.bits, fingerprint_error(&some, &shape(128)));
178    }
179
180    /// A grind sited before the lookup challenges adds its bits to this term
181    /// and leaves the underlying fingerprint bound untouched.
182    #[test]
183    fn lookup_challenge_grinding_boosts_the_term() {
184        let air = LogUpAir {
185            num_interactions: 4,
186            max_message_width: 4,
187        };
188        let s = shape(128);
189        let grinding = GrindingSites {
190            lookup_challenge: 20,
191            ..GrindingSites::NONE
192        };
193
194        let base = security_term(&air, &s, &GrindingSites::NONE).expect("has interactions");
195        let ground = security_term(&air, &s, &grinding).expect("has interactions");
196
197        assert!((ground.bits.bits() - base.bits.bits() - 20.0).abs() < 1e-12);
198    }
199
200    /// The term flows through `proven_security_report`'s `extras` and appears
201    /// in the breakdown, only ever tightening the bound.
202    #[test]
203    fn logup_term_composes_as_extra() {
204        use crate::fri::FriRegime;
205        use crate::shape::StarkAirParams;
206        use crate::stark::proven_security_report;
207
208        let regime = FriRegime {
209            log_blowup: 1,
210            num_queries: 100,
211            log_final_poly_len: 0,
212            max_log_arity: 3,
213            commit_pow_bits: 0,
214            query_pow_bits: 16,
215        };
216        let air = StarkAirParams {
217            num_constraints: 1,
218            max_constraint_degree: 2,
219            num_quotient_chunks: 1,
220            max_combo: 2,
221        };
222        // Small field so the lookup term is in range of the other terms.
223        let s = shape(64);
224        let logup = LogUpAir {
225            num_interactions: 1 << 10,
226            max_message_width: 8,
227        };
228
229        let baseline = proven_security_report(&regime, &air, &s, &[], &GrindingSites::NONE);
230        let term = security_term(&logup, &s, &GrindingSites::NONE).expect("has interactions");
231        let with_logup = proven_security_report(&regime, &air, &s, &[term], &GrindingSites::NONE);
232
233        // Extras only tighten.
234        assert!(with_logup.security_bits() <= baseline.security_bits());
235        // The term is present in the UDR regime's labeled breakdown.
236        assert!(
237            with_logup
238                .udr
239                .terms()
240                .iter()
241                .any(|t| t.label == LOGUP_LABEL)
242        );
243    }
244}