Skip to main content

openentropy_core/
verdict.rs

1//! Verdict functions for entropy analysis metrics.
2//!
3//! Each function evaluates a metric value against domain-specific thresholds
4//! and returns a [`Verdict`] (PASS / WARN / FAIL / NA). This module
5//! centralises threshold logic so that both the CLI and the Python SDK
6//! produce identical verdicts without duplicating constants.
7//!
8//! # Adding a new verdict
9//!
10//! 1. Add a `pub fn verdict_<metric>(...) -> Verdict` here.
11//! 2. Call it from the CLI formatting layer.
12//! 3. Expose it via Python bindings if needed.
13
14use serde::Serialize;
15
16use crate::analysis::RunsResult;
17
18// ---------------------------------------------------------------------------
19// Verdict enum
20// ---------------------------------------------------------------------------
21
22/// Result of evaluating a metric against quality thresholds.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
24pub enum Verdict {
25    /// Metric is within expected range for genuine randomness.
26    Pass,
27    /// Metric is borderline — not a definitive failure.
28    Warn,
29    /// Metric is outside the expected range.
30    Fail,
31    /// Metric could not be computed (NaN, insufficient data, etc.).
32    #[serde(rename = "N/A")]
33    Na,
34}
35
36impl Verdict {
37    /// Short display string for CLI tables.
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Verdict::Pass => "PASS",
41            Verdict::Warn => "WARN",
42            Verdict::Fail => "FAIL",
43            Verdict::Na => "N/A",
44        }
45    }
46}
47
48impl std::fmt::Display for Verdict {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Forensic analysis verdicts
56// ---------------------------------------------------------------------------
57
58/// Autocorrelation: max |r| across all lags.
59pub fn verdict_autocorr(max_abs: f64) -> Verdict {
60    if max_abs > 0.15 {
61        Verdict::Fail
62    } else if max_abs > 0.05 {
63        Verdict::Warn
64    } else {
65        Verdict::Pass
66    }
67}
68
69/// Spectral flatness (1.0 = white noise).
70pub fn verdict_spectral(flatness: f64) -> Verdict {
71    if flatness < 0.5 {
72        Verdict::Fail
73    } else if flatness < 0.75 {
74        Verdict::Warn
75    } else {
76        Verdict::Pass
77    }
78}
79
80/// Bit bias: overall deviation from 0.5 and per-bit significance.
81pub fn verdict_bias(overall: f64, has_significant: bool) -> Verdict {
82    if overall > 0.02 {
83        Verdict::Fail
84    } else if has_significant {
85        Verdict::Warn
86    } else {
87        Verdict::Pass
88    }
89}
90
91/// Distribution uniformity via KS p-value.
92pub fn verdict_distribution(ks_p: f64) -> Verdict {
93    if ks_p < 0.001 {
94        Verdict::Fail
95    } else if ks_p < 0.01 {
96        Verdict::Warn
97    } else {
98        Verdict::Pass
99    }
100}
101
102/// Stationarity: ANOVA F-statistic across sliding windows.
103pub fn verdict_stationarity(f_stat: f64, is_stationary: bool) -> Verdict {
104    if f_stat > 3.0 {
105        Verdict::Fail
106    } else if !is_stationary {
107        Verdict::Warn
108    } else {
109        Verdict::Pass
110    }
111}
112
113/// Runs analysis: longest run ratio and total-runs deviation.
114pub fn verdict_runs(ru: &RunsResult, _sample_size: usize) -> Verdict {
115    let longest_ratio = if ru.expected_longest_run > 0.0 {
116        ru.longest_run as f64 / ru.expected_longest_run
117    } else {
118        1.0
119    };
120    let runs_dev = if ru.expected_runs > 0.0 {
121        (ru.total_runs as f64 - ru.expected_runs).abs() / ru.expected_runs
122    } else {
123        0.0
124    };
125    if longest_ratio > 3.0 || runs_dev > 0.4 {
126        Verdict::Fail
127    } else if longest_ratio > 2.0 || runs_dev > 0.2 {
128        Verdict::Warn
129    } else {
130        Verdict::Pass
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Chaos theory verdicts
136// ---------------------------------------------------------------------------
137
138/// Hurst exponent: H ≈ 0.5 indicates random walk (no long-range dependence).
139pub fn verdict_hurst(h: f64) -> Verdict {
140    if !h.is_finite() {
141        return Verdict::Na;
142    }
143    if (0.4..=0.6).contains(&h) {
144        Verdict::Pass
145    } else if (0.3..=0.7).contains(&h) {
146        Verdict::Warn
147    } else {
148        Verdict::Fail
149    }
150}
151
152/// Lyapunov exponent: λ ≈ 0 indicates no deterministic chaos.
153pub fn verdict_lyapunov(l: f64) -> Verdict {
154    if !l.is_finite() {
155        return Verdict::Na;
156    }
157    if l.abs() < 0.1 {
158        Verdict::Pass
159    } else if l.abs() < 0.2 {
160        Verdict::Warn
161    } else {
162        Verdict::Fail
163    }
164}
165
166/// Correlation dimension: high D₂ indicates high-dimensional (random) attractor.
167pub fn verdict_corrdim(d: f64) -> Verdict {
168    if !d.is_finite() {
169        return Verdict::Na;
170    }
171    if d > 3.0 {
172        Verdict::Pass
173    } else if d > 2.0 {
174        Verdict::Warn
175    } else {
176        Verdict::Fail
177    }
178}
179
180/// BiEntropy: high values indicate maximal binary entropy.
181pub fn verdict_bientropy(b: f64) -> Verdict {
182    if !b.is_finite() {
183        return Verdict::Na;
184    }
185    if b > 0.95 {
186        Verdict::Pass
187    } else if b > 0.90 {
188        Verdict::Warn
189    } else {
190        Verdict::Fail
191    }
192}
193
194/// Epiplexity (compression ratio): ratio ≈ 1.0 means incompressible (random).
195pub fn verdict_compression(c: f64) -> Verdict {
196    if !c.is_finite() {
197        return Verdict::Na;
198    }
199    if c > 0.99 {
200        Verdict::Pass
201    } else if c > 0.95 {
202        Verdict::Warn
203    } else {
204        Verdict::Fail
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Advanced analysis verdicts
210// ---------------------------------------------------------------------------
211
212/// Sample entropy: higher values indicate more randomness.
213/// Threshold: SampEn > 1.0 is typical for random data (Richman & Moorman 2000).
214pub fn verdict_sampen(v: f64) -> Verdict {
215    if !v.is_finite() {
216        return Verdict::Na;
217    }
218    if v > 1.0 {
219        Verdict::Pass
220    } else if v >= 0.5 {
221        Verdict::Warn
222    } else {
223        Verdict::Fail
224    }
225}
226
227/// DFA scaling exponent alpha: 0.5 indicates uncorrelated random walk.
228/// Threshold: 0.4 < α < 0.6 is the random zone (Peng et al. 1994).
229pub fn verdict_dfa(alpha: f64) -> Verdict {
230    if !alpha.is_finite() {
231        return Verdict::Na;
232    }
233    if alpha > 0.4 && alpha < 0.6 {
234        Verdict::Pass
235    } else if (0.3..=0.7).contains(&alpha) {
236        Verdict::Warn
237    } else {
238        Verdict::Fail
239    }
240}
241
242/// RQA determinism: low DET indicates non-deterministic (random) data.
243/// Threshold: DET < 0.1 expected for random data (Marwan et al. 2007).
244pub fn verdict_rqa_det(det: f64) -> Verdict {
245    if !det.is_finite() {
246        return Verdict::Na;
247    }
248    if det < 0.1 {
249        Verdict::Pass
250    } else if det <= 0.3 {
251        Verdict::Warn
252    } else {
253        Verdict::Fail
254    }
255}
256
257/// Approximate entropy: higher values indicate more randomness.
258/// Threshold: ApEn > 1.0 typical for random data (Pincus 1991).
259pub fn verdict_apen(v: f64) -> Verdict {
260    if !v.is_finite() {
261        return Verdict::Na;
262    }
263    if v > 1.0 {
264        Verdict::Pass
265    } else if v >= 0.5 {
266        Verdict::Warn
267    } else {
268        Verdict::Fail
269    }
270}
271
272/// Permutation entropy: normalized to [0,1]; 1.0 = maximum disorder.
273/// Threshold: PermEn > 0.95 expected for random data (Bandt & Pompe 2002).
274pub fn verdict_permen(v: f64) -> Verdict {
275    if !v.is_finite() {
276        return Verdict::Na;
277    }
278    if v > 0.95 {
279        Verdict::Pass
280    } else if v >= 0.8 {
281        Verdict::Warn
282    } else {
283        Verdict::Fail
284    }
285}
286
287/// Anderson-Darling p-value for uniformity: p > 0.05 fails to reject H0.
288/// Threshold: p > 0.05 = uniform (PASS), 0.01-0.05 = borderline (WARN), < 0.01 = non-uniform (FAIL).
289pub fn verdict_anderson_darling(p: f64) -> Verdict {
290    if !p.is_finite() {
291        return Verdict::Na;
292    }
293    if p > 0.05 {
294        Verdict::Pass
295    } else if p >= 0.01 {
296        Verdict::Warn
297    } else {
298        Verdict::Fail
299    }
300}
301
302/// Ljung-Box p-value for autocorrelation: p > 0.05 fails to reject H0 (no autocorrelation).
303/// Threshold: p > 0.05 = no autocorrelation (PASS), 0.01-0.05 = borderline (WARN), < 0.01 = autocorrelated (FAIL).
304pub fn verdict_ljung_box(p: f64) -> Verdict {
305    if !p.is_finite() {
306        return Verdict::Na;
307    }
308    if p > 0.05 {
309        Verdict::Pass
310    } else if p >= 0.01 {
311        Verdict::Warn
312    } else {
313        Verdict::Fail
314    }
315}
316
317/// Cramér-von Mises p-value for uniformity: p > 0.05 fails to reject H0.
318/// Threshold: p > 0.05 = uniform (PASS), 0.01-0.05 = borderline (WARN), < 0.01 = non-uniform (FAIL).
319pub fn verdict_cramer_von_mises(p: f64) -> Verdict {
320    if !p.is_finite() {
321        return Verdict::Na;
322    }
323    if p > 0.05 {
324        Verdict::Pass
325    } else if p >= 0.01 {
326        Verdict::Warn
327    } else {
328        Verdict::Fail
329    }
330}
331
332// ---------------------------------------------------------------------------
333// Display helpers
334// ---------------------------------------------------------------------------
335
336/// Format a metric value for display, showing "N/A" if invalid or non-finite.
337pub fn metric_or_na(value: f64, is_valid: bool) -> String {
338    if is_valid && value.is_finite() {
339        format!("{value:.4}")
340    } else {
341        "N/A".to_string()
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn verdict_enum_display() {
355        assert_eq!(Verdict::Pass.as_str(), "PASS");
356        assert_eq!(Verdict::Warn.as_str(), "WARN");
357        assert_eq!(Verdict::Fail.as_str(), "FAIL");
358        assert_eq!(Verdict::Na.as_str(), "N/A");
359        assert_eq!(format!("{}", Verdict::Pass), "PASS");
360    }
361
362    #[test]
363    fn autocorr_thresholds() {
364        assert_eq!(verdict_autocorr(0.01), Verdict::Pass);
365        assert_eq!(verdict_autocorr(0.10), Verdict::Warn);
366        assert_eq!(verdict_autocorr(0.20), Verdict::Fail);
367    }
368
369    #[test]
370    fn spectral_thresholds() {
371        assert_eq!(verdict_spectral(0.90), Verdict::Pass);
372        assert_eq!(verdict_spectral(0.60), Verdict::Warn);
373        assert_eq!(verdict_spectral(0.30), Verdict::Fail);
374    }
375
376    #[test]
377    fn hurst_thresholds() {
378        assert_eq!(verdict_hurst(0.50), Verdict::Pass);
379        assert_eq!(verdict_hurst(0.35), Verdict::Warn);
380        assert_eq!(verdict_hurst(0.10), Verdict::Fail);
381        assert_eq!(verdict_hurst(f64::NAN), Verdict::Na);
382    }
383
384    #[test]
385    fn lyapunov_thresholds() {
386        assert_eq!(verdict_lyapunov(0.05), Verdict::Pass);
387        assert_eq!(verdict_lyapunov(0.15), Verdict::Warn);
388        assert_eq!(verdict_lyapunov(0.50), Verdict::Fail);
389        assert_eq!(verdict_lyapunov(f64::NAN), Verdict::Na);
390    }
391
392    #[test]
393    fn corrdim_thresholds() {
394        assert_eq!(verdict_corrdim(5.0), Verdict::Pass);
395        assert_eq!(verdict_corrdim(2.5), Verdict::Warn);
396        assert_eq!(verdict_corrdim(1.5), Verdict::Fail);
397        assert_eq!(verdict_corrdim(f64::NAN), Verdict::Na);
398    }
399
400    #[test]
401    fn bientropy_thresholds() {
402        assert_eq!(verdict_bientropy(0.98), Verdict::Pass);
403        assert_eq!(verdict_bientropy(0.92), Verdict::Warn);
404        assert_eq!(verdict_bientropy(0.80), Verdict::Fail);
405        assert_eq!(verdict_bientropy(f64::NAN), Verdict::Na);
406    }
407
408    #[test]
409    fn compression_thresholds() {
410        assert_eq!(verdict_compression(1.00), Verdict::Pass);
411        assert_eq!(verdict_compression(0.97), Verdict::Warn);
412        assert_eq!(verdict_compression(0.90), Verdict::Fail);
413        assert_eq!(verdict_compression(f64::NAN), Verdict::Na);
414    }
415
416    #[test]
417    fn metric_display() {
418        assert_eq!(metric_or_na(0.1234, true), "0.1234");
419        assert_eq!(metric_or_na(0.1234, false), "N/A");
420        assert_eq!(metric_or_na(f64::NAN, true), "N/A");
421    }
422}