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}