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(®ime, &air, &s, &[], &GrindingSites::NONE);
230 let term = security_term(&logup, &s, &GrindingSites::NONE).expect("has interactions");
231 let with_logup = proven_security_report(®ime, &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}