Skip to main content

wm_simulation/
claims.rs

1//! Claims ledger — the prescience track record as a first-class store.
2//!
3//! Ports the v26 `temporal_db` semantics (1 week of verified lead = 1 point,
4//! timestamped source + public validation event required) onto the v5
5//! substrate, alongside the existing [`CalibrationStore`] Brier machinery.
6//!
7//! A claim is a dated, falsifiable prediction. It is recorded with a source
8//! date and a falsification criterion; it is resolved against a validation
9//! event (validated or falsified). Validated claims earn points equal to the
10//! verified lead time in weeks. The ledger always reports the falsified
11//! count alongside the score — the honesty infrastructure is part of the
12//! store, not an afterthought (v26's 0-falsified / overconfidence lesson).
13
14#![forbid(unsafe_code)]
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19/// Status of a claim.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ClaimStatus {
23    /// Recorded, awaiting a validation event.
24    Pending,
25    /// Confirmed by a public, dated validation event.
26    Validated,
27    /// Explicitly falsified — the miss is part of the record.
28    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/// A dated validation (or falsification) event.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ValidationEvent {
45    /// What validated the claim (e.g. "Anthropic Claude Memory, Apr 23 2026").
46    pub event: String,
47    /// Epoch day (days since Unix epoch) of the validation event.
48    pub date: i64,
49    /// Optional source URL / identifier.
50    pub source: Option<String>,
51}
52
53/// A single dated, falsifiable prediction.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Claim {
56    /// Stable identifier (e.g. "claim-0001").
57    pub id: String,
58    /// The claim statement.
59    pub statement: String,
60    /// Domain / category label (e.g. "ai_governance", "agent_architecture").
61    pub domain: String,
62    /// Epoch day the claim was first documented.
63    pub source_date: i64,
64    /// What the claim predicts, resolved.
65    pub predicted_outcome: String,
66    /// Self-reported confidence in [0, 1].
67    pub confidence: f64,
68    /// Falsification criterion — the claim is WRONG if this does not happen.
69    pub falsification_criteria: String,
70    /// Resolution status.
71    pub status: ClaimStatus,
72    /// Validation event once resolved (None while pending).
73    pub validation_event: Option<ValidationEvent>,
74    /// Verified lead time in weeks once validated.
75    pub lead_time_weeks: Option<f64>,
76    /// Points earned: lead weeks for validated claims (1 week = 1 point).
77    pub points: Option<f64>,
78}
79
80impl Claim {
81    /// Verified lead time in weeks between two epoch days.
82    #[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/// The claims ledger — persistable via [`to_json`](Self::to_json) /
89/// [`from_json`](Self::from_json), like the calibration store.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct ClaimsLedger {
92    /// All claims (pending, validated, falsified).
93    pub claims: Vec<Claim>,
94    /// Sequence counter for claim IDs.
95    next_id: u64,
96}
97
98/// Prior-strength constant for the empirical-Bayes confidence shrinkage.
99///
100/// Calibrated confidence = raw + w·(hit_rate − raw) with w = n/(n + k).
101/// With n resolved claims and k = 20, a base rate estimated from 20 samples
102/// carries half the weight of the raw confidence; fewer samples → weaker
103/// shrinkage, more → stronger. Exposed in the calibration report so the
104/// effective weight is always visible.
105pub const CALIBRATION_PRIOR_SAMPLES: f64 = 20.0;
106
107/// Calibration of the resolved claims — the prescience track record's
108/// honesty statement. Computed over validated + falsified claims.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ClaimCalibration {
111    /// Number of resolved claims.
112    pub resolved: usize,
113    /// Validated / falsified counts.
114    pub validated: usize,
115    pub falsified: usize,
116    /// Mean self-reported confidence over resolved claims.
117    pub mean_confidence: f64,
118    /// Observed validation rate (validated / resolved).
119    pub hit_rate: f64,
120    /// mean_confidence − hit_rate. Positive = overconfident,
121    /// negative = underconfident (standard convention).
122    pub calibration_gap: f64,
123    /// Mean Brier score over resolved claims (lower is better).
124    pub brier: f64,
125    /// Wilson 95% confidence interval (lower, upper) for the hit rate.
126    pub hit_rate_ci95: (f64, f64),
127    /// Effective shrinkage weight w = n/(n + CALIBRATION_PRIOR_SAMPLES).
128    pub shrinkage: f64,
129}
130
131/// Wilson score 95% confidence interval for a binomial rate.
132#[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    /// Create an empty ledger.
148    #[must_use]
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Record a new pending claim.
154    ///
155    /// Returns the stored claim. The falsification criterion is mandatory —
156    /// a claim that cannot be falsified is not a claim.
157    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    /// Resolve a claim against a validation event.
186    ///
187    /// `validated: true` marks the claim validated and credits points equal
188    /// to the verified lead time in weeks. `validated: false` marks it
189    /// falsified — recorded as a miss, which is part of the track record.
190    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    /// Aggregate ledger status: totals, score, average lead, falsified count.
229    #[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        // Per-domain breakdown.
260        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    /// List claims, optionally filtered by domain and status.
304    #[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    /// Calibration report over resolved (validated + falsified) claims with statutory default k.
315    #[must_use]
316    pub fn calibration(&self) -> ClaimCalibration {
317        self.calibration_with_k(CALIBRATION_PRIOR_SAMPLES)
318    }
319
320    /// Calibration report over resolved (validated + falsified) claims with custom empirical-Bayes prior strength k.
321    #[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    /// Empirical-Bayes calibrated confidence for a raw confidence with statutory default k.
379    ///
380    /// Shrinks the raw confidence toward the observed hit rate, weighted by
381    /// resolved sample size (w = n/(n + k)). Identity when nothing is
382    /// resolved — the record is never edited, only re-read through the lens
383    /// of its own track record.
384    #[must_use]
385    pub fn calibrated_confidence(&self, raw: f64) -> f64 {
386        self.calibrated_confidence_with_k(raw, CALIBRATION_PRIOR_SAMPLES)
387    }
388
389    /// Empirical-Bayes calibrated confidence with custom prior sample weight k.
390    #[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    /// Serialize to JSON for persistence.
415    #[must_use]
416    pub fn to_json(&self) -> Value {
417        serde_json::to_value(self).unwrap_or_else(|_| json!({}))
418    }
419
420    /// Restore from persisted JSON.
421    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        // Days since 1970-01-01 for the given calendar date (proleptic).
434        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)); // 332 days
473        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); // 14 days / 7
548        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        // The tool layer enforces a non-empty criterion; the ledger stores as-is.
602        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        // 3 validated at 0.6/0.7/0.8, 1 falsified at 0.5.
621        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        // Underconfident: stated confidences run BELOW the realized hit rate.
641        assert!((cal.calibration_gap - (0.65 - 0.75)).abs() < 1e-12);
642        assert!(cal.calibration_gap < 0.0);
643        // Brier: (0.4^2 + 0.3^2 + 0.2^2 + 0.5^2) / 4 = (0.16+0.09+0.04+0.25)/4.
644        assert!((cal.brier - 0.135).abs() < 1e-12);
645        // Shrinkage weight n/(n+k) = 4/24 = 1/6.
646        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        // n=2, k=20 → w=2/22. hit_rate=0.5. raw 0.8 → 0.8 + w*(0.5-0.8).
662        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        // Underconfident raw confidences are pulled up.
671        assert!(ledger.calibrated_confidence(0.2) > 0.2);
672        // Clamped to [0, 1] even with extreme inputs.
673        assert_eq!(ledger.calibrated_confidence(1.5), 1.0);
674    }
675
676    #[test]
677    fn wilson_interval_matches_known_values() {
678        // 19/20 successes: p̂ = 0.95 → CI ≈ [0.764, 0.991].
679        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        // 0/0 → full range.
683        assert_eq!(wilson95(0, 0), (0.0, 1.0));
684        // 0/10 → [0, ~0.28].
685        let (lo, hi) = wilson95(0, 10);
686        assert_eq!(lo, 0.0);
687        assert!(hi < 0.35);
688    }
689}