Skip to main content

cpop_protocol/forensics/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use crate::forensics::transcription;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub enum ForensicVerdict {
8    /// High entropy, valid causality, non-linear composition.
9    V1VerifiedHuman,
10    /// Valid timing with minor causality drift (e.g., clock skew).
11    V2LikelyHuman,
12    /// Low entropy or high linearity — potential transcription.
13    V3Suspicious,
14    /// Perfect timing uniformity — histogram attack or bot.
15    V4LikelySynthetic,
16    /// HMAC causality lock broken — confirmed tampering.
17    V5ConfirmedForgery,
18}
19
20impl ForensicVerdict {
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            ForensicVerdict::V1VerifiedHuman => "V1_VerifiedHuman",
24            ForensicVerdict::V2LikelyHuman => "V2_LikelyHuman",
25            ForensicVerdict::V3Suspicious => "V3_Suspicious",
26            ForensicVerdict::V4LikelySynthetic => "V4_LikelySynthetic",
27            ForensicVerdict::V5ConfirmedForgery => "V5_ConfirmedForgery",
28        }
29    }
30
31    pub fn is_verified(&self) -> bool {
32        matches!(
33            self,
34            ForensicVerdict::V1VerifiedHuman | ForensicVerdict::V2LikelyHuman
35        )
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ForensicAnalysis {
41    pub verdict: ForensicVerdict,
42    pub coefficient_of_variation: f64,
43    pub linearity_score: Option<f64>,
44    pub hurst_exponent: Option<f64>,
45    pub checkpoint_count: usize,
46    pub chain_duration_secs: u64,
47    pub explanation: String,
48}
49
50impl ForensicAnalysis {
51    fn new(
52        verdict: ForensicVerdict,
53        cv: f64,
54        checkpoint_count: usize,
55        chain_duration_secs: u64,
56        explanation: impl Into<String>,
57    ) -> Self {
58        Self {
59            verdict,
60            coefficient_of_variation: cv,
61            linearity_score: None,
62            hurst_exponent: None,
63            checkpoint_count,
64            chain_duration_secs,
65            explanation: explanation.into(),
66        }
67    }
68
69    fn with_hurst(mut self, h: Option<f64>) -> Self {
70        self.hurst_exponent = h;
71        self
72    }
73
74    fn with_linearity(mut self, l: Option<f64>) -> Self {
75        self.linearity_score = l;
76        self
77    }
78}
79
80pub struct ForensicsEngine {
81    pub inter_checkpoint_intervals: Vec<f64>,
82    pub causality_chain_valid: bool,
83    pub transcription_data: Option<transcription::TranscriptionData>,
84}
85
86impl ForensicsEngine {
87    pub fn from_timestamps(timestamps: &[u64], causality_valid: bool) -> Self {
88        let intervals: Vec<f64> = timestamps
89            .windows(2)
90            .map(|w| (w[1] as f64) - (w[0] as f64))
91            .collect();
92
93        Self {
94            inter_checkpoint_intervals: intervals,
95            causality_chain_valid: causality_valid,
96            transcription_data: None,
97        }
98    }
99
100    pub fn with_transcription_data(mut self, data: transcription::TranscriptionData) -> Self {
101        self.transcription_data = Some(data);
102        self
103    }
104
105    pub fn analyze(&self) -> ForensicAnalysis {
106        let n = self.inter_checkpoint_intervals.len() + 1;
107        let dur = self.inter_checkpoint_intervals.iter().sum::<f64>().max(0.0) as u64;
108        let fa = |v, cv, msg: String| ForensicAnalysis::new(v, cv, n, dur, msg);
109
110        if !self.causality_chain_valid {
111            return fa(
112                ForensicVerdict::V5ConfirmedForgery,
113                0.0,
114                "HMAC causality lock broken — evidence has been tampered with".into(),
115            );
116        }
117
118        if self.inter_checkpoint_intervals.len() < 3 {
119            return fa(
120                ForensicVerdict::V2LikelyHuman,
121                0.0,
122                "Insufficient checkpoints for full forensic analysis".into(),
123            );
124        }
125
126        let delta = self.compute_coefficient_of_variation();
127
128        if self.detect_adversarial_collapse() {
129            return fa(
130                ForensicVerdict::V4LikelySynthetic,
131                delta,
132                "Adversarial collapse: timing intervals are uniform (non-human)".into(),
133            );
134        }
135
136        if delta < 0.15 {
137            return fa(
138                ForensicVerdict::V4LikelySynthetic,
139                delta,
140                format!(
141                    "Timing entropy too low (δ={:.3}): consistent with automated generation",
142                    delta
143                ),
144            );
145        }
146
147        if delta > 0.80 {
148            return fa(
149                ForensicVerdict::V3Suspicious,
150                delta,
151                format!(
152                    "Timing entropy too high (δ={:.3}): potential bot noise injection",
153                    delta
154                ),
155            );
156        }
157
158        let hurst = if self.inter_checkpoint_intervals.len() >= 10 {
159            Some(self.estimate_hurst_exponent())
160        } else {
161            None
162        };
163
164        if let Some(h) = hurst {
165            if h < 0.45 {
166                return fa(
167                    ForensicVerdict::V3Suspicious,
168                    delta,
169                    format!(
170                        "White-noise timing (H={:.3}): inconsistent with human composition",
171                        h
172                    ),
173                )
174                .with_hurst(Some(h));
175            }
176            if h > 0.90 {
177                return fa(
178                    ForensicVerdict::V3Suspicious,
179                    delta,
180                    format!(
181                        "Highly predictable timing (H={:.3}): consistent with scripted input",
182                        h
183                    ),
184                )
185                .with_hurst(Some(h));
186            }
187        }
188
189        let linearity_score = self.transcription_data.as_ref().map(|td| {
190            let detector = transcription::TranscriptionDetector::from_data(td);
191            detector.compute_linearity_score()
192        });
193
194        if let Some(linearity) = linearity_score {
195            if linearity > 0.92 {
196                let avg_burst = self
197                    .transcription_data
198                    .as_ref()
199                    .map(|td| td.avg_burst_length)
200                    .unwrap_or(0.0);
201
202                if avg_burst > 15.0 {
203                    return fa(
204                        ForensicVerdict::V3Suspicious,
205                        delta,
206                        format!(
207                            "High linearity ({:.3}) with long bursts ({:.1}): consistent with transcription",
208                            linearity, avg_burst
209                        ),
210                    )
211                    .with_hurst(hurst)
212                    .with_linearity(Some(linearity));
213                }
214            }
215        }
216
217        let has_minor_anomalies = hurst.is_some_and(|h| !(0.55..=0.85).contains(&h))
218            || linearity_score.is_some_and(|l| l > 0.85);
219
220        if has_minor_anomalies {
221            return fa(
222                ForensicVerdict::V2LikelyHuman,
223                delta,
224                "Timing consistent with human composition, minor anomalies noted".into(),
225            )
226            .with_hurst(hurst)
227            .with_linearity(linearity_score);
228        }
229
230        fa(
231            ForensicVerdict::V1VerifiedHuman,
232            delta,
233            "High entropy, valid causality, non-linear composition confirmed".into(),
234        )
235        .with_hurst(hurst)
236        .with_linearity(linearity_score)
237    }
238
239    fn compute_coefficient_of_variation(&self) -> f64 {
240        if self.inter_checkpoint_intervals.is_empty() {
241            return 0.0;
242        }
243        let n = self.inter_checkpoint_intervals.len() as f64;
244        let mean = self.inter_checkpoint_intervals.iter().sum::<f64>() / n;
245        if mean == 0.0 || !mean.is_finite() {
246            return 0.0;
247        }
248        let variance = self
249            .inter_checkpoint_intervals
250            .iter()
251            .map(|&x| (x - mean).powi(2))
252            .sum::<f64>()
253            / n;
254        let cv = variance.sqrt() / mean;
255        // NaN/Inf from pathological inputs must not bypass downstream threshold checks
256        if cv.is_finite() {
257            cv
258        } else {
259            0.0
260        }
261    }
262
263    fn detect_adversarial_collapse(&self) -> bool {
264        if self.inter_checkpoint_intervals.len() < 3 {
265            return false;
266        }
267
268        let first = self.inter_checkpoint_intervals[0];
269        let tolerance = (first * 0.01).max(0.001);
270
271        self.inter_checkpoint_intervals
272            .iter()
273            .all(|&x| (x - first).abs() < tolerance)
274    }
275
276    /// Rescaled range (R/S) method for Hurst exponent estimation.
277    fn estimate_hurst_exponent(&self) -> f64 {
278        let data = &self.inter_checkpoint_intervals;
279        let n = data.len();
280
281        if n < 10 {
282            return 0.5;
283        }
284
285        let mut log_n_values = Vec::with_capacity(8);
286        let mut log_rs_values = Vec::with_capacity(8);
287        let mut cumdev = Vec::new();
288
289        let mut block_size = 4;
290        while block_size <= n / 2 {
291            let num_blocks = n / block_size;
292            let mut rs_sum = 0.0;
293
294            for b in 0..num_blocks {
295                let block = &data[b * block_size..(b + 1) * block_size];
296                let mean = block.iter().sum::<f64>() / block_size as f64;
297
298                cumdev.clear();
299                cumdev.reserve(block_size);
300                let mut running = 0.0;
301                for &val in block {
302                    running += val - mean;
303                    cumdev.push(running);
304                }
305
306                let range = cumdev.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
307                    - cumdev.iter().cloned().fold(f64::INFINITY, f64::min);
308
309                let std_dev = (block.iter().map(|&x| (x - mean).powi(2)).sum::<f64>()
310                    / block_size as f64)
311                    .sqrt();
312
313                if std_dev > 0.0 {
314                    rs_sum += range / std_dev;
315                }
316            }
317
318            if num_blocks > 0 {
319                let avg_rs = rs_sum / num_blocks as f64;
320                if avg_rs > 0.0 {
321                    log_n_values.push((block_size as f64).ln());
322                    log_rs_values.push(avg_rs.ln());
323                }
324            }
325
326            block_size *= 2;
327        }
328
329        if log_n_values.len() < 2 {
330            return 0.5;
331        }
332
333        let n_pts = log_n_values.len() as f64;
334        let sum_x: f64 = log_n_values.iter().sum();
335        let sum_y: f64 = log_rs_values.iter().sum();
336        let sum_xy: f64 = log_n_values
337            .iter()
338            .zip(log_rs_values.iter())
339            .map(|(x, y)| x * y)
340            .sum();
341        let sum_xx: f64 = log_n_values.iter().map(|x| x * x).sum();
342
343        let denominator = n_pts * sum_xx - sum_x * sum_x;
344        if denominator.abs() < f64::EPSILON {
345            return 0.5;
346        }
347
348        let slope = (n_pts * sum_xy - sum_x * sum_y) / denominator;
349        if slope.is_finite() {
350            slope.clamp(0.0, 1.0)
351        } else {
352            0.5
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_human_composition_passes() {
363        let engine = ForensicsEngine {
364            inter_checkpoint_intervals: vec![
365                12.5, 8.3, 15.2, 6.1, 22.7, 15.8, 20.0, 9.4, 18.9, 14.2, 11.3, 25.1, 7.8, 19.6,
366                13.4, 16.7, 10.2, 21.5, 8.9, 17.3,
367            ],
368            causality_chain_valid: true,
369            transcription_data: None,
370        };
371        let result = engine.analyze();
372        assert!(result.verdict.is_verified());
373    }
374
375    #[test]
376    fn test_bot_uniform_timing_fails() {
377        let engine = ForensicsEngine {
378            inter_checkpoint_intervals: vec![10.0, 10.0, 10.0, 10.0, 10.0],
379            causality_chain_valid: true,
380            transcription_data: None,
381        };
382        let result = engine.analyze();
383        assert_eq!(result.verdict, ForensicVerdict::V4LikelySynthetic);
384    }
385
386    #[test]
387    fn test_broken_causality_chain() {
388        let engine = ForensicsEngine {
389            inter_checkpoint_intervals: vec![12.5, 8.3, 45.2],
390            causality_chain_valid: false,
391            transcription_data: None,
392        };
393        let result = engine.analyze();
394        assert_eq!(result.verdict, ForensicVerdict::V5ConfirmedForgery);
395    }
396
397    #[test]
398    fn test_low_entropy_synthetic() {
399        let engine = ForensicsEngine {
400            inter_checkpoint_intervals: vec![10.0, 10.1, 10.0, 9.9, 10.1, 10.0],
401            causality_chain_valid: true,
402            transcription_data: None,
403        };
404        let result = engine.analyze();
405        assert_eq!(result.verdict, ForensicVerdict::V4LikelySynthetic);
406    }
407}