1#![forbid(unsafe_code)]
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ClaimStatus {
23 Pending,
25 Validated,
27 Falsified,
29}
30
31impl ClaimStatus {
32 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Pending => "pending",
36 Self::Validated => "validated",
37 Self::Falsified => "falsified",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ValidationEvent {
45 pub event: String,
47 pub date: i64,
49 pub source: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Claim {
56 pub id: String,
58 pub statement: String,
60 pub domain: String,
62 pub source_date: i64,
64 pub predicted_outcome: String,
66 pub confidence: f64,
68 pub falsification_criteria: String,
70 pub status: ClaimStatus,
72 pub validation_event: Option<ValidationEvent>,
74 pub lead_time_weeks: Option<f64>,
76 pub points: Option<f64>,
78}
79
80impl Claim {
81 #[must_use]
83 pub fn lead_weeks(source_date: i64, validation_date: i64) -> f64 {
84 (validation_date - source_date) as f64 / 7.0
85 }
86}
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct ClaimsLedger {
92 pub claims: Vec<Claim>,
94 next_id: u64,
96}
97
98pub const CALIBRATION_PRIOR_SAMPLES: f64 = 20.0;
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ClaimCalibration {
111 pub resolved: usize,
113 pub validated: usize,
115 pub falsified: usize,
116 pub mean_confidence: f64,
118 pub hit_rate: f64,
120 pub calibration_gap: f64,
123 pub brier: f64,
125 pub hit_rate_ci95: (f64, f64),
127 pub shrinkage: f64,
129}
130
131#[must_use]
133pub fn wilson95(successes: usize, n: usize) -> (f64, f64) {
134 if n == 0 {
135 return (0.0, 1.0);
136 }
137 let z = 1.959_964;
138 let nf = n as f64;
139 let p = successes as f64 / nf;
140 let denom = 1.0 + z * z / nf;
141 let center = (p + z * z / (2.0 * nf)) / denom;
142 let margin = z * (p * (1.0 - p) / nf + z * z / (4.0 * nf * nf)).sqrt() / denom;
143 ((center - margin).max(0.0), (center + margin).min(1.0))
144}
145
146impl ClaimsLedger {
147 #[must_use]
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn record(
158 &mut self,
159 statement: &str,
160 domain: &str,
161 source_date: i64,
162 predicted_outcome: &str,
163 confidence: f64,
164 falsification_criteria: &str,
165 ) -> Claim {
166 let id = format!("claim-{:04}", self.next_id);
167 self.next_id += 1;
168 let claim = Claim {
169 id,
170 statement: statement.to_string(),
171 domain: domain.to_string(),
172 source_date,
173 predicted_outcome: predicted_outcome.to_string(),
174 confidence: confidence.clamp(0.0, 1.0),
175 falsification_criteria: falsification_criteria.to_string(),
176 status: ClaimStatus::Pending,
177 validation_event: None,
178 lead_time_weeks: None,
179 points: None,
180 };
181 self.claims.push(claim.clone());
182 claim
183 }
184
185 pub fn resolve(
191 &mut self,
192 claim_id: &str,
193 validated: bool,
194 event: &str,
195 event_date: i64,
196 source: Option<String>,
197 ) -> Result<Claim, String> {
198 let claim = self
199 .claims
200 .iter_mut()
201 .find(|c| c.id == claim_id)
202 .ok_or_else(|| format!("unknown claim id: {claim_id}"))?;
203
204 if claim.status != ClaimStatus::Pending {
205 return Err(format!("claim {} already resolved", claim.id));
206 }
207
208 claim.validation_event = Some(ValidationEvent {
209 event: event.to_string(),
210 date: event_date,
211 source,
212 });
213
214 if validated {
215 claim.status = ClaimStatus::Validated;
216 let lead = Claim::lead_weeks(claim.source_date, event_date);
217 claim.lead_time_weeks = Some(lead);
218 claim.points = Some(lead);
219 } else {
220 claim.status = ClaimStatus::Falsified;
221 claim.lead_time_weeks = None;
222 claim.points = Some(0.0);
223 }
224
225 Ok(claim.clone())
226 }
227
228 #[must_use]
230 pub fn status(&self) -> Value {
231 let total = self.claims.len();
232 let validated = self
233 .claims
234 .iter()
235 .filter(|c| c.status == ClaimStatus::Validated)
236 .count();
237 let falsified = self
238 .claims
239 .iter()
240 .filter(|c| c.status == ClaimStatus::Falsified)
241 .count();
242 let pending = self
243 .claims
244 .iter()
245 .filter(|c| c.status == ClaimStatus::Pending)
246 .count();
247 let points: f64 = self.claims.iter().filter_map(|c| c.points).sum();
248 let leads: Vec<f64> = self
249 .claims
250 .iter()
251 .filter_map(|c| c.lead_time_weeks)
252 .collect();
253 let avg_lead = if leads.is_empty() {
254 0.0
255 } else {
256 leads.iter().sum::<f64>() / leads.len() as f64
257 };
258
259 let mut domains: Vec<Value> = Vec::new();
261 let mut seen: Vec<String> = Vec::new();
262 for claim in &self.claims {
263 if seen.contains(&claim.domain) {
264 continue;
265 }
266 seen.push(claim.domain.clone());
267 let d_validated = self
268 .claims
269 .iter()
270 .filter(|c| c.domain == claim.domain && c.status == ClaimStatus::Validated)
271 .count();
272 let d_falsified = self
273 .claims
274 .iter()
275 .filter(|c| c.domain == claim.domain && c.status == ClaimStatus::Falsified)
276 .count();
277 let d_points: f64 = self
278 .claims
279 .iter()
280 .filter(|c| c.domain == claim.domain)
281 .filter_map(|c| c.points)
282 .sum();
283 domains.push(json!({
284 "domain": claim.domain,
285 "validated": d_validated,
286 "falsified": d_falsified,
287 "points": d_points,
288 }));
289 }
290
291 json!({
292 "status": "success",
293 "total_claims": total,
294 "validated": validated,
295 "falsified": falsified,
296 "pending": pending,
297 "total_points": points,
298 "avg_lead_weeks": avg_lead,
299 "domains": domains,
300 })
301 }
302
303 #[must_use]
305 pub fn list(&self, domain: Option<&str>, status: Option<ClaimStatus>) -> Vec<Claim> {
306 self.claims
307 .iter()
308 .filter(|c| domain.is_none_or(|d| c.domain == d))
309 .filter(|c| status.is_none_or(|s| c.status == s))
310 .cloned()
311 .collect()
312 }
313
314 #[must_use]
316 pub fn calibration(&self) -> ClaimCalibration {
317 let resolved: Vec<&Claim> = self
318 .claims
319 .iter()
320 .filter(|c| c.status != ClaimStatus::Pending)
321 .collect();
322 let n = resolved.len();
323 let validated = resolved
324 .iter()
325 .filter(|c| c.status == ClaimStatus::Validated)
326 .count();
327 let falsified = n - validated;
328
329 if n == 0 {
330 return ClaimCalibration {
331 resolved: 0,
332 validated: 0,
333 falsified: 0,
334 mean_confidence: 0.0,
335 hit_rate: 0.0,
336 calibration_gap: 0.0,
337 brier: 0.0,
338 hit_rate_ci95: (0.0, 1.0),
339 shrinkage: 0.0,
340 };
341 }
342
343 let nf = n as f64;
344 let mean_confidence = resolved.iter().map(|c| c.confidence).sum::<f64>() / nf;
345 let hit_rate = validated as f64 / nf;
346 let brier = resolved
347 .iter()
348 .map(|c| {
349 let y = if c.status == ClaimStatus::Validated {
350 1.0
351 } else {
352 0.0
353 };
354 (c.confidence - y).powi(2)
355 })
356 .sum::<f64>()
357 / nf;
358
359 ClaimCalibration {
360 resolved: n,
361 validated,
362 falsified,
363 mean_confidence,
364 hit_rate,
365 calibration_gap: mean_confidence - hit_rate,
366 brier,
367 hit_rate_ci95: wilson95(validated, n),
368 shrinkage: nf / (nf + CALIBRATION_PRIOR_SAMPLES),
369 }
370 }
371
372 #[must_use]
379 pub fn calibrated_confidence(&self, raw: f64) -> f64 {
380 let validated = self
381 .claims
382 .iter()
383 .filter(|c| c.status == ClaimStatus::Validated)
384 .count();
385 let resolved = self
386 .claims
387 .iter()
388 .filter(|c| c.status != ClaimStatus::Pending)
389 .count();
390 if resolved == 0 {
391 return raw.clamp(0.0, 1.0);
392 }
393 let hit_rate = validated as f64 / resolved as f64;
394 let w = resolved as f64 / (resolved as f64 + CALIBRATION_PRIOR_SAMPLES);
395 w.mul_add(hit_rate - raw, raw).clamp(0.0, 1.0)
396 }
397
398 #[must_use]
400 pub fn to_json(&self) -> Value {
401 serde_json::to_value(self).unwrap_or_else(|_| json!({}))
402 }
403
404 pub fn from_json(&mut self, value: &Value) -> Result<(), String> {
406 let restored: Self = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
407 *self = restored;
408 Ok(())
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn epoch_day(y: i64, m: i64, d: i64) -> i64 {
417 let (y, m) = if m <= 2 { (y - 1, m + 12) } else { (y, m) };
419 let a = y / 100;
420 let b = 2 - a + a / 4;
421 (365 * (y + 4716) + (y + 4716) / 4 - b + (153 * (m + 1)) / 5 + d - 1524) - 719_163
422 }
423
424 fn days_between(start: (i64, i64, i64), end: (i64, i64, i64)) -> i64 {
425 epoch_day(end.0, end.1, end.2) - epoch_day(start.0, start.1, start.2)
426 }
427
428 #[test]
429 fn record_creates_pending_claim() {
430 let mut ledger = ClaimsLedger::new();
431 let claim = ledger.record(
432 "Karma-style audit ledger ships in a major lab",
433 "ai_governance",
434 epoch_day(2025, 5, 26),
435 "Anthropic ships an append-only audit log",
436 0.7,
437 "No major lab ships an append-only agent audit log by 2026-12-31",
438 );
439 assert_eq!(claim.id, "claim-0000");
440 assert_eq!(claim.status, ClaimStatus::Pending);
441 assert!(claim.points.is_none());
442 assert_eq!(ledger.claims.len(), 1);
443 }
444
445 #[test]
446 fn resolve_validated_credits_lead_weeks() {
447 let mut ledger = ClaimsLedger::new();
448 let claim = ledger.record(
449 "Declared-vs-actual side-effect audit",
450 "ai_governance",
451 epoch_day(2025, 5, 26),
452 "Append-only audit with side-effect tracking ships publicly",
453 0.8,
454 "No public audit substrate by 2026-12-31",
455 );
456 let lead = days_between((2025, 5, 26), (2026, 4, 23)); let resolved = ledger
458 .resolve(
459 &claim.id,
460 true,
461 "Anthropic Claude Memory audit log",
462 epoch_day(2025, 5, 26) + lead,
463 Some("anthropic.com".into()),
464 )
465 .unwrap();
466 assert_eq!(resolved.status, ClaimStatus::Validated);
467 assert_eq!(resolved.points.unwrap(), 332.0 / 7.0);
468 assert_eq!(resolved.lead_time_weeks.unwrap(), 332.0 / 7.0);
469 }
470
471 #[test]
472 fn resolve_falsified_is_recorded_as_miss() {
473 let mut ledger = ClaimsLedger::new();
474 let claim = ledger.record(
475 "Speculative claim that fails",
476 "test",
477 epoch_day(2026, 1, 1),
478 "Something happens",
479 0.5,
480 "It does not happen by 2026-06-30",
481 );
482 let resolved = ledger
483 .resolve(
484 &claim.id,
485 false,
486 "Nothing happened",
487 epoch_day(2026, 7, 1),
488 None,
489 )
490 .unwrap();
491 assert_eq!(resolved.status, ClaimStatus::Falsified);
492 assert_eq!(resolved.points, Some(0.0));
493 let status = ledger.status();
494 assert_eq!(status["falsified"], 1);
495 }
496
497 #[test]
498 fn cannot_resolve_twice() {
499 let mut ledger = ClaimsLedger::new();
500 let claim = ledger.record("Once", "test", epoch_day(2026, 1, 1), "X", 0.5, "Not X");
501 ledger
502 .resolve(&claim.id, true, "event", epoch_day(2026, 1, 8), None)
503 .unwrap();
504 let second = ledger.resolve(&claim.id, true, "again", epoch_day(2026, 1, 15), None);
505 assert!(second.is_err());
506 }
507
508 #[test]
509 fn status_totals_and_domains() {
510 let mut ledger = ClaimsLedger::new();
511 let a = ledger.record(
512 "A",
513 "ai_governance",
514 epoch_day(2026, 1, 1),
515 "X",
516 0.6,
517 "not X",
518 );
519 let b = ledger.record("B", "energy", epoch_day(2026, 1, 1), "Y", 0.5, "not Y");
520 ledger
521 .resolve(&a.id, true, "ev", epoch_day(2026, 1, 15), None)
522 .unwrap();
523 ledger
524 .resolve(&b.id, false, "miss", epoch_day(2026, 2, 1), None)
525 .unwrap();
526 let status = ledger.status();
527 assert_eq!(status["total_claims"], 2);
528 assert_eq!(status["validated"], 1);
529 assert_eq!(status["falsified"], 1);
530 assert_eq!(status["pending"], 0);
531 assert_eq!(status["total_points"], 2.0); assert_eq!(status["domains"].as_array().unwrap().len(), 2);
533 }
534
535 #[test]
536 fn json_roundtrip() {
537 let mut ledger = ClaimsLedger::new();
538 let claim = ledger.record("R", "test", epoch_day(2026, 1, 1), "X", 0.5, "not X");
539 ledger
540 .resolve(&claim.id, true, "ev", epoch_day(2026, 1, 8), None)
541 .unwrap();
542 let json = ledger.to_json();
543 let mut restored = ClaimsLedger::new();
544 restored.from_json(&json).unwrap();
545 assert_eq!(restored.claims.len(), 1);
546 assert_eq!(restored.claims[0].status, ClaimStatus::Validated);
547 assert_eq!(restored.claims[0].points, Some(1.0));
548 }
549
550 #[test]
551 fn list_filters_by_domain_and_status() {
552 let mut ledger = ClaimsLedger::new();
553 let a = ledger.record(
554 "A",
555 "ai_governance",
556 epoch_day(2026, 1, 1),
557 "X",
558 0.5,
559 "not X",
560 );
561 let b = ledger.record("B", "energy", epoch_day(2026, 1, 1), "Y", 0.5, "not Y");
562 ledger
563 .resolve(&a.id, true, "ev", epoch_day(2026, 1, 8), None)
564 .unwrap();
565 assert_eq!(ledger.list(Some("ai_governance"), None).len(), 1);
566 assert_eq!(ledger.list(None, Some(ClaimStatus::Pending)).len(), 1);
567 assert_eq!(
568 ledger
569 .list(Some("energy"), Some(ClaimStatus::Pending))
570 .len(),
571 1
572 );
573 assert_eq!(
574 ledger
575 .list(Some("energy"), Some(ClaimStatus::Validated))
576 .len(),
577 0
578 );
579 assert_eq!(ledger.list(None, None).len(), 2);
580 assert_eq!(b.id, "claim-0001");
581 }
582
583 #[test]
584 fn missing_falsification_criteria_still_recorded_but_flagged() {
585 let mut ledger = ClaimsLedger::new();
587 let claim = ledger.record("Vague", "test", epoch_day(2026, 1, 1), "X", 0.5, "");
588 assert!(claim.falsification_criteria.is_empty());
589 }
590
591 #[test]
592 fn calibration_empty_ledger_is_identity() {
593 let ledger = ClaimsLedger::new();
594 let cal = ledger.calibration();
595 assert_eq!(cal.resolved, 0);
596 assert_eq!(cal.calibration_gap, 0.0);
597 assert_eq!(cal.hit_rate_ci95, (0.0, 1.0));
598 assert_eq!(ledger.calibrated_confidence(0.8), 0.8);
599 }
600
601 #[test]
602 fn calibration_reports_gap_brier_and_shrinkage() {
603 let mut ledger = ClaimsLedger::new();
604 let ids: Vec<String> = [0.6, 0.7, 0.8, 0.5]
606 .iter()
607 .map(|c| {
608 let claim = ledger.record("C", "test", epoch_day(2026, 1, 1), "X", *c, "not X");
609 claim.id
610 })
611 .collect();
612 for (i, id) in ids.iter().enumerate() {
613 ledger
614 .resolve(id, i < 3, "ev", epoch_day(2026, 1, 15), None)
615 .unwrap();
616 }
617
618 let cal = ledger.calibration();
619 assert_eq!(cal.resolved, 4);
620 assert_eq!(cal.validated, 3);
621 assert_eq!(cal.falsified, 1);
622 assert!((cal.mean_confidence - 0.65).abs() < 1e-12);
623 assert!((cal.hit_rate - 0.75).abs() < 1e-12);
624 assert!((cal.calibration_gap - (0.65 - 0.75)).abs() < 1e-12);
626 assert!(cal.calibration_gap < 0.0);
627 assert!((cal.brier - 0.135).abs() < 1e-12);
629 assert!((cal.shrinkage - 1.0 / 6.0).abs() < 1e-12);
631 }
632
633 #[test]
634 fn calibrated_confidence_shrinks_toward_hit_rate() {
635 let mut ledger = ClaimsLedger::new();
636 let ok = ledger.record("A", "test", epoch_day(2026, 1, 1), "X", 0.7, "not X");
637 let miss = ledger.record("B", "test", epoch_day(2026, 1, 1), "Y", 0.5, "not Y");
638 ledger
639 .resolve(&ok.id, true, "ev", epoch_day(2026, 1, 8), None)
640 .unwrap();
641 ledger
642 .resolve(&miss.id, false, "ev", epoch_day(2026, 1, 8), None)
643 .unwrap();
644
645 let w = 2.0_f64 / 22.0;
647 let expected = w.mul_add(0.5_f64 - 0.8, 0.8);
648 let got = ledger.calibrated_confidence(0.8);
649 assert!((got - expected).abs() < 1e-12);
650 assert!(
651 got < 0.8,
652 "overconfident raw confidences must be pulled down"
653 );
654 assert!(ledger.calibrated_confidence(0.2) > 0.2);
656 assert_eq!(ledger.calibrated_confidence(1.5), 1.0);
658 }
659
660 #[test]
661 fn wilson_interval_matches_known_values() {
662 let (lo, hi) = wilson95(19, 20);
664 assert!(lo > 0.75 && lo < 0.78, "lo {lo}");
665 assert!(hi > 0.98 && hi < 1.0, "hi {hi}");
666 assert_eq!(wilson95(0, 0), (0.0, 1.0));
668 let (lo, hi) = wilson95(0, 10);
670 assert_eq!(lo, 0.0);
671 assert!(hi < 0.35);
672 }
673}