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 self.calibration_with_k(CALIBRATION_PRIOR_SAMPLES)
318 }
319
320 #[must_use]
322 pub fn calibration_with_k(&self, k: f64) -> ClaimCalibration {
323 let resolved: Vec<&Claim> = self
324 .claims
325 .iter()
326 .filter(|c| c.status != ClaimStatus::Pending)
327 .collect();
328 let n = resolved.len();
329 let validated = resolved
330 .iter()
331 .filter(|c| c.status == ClaimStatus::Validated)
332 .count();
333 let falsified = n - validated;
334
335 if n == 0 {
336 return ClaimCalibration {
337 resolved: 0,
338 validated: 0,
339 falsified: 0,
340 mean_confidence: 0.0,
341 hit_rate: 0.0,
342 calibration_gap: 0.0,
343 brier: 0.0,
344 hit_rate_ci95: (0.0, 1.0),
345 shrinkage: 0.0,
346 };
347 }
348
349 let nf = n as f64;
350 let mean_confidence = resolved.iter().map(|c| c.confidence).sum::<f64>() / nf;
351 let hit_rate = validated as f64 / nf;
352 let brier = resolved
353 .iter()
354 .map(|c| {
355 let y = if c.status == ClaimStatus::Validated {
356 1.0
357 } else {
358 0.0
359 };
360 (c.confidence - y).powi(2)
361 })
362 .sum::<f64>()
363 / nf;
364
365 ClaimCalibration {
366 resolved: n,
367 validated,
368 falsified,
369 mean_confidence,
370 hit_rate,
371 calibration_gap: mean_confidence - hit_rate,
372 brier,
373 hit_rate_ci95: wilson95(validated, n),
374 shrinkage: if nf + k > 0.0 { nf / (nf + k) } else { 1.0 },
375 }
376 }
377
378 #[must_use]
385 pub fn calibrated_confidence(&self, raw: f64) -> f64 {
386 self.calibrated_confidence_with_k(raw, CALIBRATION_PRIOR_SAMPLES)
387 }
388
389 #[must_use]
391 pub fn calibrated_confidence_with_k(&self, raw: f64, k: f64) -> f64 {
392 let validated = self
393 .claims
394 .iter()
395 .filter(|c| c.status == ClaimStatus::Validated)
396 .count();
397 let resolved = self
398 .claims
399 .iter()
400 .filter(|c| c.status != ClaimStatus::Pending)
401 .count();
402 if resolved == 0 {
403 return raw.clamp(0.0, 1.0);
404 }
405 let hit_rate = validated as f64 / resolved as f64;
406 let w = if resolved as f64 + k > 0.0 {
407 resolved as f64 / (resolved as f64 + k)
408 } else {
409 1.0
410 };
411 w.mul_add(hit_rate - raw, raw).clamp(0.0, 1.0)
412 }
413
414 #[must_use]
416 pub fn to_json(&self) -> Value {
417 serde_json::to_value(self).unwrap_or_else(|_| json!({}))
418 }
419
420 pub fn from_json(&mut self, value: &Value) -> Result<(), String> {
422 let restored: Self = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
423 *self = restored;
424 Ok(())
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn epoch_day(y: i64, m: i64, d: i64) -> i64 {
433 let (y, m) = if m <= 2 { (y - 1, m + 12) } else { (y, m) };
435 let a = y / 100;
436 let b = 2 - a + a / 4;
437 (365 * (y + 4716) + (y + 4716) / 4 - b + (153 * (m + 1)) / 5 + d - 1524) - 719_163
438 }
439
440 fn days_between(start: (i64, i64, i64), end: (i64, i64, i64)) -> i64 {
441 epoch_day(end.0, end.1, end.2) - epoch_day(start.0, start.1, start.2)
442 }
443
444 #[test]
445 fn record_creates_pending_claim() {
446 let mut ledger = ClaimsLedger::new();
447 let claim = ledger.record(
448 "Karma-style audit ledger ships in a major lab",
449 "ai_governance",
450 epoch_day(2025, 5, 26),
451 "Anthropic ships an append-only audit log",
452 0.7,
453 "No major lab ships an append-only agent audit log by 2026-12-31",
454 );
455 assert_eq!(claim.id, "claim-0000");
456 assert_eq!(claim.status, ClaimStatus::Pending);
457 assert!(claim.points.is_none());
458 assert_eq!(ledger.claims.len(), 1);
459 }
460
461 #[test]
462 fn resolve_validated_credits_lead_weeks() {
463 let mut ledger = ClaimsLedger::new();
464 let claim = ledger.record(
465 "Declared-vs-actual side-effect audit",
466 "ai_governance",
467 epoch_day(2025, 5, 26),
468 "Append-only audit with side-effect tracking ships publicly",
469 0.8,
470 "No public audit substrate by 2026-12-31",
471 );
472 let lead = days_between((2025, 5, 26), (2026, 4, 23)); let resolved = ledger
474 .resolve(
475 &claim.id,
476 true,
477 "Anthropic Claude Memory audit log",
478 epoch_day(2025, 5, 26) + lead,
479 Some("anthropic.com".into()),
480 )
481 .unwrap();
482 assert_eq!(resolved.status, ClaimStatus::Validated);
483 assert_eq!(resolved.points.unwrap(), 332.0 / 7.0);
484 assert_eq!(resolved.lead_time_weeks.unwrap(), 332.0 / 7.0);
485 }
486
487 #[test]
488 fn resolve_falsified_is_recorded_as_miss() {
489 let mut ledger = ClaimsLedger::new();
490 let claim = ledger.record(
491 "Speculative claim that fails",
492 "test",
493 epoch_day(2026, 1, 1),
494 "Something happens",
495 0.5,
496 "It does not happen by 2026-06-30",
497 );
498 let resolved = ledger
499 .resolve(
500 &claim.id,
501 false,
502 "Nothing happened",
503 epoch_day(2026, 7, 1),
504 None,
505 )
506 .unwrap();
507 assert_eq!(resolved.status, ClaimStatus::Falsified);
508 assert_eq!(resolved.points, Some(0.0));
509 let status = ledger.status();
510 assert_eq!(status["falsified"], 1);
511 }
512
513 #[test]
514 fn cannot_resolve_twice() {
515 let mut ledger = ClaimsLedger::new();
516 let claim = ledger.record("Once", "test", epoch_day(2026, 1, 1), "X", 0.5, "Not X");
517 ledger
518 .resolve(&claim.id, true, "event", epoch_day(2026, 1, 8), None)
519 .unwrap();
520 let second = ledger.resolve(&claim.id, true, "again", epoch_day(2026, 1, 15), None);
521 assert!(second.is_err());
522 }
523
524 #[test]
525 fn status_totals_and_domains() {
526 let mut ledger = ClaimsLedger::new();
527 let a = ledger.record(
528 "A",
529 "ai_governance",
530 epoch_day(2026, 1, 1),
531 "X",
532 0.6,
533 "not X",
534 );
535 let b = ledger.record("B", "energy", epoch_day(2026, 1, 1), "Y", 0.5, "not Y");
536 ledger
537 .resolve(&a.id, true, "ev", epoch_day(2026, 1, 15), None)
538 .unwrap();
539 ledger
540 .resolve(&b.id, false, "miss", epoch_day(2026, 2, 1), None)
541 .unwrap();
542 let status = ledger.status();
543 assert_eq!(status["total_claims"], 2);
544 assert_eq!(status["validated"], 1);
545 assert_eq!(status["falsified"], 1);
546 assert_eq!(status["pending"], 0);
547 assert_eq!(status["total_points"], 2.0); assert_eq!(status["domains"].as_array().unwrap().len(), 2);
549 }
550
551 #[test]
552 fn json_roundtrip() {
553 let mut ledger = ClaimsLedger::new();
554 let claim = ledger.record("R", "test", epoch_day(2026, 1, 1), "X", 0.5, "not X");
555 ledger
556 .resolve(&claim.id, true, "ev", epoch_day(2026, 1, 8), None)
557 .unwrap();
558 let json = ledger.to_json();
559 let mut restored = ClaimsLedger::new();
560 restored.from_json(&json).unwrap();
561 assert_eq!(restored.claims.len(), 1);
562 assert_eq!(restored.claims[0].status, ClaimStatus::Validated);
563 assert_eq!(restored.claims[0].points, Some(1.0));
564 }
565
566 #[test]
567 fn list_filters_by_domain_and_status() {
568 let mut ledger = ClaimsLedger::new();
569 let a = ledger.record(
570 "A",
571 "ai_governance",
572 epoch_day(2026, 1, 1),
573 "X",
574 0.5,
575 "not X",
576 );
577 let b = ledger.record("B", "energy", epoch_day(2026, 1, 1), "Y", 0.5, "not Y");
578 ledger
579 .resolve(&a.id, true, "ev", epoch_day(2026, 1, 8), None)
580 .unwrap();
581 assert_eq!(ledger.list(Some("ai_governance"), None).len(), 1);
582 assert_eq!(ledger.list(None, Some(ClaimStatus::Pending)).len(), 1);
583 assert_eq!(
584 ledger
585 .list(Some("energy"), Some(ClaimStatus::Pending))
586 .len(),
587 1
588 );
589 assert_eq!(
590 ledger
591 .list(Some("energy"), Some(ClaimStatus::Validated))
592 .len(),
593 0
594 );
595 assert_eq!(ledger.list(None, None).len(), 2);
596 assert_eq!(b.id, "claim-0001");
597 }
598
599 #[test]
600 fn missing_falsification_criteria_still_recorded_but_flagged() {
601 let mut ledger = ClaimsLedger::new();
603 let claim = ledger.record("Vague", "test", epoch_day(2026, 1, 1), "X", 0.5, "");
604 assert!(claim.falsification_criteria.is_empty());
605 }
606
607 #[test]
608 fn calibration_empty_ledger_is_identity() {
609 let ledger = ClaimsLedger::new();
610 let cal = ledger.calibration();
611 assert_eq!(cal.resolved, 0);
612 assert_eq!(cal.calibration_gap, 0.0);
613 assert_eq!(cal.hit_rate_ci95, (0.0, 1.0));
614 assert_eq!(ledger.calibrated_confidence(0.8), 0.8);
615 }
616
617 #[test]
618 fn calibration_reports_gap_brier_and_shrinkage() {
619 let mut ledger = ClaimsLedger::new();
620 let ids: Vec<String> = [0.6, 0.7, 0.8, 0.5]
622 .iter()
623 .map(|c| {
624 let claim = ledger.record("C", "test", epoch_day(2026, 1, 1), "X", *c, "not X");
625 claim.id
626 })
627 .collect();
628 for (i, id) in ids.iter().enumerate() {
629 ledger
630 .resolve(id, i < 3, "ev", epoch_day(2026, 1, 15), None)
631 .unwrap();
632 }
633
634 let cal = ledger.calibration();
635 assert_eq!(cal.resolved, 4);
636 assert_eq!(cal.validated, 3);
637 assert_eq!(cal.falsified, 1);
638 assert!((cal.mean_confidence - 0.65).abs() < 1e-12);
639 assert!((cal.hit_rate - 0.75).abs() < 1e-12);
640 assert!((cal.calibration_gap - (0.65 - 0.75)).abs() < 1e-12);
642 assert!(cal.calibration_gap < 0.0);
643 assert!((cal.brier - 0.135).abs() < 1e-12);
645 assert!((cal.shrinkage - 1.0 / 6.0).abs() < 1e-12);
647 }
648
649 #[test]
650 fn calibrated_confidence_shrinks_toward_hit_rate() {
651 let mut ledger = ClaimsLedger::new();
652 let ok = ledger.record("A", "test", epoch_day(2026, 1, 1), "X", 0.7, "not X");
653 let miss = ledger.record("B", "test", epoch_day(2026, 1, 1), "Y", 0.5, "not Y");
654 ledger
655 .resolve(&ok.id, true, "ev", epoch_day(2026, 1, 8), None)
656 .unwrap();
657 ledger
658 .resolve(&miss.id, false, "ev", epoch_day(2026, 1, 8), None)
659 .unwrap();
660
661 let w = 2.0_f64 / 22.0;
663 let expected = w.mul_add(0.5_f64 - 0.8, 0.8);
664 let got = ledger.calibrated_confidence(0.8);
665 assert!((got - expected).abs() < 1e-12);
666 assert!(
667 got < 0.8,
668 "overconfident raw confidences must be pulled down"
669 );
670 assert!(ledger.calibrated_confidence(0.2) > 0.2);
672 assert_eq!(ledger.calibrated_confidence(1.5), 1.0);
674 }
675
676 #[test]
677 fn wilson_interval_matches_known_values() {
678 let (lo, hi) = wilson95(19, 20);
680 assert!(lo > 0.75 && lo < 0.78, "lo {lo}");
681 assert!(hi > 0.98 && hi < 1.0, "hi {hi}");
682 assert_eq!(wilson95(0, 0), (0.0, 1.0));
684 let (lo, hi) = wilson95(0, 10);
686 assert_eq!(lo, 0.0);
687 assert!(hi < 0.35);
688 }
689}