1use crate::forensics::transcription;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub enum ForensicVerdict {
8 V1VerifiedHuman,
10 V2LikelyHuman,
12 V3Suspicious,
14 V4LikelySynthetic,
16 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 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 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}