Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 86 additions & 9 deletions crypto/stark/src/proof/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProofOptions, ProofOptionsError> {
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,
Expand All @@ -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;
Expand Down
64 changes: 61 additions & 3 deletions crypto/stark/src/tests/proof_options_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Loading