Skip to main content

hekate_core/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// This file is part of the hekate project.
3// Copyright (C) 2026 Andrei Kochergin <andrei@oumuamua.dev>
4// Copyright (C) 2026 Oumuamua Labs <info@oumuamua.dev>.
5//
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18use crate::errors;
19use core::fmt;
20
21/// Production soundness floor:
22/// full GF(2^128) security. `security_bits` caps
23/// at the field size, 128 is the strongest attainable.
24pub const MIN_PRODUCTION_BITS: usize = 128;
25
26/// Precision of `log2_ratio_fixed`:
27/// 32 holds the truncation error below
28/// `num_queries · 2⁻³²`, under one bit.
29const LOG2_FRAC_BITS: u32 = 32;
30
31/// Failures produced by `Config::check_security`.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum Error {
34    /// Estimated security fell below `min_security_bits`.
35    SecurityTooLow {
36        estimated_bits: usize,
37        min_bits: usize,
38    },
39
40    /// `ldt_support_size < num_queries`;
41    /// opened columns exhaust the noise
42    /// budget and witness data leaks.
43    InsufficientSupport {
44        ldt_support_size: usize,
45        num_queries: usize,
46    },
47
48    /// `inv_rate` is not a power of two >= 2. The RS row
49    /// code takes its width from `code_width.trailing_zeros()`,
50    /// which collapses to rate-1 for a non-power-of-two rate.
51    InvalidInvRate { inv_rate: usize },
52}
53
54impl fmt::Display for Error {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::SecurityTooLow {
58                estimated_bits,
59                min_bits,
60            } => write!(
61                f,
62                "Security too low: estimated {estimated_bits} bits, but {min_bits} required",
63            ),
64            Self::InsufficientSupport {
65                ldt_support_size,
66                num_queries,
67            } => write!(
68                f,
69                "ldt_support_size ({ldt_support_size}) must be >= num_queries ({num_queries})",
70            ),
71            Self::InvalidInvRate { inv_rate } => {
72                write!(f, "inv_rate ({inv_rate}) must be a power of two >= 2",)
73            }
74        }
75    }
76}
77
78/// Security metrics snapshot for a given `Config`.
79#[derive(Clone, Copy, Debug)]
80pub struct SecurityMetrics {
81    /// Estimated relative distance
82    /// δ of the linear code.
83    pub relative_distance: f64,
84
85    /// LDT spot-check count.
86    pub num_queries: usize,
87
88    /// Soundness error:
89    /// `(1 - δ)^q`.
90    pub soundness_error: f64,
91
92    /// LDT proximity bound:
93    /// `-log₂(soundness_error)`.
94    pub ldt_bits: usize,
95
96    /// `min(ldt_bits, field_bits)`. Schwartz-Zippel
97    /// caps Sumcheck / ZeroCheck / LogUp at field size.
98    pub security_bits: usize,
99}
100
101/// Per-table row-code geometry chosen by `Config::table_geom`.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub struct TableGeom {
104    /// Random low-coord support (LDT opening mask) length.
105    pub support_size: usize,
106
107    /// Committed codeword width (power of two).
108    pub encoded_width: usize,
109}
110
111#[derive(Clone, Debug)]
112pub struct Config {
113    /// Brakedown row-code rate is `1/inv_rate`;
114    /// must be a power of two.
115    pub inv_rate: usize,
116
117    /// Number of LDT spot-check queries.
118    pub num_queries: usize,
119
120    /// Blinding columns for algebraic ZK
121    /// (Sumcheck), extends the 1D trace.
122    pub sumcheck_blinding_factor: usize,
123
124    /// Random low-coord support that masks the LDT
125    /// column openings (data ZK). Lives inside the
126    /// message, it does not widen the codeword.
127    pub ldt_support_size: usize,
128
129    /// `check_security` rejects configs
130    /// whose estimated bits fall below this.
131    pub min_security_bits: usize,
132}
133
134impl Default for Config {
135    fn default() -> Self {
136        Self::prod()
137    }
138}
139
140impl Config {
141    /// Production parameters: ≈128-bit soundness
142    /// with the `MIN_PRODUCTION_BITS` acceptance
143    /// threshold. The `Default`.
144    pub fn prod() -> Self {
145        Self {
146            inv_rate: 2,
147            num_queries: 176,
148            min_security_bits: MIN_PRODUCTION_BITS,
149            sumcheck_blinding_factor: 2,
150            ldt_support_size: 200,
151        }
152    }
153
154    /// Fast, low-soundness parameters for tests
155    /// and experiments; `min_security_bits = 0`
156    /// accepts weak grids. Never deploy.
157    pub fn dev() -> Self {
158        Self {
159            num_queries: 4,
160            min_security_bits: 0,
161            ..Self::prod()
162        }
163    }
164
165    /// Committed row-code width for the chosen per-table mode.
166    pub fn encoded_width(&self, grid_cols: usize) -> usize {
167        self.table_geom(grid_cols).encoded_width
168    }
169
170    /// Mode must derive from transcript-bound inputs and
171    /// a fixed target only, never an unabsorbed field, or
172    /// prover and verifier silently diverge on the geometry.
173    pub fn table_geom(&self, grid_cols: usize) -> TableGeom {
174        let frac = TableGeom {
175            support_size: self.ldt_support_size,
176            encoded_width: grid_cols * self.inv_rate,
177        };
178
179        let frac_msg = frac.support_size + grid_cols;
180
181        if frac.support_size <= grid_cols
182            && self.ldt_bits(frac_msg, frac.encoded_width) >= MIN_PRODUCTION_BITS
183        {
184            return frac;
185        }
186
187        TableGeom {
188            support_size: grid_cols,
189            encoded_width: grid_cols * self.inv_rate * 2,
190        }
191    }
192
193    /// `min(-log₂((1 - δ)^q), field_bits)` where
194    /// δ = relative distance, q = num_queries.
195    ///
196    /// Brakedown (Golovnev et al. 2022), Section 3.2.
197    pub fn estimated_security_bits(&self, field_bits: usize, grid_cols: usize) -> usize {
198        let g = self.table_geom(grid_cols);
199
200        self.ldt_bits(g.support_size + grid_cols, g.encoded_width)
201            .min(field_bits)
202    }
203
204    /// `field_bits`: `size_of::<F>() * 8`.
205    pub fn security_metrics(&self, field_bits: usize, grid_cols: usize) -> SecurityMetrics {
206        let g = self.table_geom(grid_cols);
207        let delta = self.estimate_relative_distance(grid_cols);
208        let bits = self.ldt_bits(g.support_size + grid_cols, g.encoded_width);
209
210        SecurityMetrics {
211            relative_distance: delta,
212            num_queries: self.num_queries,
213            soundness_error: (1.0 - delta).powf(self.num_queries as f64),
214            ldt_bits: bits,
215            security_bits: bits.min(field_bits),
216        }
217    }
218
219    /// Rejects configs whose estimated soundness at
220    /// `grid_cols` falls below `min_security_bits`.
221    pub fn check_security(&self, field_bits: usize, grid_cols: usize) -> errors::Result<()> {
222        if self.inv_rate < 2 || !self.inv_rate.is_power_of_two() {
223            return Err(Error::InvalidInvRate {
224                inv_rate: self.inv_rate,
225            }
226            .into());
227        }
228
229        // dev (min_security_bits == 0) waives the ZK floor
230        let support = self.table_geom(grid_cols).support_size;
231        if self.min_security_bits > 0 && support < self.num_queries {
232            return Err(Error::InsufficientSupport {
233                ldt_support_size: support,
234                num_queries: self.num_queries,
235            }
236            .into());
237        }
238
239        let est_bits = self.estimated_security_bits(field_bits, grid_cols);
240        if est_bits < self.min_security_bits {
241            return Err(Error::SecurityTooLow {
242                estimated_bits: est_bits,
243                min_bits: self.min_security_bits,
244            }
245            .into());
246        }
247
248        Ok(())
249    }
250
251    /// Exact MDS (Singleton) distance of the chosen geometry:
252    /// `δ = (encoded_width − support − grid_cols) / encoded_width`.
253    /// Holds for both modes (full-half yields exactly 0.5).
254    fn estimate_relative_distance(&self, grid_cols: usize) -> f64 {
255        let g = self.table_geom(grid_cols);
256
257        g.encoded_width.saturating_sub(g.support_size + grid_cols) as f64 / g.encoded_width as f64
258    }
259
260    /// `floor(-log₂((msg_len / code_width)^q))` in bits.
261    /// Integer-only, prover and verifier derive identical geometry;
262    /// libm `powf`/`log2` are not bit-reproducible across platforms.
263    fn ldt_bits(&self, msg_len: usize, code_width: usize) -> usize {
264        if msg_len >= code_width {
265            return 0;
266        }
267
268        let log2_ratio = log2_ratio_fixed(code_width as u128, msg_len as u128);
269
270        ((self.num_queries as u128 * log2_ratio) >> LOG2_FRAC_BITS) as usize
271    }
272}
273
274/// `floor(log₂(n / m) · 2^LOG2_FRAC_BITS)` for `n > m >= 1`.
275/// The Q60 mantissa keeps the squaring `y² < 2¹²²`, inside `u128`.
276fn log2_ratio_fixed(n: u128, m: u128) -> u128 {
277    const S: u32 = 60;
278
279    let mut scaled_m = m;
280    let mut int_part: u128 = 0;
281
282    while scaled_m <= n / 2 {
283        scaled_m <<= 1;
284        int_part += 1;
285    }
286
287    // m·2^int_part ∈ (n/2, n],
288    // y = n/(m·2^int_part) ∈ [1, 2) in Q_S.
289    let mut y = (n << S) / scaled_m;
290    let mut frac: u128 = 0;
291
292    for i in 0..LOG2_FRAC_BITS {
293        y = (y * y) >> S;
294
295        if y >= 2u128 << S {
296            y >>= 1;
297            frac |= 1u128 << (LOG2_FRAC_BITS - 1 - i);
298        }
299    }
300
301    (int_part << LOG2_FRAC_BITS) | frac
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    const GRID_COLS: usize = 1024;
309
310    #[test]
311    fn default_is_prod() {
312        assert_eq!(Config::default().min_security_bits, MIN_PRODUCTION_BITS);
313        assert_eq!(Config::default().num_queries, Config::prod().num_queries);
314    }
315
316    #[test]
317    fn prod_meets_production_floor() {
318        let prod = Config::prod();
319
320        assert!(prod.estimated_security_bits(128, GRID_COLS) >= MIN_PRODUCTION_BITS);
321        assert!(prod.check_security(128, GRID_COLS).is_ok());
322    }
323
324    #[test]
325    fn dev_is_lenient_on_weak_params() {
326        let dev = Config::dev();
327
328        assert!(dev.estimated_security_bits(128, GRID_COLS) < MIN_PRODUCTION_BITS);
329        assert!(dev.check_security(128, GRID_COLS).is_ok());
330    }
331
332    #[test]
333    fn prod_threshold_rejects_weak_queries() {
334        let weak = Config {
335            num_queries: 4,
336            ..Config::prod()
337        };
338
339        assert!(weak.check_security(128, GRID_COLS).is_err());
340    }
341
342    #[test]
343    fn full_half_fallback_admits_ml_dsa_grid() {
344        assert!(Config::prod().check_security(128, 512).is_ok());
345    }
346
347    #[test]
348    fn grid_below_num_queries_rejected() {
349        assert!(Config::prod().check_security(128, 128).is_err());
350    }
351
352    #[test]
353    fn rejects_invalid_inv_rate() {
354        for bad in [0usize, 1, 3, 6] {
355            let cfg = Config {
356                inv_rate: bad,
357                ..Config::prod()
358            };
359
360            assert!(
361                cfg.check_security(128, GRID_COLS).is_err(),
362                "inv_rate {bad} must be rejected",
363            );
364        }
365
366        assert!(Config::prod().check_security(128, GRID_COLS).is_ok());
367    }
368
369    #[test]
370    fn ldt_bits_matches_float_within_one_bit() {
371        let cfg = Config::prod();
372
373        for log_g in 8usize..=20 {
374            let grid_cols = 1usize << log_g;
375            let g = cfg.table_geom(grid_cols);
376            let msg: usize = g.support_size + grid_cols;
377
378            if msg >= g.encoded_width {
379                continue;
380            }
381
382            let one_minus_delta = msg as f64 / g.encoded_width as f64;
383            let reference = (-one_minus_delta.powf(cfg.num_queries as f64).log2()).floor();
384            let integer = cfg.ldt_bits(msg, g.encoded_width) as f64;
385
386            assert!(
387                (integer - reference).abs() <= 1.0,
388                "grid 2^{log_g}: integer {integer} vs float {reference}",
389            );
390        }
391    }
392
393    #[test]
394    fn table_geom_selects_integer_stable_modes() {
395        let prod = Config::prod();
396
397        let big = prod.table_geom(1 << 12);
398        assert_eq!(big.support_size, prod.ldt_support_size);
399        assert_eq!(big.encoded_width, prod.inv_rate << 12);
400
401        let small = prod.table_geom(512);
402        assert_eq!(small.support_size, 512);
403        assert_eq!(small.encoded_width, prod.inv_rate * 512 * 2);
404    }
405}