Skip to main content

openentropy_core/
dispatcher.rs

1use serde::{Deserialize, Serialize};
2
3use crate::analysis::{self, CrossCorrMatrix, SourceAnalysis};
4use crate::chaos::{self, ChaosAnalysis};
5use crate::conditioning::{self, MinEntropyReport};
6use crate::trials::{self, TrialAnalysis, TrialConfig};
7use crate::verdict::{self, Verdict};
8
9/// Controls which analysis modules the dispatcher should run.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AnalysisConfig {
12    /// Run full_analysis (autocorrelation, spectral, bias, distribution, stationarity, runs).
13    pub forensic: bool,
14    /// Run min_entropy_estimate (detailed entropy breakdown).
15    pub entropy: bool,
16    /// Run chaos_analysis (Hurst, Lyapunov, correlation dimension, BiEntropy, epiplexity).
17    pub chaos: bool,
18    /// Run trial_analysis with given config. None = skip.
19    pub trials: Option<TrialConfig>,
20    /// Run cross_correlation_matrix when 2+ sources present.
21    pub cross_correlation: bool,
22}
23
24/// Analysis profile presets.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum AnalysisProfile {
27    Quick,
28    Standard,
29    Deep,
30    Security,
31}
32
33impl AnalysisProfile {
34    pub fn to_config(self) -> AnalysisConfig {
35        match self {
36            Self::Quick => AnalysisConfig {
37                forensic: true,
38                entropy: false,
39                chaos: false,
40                trials: None,
41                cross_correlation: false,
42            },
43            Self::Standard => AnalysisConfig {
44                forensic: true,
45                entropy: false,
46                chaos: false,
47                trials: None,
48                cross_correlation: false,
49            },
50            Self::Deep => AnalysisConfig {
51                forensic: true,
52                entropy: true,
53                chaos: true,
54                trials: Some(TrialConfig::default()),
55                cross_correlation: true,
56            },
57            Self::Security => AnalysisConfig {
58                forensic: true,
59                entropy: true,
60                chaos: false,
61                trials: None,
62                cross_correlation: false,
63            },
64        }
65    }
66
67    pub fn parse(s: &str) -> Self {
68        match s.to_lowercase().as_str() {
69            "quick" => Self::Quick,
70            "deep" => Self::Deep,
71            "security" => Self::Security,
72            _ => Self::Standard,
73        }
74    }
75}
76
77impl Default for AnalysisConfig {
78    fn default() -> Self {
79        AnalysisProfile::Standard.to_config()
80    }
81}
82
83/// Collected verdicts from all analyses that were run.
84#[derive(Debug, Clone, Serialize)]
85pub struct VerdictSummary {
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub autocorrelation: Option<Verdict>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub spectral: Option<Verdict>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub bias: Option<Verdict>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub distribution: Option<Verdict>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub stationarity: Option<Verdict>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub runs: Option<Verdict>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub hurst: Option<Verdict>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub lyapunov: Option<Verdict>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub correlation_dimension: Option<Verdict>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub bientropy: Option<Verdict>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub compression: Option<Verdict>,
108}
109
110/// Per-source analysis results.
111#[derive(Debug, Clone, Serialize)]
112pub struct SourceReport {
113    pub label: String,
114    pub size: usize,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub forensic: Option<SourceAnalysis>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub entropy: Option<MinEntropyReport>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub chaos: Option<ChaosAnalysis>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub trials: Option<TrialAnalysis>,
123    pub verdicts: VerdictSummary,
124}
125
126/// Complete analysis report across all sources.
127#[derive(Debug, Clone, Serialize)]
128pub struct AnalysisReport {
129    pub config: AnalysisConfig,
130    pub sources: Vec<SourceReport>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub cross_correlation: Option<CrossCorrMatrix>,
133}
134
135/// Run selected analyses on one or more data sources.
136///
137/// This is the unified entry point - CLI, Python, and HTTP all call this.
138/// Individual analysis functions remain available for fine-grained control.
139pub fn analyze(sources: &[(&str, &[u8])], config: &AnalysisConfig) -> AnalysisReport {
140    let mut source_reports = Vec::with_capacity(sources.len());
141
142    for &(label, data) in sources {
143        let forensic = if config.forensic {
144            Some(analysis::full_analysis(label, data))
145        } else {
146            None
147        };
148
149        let entropy = if config.entropy {
150            Some(conditioning::min_entropy_estimate(data))
151        } else {
152            None
153        };
154
155        let chaos = if config.chaos {
156            Some(chaos::chaos_analysis(data))
157        } else {
158            None
159        };
160
161        let trials = config
162            .trials
163            .as_ref()
164            .map(|tc| trials::trial_analysis(data, tc));
165
166        let verdicts = build_verdicts(forensic.as_ref(), chaos.as_ref());
167
168        source_reports.push(SourceReport {
169            label: label.to_string(),
170            size: data.len(),
171            forensic,
172            entropy,
173            chaos,
174            trials,
175            verdicts,
176        });
177    }
178
179    let cross_correlation = if config.cross_correlation && sources.len() >= 2 {
180        let sources_data: Vec<(String, Vec<u8>)> = sources
181            .iter()
182            .map(|&(label, data)| (label.to_string(), data.to_vec()))
183            .collect();
184        Some(analysis::cross_correlation_matrix(&sources_data))
185    } else {
186        None
187    };
188
189    AnalysisReport {
190        config: config.clone(),
191        sources: source_reports,
192        cross_correlation,
193    }
194}
195
196fn build_verdicts(
197    forensic: Option<&SourceAnalysis>,
198    chaos_result: Option<&ChaosAnalysis>,
199) -> VerdictSummary {
200    let (autocorrelation, spectral, bias, distribution, stationarity, runs) =
201        if let Some(f) = forensic {
202            (
203                Some(verdict::verdict_autocorr(
204                    f.autocorrelation.max_abs_correlation,
205                )),
206                Some(verdict::verdict_spectral(f.spectral.flatness)),
207                Some(verdict::verdict_bias(
208                    f.bit_bias.overall_bias,
209                    f.bit_bias.has_significant_bias,
210                )),
211                Some(verdict::verdict_distribution(f.distribution.ks_p_value)),
212                Some(verdict::verdict_stationarity(
213                    f.stationarity.f_statistic,
214                    f.stationarity.is_stationary,
215                )),
216                Some(verdict::verdict_runs(&f.runs, f.sample_size)),
217            )
218        } else {
219            (None, None, None, None, None, None)
220        };
221
222    let (hurst, lyapunov, correlation_dimension, bientropy, compression) =
223        if let Some(c) = chaos_result {
224            (
225                Some(verdict::verdict_hurst(c.hurst.hurst_exponent)),
226                Some(verdict::verdict_lyapunov(c.lyapunov.lyapunov_exponent)),
227                Some(verdict::verdict_corrdim(c.correlation_dimension.dimension)),
228                Some(verdict::verdict_bientropy(c.bientropy.bien)),
229                Some(verdict::verdict_compression(c.epiplexity.compression_ratio)),
230            )
231        } else {
232            (None, None, None, None, None)
233        };
234
235    VerdictSummary {
236        autocorrelation,
237        spectral,
238        bias,
239        distribution,
240        stationarity,
241        runs,
242        hurst,
243        lyapunov,
244        correlation_dimension,
245        bientropy,
246        compression,
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn test_data() -> Vec<u8> {
255        (0..1000).map(|i| (i % 256) as u8).collect()
256    }
257
258    #[test]
259    fn profile_quick_config() {
260        let config = AnalysisProfile::Quick.to_config();
261        assert!(config.forensic);
262        assert!(!config.entropy);
263        assert!(!config.chaos);
264        assert!(config.trials.is_none());
265        assert!(!config.cross_correlation);
266    }
267
268    #[test]
269    fn profile_deep_config() {
270        let config = AnalysisProfile::Deep.to_config();
271        assert!(config.forensic);
272        assert!(config.entropy);
273        assert!(config.chaos);
274        assert!(config.trials.is_some());
275        assert!(config.cross_correlation);
276    }
277
278    #[test]
279    fn profile_security_config() {
280        let config = AnalysisProfile::Security.to_config();
281        assert!(config.forensic);
282        assert!(config.entropy);
283        assert!(!config.chaos);
284        assert!(config.trials.is_none());
285        assert!(!config.cross_correlation);
286    }
287
288    #[test]
289    fn profile_parse() {
290        assert_eq!(AnalysisProfile::parse("quick"), AnalysisProfile::Quick);
291        assert_eq!(AnalysisProfile::parse("DEEP"), AnalysisProfile::Deep);
292        assert_eq!(
293            AnalysisProfile::parse("security"),
294            AnalysisProfile::Security
295        );
296        assert_eq!(AnalysisProfile::parse("unknown"), AnalysisProfile::Standard);
297    }
298
299    #[test]
300    fn analyze_forensic_only() {
301        let data = test_data();
302        let config = AnalysisConfig {
303            forensic: true,
304            entropy: false,
305            chaos: false,
306            trials: None,
307            cross_correlation: false,
308        };
309        let report = analyze(&[("test", &data)], &config);
310        assert_eq!(report.sources.len(), 1);
311        assert!(report.sources[0].forensic.is_some());
312        assert!(report.sources[0].entropy.is_none());
313        assert!(report.sources[0].chaos.is_none());
314        assert!(report.sources[0].trials.is_none());
315        assert!(report.cross_correlation.is_none());
316        assert!(report.sources[0].verdicts.autocorrelation.is_some());
317        assert!(report.sources[0].verdicts.hurst.is_none());
318    }
319
320    #[test]
321    fn analyze_deep_profile() {
322        let data = test_data();
323        let config = AnalysisProfile::Deep.to_config();
324        let report = analyze(&[("src_a", &data), ("src_b", &data)], &config);
325        assert_eq!(report.sources.len(), 2);
326        assert!(report.sources[0].forensic.is_some());
327        assert!(report.sources[0].entropy.is_some());
328        assert!(report.sources[0].chaos.is_some());
329        assert!(report.sources[0].trials.is_some());
330        assert!(report.cross_correlation.is_some());
331        assert!(report.sources[0].verdicts.autocorrelation.is_some());
332        assert!(report.sources[0].verdicts.hurst.is_some());
333    }
334
335    #[test]
336    fn analyze_no_modules() {
337        let data = test_data();
338        let config = AnalysisConfig {
339            forensic: false,
340            entropy: false,
341            chaos: false,
342            trials: None,
343            cross_correlation: false,
344        };
345        let report = analyze(&[("test", &data)], &config);
346        assert!(report.sources[0].forensic.is_none());
347        assert!(report.sources[0].entropy.is_none());
348        assert!(report.sources[0].chaos.is_none());
349        assert!(report.sources[0].trials.is_none());
350        assert!(report.sources[0].verdicts.autocorrelation.is_none());
351    }
352
353    #[test]
354    fn analyze_cross_correlation_needs_two_sources() {
355        let data = test_data();
356        let config = AnalysisConfig {
357            forensic: false,
358            entropy: false,
359            chaos: false,
360            trials: None,
361            cross_correlation: true,
362        };
363        let report = analyze(&[("test", &data)], &config);
364        assert!(report.cross_correlation.is_none());
365        let report = analyze(&[("a", &data), ("b", &data)], &config);
366        assert!(report.cross_correlation.is_some());
367    }
368
369    #[test]
370    fn default_config_is_standard() {
371        let config = AnalysisConfig::default();
372        let standard = AnalysisProfile::Standard.to_config();
373        assert_eq!(config.forensic, standard.forensic);
374        assert_eq!(config.entropy, standard.entropy);
375        assert_eq!(config.chaos, standard.chaos);
376        assert_eq!(config.cross_correlation, standard.cross_correlation);
377    }
378
379    #[test]
380    fn analysis_report_serializes() {
381        let data = test_data();
382        let config = AnalysisProfile::Quick.to_config();
383        let report = analyze(&[("test", &data)], &config);
384        let json = serde_json::to_string(&report).expect("should serialize");
385        assert!(json.contains("\"forensic\""));
386        // Quick profile: chaos analysis result should be absent from sources
387        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
388        let source = &v["sources"][0];
389        assert!(source.get("chaos").is_none());
390        assert!(source.get("entropy").is_none());
391        assert!(source.get("trials").is_none());
392    }
393}