Skip to main content

hekate_core/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei Kochergin <andrei@oumuamua.dev>
2// SPDX-FileCopyrightText: 2026 Oumuamua Labs <info@oumuamua.dev>
3// SPDX-License-Identifier: AGPL-3.0-only
4
5use core::fmt;
6
7use crate::errors;
8use crate::utils::support_floor_vars;
9
10/// Production soundness floor.
11pub const MIN_PRODUCTION_BITS: usize = 100;
12
13/// Most row variables one table may carry. The verifier
14/// rejects a proof that claims a taller table.
15pub const MAX_TABLE_VARS: usize = 32;
16
17/// Brakedown row-code rate `1/INV_RATE`;
18/// small grids fall back to `1/(2·INV_RATE)`.
19pub const INV_RATE: usize = 2;
20
21/// Outer row-code rate `1/2^OUTER_RATE_LOG2`.
22/// Below 2 the quadratic test has no soundness.
23pub const OUTER_RATE_LOG2: u32 = 4;
24
25/// Target row count of the outer AUX oracle.
26pub const OUTER_ROWS: usize = 32;
27
28/// Precision of `log2_ratio_fixed`:
29/// 32 holds the truncation error below
30/// `num_queries · 2⁻³²`, under one bit.
31pub(crate) const LOG2_FRAC_BITS: u32 = 32;
32
33/// Failures produced by `Config::check_security`.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum Error {
36    /// Estimated security fell below `min_security_bits`.
37    SecurityTooLow {
38        estimated_bits: usize,
39        min_bits: usize,
40    },
41
42    /// `ldt_support_size <= num_queries`;
43    /// the openings use up the support and
44    /// the unopened leaves leak the witness.
45    InsufficientSupport {
46        ldt_support_size: usize,
47        num_queries: usize,
48    },
49
50    /// No scalar to mask;
51    /// a pad of length zero hides nothing.
52    EmptyOuterStatement,
53
54    /// No column opening;
55    /// an unopened oracle proves nothing.
56    ZeroOuterQueries,
57
58    /// The outer code outgrew the largest
59    /// additive-FFT domain we encode.
60    OuterDomainTooLarge { code_len: usize, max_log2: u32 },
61}
62
63impl fmt::Display for Error {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::SecurityTooLow {
67                estimated_bits,
68                min_bits,
69            } => write!(
70                f,
71                "Security too low: estimated {estimated_bits} bits, but {min_bits} required",
72            ),
73            Self::InsufficientSupport {
74                ldt_support_size,
75                num_queries,
76            } => write!(
77                f,
78                "ldt_support_size ({ldt_support_size}) must be > num_queries ({num_queries})",
79            ),
80            Self::EmptyOuterStatement => {
81                write!(f, "outer statement masks no scalars")
82            }
83            Self::ZeroOuterQueries => write!(f, "outer_queries cannot be zero"),
84            Self::OuterDomainTooLarge { code_len, max_log2 } => write!(
85                f,
86                "outer code length {code_len} needs a domain above the 2^{max_log2} cap",
87            ),
88        }
89    }
90}
91
92/// Security metrics snapshot for a given `Config`.
93#[derive(Clone, Copy, Debug)]
94pub struct SecurityMetrics {
95    /// Estimated relative distance δ of the linear code.
96    pub relative_distance: f64,
97
98    /// LDT spot-check count.
99    pub num_queries: usize,
100
101    /// LDT query bound: `-log₂(((1 + ρ)/2)^q)`.
102    pub ldt_bits: usize,
103
104    /// Proximity-gap term `(log2(rows) + units - 1) · n / |F|`.
105    pub proximity_bits: usize,
106
107    /// `min(ldt_bits, proximity_bits)`.
108    pub security_bits: usize,
109}
110
111/// Fold the proximity term is stated over: the committed
112/// grid, the eq-tensor's row count and the exponent count
113/// of the `eta` and `lambda` walks, from `PoolLayout`.
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub struct FoldShape {
116    pub grid_cols: usize,
117    pub grid_rows: usize,
118    pub units: usize,
119}
120
121/// Row-code geometry chosen by `Config::table_geom`.
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub struct TableGeom {
124    /// Random low-coord support (LDT opening mask) length.
125    pub support_size: usize,
126
127    /// Committed codeword width (power of two).
128    pub encoded_width: usize,
129}
130
131#[derive(Clone, Debug)]
132pub struct Config {
133    /// Number of LDT spot-check queries.
134    pub num_queries: usize,
135
136    /// Random low-coord support that masks the LDT
137    /// column openings (data ZK). Lives inside the
138    /// message, it does not widen the codeword.
139    pub ldt_support_size: usize,
140
141    /// `check_security` rejects configs
142    /// whose estimated bits fall below this.
143    pub min_security_bits: usize,
144
145    /// Column openings of the outer argument.
146    pub outer_queries: usize,
147
148    /// Blind units, one-time pad and outer
149    /// argument on; `false` proves in the clear.
150    pub zero_knowledge: bool,
151}
152
153impl Default for Config {
154    fn default() -> Self {
155        Self::prod()
156    }
157}
158
159impl Config {
160    /// Production parameters: 100-bit soundness with the
161    /// `MIN_PRODUCTION_BITS` acceptance threshold. The `Default`.
162    pub fn prod() -> Self {
163        Self {
164            num_queries: 287,
165            ldt_support_size: 288,
166            min_security_bits: MIN_PRODUCTION_BITS,
167            outer_queries: 121,
168            zero_knowledge: true,
169        }
170    }
171
172    /// Fast, low-soundness parameters for tests and experiments;
173    /// `min_security_bits = 0` accepts weak grids. Never deploy.
174    pub fn dev() -> Self {
175        Self {
176            num_queries: 4,
177            min_security_bits: 0,
178            outer_queries: 4,
179            zero_knowledge: false,
180            ..Self::prod()
181        }
182    }
183
184    /// Blind columns committed with the whole eval master.
185    pub fn blind_units(&self) -> usize {
186        usize::from(self.zero_knowledge)
187    }
188
189    /// Fewest rows whose unpacked grid holds the LDT support.
190    pub fn min_table_rows(&self) -> usize {
191        1 << support_floor_vars(self.ldt_support_size)
192    }
193
194    /// Committed row-code width for the chosen mode.
195    pub fn encoded_width(&self, grid_cols: usize) -> usize {
196        self.table_geom(grid_cols).encoded_width
197    }
198
199    /// Mode must derive from transcript-bound inputs and
200    /// a fixed target only, never an unabsorbed field, or
201    /// prover and verifier silently diverge on the geometry.
202    pub fn table_geom(&self, grid_cols: usize) -> TableGeom {
203        let frac = TableGeom {
204            support_size: self.ldt_support_size,
205            encoded_width: grid_cols * INV_RATE,
206        };
207
208        let frac_msg = frac.support_size + grid_cols;
209
210        if frac.support_size <= grid_cols
211            && self.ldt_bits(frac_msg, frac.encoded_width) >= MIN_PRODUCTION_BITS
212        {
213            return frac;
214        }
215
216        TableGeom {
217            support_size: grid_cols,
218            encoded_width: grid_cols * INV_RATE * 2,
219        }
220    }
221
222    /// `min(-log₂(((1 + ρ)/2)^q), proximity_gap_bits)`
223    /// where ρ = msg_len / code_width, q = num_queries.
224    ///
225    /// Brakedown (Golovnev et al. 2022), Appendix B Theorem 7.
226    pub fn estimated_security_bits(&self, field_bits: usize, shape: FoldShape) -> usize {
227        let g = self.table_geom(shape.grid_cols);
228
229        self.ldt_bits(g.support_size + shape.grid_cols, g.encoded_width)
230            .min(self.proximity_gap_bits(field_bits, shape))
231    }
232
233    /// `(log2(rows) + units - 1) · n / |F|`: BCIKS20's `n / |F|`,
234    /// Diamond-Gruen 2024/1351's eq-tensor factor over the rows,
235    /// BCIKS20 Thm 1.5's curve factor over the `eta` and `lambda` walks.
236    pub fn proximity_gap_bits(&self, field_bits: usize, shape: FoldShape) -> usize {
237        let width = self.table_geom(shape.grid_cols).encoded_width;
238
239        let theta = shape.grid_rows.next_power_of_two().ilog2() as usize;
240        let fold_terms = (theta + shape.units.saturating_sub(1)).max(1);
241
242        field_bits
243            .saturating_sub(width.next_power_of_two().ilog2() as usize)
244            .saturating_sub(fold_terms.next_power_of_two().ilog2() as usize)
245    }
246
247    /// Per-table terms at `shape`; the LogUp `γ` term is
248    /// charged separately by [`Config::logup_gamma_bits`].
249    pub fn security_metrics(&self, field_bits: usize, shape: FoldShape) -> SecurityMetrics {
250        let g = self.table_geom(shape.grid_cols);
251
252        SecurityMetrics {
253            relative_distance: self.estimate_relative_distance(shape.grid_cols),
254            num_queries: self.num_queries,
255            ldt_bits: self.ldt_bits(g.support_size + shape.grid_cols, g.encoded_width),
256            proximity_bits: self.proximity_gap_bits(field_bits, shape),
257            security_bits: self.estimated_security_bits(field_bits, shape),
258        }
259    }
260
261    /// Rejects configs whose estimated soundness at
262    /// `grid_cols` falls below `min_security_bits`.
263    pub fn check_security(&self, field_bits: usize, shape: FoldShape) -> errors::Result<()> {
264        // dev (min_security_bits == 0) waives the ZK floor
265        let support = self.table_geom(shape.grid_cols).support_size;
266        if self.min_security_bits > 0 && support <= self.num_queries {
267            return Err(Error::InsufficientSupport {
268                ldt_support_size: support,
269                num_queries: self.num_queries,
270            }
271            .into());
272        }
273
274        let est_bits = self.estimated_security_bits(field_bits, shape);
275        if est_bits < self.min_security_bits {
276            return Err(Error::SecurityTooLow {
277                estimated_bits: est_bits,
278                min_bits: self.min_security_bits,
279            }
280            .into());
281        }
282
283        Ok(())
284    }
285
286    /// Rejects a proof whose total bus height drops
287    /// the `γ` term below `min_security_bits`.
288    pub fn check_logup_security(&self, field_bits: usize, bus_rows: u64) -> errors::Result<()> {
289        if self.min_security_bits == 0 {
290            return Ok(());
291        }
292
293        let bits = self.logup_gamma_bits(field_bits, bus_rows);
294
295        if bits < self.min_security_bits {
296            return Err(Error::SecurityTooLow {
297                estimated_bits: bits,
298                min_bits: self.min_security_bits,
299            }
300            .into());
301        }
302
303        Ok(())
304    }
305
306    /// Exact MDS (Singleton) distance of the chosen geometry:
307    /// `δ = (encoded_width − support − grid_cols) / encoded_width`.
308    /// Holds for both modes (full-half yields exactly 0.5).
309    /// MDS: `hekate-math` `verus/fft.rs::fwd_semantics`.
310    fn estimate_relative_distance(&self, grid_cols: usize) -> f64 {
311        let g = self.table_geom(grid_cols);
312
313        g.encoded_width.saturating_sub(g.support_size + grid_cols) as f64 / g.encoded_width as f64
314    }
315
316    /// `floor(-log₂(((1 + ρ)/2)^q))` in bits, ρ = msg_len / code_width.
317    /// Integer-only, prover and verifier derive identical geometry;
318    /// libm `powf`/`log2` are not bit-reproducible across platforms.
319    fn ldt_bits(&self, msg_len: usize, code_width: usize) -> usize {
320        if msg_len >= code_width {
321            return 0;
322        }
323
324        let log2_ratio =
325            log2_ratio_fixed(2 * code_width as u128, code_width as u128 + msg_len as u128);
326
327        ((self.num_queries as u128 * log2_ratio) >> LOG2_FRAC_BITS) as usize
328    }
329
330    /// Schwartz-Zippel over the global LogUp `γ`, in bits:
331    /// the bus argument collapses if `γ` roots any one of the
332    /// `γ + key[i]` denominators, over every bus row in the proof.
333    pub fn logup_gamma_bits(&self, field_bits: usize, bus_rows: u64) -> usize {
334        let rows = bus_rows.max(1).next_power_of_two().ilog2() as usize;
335
336        field_bits.saturating_sub(rows)
337    }
338}
339
340/// `floor(log₂(n / m) · 2^LOG2_FRAC_BITS)` for `n > m >= 1`.
341/// The Q60 mantissa keeps the squaring `y² < 2¹²²`, inside `u128`.
342pub(crate) fn log2_ratio_fixed(n: u128, m: u128) -> u128 {
343    const S: u32 = 60;
344
345    let mut scaled_m = m;
346    let mut int_part: u128 = 0;
347
348    while scaled_m <= n / 2 {
349        scaled_m <<= 1;
350        int_part += 1;
351    }
352
353    // m·2^int_part ∈ (n/2, n],
354    // y = n/(m·2^int_part) ∈ [1, 2) in Q_S.
355    let mut y = (n << S) / scaled_m;
356    let mut frac: u128 = 0;
357
358    for i in 0..LOG2_FRAC_BITS {
359        y = (y * y) >> S;
360
361        if y >= 2u128 << S {
362            y >>= 1;
363            frac |= 1u128 << (LOG2_FRAC_BITS - 1 - i);
364        }
365    }
366
367    (int_part << LOG2_FRAC_BITS) | frac
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    const GRID_COLS: usize = 1024;
375
376    /// A single-row, single-unit fold contributes no tensor
377    /// and no curve factor, isolating the terms under test.
378    fn flat(grid_cols: usize) -> FoldShape {
379        FoldShape {
380            grid_cols,
381            grid_rows: 1,
382            units: 1,
383        }
384    }
385
386    #[test]
387    fn default_is_prod() {
388        assert_eq!(Config::default().min_security_bits, MIN_PRODUCTION_BITS);
389        assert_eq!(Config::default().num_queries, Config::prod().num_queries);
390    }
391
392    #[test]
393    fn prod_meets_production_floor() {
394        let prod = Config::prod();
395
396        assert!(prod.estimated_security_bits(128, flat(GRID_COLS)) >= MIN_PRODUCTION_BITS);
397        assert!(prod.check_security(128, flat(GRID_COLS)).is_ok());
398    }
399
400    #[test]
401    fn dev_is_lenient_on_weak_params() {
402        let dev = Config::dev();
403
404        assert!(dev.estimated_security_bits(128, flat(GRID_COLS)) < MIN_PRODUCTION_BITS);
405        assert!(dev.check_security(128, flat(GRID_COLS)).is_ok());
406    }
407
408    #[test]
409    fn prod_threshold_rejects_weak_queries() {
410        let weak = Config {
411            num_queries: 4,
412            ..Config::prod()
413        };
414
415        assert!(weak.check_security(128, flat(GRID_COLS)).is_err());
416    }
417
418    #[test]
419    fn full_half_fallback_admits_ml_dsa_grid() {
420        assert!(Config::prod().check_security(128, flat(512)).is_ok());
421    }
422
423    #[test]
424    fn grid_below_num_queries_rejected() {
425        assert!(Config::prod().check_security(128, flat(128)).is_err());
426    }
427
428    #[test]
429    fn ldt_bits_matches_float_within_one_bit() {
430        let cfg = Config::prod();
431
432        for log_g in 8usize..=20 {
433            let grid_cols = 1usize << log_g;
434            let g = cfg.table_geom(grid_cols);
435            let msg: usize = g.support_size + grid_cols;
436
437            if msg >= g.encoded_width {
438                continue;
439            }
440
441            let rho = msg as f64 / g.encoded_width as f64;
442            let per_query = (1.0 + rho) / 2.0;
443            let reference = (-per_query.powf(cfg.num_queries as f64).log2()).floor();
444            let integer = cfg.ldt_bits(msg, g.encoded_width) as f64;
445
446            assert!(
447                (integer - reference).abs() <= 1.0,
448                "grid 2^{log_g}: integer {integer} vs float {reference}",
449            );
450        }
451    }
452
453    #[test]
454    fn table_geom_selects_integer_stable_modes() {
455        let prod = Config::prod();
456
457        let big = prod.table_geom(1 << 12);
458        assert_eq!(big.support_size, prod.ldt_support_size);
459        assert_eq!(big.encoded_width, INV_RATE << 12);
460
461        let small = prod.table_geom(512);
462        assert_eq!(small.support_size, 512);
463        assert_eq!(small.encoded_width, INV_RATE * 512 * 2);
464    }
465
466    #[test]
467    fn prod_queries_sit_on_fractional_mode_edge() {
468        let at = |t: usize| Config {
469            num_queries: t,
470            ldt_support_size: t + 1,
471            ..Config::prod()
472        };
473
474        let binding = 1usize << 11;
475
476        assert_eq!(Config::prod().num_queries, 287);
477        assert_eq!(at(287).encoded_width(binding), binding * INV_RATE);
478        assert_eq!(at(286).encoded_width(binding), binding * INV_RATE * 2);
479    }
480
481    #[test]
482    fn grid_1024_never_clears_floor_fractionally() {
483        let best = (200..=1200)
484            .map(|t| {
485                let cfg = Config {
486                    num_queries: t,
487                    ldt_support_size: t,
488                    ..Config::prod()
489                };
490
491                cfg.ldt_bits(t + 1024, 2048)
492            })
493            .max()
494            .unwrap();
495
496        assert!(best < MIN_PRODUCTION_BITS, "peaked at {best}");
497    }
498
499    #[test]
500    fn proximity_gap_charges_tensor_and_curve_factors() {
501        let cfg = Config::prod();
502        let grid_cols = 1usize << 14;
503
504        let width_only = cfg.proximity_gap_bits(128, flat(grid_cols));
505
506        let charged = cfg.proximity_gap_bits(
507            128,
508            FoldShape {
509                grid_cols,
510                grid_rows: 1 << 7,
511                units: 80,
512            },
513        );
514
515        assert_eq!(cfg.encoded_width(grid_cols), 1 << 15);
516        assert_eq!(width_only, 128 - 15);
517        assert_eq!(
518            width_only - charged,
519            (7 + 79usize).next_power_of_two().ilog2() as usize
520        );
521    }
522
523    #[test]
524    fn logup_gamma_sits_on_production_floor_edge() {
525        let prod = Config::prod();
526        let edge = 1u64 << (128 - MIN_PRODUCTION_BITS);
527
528        assert_eq!(prod.logup_gamma_bits(128, edge), MIN_PRODUCTION_BITS);
529        assert_eq!(
530            prod.logup_gamma_bits(128, edge + 1),
531            MIN_PRODUCTION_BITS - 1
532        );
533
534        assert!(prod.check_logup_security(128, edge).is_ok());
535        assert!(prod.check_logup_security(128, edge + 1).is_err());
536    }
537
538    #[test]
539    fn logup_gamma_clears_for_busless_and_dev_proofs() {
540        let prod = Config::prod();
541
542        assert_eq!(prod.logup_gamma_bits(128, 0), 128);
543        assert_eq!(prod.logup_gamma_bits(128, 1), 128);
544        assert!(prod.check_logup_security(128, 0).is_ok());
545
546        let dev = Config::dev();
547
548        assert_eq!(dev.min_security_bits, 0);
549        assert!(dev.check_logup_security(128, 1 << 60).is_ok());
550    }
551
552    #[test]
553    fn support_below_queries_rejected() {
554        let config = Config {
555            ldt_support_size: 32,
556            ..Config::prod()
557        };
558
559        assert!(matches!(
560            config.check_security(128, flat(1 << 12)),
561            Err(errors::Error::Config(Error::InsufficientSupport { .. }))
562        ));
563    }
564
565    #[test]
566    fn support_equal_to_queries_rejected() {
567        let config = Config {
568            ldt_support_size: Config::prod().num_queries,
569            ..Config::prod()
570        };
571
572        assert!(matches!(
573            config.check_security(128, flat(1 << 12)),
574            Err(errors::Error::Config(Error::InsufficientSupport { .. }))
575        ));
576    }
577
578    #[test]
579    fn support_one_above_queries_accepted() {
580        let prod = Config::prod();
581
582        assert_eq!(prod.ldt_support_size, prod.num_queries + 1);
583        assert!(prod.check_security(128, flat(1 << 12)).is_ok());
584    }
585
586    #[test]
587    fn support_below_queries_waived_in_dev() {
588        let config = Config {
589            ldt_support_size: 32,
590            min_security_bits: 0,
591            ..Config::prod()
592        };
593
594        assert!(config.check_security(128, flat(1 << 12)).is_ok());
595    }
596}