diff --git a/crypto/stark/src/proof/options.rs b/crypto/stark/src/proof/options.rs index 15e2c8909..334fb02e2 100644 --- a/crypto/stark/src/proof/options.rs +++ b/crypto/stark/src/proof/options.rs @@ -79,30 +79,107 @@ impl ProofOptions { /// Goldilocks base field: 64 bits (p = 2^64 - 2^32 + 1) /// Cubic extension: degree 3 (w^3 = 2), giving 192-bit effective field size. /// -/// Computes FRI query count using the Johnson Bound Regime (JBR): -/// proximity = 1 - sqrt(1/blowup) - 1/300 +/// Computes the FRI query count in the Johnson Bound Regime (JBR): +/// proximity = 1 - sqrt(1/blowup) - eta /// bits_per_query = -log2(1 - proximity) /// queries = ceil((security_bits - grinding) / bits_per_query) /// -/// The 192-bit effective field comfortably supports up to 152-bit security -/// (192 - 40 bits max domain), so the FRI query count is always the -/// security bottleneck — field size is not. +/// # ⚠ What `security_bits` is, and what it is not +/// +/// Delivered soundness is the WORSE of two independent error terms: the FRI +/// query error sized here, and the commit-phase / proximity-gaps error +/// (`eps_C`). **This module computes only the first.** `security_bits` is +/// therefore a QUERY BUDGET, not the security the system delivers — nothing +/// here models `eps_C`, and grinding attaches to the query round alone, so it +/// cannot buy `eps_C` back either. +/// +/// At the production posture the query term is NOT the bottleneck: `eps_C` is, +/// by a wide margin, and neither the query count nor the grinding factor moves +/// it. Only [`johnson_gap`] does. Sizing queries past the point where `eps_C` +/// binds buys nothing and costs proving time. pub struct GoldilocksCubicProofOptions; +/// The Johnson gap `eta` — a FREE ANALYSIS PARAMETER, not a property of the +/// system, and the only knob in this file that moves delivered soundness. +/// +/// It sets how close the decoding radius sits to the Johnson bound, and it +/// TRADES THE TWO ERROR TERMS AGAINST EACH OTHER: a smaller `eta` pushes the +/// radius toward the bound, which maximises `bits_per_query` (cheap queries) +/// and simultaneously MINIMISES `eps_C`. Since soundness is the max of the two, +/// tuning `eta` down past the crossover buys query-term bits that cannot be +/// spent while wrecking the term that actually binds. Equivalently `eta` sets +/// the proximity parameter `m = sqrt(rate) / (2 * eta)`, so this is an `m` +/// re-optimisation — any external result that also re-optimises `m` OVERLAPS +/// with this and must not be multiplied in. +/// +/// ⚠ **The values below are calibrated for one posture and are not universal.** +/// The optimum depends on the tallest evaluation domain `|D_0|`, the DEEP batch +/// size `L` and the number of FRI instances the union runs over — none of which +/// `with_params` receives. They are derived for the block posture: blowup 4 at +/// 110 queries and grinding 20, `|D_0| = 2^24` (LOCAL_TO_GLOBAL saturates at +/// 2^22 rows), ~40 per-table FRI instances per epoch and ~32 proof instances +/// per block. Applying them to a materially different shape is unanalysed. +/// +/// Blowups without a calibrated value keep the historical `1/300`. That is not +/// an endorsement of it — it is the value whose `eps_C` cost has been measured +/// and is known to be poor, kept because a substitute has not been derived for +/// those shapes. `blowup 8` is in that set deliberately: its DELIVERED bits are +/// not computed, and inheriting a neighbour's constant would state a soundness +/// claim nobody has checked. +fn johnson_gap(blowup_factor: u8) -> f64 { + match blowup_factor { + 2 => 1.0 / 39.6, + 4 => 1.0 / 32.0, + _ => 1.0 / 300.0, + } +} + +/// The query budget [`GoldilocksCubicProofOptions::with_blowup`] targets, per +/// blowup. +/// +/// It moves WITH [`johnson_gap`] and for one reason: `eta` feeds both `eps_C` +/// and `bits_per_query`, so re-tuning it alone would make each query cheaper +/// and buy MORE of them (110 -> 119 at blowup 4). The pairs below are chosen to +/// leave every production query count exactly where it was — the re-tune is +/// free, and a query count that moves means the pair is wrong. +fn query_budget_bits(blowup_factor: u8) -> u8 { + match blowup_factor { + 2 => 118, + 4 => 120, + _ => 128, + } +} + // Shared by both ProofOptions::default_test_options and GoldilocksCubicProofOptions::with_params. const DEFAULT_FRI_FINAL_POLY_LOG_DEGREE: u8 = 7; impl GoldilocksCubicProofOptions { const DEFAULT_GRINDING: u8 = 20; - /// Create proof options targeting 128-bit security with default grinding (20 bits). + /// Create proof options at this blowup's query budget, with default + /// grinding (20 bits). + /// + /// The budget is per-blowup ([`query_budget_bits`]) rather than a flat 128 + /// because 128 was never delivered: it sized the query term while `eps_C` + /// bound far below it. See [`johnson_gap`] for what actually moves + /// soundness and for the posture these constants are calibrated at. /// /// `blowup_factor` must be a power of 2 >= 2 (e.g., 2, 4, 8, 16, 32, 64). pub fn with_blowup(blowup_factor: u8) -> Result { - Self::with_params(blowup_factor, 128, Self::DEFAULT_GRINDING) + Self::with_params( + blowup_factor, + query_budget_bits(blowup_factor), + Self::DEFAULT_GRINDING, + ) } - /// Create proof options with custom security target and grinding factor. + /// Create proof options with a custom query budget and grinding factor. + /// + /// ⚠ `security_bits` sizes the QUERY term only — see the note on + /// [`GoldilocksCubicProofOptions`]. A caller passing its own budget still + /// gets this blowup's [`johnson_gap`], which is calibrated for the block + /// posture; at a materially different shape the resulting `eps_C` is + /// unanalysed, and the delivered soundness is not the number passed here. pub fn with_params( blowup_factor: u8, security_bits: u8, @@ -119,7 +196,7 @@ impl GoldilocksCubicProofOptions { } let rate = 1.0 / blowup_factor as f64; - let proximity = 1.0 - rate.sqrt() - 1.0 / 300.0; + let proximity = 1.0 - rate.sqrt() - johnson_gap(blowup_factor); let bits_per_query = -(1.0 - proximity).log2(); let fri_number_of_queries = ((security_bits as f64 - grinding_factor as f64) / bits_per_query).ceil() as usize; diff --git a/crypto/stark/src/tests/proof_options_tests.rs b/crypto/stark/src/tests/proof_options_tests.rs index 8e934eb7c..216ef8041 100644 --- a/crypto/stark/src/tests/proof_options_tests.rs +++ b/crypto/stark/src/tests/proof_options_tests.rs @@ -2,7 +2,13 @@ use crate::proof::options::{GoldilocksCubicProofOptions, ProofOptions, ProofOpti #[test] fn jbr_queries_match_expected_values() { - // Verified against zisk's pil2-proofman-js security calculator + // These counts were verified against zisk's pil2-proofman-js security + // calculator, which runs at the historical Johnson gap of 1/300. + // ⚠ Blowups 2 and 4 now use a re-tuned gap, so their agreement with that + // calculator is no longer the reason they hold — the (gap, budget) pair is + // chosen to keep the count fixed, and `the_production_posture_is_pinned` + // is what states that intent. Blowups 8, 32 and 64 are still on 1/300 and + // still match the calculator directly. assert_eq!( GoldilocksCubicProofOptions::with_blowup(2) .unwrap() @@ -69,15 +75,67 @@ fn default_grinding_is_20() { fn custom_grinding() { let opts = GoldilocksCubicProofOptions::with_params(4, 128, 22).unwrap(); assert_eq!(opts.grinding_factor, 22); - // More grinding → fewer queries needed + // More grinding → fewer queries needed, HOLDING THE BUDGET FIXED. Both + // sides must pass the same `security_bits`: `with_blowup` carries a + // per-blowup budget, so comparing against it would attribute a budget + // difference to grinding and read backwards the moment the two diverge. assert!( opts.fri_number_of_queries - < GoldilocksCubicProofOptions::with_blowup(4) + < GoldilocksCubicProofOptions::with_params(4, 128, 20) .unwrap() .fri_number_of_queries ); } +/// ★ The posture pin. Every one of these numbers is a deliberate choice, and +/// the defect it guards is a constant drifting silently: the Johnson gap sat at +/// 1/300 for a year while nothing recomputed what it cost. +/// +/// ⚠ QUERY COUNTS MUST NOT MOVE. The re-tune is free precisely because it holds +/// 219 / 110 / 73 — the gap feeds both `eps_C` and `bits_per_query`, so the +/// budget moves with it to keep the count fixed. A query count changing here +/// means the (gap, budget) pair is wrong, not that this test is stale. +#[test] +fn the_production_posture_is_pinned() { + // (blowup, security_bits, grinding, queries) + let expected = [(2u8, 118u8, 20u8, 219usize), (4, 120, 20, 110)]; + for (blowup, bits, grinding, queries) in expected { + let opts = GoldilocksCubicProofOptions::with_blowup(blowup).expect("valid blowup"); + assert_eq!( + opts.fri_number_of_queries, queries, + "blowup {blowup}: query count moved — the re-tune is only free if it does not" + ); + assert_eq!(opts.grinding_factor, grinding, "blowup {blowup}: grinding"); + // The budget is not stored on ProofOptions, so pin it the only way it + // is observable: passing the same budget explicitly must reproduce the + // count `with_blowup` produces. + assert_eq!( + GoldilocksCubicProofOptions::with_params(blowup, bits, grinding) + .expect("valid params") + .fri_number_of_queries, + queries, + "blowup {blowup}: with_blowup must be passing security_bits {bits}" + ); + } + + // ⚠ blowup 8 is NOT re-tuned: its delivered bits are not computed, so it + // keeps the historical gap and the 128 budget rather than inheriting a + // neighbour's constant. 73 queries either way — the claim is what differs. + assert_eq!( + GoldilocksCubicProofOptions::with_blowup(8) + .unwrap() + .fri_number_of_queries, + 73 + ); + assert_eq!( + GoldilocksCubicProofOptions::with_params(8, 128, 20) + .unwrap() + .fri_number_of_queries, + 73, + "blowup 8 must still be on the 128 budget" + ); +} + #[test] fn higher_blowup_means_fewer_queries() { let q2 = GoldilocksCubicProofOptions::with_blowup(2)