Skip to main content

openentropy_core/
trials.rs

1//! PEAR-style trial analysis for entropy data.
2//!
3//! The PEAR Lab (Princeton Engineering Anomalies Research) used 200-bit trials
4//! at ~1/sec with cumulative deviation tracking, terminal Z-scores, and effect
5//! sizes at ~10^-4. This module provides those metrics on raw byte streams so
6//! recorded sessions can be analyzed with PEAR-standard statistics.
7//! Historical references and formulas are documented in `docs/TRIALS.md`.
8//!
9//! All functions are pure: `&[u8]` in, structs out. No I/O.
10
11use serde::{Deserialize, Serialize};
12
13use crate::analysis;
14use crate::conditioning::quick_shannon;
15
16// ---------------------------------------------------------------------------
17// Types
18// ---------------------------------------------------------------------------
19
20/// Configuration for trial slicing.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TrialConfig {
23    /// Bits per trial (must be divisible by 8). Default: 200.
24    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/// A single trial result.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Trial {
38    /// Zero-based trial index.
39    pub index: usize,
40    /// Number of 1-bits in this trial.
41    pub ones_count: u32,
42    /// Z-score for this trial: (ones - N/2) / sqrt(N/4).
43    pub z_score: f64,
44    /// Running cumulative deviation: sum of (ones_i - N/2) up to this trial.
45    pub cumulative_deviation: f64,
46}
47
48/// Complete trial analysis result.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TrialAnalysis {
51    /// Config used for this analysis.
52    pub config: TrialConfig,
53    /// Total bytes consumed from input.
54    pub bytes_consumed: usize,
55    /// Number of complete trials extracted.
56    pub num_trials: usize,
57    /// Bits per trial (copied from config for convenience).
58    pub bits_per_trial: usize,
59    /// Per-trial results.
60    #[serde(default)]
61    #[serde(skip_serializing_if = "Vec::is_empty")]
62    pub trials: Vec<Trial>,
63    /// Final cumulative deviation after all trials.
64    pub terminal_cumulative_deviation: f64,
65    /// Terminal Z-score: cum_dev / sqrt(num_trials * N/4).
66    pub terminal_z: f64,
67    /// Effect size: terminal_z / sqrt(num_trials).
68    pub effect_size: f64,
69    /// Mean of per-trial Z-scores (should be ~0 for unbiased data).
70    pub mean_z: f64,
71    /// Std deviation of per-trial Z-scores (should be ~1).
72    pub std_z: f64,
73    /// Two-tailed p-value from the terminal Z-score.
74    pub terminal_p_value: f64,
75}
76
77/// Result of combining multiple sessions via Stouffer's method.
78#[derive(Debug, Clone, Serialize)]
79pub struct StoufferResult {
80    /// Number of sessions combined (non-empty trial analyses only).
81    pub num_sessions: usize,
82    /// Terminal Z-score from each session.
83    pub session_z_scores: Vec<f64>,
84    /// Combined Z using weighted Stouffer:
85    /// sum(w_i * Z_i) / sqrt(sum(w_i^2)), where w_i = sqrt(num_trials_i).
86    pub stouffer_z: f64,
87    /// Two-tailed p-value from the combined Z.
88    pub p_value: f64,
89    /// Combined effect size: stouffer_z / sqrt(total_trials).
90    pub combined_effect_size: f64,
91    /// Total trials across all sessions.
92    pub total_trials: usize,
93}
94
95/// Calibration assessment for a data source.
96#[derive(Debug, Clone, Serialize)]
97pub struct CalibrationResult {
98    /// Trial analysis of the calibration data.
99    pub analysis: TrialAnalysis,
100    /// Whether this source passed calibration.
101    pub is_suitable: bool,
102    /// Any warnings or failure reasons.
103    pub warnings: Vec<String>,
104    /// Shannon entropy of the calibration data (bits/byte, max 8.0).
105    pub shannon_entropy: f64,
106    /// Overall bit bias (deviation from 0.5).
107    pub bit_bias: f64,
108}
109
110// ---------------------------------------------------------------------------
111// Core functions
112// ---------------------------------------------------------------------------
113
114/// PEAR-style trial analysis on raw bytes.
115///
116/// Slices `data` into trials of `config.bits_per_trial` bits each.
117/// Requires `bits_per_trial % 8 == 0`.
118///
119/// Returns a `TrialAnalysis` with per-trial Z-scores, cumulative deviation,
120/// terminal Z, effect size, and p-value.
121pub 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(); // sqrt(N * p * (1-p)) = sqrt(N/4)
134
135    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    // Terminal Z: cumulative deviation normalized by sqrt(num_trials * N/4)
162    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    // Effect size: terminal_z / sqrt(num_trials)
169    let effect_size = if num_trials > 0 {
170        terminal_z / (num_trials as f64).sqrt()
171    } else {
172        0.0
173    };
174
175    // Z-score statistics
176    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
206/// Combine terminal Z-scores from multiple sessions using weighted Stouffer's method.
207///
208/// Uses weights `w_i = sqrt(num_trials_i)`, which is the natural weighting for
209/// per-session terminal Z values derived from trial counts.
210///
211/// Sessions with zero trials are ignored.
212pub 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
250/// Assess whether data is suitable as a calibration baseline.
251///
252/// Checks:
253/// - `|terminal_z| < 2.0` (no significant deviation)
254/// - `bit_bias < 0.005` (negligible bias)
255/// - `shannon_entropy > 7.9` (near-maximal entropy)
256/// - `std_z` in `[0.85, 1.15]` (Z-scores normally distributed)
257pub 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
308// ---------------------------------------------------------------------------
309// Internal helpers
310// ---------------------------------------------------------------------------
311
312/// Standard normal CDF using the Abramowitz & Stegun approximation (26.2.17).
313///
314/// Accurate to ~7.5 x 10^-8 for all x.
315fn normal_cdf(x: f64) -> f64 {
316    if x.is_nan() {
317        return 0.5;
318    }
319
320    // Constants from A&S 26.2.17
321    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// ---------------------------------------------------------------------------
342// Tests
343// ---------------------------------------------------------------------------
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_all_ones() {
351        // 50 bytes of 0xFF = 400 bits, all ones → 2 trials of 200 bits
352        let data = vec![0xFF; 50];
353        let config = TrialConfig::default(); // 200 bits
354        let result = trial_analysis(&data, &config);
355
356        assert_eq!(result.num_trials, 2);
357        assert_eq!(result.bits_per_trial, 200);
358
359        // Each trial: 200 ones, expected 100, z = (200-100)/sqrt(50) = 100/7.07 ≈ 14.14
360        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        // 0xAA = 10101010 → 4 ones per byte, so 100 ones per 25-byte trial
370        // That's exactly 50% → Z ≈ 0
371        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); // exactly 50%
378            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        // Use a simple PRNG for reproducibility
386        let mut state: u64 = 42;
387        let data: Vec<u8> = (0..2500)
388            .map(|_| {
389                // xorshift64
390                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); // 2500 / 25 = 100 trials
401        // For pseudo-random data, Z should be small and effect size tiny
402        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        // std_z should be near 1.0
413        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        // Create three analyses with known terminal Z-scores
423        let config = TrialConfig::default();
424        let data = vec![0xAA; 50]; // 2 trials, Z ≈ 0
425
426        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        // All Z ≈ 0, so Stouffer Z ≈ 0
434        assert!(result.stouffer_z.abs() < 0.01);
435    }
436
437    #[test]
438    fn test_stouffer_scaling() {
439        // With equal trial counts, weighted Stouffer reduces to sum/sqrt(k).
440        let config = TrialConfig::default();
441
442        // Create biased data that gives consistent positive Z
443        let data = vec![0xFF; 25]; // 1 trial, all ones, large Z
444        let a = trial_analysis(&data, &config);
445        let z = a.terminal_z;
446
447        // Combine 4 identical sessions
448        let result = stouffer_combine(&[&a, &a, &a, &a]);
449        // stouffer_z = 4*z / sqrt(4) = 2*z
450        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        // 1 trial with strong positive Z.
464        let short = trial_analysis(&[0xFF; 25], &config);
465        // 100 trials with ~zero Z.
466        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        // Weighted by trial count: one short outlier should be diluted by the long run.
478        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        // Pseudo-random data should pass calibration — use enough data to reduce bias
497        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        // Heavily biased data (all 0xFF) should fail
522        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        // CDF(0) = 0.5
533        assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
534
535        // CDF(1.96) ≈ 0.975
536        assert!((normal_cdf(1.96) - 0.975).abs() < 0.001);
537
538        // CDF(-1.96) ≈ 0.025
539        assert!((normal_cdf(-1.96) - 0.025).abs() < 0.001);
540
541        // CDF(3.0) ≈ 0.99865
542        assert!((normal_cdf(3.0) - 0.99865).abs() < 0.001);
543
544        // Symmetry
545        assert!((normal_cdf(1.0) + normal_cdf(-1.0) - 1.0).abs() < 1e-6);
546    }
547
548    #[test]
549    fn test_insufficient_data() {
550        // Less than 25 bytes → 0 trials
551        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        // 8 bits per trial = 1 byte per trial
573        let config = TrialConfig { bits_per_trial: 8 };
574        let data = vec![0xFF; 10]; // 10 trials
575        let result = trial_analysis(&data, &config);
576        assert_eq!(result.num_trials, 10);
577
578        // Each trial: 8 ones, expected 4, z = (8-4)/sqrt(2) = 4/1.414 ≈ 2.828
579        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        // Very large positive: CDF → 1.0
621        assert!((normal_cdf(10.0) - 1.0).abs() < 1e-10);
622        // Very large negative: CDF → 0.0
623        assert!(normal_cdf(-10.0) < 1e-10);
624        // NaN → 0.5
625        assert_eq!(normal_cdf(f64::NAN), 0.5);
626    }
627
628    #[test]
629    fn test_p_value_range() {
630        // P-value should always be in [0.0, 1.0]
631        let config = TrialConfig::default();
632
633        // Extreme bias (all ones)
634        let result = trial_analysis(&vec![0xFF; 250], &config);
635        assert!(result.terminal_p_value >= 0.0 && result.terminal_p_value <= 1.0);
636
637        // No bias (alternating)
638        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        // Exactly 1 trial: terminal_z should equal the single trial's z_score
645        let config = TrialConfig::default(); // 200 bits = 25 bytes
646        let data = vec![0xFF; 25]; // 1 trial
647        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        // effect_size = terminal_z / sqrt(1) = terminal_z
656        assert!(
657            (result.effect_size - result.terminal_z).abs() < 1e-10,
658            "With 1 trial, effect_size should equal terminal_z"
659        );
660        // std_z should be 0 (population std with 1 sample)
661        assert_eq!(result.std_z, 0.0);
662    }
663
664    #[test]
665    fn test_trailing_bytes_ignored() {
666        // 26 bytes with 25-byte trial size → 1 trial, 1 byte ignored
667        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        // 0xAA (4 ones) then 0xFF (8 ones) per byte
678        // Trial of 8 bits: expected 4 ones
679        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        // Trial 0: 4 ones, dev = 0
685        assert_eq!(result.trials[0].ones_count, 4);
686        assert!((result.trials[0].cumulative_deviation - 0.0).abs() < 0.001);
687        // Trial 1: 8 ones, dev = +4, cum = 4
688        assert_eq!(result.trials[1].ones_count, 8);
689        assert!((result.trials[1].cumulative_deviation - 4.0).abs() < 0.001);
690        // Trial 2: 0 ones, dev = -4, cum = 0
691        assert_eq!(result.trials[2].ones_count, 0);
692        assert!((result.trials[2].cumulative_deviation - 0.0).abs() < 0.001);
693    }
694}