Skip to main content

tacet/
config.rs

1//! Configuration for adaptive Bayesian timing analysis.
2//!
3//! See spec Section 4.2 (Configuration) for the full specification.
4
5use std::time::Duration;
6
7use crate::constants::{
8    DEFAULT_BATCH_SIZE, DEFAULT_BOOTSTRAP_ITERATIONS, DEFAULT_CALIBRATION_SAMPLES,
9    DEFAULT_FAIL_THRESHOLD, DEFAULT_MAX_SAMPLES, DEFAULT_PASS_THRESHOLD, DEFAULT_TIME_BUDGET_SECS,
10};
11use crate::types::AttackerModel;
12
13/// Configuration options for `TimingOracle`.
14///
15/// The adaptive Bayesian oracle uses these settings to control the
16/// analysis behavior, thresholds, and resource limits.
17///
18/// See spec Section 4.2 (Configuration).
19#[derive(Debug, Clone)]
20pub struct Config {
21    // =========================================================================
22    // Decision thresholds (new for adaptive Bayesian)
23    // =========================================================================
24    /// Threshold for declaring "Pass" (no leak detected).
25    ///
26    /// If the posterior probability of a timing leak falls below this threshold,
27    /// the test passes. Default: 0.05 (5%).
28    ///
29    /// Lower values require more confidence to pass (more conservative).
30    pub pass_threshold: f64,
31
32    /// Threshold for declaring "Fail" (leak detected).
33    ///
34    /// If the posterior probability of a timing leak exceeds this threshold,
35    /// the test fails. Default: 0.95 (95%).
36    ///
37    /// Higher values require more confidence to fail (more conservative).
38    pub fail_threshold: f64,
39
40    // =========================================================================
41    // Resource limits
42    // =========================================================================
43    /// Maximum time budget for the analysis.
44    ///
45    /// The oracle will stop collecting samples and return Inconclusive if this
46    /// time limit is reached. Default: 60 seconds.
47    pub time_budget: Duration,
48
49    /// Maximum number of samples to collect per class.
50    ///
51    /// The oracle will stop and return Inconclusive if this limit is reached
52    /// without achieving a conclusive result. Default: 1,000,000.
53    pub max_samples: usize,
54
55    /// Number of samples to collect per batch during adaptive sampling.
56    ///
57    /// Larger batches are more efficient but less responsive to early stopping.
58    /// Default: 1,000.
59    pub batch_size: usize,
60
61    /// Number of samples for initial calibration (covariance estimation).
62    ///
63    /// This fixed number of samples is collected before the adaptive phase
64    /// begins. Used to estimate the noise covariance matrix. Default: 5,000.
65    ///
66    /// Note: This is a fixed overhead, not prominently configurable.
67    pub calibration_samples: usize,
68
69    // =========================================================================
70    // Effect thresholds (attacker model)
71    // =========================================================================
72    /// Minimum effect size we care about in nanoseconds.
73    ///
74    /// Effects smaller than this won't trigger high posterior probabilities
75    /// even if statistically detectable. This encodes practical relevance.
76    ///
77    /// Note: When `attacker_model` is set, this value may be overridden
78    /// at runtime based on the attacker model's threshold.
79    ///
80    /// Default: 10.0 ns.
81    pub min_effect_of_concern_ns: f64,
82
83    /// Attacker model preset.
84    ///
85    /// When set, the attacker model's threshold is used instead of
86    /// `min_effect_of_concern_ns`. The threshold is computed at runtime
87    /// based on the timer's resolution and CPU frequency.
88    ///
89    /// See [`AttackerModel`] for available presets.
90    ///
91    /// Default: None (uses min_effect_of_concern_ns).
92    pub attacker_model: Option<AttackerModel>,
93
94    /// Optional hard effect threshold in nanoseconds for reporting/panic.
95    ///
96    /// If the detected effect exceeds this threshold, the result is flagged
97    /// prominently. Default: None.
98    pub effect_threshold_ns: Option<f64>,
99
100    // =========================================================================
101    // Measurement configuration
102    // =========================================================================
103    /// Warmup iterations before measurement.
104    ///
105    /// These iterations warm CPU caches, stabilize frequency scaling, and
106    /// trigger any JIT compilation before actual measurement begins.
107    /// Default: 1,000.
108    pub warmup: usize,
109
110    /// Percentile for outlier winsorization.
111    ///
112    /// Samples beyond this percentile are capped (not dropped) to reduce
113    /// the impact of extreme outliers while preserving information about
114    /// tail-heavy distributions. Set to 1.0 to disable.
115    ///
116    /// Default: 0.9999 (99.99th percentile).
117    pub outlier_percentile: f64,
118
119    /// Iterations per timing sample.
120    ///
121    /// When set to `Auto`, the library detects timer resolution and
122    /// automatically batches iterations when needed for coarse timers.
123    /// Set to a specific value to override auto-detection.
124    ///
125    /// Default: Auto.
126    pub iterations_per_sample: IterationsPerSample,
127
128    /// Pin the measurement thread to its current CPU core.
129    ///
130    /// Reduces timing noise from thread migration between cores, which can
131    /// cause cache invalidation and expose different core frequencies.
132    /// Enabled by default.
133    ///
134    /// - **Linux**: Enforced via `sched_setaffinity` (no privileges needed)
135    /// - **macOS**: Advisory hint via `thread_policy_set` (kernel may ignore)
136    ///
137    /// Set to `false` if CPU pinning causes issues on your system.
138    ///
139    /// Default: true.
140    pub cpu_affinity: bool,
141
142    /// Elevate thread priority during measurement.
143    ///
144    /// Attempts to reduce preemption by other processes by raising the
145    /// measurement thread's priority. This is best-effort and fails silently
146    /// if privileges are insufficient.
147    ///
148    /// - **Linux**: Lowers nice value and sets `SCHED_BATCH` policy
149    /// - **macOS**: Lowers nice value and sets thread precedence hint
150    ///
151    /// Set to `false` if priority elevation causes issues on your system.
152    ///
153    /// Default: true.
154    pub thread_priority: bool,
155
156    /// Duration of frequency stabilization spin-wait in milliseconds.
157    ///
158    /// Before measurement begins, a brief busy-wait loop runs to let the CPU
159    /// frequency ramp up and stabilize. Many CPUs start in low-power mode and
160    /// take several milliseconds to reach their turbo/boost frequency.
161    ///
162    /// Set to `0` to disable frequency stabilization.
163    ///
164    /// Default: 5 ms.
165    pub frequency_stabilization_ms: u64,
166
167    // =========================================================================
168    // Bayesian inference configuration
169    // =========================================================================
170    /// Prior probability of no leak.
171    ///
172    /// This is the prior belief that the code under test is constant-time.
173    /// Higher values make the test more conservative (harder to fail).
174    ///
175    /// Default: 0.75 (75% prior belief in no leak).
176    pub prior_no_leak: f64,
177
178    /// Bootstrap iterations for covariance estimation.
179    ///
180    /// Used during the calibration phase to estimate the noise covariance
181    /// matrix via block bootstrap. More iterations give better estimates
182    /// but take longer.
183    ///
184    /// Default: 2,000.
185    pub cov_bootstrap_iterations: usize,
186
187    // =========================================================================
188    // Sample splitting
189    // =========================================================================
190    /// Fraction of samples held out for calibration/preflight.
191    ///
192    /// In non-adaptive mode, this fraction of samples is used for covariance
193    /// estimation. In adaptive mode, this is less relevant since calibration
194    /// is a fixed upfront cost.
195    ///
196    /// Default: 0.3 (30% for calibration).
197    pub calibration_fraction: f32,
198
199    // =========================================================================
200    // Optional limits and debugging
201    // =========================================================================
202    /// Optional guardrail for max duration in milliseconds (legacy).
203    ///
204    /// Prefer using `time_budget` instead. This is kept for backwards
205    /// compatibility but will be removed in a future version.
206    #[deprecated(since = "0.2.0", note = "Use time_budget instead")]
207    pub max_duration_ms: Option<u64>,
208
209    /// Optional deterministic seed for measurement randomness.
210    ///
211    /// When set, the measurement order (interleaving of classes) is
212    /// deterministic, which can help with debugging and reproducibility.
213    ///
214    /// Default: None (random seed).
215    pub measurement_seed: Option<u64>,
216
217    /// Force discrete mode for testing.
218    ///
219    /// When true, discrete mode (m-out-of-n bootstrap with mid-quantiles)
220    /// is used regardless of timer resolution. This is primarily for
221    /// testing the discrete mode code path on machines with high-resolution timers.
222    ///
223    /// In production, discrete mode is triggered automatically when the
224    /// minimum uniqueness ratio < 10% (per spec Section 2.4).
225    ///
226    /// Default: false.
227    pub force_discrete_mode: bool,
228}
229
230impl Default for Config {
231    fn default() -> Self {
232        #[allow(deprecated)]
233        Self {
234            // Decision thresholds
235            pass_threshold: DEFAULT_PASS_THRESHOLD,
236            fail_threshold: DEFAULT_FAIL_THRESHOLD,
237
238            // Resource limits
239            time_budget: Duration::from_secs(DEFAULT_TIME_BUDGET_SECS),
240            max_samples: DEFAULT_MAX_SAMPLES,
241            batch_size: DEFAULT_BATCH_SIZE,
242            calibration_samples: DEFAULT_CALIBRATION_SAMPLES,
243
244            // Effect thresholds
245            min_effect_of_concern_ns: 10.0,
246            attacker_model: None,
247            effect_threshold_ns: None,
248
249            // Measurement configuration
250            warmup: 1_000,
251            outlier_percentile: 0.9999,
252            iterations_per_sample: IterationsPerSample::Auto,
253            cpu_affinity: true,
254            thread_priority: true,
255            frequency_stabilization_ms: 5,
256
257            // Bayesian inference
258            prior_no_leak: 0.75,
259            cov_bootstrap_iterations: DEFAULT_BOOTSTRAP_ITERATIONS,
260
261            // Sample splitting
262            calibration_fraction: 0.3,
263
264            // Optional limits
265            max_duration_ms: None,
266            measurement_seed: None,
267            force_discrete_mode: false,
268        }
269    }
270}
271
272impl Config {
273    /// Create a new configuration with default settings.
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    // =========================================================================
279    // Builder methods
280    // =========================================================================
281
282    /// Set the pass threshold.
283    pub fn pass_threshold(mut self, threshold: f64) -> Self {
284        assert!(
285            threshold > 0.0 && threshold < 1.0,
286            "pass_threshold must be in (0, 1)"
287        );
288        assert!(
289            threshold < self.fail_threshold,
290            "pass_threshold must be < fail_threshold"
291        );
292        self.pass_threshold = threshold;
293        self
294    }
295
296    /// Set the fail threshold.
297    pub fn fail_threshold(mut self, threshold: f64) -> Self {
298        assert!(
299            threshold > 0.0 && threshold < 1.0,
300            "fail_threshold must be in (0, 1)"
301        );
302        assert!(
303            threshold > self.pass_threshold,
304            "fail_threshold must be > pass_threshold"
305        );
306        self.fail_threshold = threshold;
307        self
308    }
309
310    /// Set the time budget.
311    pub fn time_budget(mut self, budget: Duration) -> Self {
312        self.time_budget = budget;
313        self
314    }
315
316    /// Set the time budget in seconds.
317    pub fn time_budget_secs(mut self, secs: u64) -> Self {
318        self.time_budget = Duration::from_secs(secs);
319        self
320    }
321
322    /// Set the maximum number of samples.
323    pub fn max_samples(mut self, max: usize) -> Self {
324        assert!(max > 0, "max_samples must be positive");
325        self.max_samples = max;
326        self
327    }
328
329    /// Set the batch size for adaptive sampling.
330    pub fn batch_size(mut self, size: usize) -> Self {
331        assert!(size > 0, "batch_size must be positive");
332        self.batch_size = size;
333        self
334    }
335
336    /// Set the number of calibration samples.
337    pub fn calibration_samples(mut self, samples: usize) -> Self {
338        assert!(samples > 0, "calibration_samples must be positive");
339        self.calibration_samples = samples;
340        self
341    }
342
343    /// Set the attacker model.
344    pub fn attacker_model(mut self, model: AttackerModel) -> Self {
345        self.attacker_model = Some(model);
346        self
347    }
348
349    /// Set the warmup iterations.
350    pub fn warmup(mut self, iterations: usize) -> Self {
351        self.warmup = iterations;
352        self
353    }
354
355    /// Set the outlier percentile.
356    pub fn outlier_percentile(mut self, percentile: f64) -> Self {
357        assert!(
358            percentile > 0.0 && percentile <= 1.0,
359            "outlier_percentile must be in (0, 1]"
360        );
361        self.outlier_percentile = percentile;
362        self
363    }
364
365    /// Set the iterations per sample.
366    pub fn iterations_per_sample(mut self, iterations: IterationsPerSample) -> Self {
367        self.iterations_per_sample = iterations;
368        self
369    }
370
371    /// Enable or disable CPU affinity pinning.
372    ///
373    /// When enabled (default), the measurement thread is pinned to its
374    /// current CPU to reduce noise from thread migration.
375    ///
376    /// - **Linux**: Enforced via `sched_setaffinity`
377    /// - **macOS**: Advisory hint via `thread_policy_set`
378    pub fn cpu_affinity(mut self, enabled: bool) -> Self {
379        self.cpu_affinity = enabled;
380        self
381    }
382
383    /// Enable or disable thread priority elevation.
384    ///
385    /// When enabled (default), attempts to raise thread priority to reduce
386    /// preemption during measurement. Fails silently if insufficient privileges.
387    ///
388    /// - **Linux**: Lowers nice value, sets `SCHED_BATCH`
389    /// - **macOS**: Lowers nice value, sets thread precedence hint
390    pub fn thread_priority(mut self, enabled: bool) -> Self {
391        self.thread_priority = enabled;
392        self
393    }
394
395    /// Set the frequency stabilization duration in milliseconds.
396    ///
397    /// A brief spin-wait loop runs before measurement to let the CPU
398    /// frequency ramp up and stabilize. Set to 0 to disable.
399    ///
400    /// Default: 5 ms.
401    pub fn frequency_stabilization_ms(mut self, ms: u64) -> Self {
402        self.frequency_stabilization_ms = ms;
403        self
404    }
405
406    /// Set the prior probability of no leak.
407    pub fn prior_no_leak(mut self, prior: f64) -> Self {
408        assert!(
409            prior > 0.0 && prior < 1.0,
410            "prior_no_leak must be in (0, 1)"
411        );
412        self.prior_no_leak = prior;
413        self
414    }
415
416    /// Set the covariance bootstrap iterations.
417    pub fn cov_bootstrap_iterations(mut self, iterations: usize) -> Self {
418        assert!(iterations > 0, "cov_bootstrap_iterations must be positive");
419        self.cov_bootstrap_iterations = iterations;
420        self
421    }
422
423    /// Set the calibration fraction.
424    pub fn calibration_fraction(mut self, fraction: f32) -> Self {
425        assert!(
426            fraction > 0.0 && fraction < 1.0,
427            "calibration_fraction must be in (0, 1)"
428        );
429        self.calibration_fraction = fraction;
430        self
431    }
432
433    /// Set a deterministic seed for measurement.
434    pub fn seed(mut self, seed: u64) -> Self {
435        self.measurement_seed = Some(seed);
436        self
437    }
438
439    /// Force discrete mode for testing.
440    pub fn force_discrete_mode(mut self, force: bool) -> Self {
441        self.force_discrete_mode = force;
442        self
443    }
444
445    // =========================================================================
446    // Resolution methods
447    // =========================================================================
448
449    /// Resolve the minimum effect of concern in nanoseconds.
450    ///
451    /// If an attacker model is set, returns its threshold in nanoseconds.
452    /// Otherwise, returns the manually configured `min_effect_of_concern_ns`.
453    ///
454    /// # Arguments
455    ///
456    /// * `_cpu_freq_ghz` - Deprecated, kept for API compatibility
457    /// * `_timer_resolution_ns` - Deprecated, kept for API compatibility
458    ///
459    /// # Returns
460    ///
461    /// The resolved threshold in nanoseconds.
462    pub fn resolve_min_effect_ns(
463        &self,
464        _cpu_freq_ghz: Option<f64>,
465        _timer_resolution_ns: Option<f64>,
466    ) -> f64 {
467        if let Some(model) = &self.attacker_model {
468            model.to_threshold_ns()
469        } else {
470            self.min_effect_of_concern_ns
471        }
472    }
473
474    /// Check if the configuration is valid.
475    ///
476    /// Returns an error message if the configuration is invalid.
477    pub fn validate(&self) -> Result<(), String> {
478        if self.pass_threshold <= 0.0 || self.pass_threshold >= 1.0 {
479            return Err("pass_threshold must be in (0, 1)".to_string());
480        }
481        if self.fail_threshold <= 0.0 || self.fail_threshold >= 1.0 {
482            return Err("fail_threshold must be in (0, 1)".to_string());
483        }
484        if self.pass_threshold >= self.fail_threshold {
485            return Err("pass_threshold must be < fail_threshold".to_string());
486        }
487        if self.max_samples == 0 {
488            return Err("max_samples must be positive".to_string());
489        }
490        if self.batch_size == 0 {
491            return Err("batch_size must be positive".to_string());
492        }
493        if self.calibration_samples == 0 {
494            return Err("calibration_samples must be positive".to_string());
495        }
496        Ok(())
497    }
498}
499
500/// Configuration for iterations per timing sample.
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
502pub enum IterationsPerSample {
503    /// Automatically detect based on timer resolution.
504    ///
505    /// On ARM64 with coarse timers (~40ns on Apple Silicon, Ampere Altra),
506    /// this will batch multiple iterations per sample for reliable timing.
507    /// On x86 or ARMv8.6+ (~1ns resolution), this typically uses 1 iteration.
508    #[default]
509    Auto,
510
511    /// Use exactly N iterations per sample.
512    ///
513    /// The measured time will be divided by N to get per-iteration timing.
514    Fixed(usize),
515}
516
517impl IterationsPerSample {
518    /// Resolve the iterations count for a given timer.
519    ///
520    /// For `Auto`, uses the timer's resolution to suggest iterations.
521    /// For `Fixed(n)`, returns `n`.
522    pub fn resolve(&self, timer: &crate::measurement::Timer) -> usize {
523        match self {
524            Self::Auto => {
525                // Target 10ns effective resolution for statistical reliability
526                timer.suggested_iterations(10.0)
527            }
528            Self::Fixed(n) => *n,
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_default_config() {
539        let config = Config::default();
540        assert_eq!(config.pass_threshold, 0.05);
541        assert_eq!(config.fail_threshold, 0.95);
542        assert_eq!(config.time_budget, Duration::from_secs(60));
543        assert_eq!(config.max_samples, 1_000_000);
544        assert_eq!(config.batch_size, 1_000);
545        assert_eq!(config.calibration_samples, 5_000);
546    }
547
548    #[test]
549    fn test_builder_methods() {
550        let config = Config::new()
551            .pass_threshold(0.01)
552            .fail_threshold(0.99)
553            .time_budget_secs(120)
554            .max_samples(500_000)
555            .batch_size(2_000);
556
557        assert_eq!(config.pass_threshold, 0.01);
558        assert_eq!(config.fail_threshold, 0.99);
559        assert_eq!(config.time_budget, Duration::from_secs(120));
560        assert_eq!(config.max_samples, 500_000);
561        assert_eq!(config.batch_size, 2_000);
562    }
563
564    #[test]
565    fn test_validation() {
566        let valid = Config::default();
567        assert!(valid.validate().is_ok());
568
569        let invalid = Config {
570            pass_threshold: 0.0,
571            ..Default::default()
572        };
573        assert!(invalid.validate().is_err());
574
575        let invalid = Config {
576            pass_threshold: 0.99,
577            fail_threshold: 0.01,
578            ..Default::default()
579        };
580        assert!(invalid.validate().is_err());
581    }
582
583    #[test]
584    #[should_panic]
585    fn test_invalid_pass_threshold() {
586        Config::new().pass_threshold(1.5);
587    }
588
589    #[test]
590    #[should_panic]
591    fn test_invalid_threshold_order() {
592        Config::new().pass_threshold(0.5).fail_threshold(0.4);
593    }
594}