1use serde::{Deserialize, Serialize};
12
13use crate::analysis;
14use crate::conditioning::quick_shannon;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TrialConfig {
23 pub bits_per_trial: usize,
25}
26
27impl Default for TrialConfig {
28 fn default() -> Self {
29 Self {
30 bits_per_trial: 200,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Trial {
38 pub index: usize,
40 pub ones_count: u32,
42 pub z_score: f64,
44 pub cumulative_deviation: f64,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TrialAnalysis {
51 pub config: TrialConfig,
53 pub bytes_consumed: usize,
55 pub num_trials: usize,
57 pub bits_per_trial: usize,
59 #[serde(default)]
61 #[serde(skip_serializing_if = "Vec::is_empty")]
62 pub trials: Vec<Trial>,
63 pub terminal_cumulative_deviation: f64,
65 pub terminal_z: f64,
67 pub effect_size: f64,
69 pub mean_z: f64,
71 pub std_z: f64,
73 pub terminal_p_value: f64,
75}
76
77#[derive(Debug, Clone, Serialize)]
79pub struct StoufferResult {
80 pub num_sessions: usize,
82 pub session_z_scores: Vec<f64>,
84 pub stouffer_z: f64,
87 pub p_value: f64,
89 pub combined_effect_size: f64,
91 pub total_trials: usize,
93}
94
95#[derive(Debug, Clone, Serialize)]
97pub struct CalibrationResult {
98 pub analysis: TrialAnalysis,
100 pub is_suitable: bool,
102 pub warnings: Vec<String>,
104 pub shannon_entropy: f64,
106 pub bit_bias: f64,
108}
109
110pub fn trial_analysis(data: &[u8], config: &TrialConfig) -> TrialAnalysis {
122 assert!(
123 config.bits_per_trial > 0 && config.bits_per_trial.is_multiple_of(8),
124 "bits_per_trial must be a positive multiple of 8"
125 );
126
127 let bytes_per_trial = config.bits_per_trial / 8;
128 let num_trials = data.len() / bytes_per_trial;
129 let bytes_consumed = num_trials * bytes_per_trial;
130
131 let n = config.bits_per_trial as f64;
132 let expected = n / 2.0;
133 let std_dev = (n / 4.0).sqrt(); let mut trials = Vec::with_capacity(num_trials);
136 let mut cum_dev = 0.0;
137
138 for i in 0..num_trials {
139 let start = i * bytes_per_trial;
140 let end = start + bytes_per_trial;
141 let chunk = &data[start..end];
142
143 let ones: u32 = chunk.iter().map(|b| b.count_ones()).sum();
144 let deviation = ones as f64 - expected;
145 cum_dev += deviation;
146
147 let z = if std_dev > 0.0 {
148 deviation / std_dev
149 } else {
150 0.0
151 };
152
153 trials.push(Trial {
154 index: i,
155 ones_count: ones,
156 z_score: z,
157 cumulative_deviation: cum_dev,
158 });
159 }
160
161 let terminal_z = if num_trials > 0 && std_dev > 0.0 {
163 cum_dev / (std_dev * (num_trials as f64).sqrt())
164 } else {
165 0.0
166 };
167
168 let effect_size = if num_trials > 0 {
170 terminal_z / (num_trials as f64).sqrt()
171 } else {
172 0.0
173 };
174
175 let (mean_z, std_z) = if !trials.is_empty() {
177 let sum: f64 = trials.iter().map(|t| t.z_score).sum();
178 let mean = sum / trials.len() as f64;
179 let var: f64 = trials
180 .iter()
181 .map(|t| (t.z_score - mean).powi(2))
182 .sum::<f64>()
183 / trials.len() as f64;
184 (mean, var.sqrt())
185 } else {
186 (0.0, 0.0)
187 };
188
189 let terminal_p_value = 2.0 * (1.0 - normal_cdf(terminal_z.abs()));
190
191 TrialAnalysis {
192 config: config.clone(),
193 bytes_consumed,
194 num_trials,
195 bits_per_trial: config.bits_per_trial,
196 trials,
197 terminal_cumulative_deviation: cum_dev,
198 terminal_z,
199 effect_size,
200 mean_z,
201 std_z,
202 terminal_p_value,
203 }
204}
205
206pub fn stouffer_combine(analyses: &[&TrialAnalysis]) -> StoufferResult {
213 let contributing: Vec<&TrialAnalysis> = analyses
214 .iter()
215 .copied()
216 .filter(|a| a.num_trials > 0)
217 .collect();
218 let k = contributing.len();
219 let session_z_scores: Vec<f64> = contributing.iter().map(|a| a.terminal_z).collect();
220 let total_trials: usize = contributing.iter().map(|a| a.num_trials).sum();
221
222 let weighted_z_sum: f64 = contributing
223 .iter()
224 .map(|a| a.terminal_z * (a.num_trials as f64).sqrt())
225 .sum();
226 let stouffer_z = if total_trials > 0 {
227 weighted_z_sum / (total_trials as f64).sqrt()
228 } else {
229 0.0
230 };
231
232 let p_value = 2.0 * (1.0 - normal_cdf(stouffer_z.abs()));
233
234 let combined_effect_size = if total_trials > 0 {
235 stouffer_z / (total_trials as f64).sqrt()
236 } else {
237 0.0
238 };
239
240 StoufferResult {
241 num_sessions: k,
242 session_z_scores,
243 stouffer_z,
244 p_value,
245 combined_effect_size,
246 total_trials,
247 }
248}
249
250pub fn calibration_check(data: &[u8], config: &TrialConfig) -> CalibrationResult {
258 let analysis = trial_analysis(data, config);
259 let shannon = quick_shannon(data);
260 let bias = analysis::bit_bias(data).overall_bias;
261
262 let mut warnings = Vec::new();
263 let mut suitable = true;
264
265 if analysis.terminal_z.abs() >= 2.0 {
266 warnings.push(format!(
267 "Terminal Z = {:.4} exceeds +/-2.0 threshold",
268 analysis.terminal_z
269 ));
270 suitable = false;
271 }
272
273 if bias >= 0.005 {
274 warnings.push(format!("Bit bias = {:.6} exceeds 0.005 threshold", bias));
275 suitable = false;
276 }
277
278 if shannon <= 7.9 {
279 warnings.push(format!(
280 "Shannon entropy = {:.4} below 7.9 threshold",
281 shannon
282 ));
283 suitable = false;
284 }
285
286 if analysis.num_trials > 1 && (analysis.std_z < 0.85 || analysis.std_z > 1.15) {
287 warnings.push(format!(
288 "Z-score std = {:.4} outside [0.85, 1.15] range",
289 analysis.std_z
290 ));
291 suitable = false;
292 }
293
294 if analysis.num_trials == 0 {
295 warnings.push("Insufficient data for any trials".to_string());
296 suitable = false;
297 }
298
299 CalibrationResult {
300 analysis,
301 is_suitable: suitable,
302 warnings,
303 shannon_entropy: shannon,
304 bit_bias: bias,
305 }
306}
307
308fn normal_cdf(x: f64) -> f64 {
316 if x.is_nan() {
317 return 0.5;
318 }
319
320 const B1: f64 = 0.319381530;
322 const B2: f64 = -0.356563782;
323 const B3: f64 = 1.781477937;
324 const B4: f64 = -1.821255978;
325 const B5: f64 = 1.330274429;
326 const P: f64 = 0.2316419;
327
328 let abs_x = x.abs();
329 let t = 1.0 / (1.0 + P * abs_x);
330 let t2 = t * t;
331 let t3 = t2 * t;
332 let t4 = t3 * t;
333 let t5 = t4 * t;
334
335 let pdf = (-0.5 * abs_x * abs_x).exp() / (2.0 * std::f64::consts::PI).sqrt();
336 let cdf = 1.0 - pdf * (B1 * t + B2 * t2 + B3 * t3 + B4 * t4 + B5 * t5);
337
338 if x >= 0.0 { cdf } else { 1.0 - cdf }
339}
340
341#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_all_ones() {
351 let data = vec![0xFF; 50];
353 let config = TrialConfig::default(); let result = trial_analysis(&data, &config);
355
356 assert_eq!(result.num_trials, 2);
357 assert_eq!(result.bits_per_trial, 200);
358
359 for trial in &result.trials {
361 assert_eq!(trial.ones_count, 200);
362 assert!(trial.z_score > 14.0);
363 }
364 assert!(result.terminal_z > 10.0);
365 }
366
367 #[test]
368 fn test_alternating_bits() {
369 let data = vec![0xAA; 50];
372 let config = TrialConfig::default();
373 let result = trial_analysis(&data, &config);
374
375 assert_eq!(result.num_trials, 2);
376 for trial in &result.trials {
377 assert_eq!(trial.ones_count, 100); assert!(trial.z_score.abs() < 0.001);
379 }
380 assert!(result.terminal_z.abs() < 0.001);
381 }
382
383 #[test]
384 fn test_pseudo_random() {
385 let mut state: u64 = 42;
387 let data: Vec<u8> = (0..2500)
388 .map(|_| {
389 state ^= state << 13;
391 state ^= state >> 7;
392 state ^= state << 17;
393 (state & 0xFF) as u8
394 })
395 .collect();
396
397 let config = TrialConfig::default();
398 let result = trial_analysis(&data, &config);
399
400 assert_eq!(result.num_trials, 100); assert!(
403 result.terminal_z.abs() < 4.0,
404 "terminal_z={} too large for PRNG",
405 result.terminal_z
406 );
407 assert!(
408 result.effect_size.abs() < 0.5,
409 "effect_size={} too large",
410 result.effect_size
411 );
412 assert!(
414 result.std_z > 0.7 && result.std_z < 1.3,
415 "std_z={} not near 1.0",
416 result.std_z
417 );
418 }
419
420 #[test]
421 fn test_stouffer_combine() {
422 let config = TrialConfig::default();
424 let data = vec![0xAA; 50]; let a1 = trial_analysis(&data, &config);
427 let a2 = trial_analysis(&data, &config);
428 let a3 = trial_analysis(&data, &config);
429
430 let result = stouffer_combine(&[&a1, &a2, &a3]);
431 assert_eq!(result.num_sessions, 3);
432 assert_eq!(result.total_trials, 6);
433 assert!(result.stouffer_z.abs() < 0.01);
435 }
436
437 #[test]
438 fn test_stouffer_scaling() {
439 let config = TrialConfig::default();
441
442 let data = vec![0xFF; 25]; let a = trial_analysis(&data, &config);
445 let z = a.terminal_z;
446
447 let result = stouffer_combine(&[&a, &a, &a, &a]);
449 let expected = 4.0 * z / 2.0;
451 assert!(
452 (result.stouffer_z - expected).abs() < 0.001,
453 "stouffer_z={} expected={}",
454 result.stouffer_z,
455 expected
456 );
457 }
458
459 #[test]
460 fn test_stouffer_weighted_by_trial_count() {
461 let config = TrialConfig::default();
462
463 let short = trial_analysis(&[0xFF; 25], &config);
465 let long = trial_analysis(&[0xAA; 2500], &config);
467
468 let result = stouffer_combine(&[&short, &long]);
469 assert_eq!(result.num_sessions, 2);
470 assert_eq!(result.total_trials, 101);
471
472 let expected = (short.terminal_z * (short.num_trials as f64).sqrt()
473 + long.terminal_z * (long.num_trials as f64).sqrt())
474 / (result.total_trials as f64).sqrt();
475 assert!((result.stouffer_z - expected).abs() < 1e-10);
476
477 assert!(result.stouffer_z.abs() < short.terminal_z.abs());
479 }
480
481 #[test]
482 fn test_stouffer_ignores_zero_trial_sessions() {
483 let config = TrialConfig::default();
484 let empty = trial_analysis(&[], &config);
485 let non_empty = trial_analysis(&[0xFF; 25], &config);
486
487 let result = stouffer_combine(&[&empty, &non_empty]);
488 assert_eq!(result.num_sessions, 1);
489 assert_eq!(result.total_trials, 1);
490 assert_eq!(result.session_z_scores.len(), 1);
491 assert!((result.stouffer_z - non_empty.terminal_z).abs() < 1e-10);
492 }
493
494 #[test]
495 fn test_calibration_pass() {
496 let mut state: u64 = 12345;
498 let data: Vec<u8> = (0..50_000)
499 .map(|_| {
500 state ^= state << 13;
501 state ^= state >> 7;
502 state ^= state << 17;
503 (state & 0xFF) as u8
504 })
505 .collect();
506
507 let config = TrialConfig::default();
508 let result = calibration_check(&data, &config);
509 assert!(
510 result.is_suitable,
511 "PRNG data should pass calibration, warnings: {:?}",
512 result.warnings
513 );
514 assert!(result.warnings.is_empty());
515 assert!(result.shannon_entropy > 7.9);
516 assert!(result.bit_bias < 0.005);
517 }
518
519 #[test]
520 fn test_calibration_fail_biased() {
521 let data = vec![0xFF; 5000];
523 let config = TrialConfig::default();
524 let result = calibration_check(&data, &config);
525
526 assert!(!result.is_suitable);
527 assert!(!result.warnings.is_empty());
528 }
529
530 #[test]
531 fn test_normal_cdf_known_values() {
532 assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
534
535 assert!((normal_cdf(1.96) - 0.975).abs() < 0.001);
537
538 assert!((normal_cdf(-1.96) - 0.025).abs() < 0.001);
540
541 assert!((normal_cdf(3.0) - 0.99865).abs() < 0.001);
543
544 assert!((normal_cdf(1.0) + normal_cdf(-1.0) - 1.0).abs() < 1e-6);
546 }
547
548 #[test]
549 fn test_insufficient_data() {
550 let data = vec![0x42; 10];
552 let config = TrialConfig::default();
553 let result = trial_analysis(&data, &config);
554
555 assert_eq!(result.num_trials, 0);
556 assert_eq!(result.bytes_consumed, 0);
557 assert!(result.trials.is_empty());
558 assert_eq!(result.terminal_z, 0.0);
559 assert_eq!(result.effect_size, 0.0);
560 }
561
562 #[test]
563 fn test_empty_data() {
564 let config = TrialConfig::default();
565 let result = trial_analysis(&[], &config);
566 assert_eq!(result.num_trials, 0);
567 assert_eq!(result.terminal_z, 0.0);
568 }
569
570 #[test]
571 fn test_custom_bits_per_trial() {
572 let config = TrialConfig { bits_per_trial: 8 };
574 let data = vec![0xFF; 10]; let result = trial_analysis(&data, &config);
576 assert_eq!(result.num_trials, 10);
577
578 for trial in &result.trials {
580 assert_eq!(trial.ones_count, 8);
581 assert!((trial.z_score - 2.828).abs() < 0.01);
582 }
583 }
584
585 #[test]
586 #[should_panic(expected = "bits_per_trial must be a positive multiple of 8")]
587 fn test_zero_bits_per_trial_panics() {
588 let config = TrialConfig { bits_per_trial: 0 };
589 trial_analysis(&[0x42], &config);
590 }
591
592 #[test]
593 #[should_panic(expected = "bits_per_trial must be a positive multiple of 8")]
594 fn test_non_multiple_of_8_panics() {
595 let config = TrialConfig { bits_per_trial: 13 };
596 trial_analysis(&[0x42; 100], &config);
597 }
598
599 #[test]
600 fn test_stouffer_empty() {
601 let result = stouffer_combine(&[]);
602 assert_eq!(result.num_sessions, 0);
603 assert_eq!(result.stouffer_z, 0.0);
604 assert_eq!(result.total_trials, 0);
605 }
606
607 #[test]
608 fn test_calibration_empty_data() {
609 let config = TrialConfig::default();
610 let result = calibration_check(&[], &config);
611 assert!(!result.is_suitable);
612 assert!(
613 result.warnings.iter().any(|w| w.contains("Insufficient")),
614 "Should warn about insufficient data"
615 );
616 }
617
618 #[test]
619 fn test_normal_cdf_extreme_values() {
620 assert!((normal_cdf(10.0) - 1.0).abs() < 1e-10);
622 assert!(normal_cdf(-10.0) < 1e-10);
624 assert_eq!(normal_cdf(f64::NAN), 0.5);
626 }
627
628 #[test]
629 fn test_p_value_range() {
630 let config = TrialConfig::default();
632
633 let result = trial_analysis(&vec![0xFF; 250], &config);
635 assert!(result.terminal_p_value >= 0.0 && result.terminal_p_value <= 1.0);
636
637 let result = trial_analysis(&vec![0xAA; 250], &config);
639 assert!(result.terminal_p_value >= 0.0 && result.terminal_p_value <= 1.0);
640 }
641
642 #[test]
643 fn test_single_trial() {
644 let config = TrialConfig::default(); let data = vec![0xFF; 25]; let result = trial_analysis(&data, &config);
648
649 assert_eq!(result.num_trials, 1);
650 assert_eq!(result.trials.len(), 1);
651 assert!(
652 (result.terminal_z - result.trials[0].z_score).abs() < 1e-10,
653 "With 1 trial, terminal_z should equal the trial's z_score"
654 );
655 assert!(
657 (result.effect_size - result.terminal_z).abs() < 1e-10,
658 "With 1 trial, effect_size should equal terminal_z"
659 );
660 assert_eq!(result.std_z, 0.0);
662 }
663
664 #[test]
665 fn test_trailing_bytes_ignored() {
666 let config = TrialConfig::default();
668 let data = vec![0xAA; 26];
669 let result = trial_analysis(&data, &config);
670
671 assert_eq!(result.num_trials, 1);
672 assert_eq!(result.bytes_consumed, 25);
673 }
674
675 #[test]
676 fn test_cumulative_deviation_tracking() {
677 let config = TrialConfig { bits_per_trial: 8 };
680 let data = vec![0xAA, 0xFF, 0x00];
681 let result = trial_analysis(&data, &config);
682
683 assert_eq!(result.num_trials, 3);
684 assert_eq!(result.trials[0].ones_count, 4);
686 assert!((result.trials[0].cumulative_deviation - 0.0).abs() < 0.001);
687 assert_eq!(result.trials[1].ones_count, 8);
689 assert!((result.trials[1].cumulative_deviation - 4.0).abs() < 0.001);
690 assert_eq!(result.trials[2].ones_count, 0);
692 assert!((result.trials[2].cumulative_deviation - 0.0).abs() < 0.001);
693 }
694}