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.
315    #[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    /// Empirical-Bayes calibrated confidence for a raw confidence.
373    ///
374    /// Shrinks the raw confidence toward the observed hit rate, weighted by
375    /// resolved sample size (w = n/(n + k)). Identity when nothing is
376    /// resolved — the record is never edited, only re-read through the lens
377    /// of its own track record.
378    #[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    /// Serialize to JSON for persistence.
399    #[must_use]
400    pub fn to_json(&self) -> Value {
401        serde_json::to_value(self).unwrap_or_else(|_| json!({}))
402    }
403
404    /// Restore from persisted JSON.
405    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        // Days since 1970-01-01 for the given calendar date (proleptic).
418        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)); // 332 days
457        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); // 14 days / 7
532        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        // The tool layer enforces a non-empty criterion; the ledger stores as-is.
586        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        // 3 validated at 0.6/0.7/0.8, 1 falsified at 0.5.
605        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        // Underconfident: stated confidences run BELOW the realized hit rate.
625        assert!((cal.calibration_gap - (0.65 - 0.75)).abs() < 1e-12);
626        assert!(cal.calibration_gap < 0.0);
627        // Brier: (0.4^2 + 0.3^2 + 0.2^2 + 0.5^2) / 4 = (0.16+0.09+0.04+0.25)/4.
628        assert!((cal.brier - 0.135).abs() < 1e-12);
629        // Shrinkage weight n/(n+k) = 4/24 = 1/6.
630        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        // n=2, k=20 → w=2/22. hit_rate=0.5. raw 0.8 → 0.8 + w*(0.5-0.8).
646        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        // Underconfident raw confidences are pulled up.
655        assert!(ledger.calibrated_confidence(0.2) > 0.2);
656        // Clamped to [0, 1] even with extreme inputs.
657        assert_eq!(ledger.calibrated_confidence(1.5), 1.0);
658    }
659
660    #[test]
661    fn wilson_interval_matches_known_values() {
662        // 19/20 successes: p̂ = 0.95 → CI ≈ [0.764, 0.991].
663        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        // 0/0 → full range.
667        assert_eq!(wilson95(0, 0), (0.0, 1.0));
668        // 0/10 → [0, ~0.28].
669        let (lo, hi) = wilson95(0, 10);
670        assert_eq!(lo, 0.0);
671        assert!(hi < 0.35);
672    }
673}