Skip to main content

openentropy_core/
benchmark.rs

1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use serde::{Deserialize, Serialize};
5
6use crate::conditioning::{ConditioningMode, quick_min_entropy, quick_shannon};
7use crate::grade_min_entropy;
8use crate::pool::{EntropyPool, SourceHealth};
9
10#[derive(Serialize, Deserialize, Clone, Debug)]
11pub struct BenchConfig {
12    pub samples_per_round: usize,
13    pub rounds: usize,
14    pub warmup_rounds: usize,
15    pub timeout_sec: f64,
16    pub rank_by: RankBy,
17    pub include_pool_quality: bool,
18    pub pool_quality_bytes: usize,
19    #[serde(with = "serde_conditioning_mode")]
20    pub conditioning: ConditioningMode,
21}
22
23impl Default for BenchConfig {
24    fn default() -> Self {
25        Self {
26            samples_per_round: 2048,
27            rounds: 3,
28            warmup_rounds: 1,
29            timeout_sec: 2.0,
30            rank_by: RankBy::Balanced,
31            include_pool_quality: true,
32            pool_quality_bytes: 65_536,
33            conditioning: ConditioningMode::Sha256,
34        }
35    }
36}
37
38#[derive(Serialize, Deserialize, Clone, Debug)]
39pub struct BenchReport {
40    pub generated_unix: u64,
41    pub config: BenchConfig,
42    pub sources: Vec<BenchSourceReport>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub pool: Option<PoolQualityReport>,
45}
46
47#[derive(Serialize, Deserialize, Clone, Debug)]
48pub struct BenchSourceReport {
49    pub name: String,
50    pub composite: bool,
51    pub healthy: bool,
52    pub success_rounds: usize,
53    pub failures: u64,
54    pub avg_shannon: f64,
55    pub avg_min_entropy: f64,
56    pub avg_throughput_bps: f64,
57    pub avg_autocorrelation: f64,
58    pub p99_latency_ms: f64,
59    pub stability: f64,
60    pub grade: char,
61    pub score: f64,
62}
63
64#[derive(Serialize, Deserialize, Clone, Debug)]
65pub struct PoolQualityReport {
66    pub bytes: usize,
67    pub shannon_entropy: f64,
68    pub min_entropy: f64,
69    pub healthy_sources: usize,
70    pub total_sources: usize,
71}
72
73#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum RankBy {
76    Balanced,
77    MinEntropy,
78    Throughput,
79}
80
81#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
82pub enum BenchError {
83    InvalidConfig(String),
84}
85
86impl std::fmt::Display for BenchError {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::InvalidConfig(msg) => write!(f, "invalid benchmark config: {msg}"),
90        }
91    }
92}
93
94impl std::error::Error for BenchError {}
95
96#[derive(Default)]
97struct SourceAccumulator {
98    success_rounds: usize,
99    failures: u64,
100    shannon_sum: f64,
101    min_entropy_sum: f64,
102    throughput_sum: f64,
103    autocorrelation_sum: f64,
104    min_entropy_values: Vec<f64>,
105    collection_times_ms: Vec<f64>,
106}
107
108#[derive(Clone)]
109struct BenchRow {
110    name: String,
111    composite: bool,
112    success_rounds: usize,
113    failures: u64,
114    avg_shannon: f64,
115    avg_min_entropy: f64,
116    avg_throughput_bps: f64,
117    avg_autocorrelation: f64,
118    p99_latency_ms: f64,
119    stability: f64,
120    score: f64,
121}
122
123pub fn benchmark_sources(
124    pool: &EntropyPool,
125    config: &BenchConfig,
126) -> Result<BenchReport, BenchError> {
127    validate_config(config)?;
128
129    let infos = pool.source_infos();
130
131    for _ in 0..config.warmup_rounds {
132        let _ = pool.collect_all_parallel_n(config.timeout_sec, config.samples_per_round);
133    }
134
135    let mut prev = snapshot_counters(&pool.health_report().sources);
136    let mut accum: HashMap<String, SourceAccumulator> = HashMap::new();
137
138    for _ in 0..config.rounds {
139        let _ = pool.collect_all_parallel_n(config.timeout_sec, config.samples_per_round);
140        let health = pool.health_report();
141
142        for src in &health.sources {
143            let (prev_bytes, prev_failures) = prev
144                .get(&src.name)
145                .copied()
146                .unwrap_or((src.bytes, src.failures));
147            let bytes_delta = src.bytes.saturating_sub(prev_bytes);
148            let failures_delta = src.failures.saturating_sub(prev_failures);
149
150            let entry = accum.entry(src.name.clone()).or_default();
151            entry.failures += failures_delta;
152
153            if bytes_delta > 0 {
154                entry.success_rounds += 1;
155                entry.shannon_sum += src.entropy;
156                entry.min_entropy_sum += src.min_entropy;
157                entry.autocorrelation_sum += src.autocorrelation;
158                entry.min_entropy_values.push(src.min_entropy);
159                entry.collection_times_ms.push(src.time * 1000.0);
160                if src.time > 0.0 {
161                    entry.throughput_sum += bytes_delta as f64 / src.time;
162                }
163            }
164
165            prev.insert(src.name.clone(), (src.bytes, src.failures));
166        }
167    }
168
169    let mut rows: Vec<BenchRow> = infos
170        .iter()
171        .map(|info| {
172            let (
173                success_rounds,
174                failures,
175                avg_shannon,
176                avg_min_entropy,
177                avg_throughput_bps,
178                avg_autocorrelation,
179                p99_latency_ms,
180                stability,
181            ) = if let Some(src_acc) = accum.get(&info.name) {
182                let success_rounds = src_acc.success_rounds;
183                if success_rounds > 0 {
184                    let n = success_rounds as f64;
185                    (
186                        success_rounds,
187                        src_acc.failures,
188                        src_acc.shannon_sum / n,
189                        src_acc.min_entropy_sum / n,
190                        src_acc.throughput_sum / n,
191                        src_acc.autocorrelation_sum / n,
192                        percentile(&src_acc.collection_times_ms, 99.0),
193                        stability_index(&src_acc.min_entropy_values),
194                    )
195                } else {
196                    (0, src_acc.failures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
197                }
198            } else {
199                (0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
200            };
201
202            BenchRow {
203                name: info.name.clone(),
204                composite: info.composite,
205                success_rounds,
206                failures,
207                avg_shannon,
208                avg_min_entropy,
209                avg_throughput_bps,
210                avg_autocorrelation,
211                p99_latency_ms,
212                stability,
213                score: 0.0,
214            }
215        })
216        .collect();
217
218    let max_throughput = rows
219        .iter()
220        .map(|r| r.avg_throughput_bps)
221        .fold(0.0_f64, f64::max);
222
223    for row in &mut rows {
224        row.score = score_row(config.rank_by, row, max_throughput, config.rounds);
225    }
226
227    rows.sort_by(|a, b| {
228        b.score
229            .partial_cmp(&a.score)
230            .unwrap_or(std::cmp::Ordering::Equal)
231    });
232
233    let sources = rows
234        .iter()
235        .map(|row| BenchSourceReport {
236            name: row.name.clone(),
237            composite: row.composite,
238            healthy: row.avg_min_entropy > 1.0 && row.failures == 0,
239            success_rounds: row.success_rounds,
240            failures: row.failures,
241            avg_shannon: row.avg_shannon,
242            avg_min_entropy: row.avg_min_entropy,
243            avg_throughput_bps: row.avg_throughput_bps,
244            avg_autocorrelation: row.avg_autocorrelation,
245            p99_latency_ms: row.p99_latency_ms,
246            stability: row.stability,
247            grade: grade_min_entropy(row.avg_min_entropy.max(0.0)),
248            score: row.score,
249        })
250        .collect();
251
252    let pool = if config.include_pool_quality {
253        let output = pool.get_bytes(config.pool_quality_bytes, config.conditioning);
254        let health = pool.health_report();
255        Some(PoolQualityReport {
256            bytes: output.len(),
257            shannon_entropy: quick_shannon(&output),
258            min_entropy: quick_min_entropy(&output),
259            healthy_sources: health.healthy,
260            total_sources: health.total,
261        })
262    } else {
263        None
264    };
265
266    Ok(BenchReport {
267        generated_unix: unix_timestamp_now(),
268        config: config.clone(),
269        sources,
270        pool,
271    })
272}
273
274fn score_row(rank_by: RankBy, row: &BenchRow, max_throughput: f64, rounds: usize) -> f64 {
275    let mut score = match rank_by {
276        RankBy::MinEntropy => row.avg_min_entropy,
277        RankBy::Throughput => row.avg_throughput_bps,
278        RankBy::Balanced => {
279            let min_h_term = (row.avg_min_entropy / 8.0).clamp(0.0, 1.0);
280            let throughput_term = if max_throughput > 0.0 {
281                (row.avg_throughput_bps / max_throughput).clamp(0.0, 1.0)
282            } else {
283                0.0
284            };
285            0.7 * min_h_term + 0.2 * throughput_term + 0.1 * row.stability
286        }
287    };
288
289    let missed = rounds.saturating_sub(row.success_rounds) as f64;
290    let total_issues = missed + row.failures as f64;
291    let expected = rounds as f64;
292    if total_issues > 0.0 && expected > 0.0 {
293        let failure_rate = (total_issues / expected).clamp(0.0, 1.0);
294        score *= 1.0 - 0.5 * failure_rate;
295    }
296
297    score
298}
299
300fn validate_config(config: &BenchConfig) -> Result<(), BenchError> {
301    if config.samples_per_round == 0 {
302        return Err(BenchError::InvalidConfig(
303            "samples_per_round must be > 0".to_string(),
304        ));
305    }
306    if config.rounds == 0 {
307        return Err(BenchError::InvalidConfig("rounds must be > 0".to_string()));
308    }
309    if config.timeout_sec <= 0.0 {
310        return Err(BenchError::InvalidConfig(
311            "timeout_sec must be > 0".to_string(),
312        ));
313    }
314    if config.include_pool_quality && config.pool_quality_bytes == 0 {
315        return Err(BenchError::InvalidConfig(
316            "pool_quality_bytes must be > 0 when include_pool_quality is true".to_string(),
317        ));
318    }
319    Ok(())
320}
321
322fn snapshot_counters(sources: &[SourceHealth]) -> HashMap<String, (u64, u64)> {
323    sources
324        .iter()
325        .map(|s| (s.name.clone(), (s.bytes, s.failures)))
326        .collect()
327}
328
329fn unix_timestamp_now() -> u64 {
330    SystemTime::now()
331        .duration_since(UNIX_EPOCH)
332        .unwrap_or_default()
333        .as_secs()
334}
335
336fn percentile(values: &[f64], p: f64) -> f64 {
337    if values.is_empty() {
338        return 0.0;
339    }
340    let mut sorted: Vec<f64> = values.to_vec();
341    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
342    let rank = ((p / 100.0 * sorted.len() as f64).ceil() as usize).max(1);
343    sorted[rank.min(sorted.len()) - 1]
344}
345
346fn stability_index(values: &[f64]) -> f64 {
347    if values.is_empty() {
348        return 0.0;
349    }
350    if values.len() == 1 {
351        return 1.0;
352    }
353    let mean = values.iter().sum::<f64>() / values.len() as f64;
354    let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
355    let stddev = var.sqrt();
356    if mean.abs() < f64::EPSILON {
357        return if stddev < f64::EPSILON { 1.0 } else { 0.0 };
358    }
359    let cv = (stddev / mean.abs()).min(1.0);
360    (1.0 - cv).clamp(0.0, 1.0)
361}
362
363mod serde_conditioning_mode {
364    use serde::{Deserialize, Deserializer, Serializer};
365
366    use crate::conditioning::ConditioningMode;
367
368    pub fn serialize<S>(mode: &ConditioningMode, serializer: S) -> Result<S::Ok, S::Error>
369    where
370        S: Serializer,
371    {
372        serializer.serialize_str(match mode {
373            ConditioningMode::Raw => "raw",
374            ConditioningMode::VonNeumann => "von_neumann",
375            ConditioningMode::Sha256 => "sha256",
376        })
377    }
378
379    pub fn deserialize<'de, D>(deserializer: D) -> Result<ConditioningMode, D::Error>
380    where
381        D: Deserializer<'de>,
382    {
383        let s = String::deserialize(deserializer)?;
384        match s.as_str() {
385            "raw" => Ok(ConditioningMode::Raw),
386            "vonneumann" | "vn" | "von_neumann" => Ok(ConditioningMode::VonNeumann),
387            "sha" | "sha256" => Ok(ConditioningMode::Sha256),
388            _ => Err(serde::de::Error::custom(format!(
389                "invalid conditioning mode '{s}', expected raw|vonneumann|vn|von_neumann|sha256"
390            ))),
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn approx_eq(a: f64, b: f64, eps: f64) {
400        assert!(
401            (a - b).abs() <= eps,
402            "values differ: left={a}, right={b}, eps={eps}"
403        );
404    }
405
406    #[test]
407    fn benchmark_default_config_matches_quick_profile() {
408        let config = BenchConfig::default();
409        assert_eq!(config.samples_per_round, 2048);
410        assert_eq!(config.rounds, 3);
411        assert_eq!(config.warmup_rounds, 1);
412        approx_eq(config.timeout_sec, 2.0, f64::EPSILON);
413        assert_eq!(config.rank_by, RankBy::Balanced);
414        assert!(config.include_pool_quality);
415        assert_eq!(config.pool_quality_bytes, 65_536);
416        assert_eq!(config.conditioning, ConditioningMode::Sha256);
417    }
418
419    #[test]
420    fn benchmark_percentile_uses_nearest_rank() {
421        let values = [10.0, 50.0, 20.0, 40.0, 30.0];
422        approx_eq(percentile(&values, 99.0), 50.0, f64::EPSILON);
423        approx_eq(percentile(&values, 50.0), 30.0, f64::EPSILON);
424        approx_eq(percentile(&[], 50.0), 0.0, f64::EPSILON);
425    }
426
427    #[test]
428    fn benchmark_stability_index_expected_behavior() {
429        approx_eq(stability_index(&[]), 0.0, f64::EPSILON);
430        approx_eq(stability_index(&[4.2]), 1.0, f64::EPSILON);
431        approx_eq(stability_index(&[2.0, 2.0, 2.0]), 1.0, 1e-12);
432        let unstable = stability_index(&[0.5, 4.0, 7.5]);
433        assert!(unstable < 0.7);
434    }
435
436    #[test]
437    fn benchmark_scoring_math_balanced_with_reliability_penalty() {
438        let row = BenchRow {
439            name: "src".to_string(),
440            composite: false,
441            success_rounds: 8,
442            failures: 1,
443            avg_shannon: 0.0,
444            avg_min_entropy: 4.0,
445            avg_throughput_bps: 50.0,
446            avg_autocorrelation: 0.0,
447            p99_latency_ms: 0.0,
448            stability: 0.8,
449            score: 0.0,
450        };
451        let score = score_row(RankBy::Balanced, &row, 100.0, 10);
452        approx_eq(score, 0.4505, 1e-12);
453    }
454
455    #[test]
456    fn benchmark_scoring_math_min_entropy_and_throughput_modes() {
457        let row = BenchRow {
458            name: "src".to_string(),
459            composite: false,
460            success_rounds: 5,
461            failures: 0,
462            avg_shannon: 0.0,
463            avg_min_entropy: 3.25,
464            avg_throughput_bps: 1234.0,
465            avg_autocorrelation: 0.0,
466            p99_latency_ms: 0.0,
467            stability: 0.0,
468            score: 0.0,
469        };
470        approx_eq(score_row(RankBy::MinEntropy, &row, 5000.0, 5), 3.25, 1e-12);
471        approx_eq(score_row(RankBy::Throughput, &row, 5000.0, 5), 1234.0, 1e-9);
472    }
473
474    #[test]
475    fn benchmark_sources_runs_with_real_auto_pool() {
476        let pool = EntropyPool::auto();
477        let config = BenchConfig {
478            samples_per_round: 64,
479            rounds: 1,
480            warmup_rounds: 0,
481            timeout_sec: 0.5,
482            rank_by: RankBy::Balanced,
483            include_pool_quality: false,
484            pool_quality_bytes: 64,
485            conditioning: ConditioningMode::Sha256,
486        };
487
488        let report = benchmark_sources(&pool, &config).expect("benchmark should succeed");
489        assert_eq!(report.sources.len(), pool.source_infos().len());
490        for source in &report.sources {
491            assert!(source.score.is_finite());
492        }
493    }
494}