use ndarray::{Array1, Array2};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EProcess {
log_e: f64,
steps: usize,
log_e_max: f64,
}
impl EProcess {
pub fn new() -> Self {
Self {
log_e: 0.0,
steps: 0,
log_e_max: 0.0,
}
}
pub fn absorb(&mut self, e_value: f64) -> Result<(), String> {
if e_value.is_nan() || e_value < 0.0 {
return Err(format!("e-value must be in [0, ∞], got {e_value}"));
}
self.absorb_log(e_value.ln())
}
pub fn absorb_log(&mut self, log_e_value: f64) -> Result<(), String> {
let next_log_e = checked_log_e_sum(self.log_e, log_e_value)?;
self.log_e = next_log_e;
self.steps += 1;
if self.log_e > self.log_e_max {
self.log_e_max = self.log_e;
}
Ok(())
}
pub fn log_evidence(&self) -> f64 {
self.log_e
}
pub fn steps(&self) -> usize {
self.steps
}
pub fn rejects_at(&self, alpha: f64) -> bool {
alpha > 0.0 && self.log_e_max >= -(alpha.ln())
}
pub fn current_e_value_log(&self) -> f64 {
self.log_e
}
}
impl Default for EProcess {
fn default() -> Self {
Self::new()
}
}
fn checked_log_e_sum(current: f64, increment: f64) -> Result<f64, String> {
if current.is_nan() {
return Err("EProcess invariant violation: current log evidence is NaN".to_string());
}
if increment.is_nan() {
return Err("log e-value must not be NaN".to_string());
}
if current.is_infinite()
&& increment.is_infinite()
&& current.is_sign_positive() != increment.is_sign_positive()
{
return Err(format!(
"cannot combine opposing infinite log e-values: current {current}, increment {increment}"
));
}
Ok(current + increment)
}
pub fn split_likelihood_log_e_value(
log_lik_alternative_on_eval: f64,
log_lik_null_sup_on_eval: f64,
) -> f64 {
if log_lik_alternative_on_eval.is_nan() || log_lik_null_sup_on_eval.is_nan() {
return 0.0;
}
if log_lik_alternative_on_eval.is_infinite()
&& log_lik_null_sup_on_eval.is_infinite()
&& log_lik_alternative_on_eval.is_sign_positive()
== log_lik_null_sup_on_eval.is_sign_positive()
{
return 0.0;
}
log_lik_alternative_on_eval - log_lik_null_sup_on_eval
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PredictablePluginEProcess {
pub process: EProcess,
}
impl PredictablePluginEProcess {
pub fn new() -> Self {
Self {
process: EProcess::new(),
}
}
pub fn try_absorb_batch(
&mut self,
log_lik_alternative_prefit: f64,
log_lik_null_sup_on_batch: f64,
) -> Result<(), String> {
self.process.absorb_log(split_likelihood_log_e_value(
log_lik_alternative_prefit,
log_lik_null_sup_on_batch,
))
}
}
impl Default for PredictablePluginEProcess {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum GateVerdict {
Certified { log_e: f64 },
Contested { log_e: f64 },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AtomBirthGate {
pub test: PredictablePluginEProcess,
alpha: f64,
}
impl AtomBirthGate {
pub fn new(alpha: f64) -> Result<Self, String> {
if !(alpha > 0.0 && alpha < 1.0) {
return Err(format!(
"AtomBirthGate: alpha must be in (0,1), got {alpha}"
));
}
Ok(Self {
test: PredictablePluginEProcess::new(),
alpha,
})
}
pub fn alpha(&self) -> f64 {
self.alpha
}
pub fn try_absorb_shard(
&mut self,
log_lik_alternative_prefit: f64,
log_lik_null_sup_on_shard: f64,
) -> Result<(), String> {
self.test
.try_absorb_batch(log_lik_alternative_prefit, log_lik_null_sup_on_shard)
}
pub fn absorb_shard(
&mut self,
log_lik_alternative_prefit: f64,
log_lik_null_sup_on_shard: f64,
) {
self.try_absorb_shard(log_lik_alternative_prefit, log_lik_null_sup_on_shard)
.expect("AtomBirthGate received invalid log evidence");
}
pub fn verdict(&self) -> GateVerdict {
if self.test.process.rejects_at(self.alpha) {
GateVerdict::Certified {
log_e: self.test.process.log_evidence(),
}
} else {
GateVerdict::Contested {
log_e: self.test.process.log_evidence(),
}
}
}
}
pub fn run_atom_birth_gate<S, A>(
alpha: f64,
initial_alternative: A,
shards: impl IntoIterator<Item = S>,
mut alternative_log_lik: impl FnMut(&A, &S) -> f64,
mut null_sup_log_lik: impl FnMut(&S) -> f64,
mut refit_alternative: impl FnMut(A, &S) -> A,
) -> Result<(AtomBirthGate, A), String> {
let mut gate = AtomBirthGate::new(alpha)?;
let mut alt = initial_alternative;
for shard in shards {
if !matches!(gate.verdict(), GateVerdict::Certified { .. }) {
let log_lik_alt = alternative_log_lik(&alt, &shard);
let log_lik_null = null_sup_log_lik(&shard);
gate.try_absorb_shard(log_lik_alt, log_lik_null)?;
}
alt = refit_alternative(alt, &shard);
}
Ok((gate, alt))
}
pub fn e_benjamini_hochberg(log_e_values: &[f64], alpha: f64) -> Vec<usize> {
let m = log_e_values.len();
if m == 0 || !(alpha.is_finite() && alpha > 0.0) {
return Vec::new();
}
let sanitized: Vec<f64> = log_e_values
.iter()
.map(|&v| if v.is_nan() { f64::NEG_INFINITY } else { v })
.collect();
let mut order: Vec<usize> = (0..m).collect();
order.sort_by(|&a, &b| sanitized[b].total_cmp(&sanitized[a]));
let m_f = m as f64;
let mut k_star = 0usize;
for (rank0, &idx) in order.iter().enumerate() {
let k = (rank0 + 1) as f64;
if sanitized[idx] >= m_f.ln() - alpha.ln() - k.ln() {
k_star = rank0 + 1;
}
}
order.truncate(k_star);
order
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ClaimKind {
AtomExists { atom: usize },
BindingEdge { a: usize, b: usize },
GeometryKind { atom: usize, kind: String },
Custom { label: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StructuralClaim {
pub kind: ClaimKind,
pub evidence: EProcess,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct StructureLedger {
claims: Vec<StructuralClaim>,
}
impl StructureLedger {
pub fn new() -> Self {
Self { claims: Vec::new() }
}
pub fn register(&mut self, kind: ClaimKind) -> usize {
if let Some(idx) = self.claims.iter().position(|c| c.kind == kind) {
return idx;
}
self.claims.push(StructuralClaim {
kind,
evidence: EProcess::new(),
});
self.claims.len() - 1
}
pub fn absorb_log(&mut self, idx: usize, log_e_value: f64) -> Result<(), String> {
let n = self.claims.len();
let claim = self.claims.get_mut(idx).ok_or_else(|| {
format!("StructureLedger: claim index {idx} out of range ({n} claims)")
})?;
claim.evidence.absorb_log(log_e_value)
}
pub fn claims(&self) -> &[StructuralClaim] {
&self.claims
}
pub fn absorb_probe_outcome(
&mut self,
idx: usize,
log_lik_alt_on_outcome: f64,
log_lik_null_on_outcome: f64,
) -> Result<(), String> {
self.absorb_log(
idx,
split_likelihood_log_e_value(log_lik_alt_on_outcome, log_lik_null_on_outcome),
)
}
pub fn certify(&self, alpha: f64) -> StructureCertificate {
let log_e: Vec<f64> = self
.claims
.iter()
.map(|c| c.evidence.current_e_value_log())
.collect();
let confirmed_idx = e_benjamini_hochberg(&log_e, alpha);
let mut entries: Vec<CertificateEntry> = self
.claims
.iter()
.zip(&log_e)
.map(|(c, &le)| CertificateEntry {
kind: c.kind.clone(),
log_e: le,
steps: c.evidence.steps(),
confirmed: false,
})
.collect();
for &i in &confirmed_idx {
entries[i].confirmed = true;
}
StructureCertificate { alpha, entries }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CertificateEntry {
pub kind: ClaimKind,
pub log_e: f64,
pub steps: usize,
pub confirmed: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StructureCertificate {
pub alpha: f64,
pub entries: Vec<CertificateEntry>,
}
impl StructureCertificate {
pub fn confirmed(&self) -> impl Iterator<Item = &CertificateEntry> {
self.entries.iter().filter(|e| e.confirmed)
}
pub fn contested(&self) -> impl Iterator<Item = &CertificateEntry> {
self.entries.iter().filter(|e| !e.confirmed)
}
}
pub fn log_e_from_p_calibrator(p_value: f64) -> Result<f64, String> {
if !(p_value > 0.0) || p_value > 1.0 {
return Err(format!("p-value must be in (0, 1], got {p_value}"));
}
Ok(0.5f64.ln() - 0.5 * p_value.ln())
}
pub struct CandidateProbe {
pub delta: Array1<f64>,
pub predicted_mean_null: Array1<f64>,
pub predicted_mean_alt: Array1<f64>,
}
pub fn select_probe_by_expected_evidence(
probes: &[CandidateProbe],
fisher: &Array2<f64>,
) -> Option<(usize, f64)> {
let mut best: Option<(usize, f64)> = None;
for (idx, probe) in probes.iter().enumerate() {
let diff = &probe.predicted_mean_alt - &probe.predicted_mean_null;
if diff.len() != fisher.nrows() {
continue;
}
let f_diff = fisher.dot(&diff);
let growth = 0.5 * diff.dot(&f_diff);
if growth.is_finite() && growth > 0.0 {
match best {
Some((_, g)) if g >= growth => {}
_ => best = Some((idx, growth)),
}
}
}
best
}
pub fn expected_resolution_budget(alpha: f64, growth_nats_per_obs: f64) -> Option<f64> {
if alpha <= 0.0 || alpha >= 1.0 || growth_nats_per_obs <= 0.0 {
return None;
}
Some(-(alpha.ln()) / growth_nats_per_obs)
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProbePlan {
pub probe: usize,
pub expected_log_growth: f64,
pub budget_from_scratch: f64,
pub budget_remaining: f64,
}
pub fn plan_probe_for_contested_claim(
probes: &[CandidateProbe],
fisher: &Array2<f64>,
alpha: f64,
current_log_e: f64,
) -> Option<ProbePlan> {
let (probe, expected_log_growth) = select_probe_by_expected_evidence(probes, fisher)?;
let budget_from_scratch = expected_resolution_budget(alpha, expected_log_growth)?;
let nats_remaining = (-(alpha.ln()) - current_log_e).max(0.0);
Some(ProbePlan {
probe,
expected_log_growth,
budget_from_scratch,
budget_remaining: nats_remaining / expected_log_growth,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn e_bh_rejects_exactly_the_qualifying_prefix() {
let log_e = [45.0f64.ln(), 21.0f64.ln(), 12.0f64.ln(), 1.0f64.ln()];
let rejected = e_benjamini_hochberg(&log_e, 0.1);
assert_eq!(rejected, vec![0, 1]);
let log_e2 = [45.0f64.ln(), 5.0f64.ln(), 2.0f64.ln(), 1.0f64.ln()];
assert_eq!(e_benjamini_hochberg(&log_e2, 0.1), vec![0]);
}
#[test]
fn split_likelihood_equal_impossibility_is_neutral_log_evidence() {
let log_e = split_likelihood_log_e_value(f64::NEG_INFINITY, f64::NEG_INFINITY);
assert_eq!(log_e, 0.0);
assert!(log_e.is_finite());
let mut proc = EProcess::new();
proc.absorb_log(log_e).unwrap();
assert_eq!(proc.log_evidence(), 0.0);
assert_eq!(proc.steps(), 1);
}
#[test]
fn e_bh_orders_infinite_log_e_values_without_comparator_panic() {
let log_e = [f64::NEG_INFINITY, f64::INFINITY, 45.0f64.ln(), 1.0f64.ln()];
assert_eq!(e_benjamini_hochberg(&log_e, 0.1), vec![1, 2]);
}
#[test]
fn e_bh_treats_nan_as_least_evidence_without_panicking() {
let log_e = [45.0f64.ln(), f64::NAN];
let rejected = e_benjamini_hochberg(&log_e, 0.1);
assert_eq!(
rejected,
vec![0],
"strong claim survives; NaN claim never rejected"
);
let all_nan = [f64::NAN, f64::NAN, f64::NAN];
assert!(e_benjamini_hochberg(&all_nan, 0.1).is_empty());
}
#[test]
fn degenerate_split_lr_flows_through_certify_without_nan_panic() {
let neutral = split_likelihood_log_e_value(f64::NEG_INFINITY, f64::NEG_INFINITY);
assert_eq!(neutral, 0.0);
let from_nan = split_likelihood_log_e_value(f64::NAN, -3.0);
assert!(from_nan.is_finite());
assert_eq!(from_nan, 0.0);
let mut ledger = StructureLedger::new();
let degenerate = ledger.register(ClaimKind::AtomExists { atom: 0 });
let strong = ledger.register(ClaimKind::AtomExists { atom: 1 });
ledger.absorb_log(degenerate, neutral).unwrap();
ledger.absorb_log(strong, 45.0f64.ln()).unwrap();
let certificate = ledger.certify(0.1);
let degenerate_entry = certificate
.entries
.iter()
.find(|e| e.kind == ClaimKind::AtomExists { atom: 0 })
.expect("degenerate claim present");
assert!(!degenerate_entry.confirmed);
assert_eq!(degenerate_entry.log_e, 0.0);
}
#[test]
fn e_process_absorb_log_rejects_undefined_log_products() {
let mut proc = EProcess::new();
assert!(proc.absorb_log(f64::NAN).is_err());
proc.absorb_log(f64::INFINITY).unwrap();
assert!(proc.absorb_log(f64::NEG_INFINITY).is_err());
assert_eq!(proc.log_evidence(), f64::INFINITY);
assert_eq!(proc.steps(), 1);
}
#[test]
fn e_process_crossing_is_permanent_and_directional() {
let mu = 0.6f64;
let mut proc_alt = EProcess::new();
let mut crossed_at: Option<usize> = None;
for t in 0..200 {
let x = mu + 0.9 * ((t as f64 * 0.7321).sin());
proc_alt.absorb_log(mu * x - 0.5 * mu * mu).unwrap();
if proc_alt.rejects_at(0.05) && crossed_at.is_none() {
crossed_at = Some(t);
}
}
let t_cross = crossed_at.expect("true alternative must cross 1/α");
assert!(t_cross < 100, "evidence should accumulate quickly");
assert!(proc_alt.rejects_at(0.05));
let mut proc_null = EProcess::new();
for t in 0..200 {
let x = 0.9 * ((t as f64 * 0.7321).sin());
proc_null.absorb_log(mu * x - 0.5 * mu * mu).unwrap();
}
assert!(
!proc_null.rejects_at(0.05),
"null stream must not accumulate evidence (log E = {:.3})",
proc_null.log_evidence()
);
}
#[test]
fn probe_selection_prefers_discrimination_over_impact() {
let fisher = array![[2.0, 0.0], [0.0, 0.5]];
let probes = vec![
CandidateProbe {
delta: array![1.0, 0.0],
predicted_mean_null: array![10.0, 10.0],
predicted_mean_alt: array![10.0, 10.0],
},
CandidateProbe {
delta: array![0.0, 1.0],
predicted_mean_null: array![0.0, 0.0],
predicted_mean_alt: array![1.0, 0.2],
},
];
let (idx, growth) =
select_probe_by_expected_evidence(&probes, &fisher).expect("a probe discriminates");
assert_eq!(idx, 1);
assert!((growth - 1.01).abs() < 1e-12);
let budget = expected_resolution_budget(0.05, growth).expect("budget");
assert!(budget > 2.0 && budget < 4.0);
}
#[test]
fn birth_gate_certifies_alternative_and_demotes_never_rejects() {
let mut gate = AtomBirthGate::new(0.05).expect("valid alpha");
for _ in 0..5 {
gate.absorb_shard(-100.0, -101.0);
}
match gate.verdict() {
GateVerdict::Certified { log_e } => assert!((log_e - 5.0).abs() < 1e-12),
v => panic!("5 nats must certify at α=0.05, got {v:?}"),
}
gate.absorb_shard(-110.0, -100.0);
assert!(matches!(gate.verdict(), GateVerdict::Certified { .. }));
let mut null_gate = AtomBirthGate::new(0.05).expect("valid alpha");
for _ in 0..50 {
null_gate.absorb_shard(-100.3, -100.0);
}
match null_gate.verdict() {
GateVerdict::Contested { log_e } => assert!(log_e < 0.0),
v => panic!("null stream must stay contested, got {v:?}"),
}
assert!(AtomBirthGate::new(0.0).is_err());
assert!(AtomBirthGate::new(1.0).is_err());
}
#[test]
fn ledger_certificate_splits_confirmed_and_contested() {
let mut ledger = StructureLedger::new();
let a0 = ledger.register(ClaimKind::AtomExists { atom: 0 });
let a1 = ledger.register(ClaimKind::AtomExists { atom: 1 });
let edge = ledger.register(ClaimKind::BindingEdge { a: 0, b: 1 });
ledger.absorb_log(a0, 40.0f64.ln()).unwrap();
ledger.absorb_log(a1, 20.0f64.ln()).unwrap();
ledger.absorb_log(edge, 2.0f64.ln()).unwrap();
let a0_again = ledger.register(ClaimKind::AtomExists { atom: 0 });
assert_eq!(a0_again, a0);
assert_eq!(ledger.claims()[a0].evidence.steps(), 1);
let cert = ledger.certify(0.1);
let confirmed: Vec<&ClaimKind> = cert.confirmed().map(|e| &e.kind).collect();
assert_eq!(confirmed.len(), 2);
assert!(confirmed.contains(&&ClaimKind::AtomExists { atom: 0 }));
assert!(confirmed.contains(&&ClaimKind::AtomExists { atom: 1 }));
let contested: Vec<&CertificateEntry> = cert.contested().collect();
assert_eq!(contested.len(), 1);
assert_eq!(contested[0].kind, ClaimKind::BindingEdge { a: 0, b: 1 });
assert!(ledger.absorb_log(99, 0.0).is_err());
}
#[test]
fn ledger_evidence_resumes_across_serialization() {
let mut ledger = StructureLedger::new();
let idx = ledger.register(ClaimKind::GeometryKind {
atom: 3,
kind: "circle".to_string(),
});
ledger.absorb_log(idx, 1.25).unwrap();
let persisted = serde_json::to_string(&ledger).expect("serialize ledger");
let mut resumed: StructureLedger =
serde_json::from_str(&persisted).expect("deserialize ledger");
assert_eq!(resumed.claims()[idx].evidence.steps(), 1);
resumed.absorb_log(idx, 0.75).unwrap();
let log_e = resumed.claims()[idx].evidence.log_evidence();
assert!((log_e - 2.0).abs() < 1e-12);
}
#[test]
fn probe_plan_discounts_remaining_budget_by_current_evidence() {
let fisher = array![[2.0, 0.0], [0.0, 0.5]];
let probes = vec![CandidateProbe {
delta: array![0.0, 1.0],
predicted_mean_null: array![0.0, 0.0],
predicted_mean_alt: array![1.0, 0.2],
}];
let from_zero = plan_probe_for_contested_claim(&probes, &fisher, 0.05, 0.0).expect("plan");
assert_eq!(from_zero.probe, 0);
assert!((from_zero.budget_remaining - from_zero.budget_from_scratch).abs() < 1e-12);
let halfway = plan_probe_for_contested_claim(&probes, &fisher, 0.05, 1.5).expect("plan");
assert!(halfway.budget_remaining < from_zero.budget_remaining);
assert!((halfway.budget_remaining - (-(0.05f64.ln()) - 1.5) / 1.01).abs() < 1e-12);
let across = plan_probe_for_contested_claim(&probes, &fisher, 0.05, 10.0).expect("plan");
assert_eq!(across.budget_remaining, 0.0);
let blind = vec![CandidateProbe {
delta: array![1.0, 0.0],
predicted_mean_null: array![5.0, 5.0],
predicted_mean_alt: array![5.0, 5.0],
}];
assert!(plan_probe_for_contested_claim(&blind, &fisher, 0.05, 0.0).is_none());
}
#[test]
fn p_to_e_calibrator_hand_values() {
assert!((log_e_from_p_calibrator(1.0).unwrap() - 0.5f64.ln()).abs() < 1e-12);
assert!((log_e_from_p_calibrator(0.04).unwrap() - 2.5f64.ln()).abs() < 1e-12);
assert!((log_e_from_p_calibrator(1e-4).unwrap() - 50.0f64.ln()).abs() < 1e-12);
assert!(log_e_from_p_calibrator(0.0).is_err());
assert!(log_e_from_p_calibrator(1.5).is_err());
assert!(log_e_from_p_calibrator(f64::NAN).is_err());
}
#[test]
fn p_to_e_calibrator_null_expectation_at_most_one() {
let n = 2_000_000usize;
let h = 1.0 / n as f64;
let mut mean_e = 0.0_f64;
for i in 0..n {
let p = (i as f64 + 0.5) * h;
let e = log_e_from_p_calibrator(p).unwrap().exp();
mean_e += e * h;
}
assert!(
mean_e <= 1.0 + 1e-3,
"calibrated e-value null expectation {mean_e} exceeds 1 — not a valid e-value"
);
assert!(
mean_e > 0.99,
"calibrated e-value null expectation {mean_e} far below the analytic 1.0"
);
}
#[test]
fn power_study_null_naive_peeking_gate_false_accepts_e_gate_never() {
let mu = 0.6f64;
let amp = 0.9f64;
let omega = 0.7321f64;
let n_phases = 60usize;
let n_shards = 200usize;
let mut naive_false_accepts = 0usize;
let mut e_gate_false_accepts = 0usize;
for k in 0..n_phases {
let phase = 2.0 * std::f64::consts::PI * (k as f64) / (n_phases as f64);
let mut gate = AtomBirthGate::new(0.05).expect("alpha");
let mut cum_log_lr = 0.0f64;
let mut naive_accepted = false;
for t in 0..n_shards {
let x = amp * ((t as f64) * omega + phase).sin();
let log_lr = mu * x - 0.5 * mu * mu;
cum_log_lr += log_lr;
if cum_log_lr > 0.0 {
naive_accepted = true;
}
gate.absorb_shard(log_lr, 0.0);
}
if naive_accepted {
naive_false_accepts += 1;
}
if matches!(gate.verdict(), GateVerdict::Certified { .. }) {
e_gate_false_accepts += 1;
}
}
assert!(
naive_false_accepts >= n_phases / 3,
"the peeking gate should false-accept often under the null \
(got {naive_false_accepts}/{n_phases})"
);
assert_eq!(
e_gate_false_accepts, 0,
"the e-gate must never certify under the null"
);
}
#[test]
fn power_study_planted_atom_certifies_at_the_predicted_budget() {
let growth = 0.5f64;
let (gate, alt_state) = run_atom_birth_gate(
0.05,
0usize, 0..20usize,
|_, _| -99.5, |_| -100.0, |folded, _| folded + 1,
)
.expect("valid alpha");
match gate.verdict() {
GateVerdict::Certified { log_e } => assert!((log_e - 3.0).abs() < 1e-12),
v => panic!("planted atom must certify, got {v:?}"),
}
let budget = expected_resolution_budget(0.05, growth).expect("budget");
assert_eq!(gate.test.process.steps(), budget.ceil() as usize);
assert_eq!(gate.test.process.steps(), 6);
assert_eq!(alt_state, 20);
}
#[test]
fn design_loop_resolves_contested_claim_within_predicted_budget() {
let mut ledger = StructureLedger::new();
let idx = ledger.register(ClaimKind::GeometryKind {
atom: 0,
kind: "circle".to_string(),
});
let fisher = array![[1.0, 0.0], [0.0, 1.0]];
let mu0 = array![0.0, 0.0];
let mu1 = array![1.2, 0.5];
let probes = vec![CandidateProbe {
delta: array![0.0, 1.0],
predicted_mean_null: mu0.clone(),
predicted_mean_alt: mu1.clone(),
}];
let alpha = 0.05;
let plan = plan_probe_for_contested_claim(&probes, &fisher, alpha, 0.0).expect("plan");
assert_eq!(plan.probe, 0);
assert!((plan.expected_log_growth - 0.845).abs() < 1e-12);
let budget = plan.budget_remaining.ceil().max(1.0) as usize;
let mut observations = 0usize;
while !ledger.claims()[idx].evidence.rejects_at(alpha) {
observations += 1;
assert!(
observations <= 4 * budget,
"claim must resolve within a small multiple of the predicted \
budget {budget}; still contested after {observations} probes"
);
let t = observations as f64;
let eps0 = 0.8 * (t * 0.7321).sin();
let eps1 = 0.8 * (t * 1.1173).cos();
let y = array![mu1[0] + eps0, mu1[1] + eps1];
let d1 = &y - &mu1;
let d0 = &y - &mu0;
ledger
.absorb_probe_outcome(idx, -0.5 * d1.dot(&d1), -0.5 * d0.dot(&d0))
.expect("absorb");
}
let cert = ledger.certify(alpha);
assert!(
cert.confirmed()
.any(|e| matches!(e.kind, ClaimKind::GeometryKind { atom: 0, .. }))
);
}
}