Skip to main content

bicmath_statistics/
sequential.rs

1//! Sequential and always-valid inference.
2//!
3//! `sprt_bernoulli` runs Wald's sequential probability ratio test for a
4//! Bernoulli proportion. The confidence sequences are time-uniform: at each
5//! observation time `t` a pointwise concentration inequality is inverted at
6//! level `alpha_t = alpha * 6 / (pi^2 t^2)`. Since the schedule sums to one
7//! over `t = 1, 2, ...`, a union bound makes the intervals simultaneously
8//! valid with probability at least `1 - alpha`. This discrete schedule follows
9//! the time-uniform construction of Howard, Ramdas, McAuliffe, and Sekhon
10//! (2021, Annals of Statistics 49(2), 1055-1080); the bound is deliberately
11//! reported with the schedule it uses.
12
13use std::sync::Arc;
14
15use bicmath_core::context::ExecContext;
16use bicmath_core::contract::{
17    Args, Assumption, CostClass, Example, FunctionDescriptor, Outcome, ParamDescriptor,
18    SimpleFunction, require_mode,
19};
20use bicmath_core::error::{EngineError, ErrorCode};
21use bicmath_core::schema::ValueSchema;
22use num_bigint::BigInt;
23
24use crate::common::*;
25use crate::mathfn::{ln, sqrt};
26
27// ---------------------------------------------------------------------------
28// Time-uniform schedule
29// ---------------------------------------------------------------------------
30
31/// `alpha_t = alpha * 6 / (pi^2 t^2)`, the discrete schedule whose sum over
32/// `t = 1, 2, ...` is exactly `alpha`.
33fn time_uniform_alpha(alpha: f64, t: f64) -> f64 {
34    alpha * 6.0 / (std::f64::consts::PI * std::f64::consts::PI * t * t)
35}
36
37fn validate_count_pair(
38    successes: &BigInt,
39    n: &BigInt,
40    prefix: &str,
41) -> Result<(u64, u64), EngineError> {
42    let n_value = non_negative_u64(n, &format!("{prefix}n"))?;
43    if n_value == 0 {
44        return Err(EngineError::domain("n must be at least 1").with_path(format!("{prefix}n")));
45    }
46    let s_value = non_negative_u64(successes, &format!("{prefix}successes"))?;
47    if s_value > n_value {
48        return Err(
49            EngineError::domain("successes must be between 0 and n inclusive")
50                .with_path(format!("{prefix}successes")),
51        );
52    }
53    Ok((s_value, n_value))
54}
55
56fn attach_assumptions(mut outcome: Outcome, prefix: &str, statements: &[&str]) -> Outcome {
57    for (index, statement) in statements.iter().enumerate() {
58        outcome = outcome.with_assumption(Assumption::unverified(
59            format!("{prefix}_{index}"),
60            *statement,
61        ));
62    }
63    outcome
64}
65
66// ---------------------------------------------------------------------------
67// sprt_bernoulli
68// ---------------------------------------------------------------------------
69
70fn sprt_descriptor() -> FunctionDescriptor {
71    FunctionDescriptor::new(
72        "statistics.sprt_bernoulli",
73        "statistics",
74        "1.0.0",
75        "Wald sequential probability ratio test for a Bernoulli proportion",
76        "Sequential log-likelihood ratio test between two simple Bernoulli hypotheses.",
77    )
78    .with_description(
79        "Tests H0: p = p0 against H1: p = p1 with Wald's sequential probability ratio test, \
80         where 0 < p0 < p1 < 1. successes_a and successes_b are the successes in two \
81         successive looks (or two independent batches) with n_a and n_b trials; the batches \
82         are pooled into total successes S and total trials N, and the Bernoulli \
83         log-likelihood ratio LLR = S ln(p1 / p0) + (N - S) ln((1 - p1) / (1 - p0)) is \
84         compared with the Wald boundaries ln((1 - beta) / alpha) and ln(beta / (1 - alpha)). \
85         The decision is accept_h1 when LLR >= the upper boundary, accept_h0 when LLR <= the \
86         lower boundary, and continue otherwise. alpha (default 0.05) and beta (default 0.1) \
87         are the type I and type II error probabilities, each strictly between 0 and 1. The \
88         output reports method = \"wald_sprt\", the pooled counts, the thresholds, and the \
89         assumptions; the error rates hold under optional stopping because the test is \
90         sequential by construction.",
91    )
92    .with_parameters(vec![
93        ParamDescriptor::required(
94            "successes_a",
95            "Successes in the first batch; integer in 0..=n_a.",
96            integer_schema(),
97        ),
98        ParamDescriptor::required(
99            "n_a",
100            "Trials in the first batch; integer >= 1.",
101            integer_schema(),
102        ),
103        ParamDescriptor::required(
104            "successes_b",
105            "Successes in the second batch; integer in 0..=n_b.",
106            integer_schema(),
107        ),
108        ParamDescriptor::required(
109            "n_b",
110            "Trials in the second batch; integer >= 1.",
111            integer_schema(),
112        ),
113        ParamDescriptor::required(
114            "p0",
115            "Null success probability; strictly between 0 and p1.",
116            any_number_schema(),
117        ),
118        ParamDescriptor::required(
119            "p1",
120            "Alternative success probability; strictly between p0 and 1.",
121            any_number_schema(),
122        ),
123        ParamDescriptor::optional(
124            "alpha",
125            "Type I error probability in (0, 1); default 0.05.",
126            any_number_schema(),
127        ),
128        ParamDescriptor::optional(
129            "beta",
130            "Type II error probability in (0, 1); default 0.1.",
131            any_number_schema(),
132        ),
133    ])
134    .with_output(
135        record_schema(
136            vec![
137                field("log_likelihood_ratio", float64_schema()),
138                field("decision", text_schema()),
139                field("threshold_upper", float64_schema()),
140                field("threshold_lower", float64_schema()),
141                field("total_successes", integer_schema()),
142                field("total_trials", integer_schema()),
143                field("alpha", float64_schema()),
144                field("beta", float64_schema()),
145                field("method", text_schema()),
146                field("assumptions", array_schema(text_schema())),
147            ],
148            false,
149        ),
150        "Wald SPRT record with the pooled counts and decision boundaries.",
151    )
152    .with_modes(inferential_modes())
153    .with_cost(CostClass::Constant)
154    .with_method_ref("docs/methods/statistics.md#sprt_bernoulli")
155    .with_examples(vec![
156        Example::new(
157            "clear evidence for the alternative",
158            example_args(&[
159                ("successes_a", serde_json::json!(9)),
160                ("n_a", serde_json::json!(10)),
161                ("successes_b", serde_json::json!(9)),
162                ("n_b", serde_json::json!(10)),
163                ("p0", serde_json::json!(0.2)),
164                ("p1", serde_json::json!(0.4)),
165            ]),
166        )
167        .with_contains("accept_h1"),
168        Example::new(
169            "the null is not below the alternative",
170            example_args(&[
171                ("successes_a", serde_json::json!(1)),
172                ("n_a", serde_json::json!(10)),
173                ("successes_b", serde_json::json!(1)),
174                ("n_b", serde_json::json!(10)),
175                ("p0", serde_json::json!(0.4)),
176                ("p1", serde_json::json!(0.2)),
177            ]),
178        )
179        .with_error(ErrorCode::DomainViolation),
180    ])
181}
182
183fn invoke_sprt(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
184    require_mode(ctx, &inferential_modes(), "statistics.sprt_bernoulli")?;
185    let (successes_a, n_a) =
186        validate_count_pair(&args.integer("successes_a")?, &args.integer("n_a")?, "a_")?;
187    let (successes_b, n_b) =
188        validate_count_pair(&args.integer("successes_b")?, &args.integer("n_b")?, "b_")?;
189    let p0 = scalar_f64(args, "p0")?;
190    let p1 = scalar_f64(args, "p1")?;
191    if !(0.0 < p0 && p0 < p1 && p1 < 1.0) {
192        return Err(EngineError::domain(
193            "the simple hypotheses must satisfy 0 < p0 < p1 < 1",
194        ));
195    }
196    let alpha = optional_f64_param(args, "alpha")?.unwrap_or(0.05);
197    let beta = optional_f64_param(args, "beta")?.unwrap_or(0.1);
198    if !(0.0..1.0).contains(&alpha) {
199        return Err(
200            EngineError::domain("alpha must be strictly between 0 and 1")
201                .with_path("alpha".to_string()),
202        );
203    }
204    if !(0.0..1.0).contains(&beta) {
205        return Err(EngineError::domain("beta must be strictly between 0 and 1")
206            .with_path("beta".to_string()));
207    }
208    let total_successes = successes_a.checked_add(successes_b).ok_or_else(|| {
209        EngineError::new(
210            ErrorCode::ResourceLimit,
211            "the total success count overflows",
212        )
213    })?;
214    let total_trials = n_a.checked_add(n_b).ok_or_else(|| {
215        EngineError::new(ErrorCode::ResourceLimit, "the total trial count overflows")
216    })?;
217    let successes = total_successes as f64;
218    let failures = (total_trials - total_successes) as f64;
219    let log_likelihood_ratio = successes * ln(p1 / p0) + failures * ln((1.0 - p1) / (1.0 - p0));
220    let threshold_upper = ln((1.0 - beta) / alpha);
221    let threshold_lower = ln(beta / (1.0 - alpha));
222    let decision = if log_likelihood_ratio >= threshold_upper {
223        "accept_h1"
224    } else if log_likelihood_ratio <= threshold_lower {
225        "accept_h0"
226    } else {
227        "continue"
228    };
229    let assumptions = [
230        "the observations are independent Bernoulli draws with a constant success probability",
231        "successes_a/n_a and successes_b/n_b are successive looks at the same stream (or two independent batches) and are pooled before the test",
232        "the hypotheses are simple, H0: p = p0 versus H1: p = p1, and the test statistic is the Bernoulli log-likelihood ratio",
233        "the thresholds are the Wald boundaries ln((1 - beta) / alpha) and ln(beta / (1 - alpha)); no optional-stopping correction is needed because the test is sequential by construction",
234    ];
235    let value = record(vec![
236        ("log_likelihood_ratio", float_value(log_likelihood_ratio)?),
237        ("decision", text(decision)),
238        ("threshold_upper", float_value(threshold_upper)?),
239        ("threshold_lower", float_value(threshold_lower)?),
240        ("total_successes", integer_value(total_successes)),
241        ("total_trials", integer_value(total_trials)),
242        ("alpha", float_value(alpha)?),
243        ("beta", float_value(beta)?),
244        ("method", text("wald_sprt")),
245        ("assumptions", assumptions_value(&assumptions)),
246    ]);
247    Ok(attach_assumptions(
248        Outcome::approximate(value),
249        "sprt_bernoulli",
250        &assumptions,
251    ))
252}
253
254// ---------------------------------------------------------------------------
255// confidence_sequence_mean
256// ---------------------------------------------------------------------------
257
258fn confidence_sequence_mean_descriptor() -> FunctionDescriptor {
259    FunctionDescriptor::new(
260        "statistics.confidence_sequence_mean",
261        "statistics",
262        "1.0.0",
263        "Time-uniform confidence sequence for a mean",
264        "Always-valid confidence sequence for a mean using a time-uniform concentration bound.",
265    )
266    .with_description(
267        "Computes a confidence sequence for the mean that is valid simultaneously for every \
268         observation time t = 1, ..., n. At each t the bound inverts a pointwise \
269         concentration inequality at level alpha_t = alpha * 6 / (pi^2 t^2), where \
270         alpha = 1 - confidence; the schedule sums to alpha over t = 1, 2, ..., so the union \
271         bound gives coverage at least confidence for all t. method = \"hoeffding\" (default) \
272         requires either the known sub-Gaussian standard deviation sigma, or a known bounded \
273         range supplied as lower and upper (every observation must lie inside it). \
274         method = \"empirical_bernstein\" uses the observed range and the empirical (biased) \
275         variance and ignores sigma. The output reports estimate, lower, upper, half_width, n, \
276         method, the variance_source actually used, confidence, and the assumptions. The \
277         Howard-Ramdas-McAuliffe-Sekhon time-uniform construction is used with this discrete \
278         schedule; widths shrink at a root-logarithmic rate rather than the 1/sqrt(n) rate of \
279         a fixed-time interval.",
280    )
281    .with_parameters(vec![
282        ParamDescriptor::required(
283            "values",
284            "Observations in observation order.",
285            array_schema(any_number_schema()),
286        ),
287        ParamDescriptor::optional(
288            "confidence",
289            "Confidence level in (0, 1); default 0.95.",
290            any_number_schema(),
291        ),
292        ParamDescriptor::optional(
293            "sigma",
294            "Known sub-Gaussian standard deviation (variance proxy sigma^2); when supplied the sub-Gaussian bound is used instead of a bounded range. Only valid with method = \"hoeffding\".",
295            any_number_schema(),
296        ),
297        ParamDescriptor::optional(
298            "method",
299            "Bound method: hoeffding (default) or empirical_bernstein.",
300            ValueSchema::Enum {
301                variants: vec!["hoeffding".to_string(), "empirical_bernstein".to_string()],
302            },
303        ),
304        ParamDescriptor::optional(
305            "lower",
306            "Known lower bound of every observation; required for the hoeffding method without sigma.",
307            any_number_schema(),
308        ),
309        ParamDescriptor::optional(
310            "upper",
311            "Known upper bound of every observation; required for the hoeffding method without sigma.",
312            any_number_schema(),
313        ),
314    ])
315    .with_output(
316        record_schema(
317            vec![
318                field("estimate", float64_schema()),
319                field("lower", float64_schema()),
320                field("upper", float64_schema()),
321                field("half_width", float64_schema()),
322                field("n", integer_schema()),
323                field("method", text_schema()),
324                field("variance_source", text_schema()),
325                field("confidence", float64_schema()),
326                field("assumptions", array_schema(text_schema())),
327            ],
328            false,
329        ),
330        "Time-uniform confidence sequence record for the mean.",
331    )
332    .with_modes(inferential_modes())
333    .with_cost(CostClass::Linear)
334    .with_method_ref("docs/methods/statistics.md#confidence_sequence_mean")
335    .with_examples(vec![
336        Example::new(
337            "bounded Hoeffding sequence",
338            example_args(&[
339                ("values", serde_json::json!([0, 1, 2, 3, 4])),
340                ("lower", serde_json::json!(0)),
341                ("upper", serde_json::json!(4)),
342            ]),
343        )
344        .with_contains("hoeffding"),
345        Example::new(
346            "empirical Bernstein sequence",
347            example_args(&[
348                ("values", serde_json::json!([1.0, 2.0, 3.0])),
349                ("method", serde_json::json!("empirical_bernstein")),
350            ]),
351        )
352        .with_contains("empirical_bernstein"),
353        Example::new(
354            "hoeffding without bounds",
355            example_args(&[("values", serde_json::json!([1, 2, 3]))]),
356        )
357        .with_error(ErrorCode::DomainViolation),
358    ])
359}
360
361fn invoke_confidence_sequence_mean(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
362    require_mode(
363        ctx,
364        &inferential_modes(),
365        "statistics.confidence_sequence_mean",
366    )?;
367    let confidence = confidence_param(args)?;
368    let method = parse_choice(
369        args,
370        "method",
371        "hoeffding",
372        &["hoeffding", "empirical_bernstein"],
373    )?;
374    let values = classify_series(args, "values", ctx)?.to_f64_vec()?;
375    if values.is_empty() {
376        return Err(insufficient("values must not be empty"));
377    }
378    let n = values.len();
379    let t = n as f64;
380    let alpha = 1.0 - confidence;
381    let alpha_t = time_uniform_alpha(alpha, t);
382    let estimate = mean_f64(&values);
383    let sigma = optional_f64_param(args, "sigma")?;
384    let (half_width, variance_source, assumptions): (f64, &str, Vec<&str>) = if method
385        == "empirical_bernstein"
386    {
387        if sigma.is_some() {
388            return Err(EngineError::domain(
389                    "sigma applies only to the hoeffding method; empirical_bernstein uses the observed range",
390                )
391                .with_path("sigma".to_string()));
392        }
393        let mut minimum = values[0];
394        let mut maximum = values[0];
395        let mut sum_squares = 0.0f64;
396        for value in &values {
397            minimum = minimum.min(*value);
398            maximum = maximum.max(*value);
399            sum_squares += (value - estimate) * (value - estimate);
400        }
401        let range = maximum - minimum;
402        let variance = sum_squares / t;
403        let log_term = ln(6.0 / alpha_t);
404        let half = sqrt(2.0 * variance * log_term / t) + 3.0 * range * log_term / t;
405        (
406            half,
407            "observed_range",
408            vec![
409                "observations are independent and identically distributed",
410                "the empirical Bernstein bound uses the observed range and the empirical (biased) variance",
411                "the time-uniform schedule alpha_t = alpha * 6 / (pi^2 t^2) makes the interval valid for every t simultaneously with probability at least confidence",
412            ],
413        )
414    } else if let Some(sigma) = sigma {
415        if !sigma.is_finite() || sigma <= 0.0 {
416            return Err(EngineError::domain("sigma must be strictly positive")
417                .with_path("sigma".to_string()));
418        }
419        let half = sigma * sqrt(2.0 * ln(2.0 / alpha_t) / t);
420        (
421            half,
422            "known_sigma",
423            vec![
424                "observations are independent and sub-Gaussian with variance proxy sigma^2",
425                "sigma is treated as known rather than estimated from the sample",
426                "the time-uniform schedule alpha_t = alpha * 6 / (pi^2 t^2) makes the interval valid for every t simultaneously with probability at least confidence",
427            ],
428        )
429    } else {
430        let lower = optional_f64_param(args, "lower")?.ok_or_else(|| {
431            EngineError::domain(
432                "the hoeffding method requires lower and upper bounds or a known sigma",
433            )
434            .with_path("lower".to_string())
435        })?;
436        let upper = optional_f64_param(args, "upper")?.ok_or_else(|| {
437            EngineError::domain(
438                "the hoeffding method requires lower and upper bounds or a known sigma",
439            )
440            .with_path("upper".to_string())
441        })?;
442        if !lower.is_finite() || !upper.is_finite() || upper <= lower {
443            return Err(EngineError::domain(
444                "lower and upper must be finite with lower < upper",
445            ));
446        }
447        for (index, value) in values.iter().enumerate() {
448            if *value < lower || *value > upper {
449                return Err(EngineError::domain(format!(
450                    "observation {value} lies outside the declared range [{lower}, {upper}]"
451                ))
452                .with_path(format!("values[{index}]")));
453            }
454        }
455        let range = upper - lower;
456        let half = range * sqrt(ln(2.0 / alpha_t) / (2.0 * t));
457        (
458            half,
459            "bounded_range",
460            vec![
461                "observations are independent and lie in the declared range [lower, upper] (checked for the supplied values)",
462                "the range is treated as known rather than estimated from the sample",
463                "the time-uniform schedule alpha_t = alpha * 6 / (pi^2 t^2) makes the interval valid for every t simultaneously with probability at least confidence",
464            ],
465        )
466    };
467    let lower_bound = estimate - half_width;
468    let upper_bound = estimate + half_width;
469    let value = record(vec![
470        ("estimate", float_value(estimate)?),
471        ("lower", float_value(lower_bound)?),
472        ("upper", float_value(upper_bound)?),
473        ("half_width", float_value(half_width)?),
474        ("n", integer_value(n as u64)),
475        ("method", text(method)),
476        ("variance_source", text(variance_source)),
477        ("confidence", float_value(confidence)?),
478        ("assumptions", assumptions_value(&assumptions)),
479    ]);
480    Ok(attach_assumptions(
481        Outcome::approximate(value),
482        "confidence_sequence_mean",
483        &assumptions,
484    ))
485}
486
487// ---------------------------------------------------------------------------
488// confidence_sequence_proportion
489// ---------------------------------------------------------------------------
490
491fn confidence_sequence_proportion_descriptor() -> FunctionDescriptor {
492    FunctionDescriptor::new(
493        "statistics.confidence_sequence_proportion",
494        "statistics",
495        "1.0.0",
496        "Time-uniform confidence sequence for a Bernoulli proportion",
497        "Always-valid confidence sequence for a proportion using the time-uniform Hoeffding bound on [0, 1].",
498    )
499    .with_description(
500        "Computes a time-uniform confidence sequence for a Bernoulli success probability from \
501         successes successes in n trials. The pointwise Hoeffding bound on [0, 1] is inverted \
502         at level alpha_t = alpha * 6 / (pi^2 t^2) with alpha = 1 - confidence and t = n, so \
503         the interval covers the true proportion for every n simultaneously with probability \
504         at least confidence. The endpoints are clipped to [0, 1]. The output reports estimate, \
505         lower, upper, half_width, n, method = \"hoeffding\", confidence, and the assumptions.",
506    )
507    .with_parameters(vec![
508        ParamDescriptor::required(
509            "successes",
510            "Number of successes; integer in 0..=n.",
511            integer_schema(),
512        ),
513        ParamDescriptor::required("n", "Number of trials; integer >= 1.", integer_schema()),
514        ParamDescriptor::optional(
515            "confidence",
516            "Confidence level in (0, 1); default 0.95.",
517            any_number_schema(),
518        ),
519        ParamDescriptor::optional(
520            "method",
521            "Bound method; only hoeffding is supported.",
522            ValueSchema::Enum {
523                variants: vec!["hoeffding".to_string()],
524            },
525        ),
526    ])
527    .with_output(
528        record_schema(
529            vec![
530                field("estimate", float64_schema()),
531                field("lower", float64_schema()),
532                field("upper", float64_schema()),
533                field("half_width", float64_schema()),
534                field("n", integer_schema()),
535                field("method", text_schema()),
536                field("confidence", float64_schema()),
537                field("assumptions", array_schema(text_schema())),
538            ],
539            false,
540        ),
541        "Time-uniform confidence sequence record for a proportion.",
542    )
543    .with_modes(inferential_modes())
544    .with_cost(CostClass::Constant)
545    .with_method_ref("docs/methods/statistics.md#confidence_sequence_proportion")
546    .with_examples(vec![
547        Example::new(
548            "three successes in ten trials",
549            example_args(&[
550                ("successes", serde_json::json!(3)),
551                ("n", serde_json::json!(10)),
552            ]),
553        )
554        .with_contains("hoeffding"),
555        Example::new(
556            "successes exceed trials",
557            example_args(&[
558                ("successes", serde_json::json!(12)),
559                ("n", serde_json::json!(10)),
560            ]),
561        )
562        .with_error(ErrorCode::DomainViolation),
563    ])
564}
565
566fn invoke_confidence_sequence_proportion(
567    args: &Args,
568    ctx: &ExecContext,
569) -> Result<Outcome, EngineError> {
570    require_mode(
571        ctx,
572        &inferential_modes(),
573        "statistics.confidence_sequence_proportion",
574    )?;
575    let (successes, n) = validate_count_pair(&args.integer("successes")?, &args.integer("n")?, "")?;
576    let confidence = confidence_param(args)?;
577    let method = parse_choice(args, "method", "hoeffding", &["hoeffding"])?;
578    let estimate = successes as f64 / n as f64;
579    let alpha = 1.0 - confidence;
580    let alpha_t = time_uniform_alpha(alpha, n as f64);
581    let half_width = sqrt(ln(2.0 / alpha_t) / (2.0 * n as f64));
582    let lower = (estimate - half_width).max(0.0);
583    let upper = (estimate + half_width).min(1.0);
584    let assumptions = [
585        "trials are independent Bernoulli draws with a constant success probability",
586        "the time-uniform Hoeffding bound uses the support [0, 1] and the schedule alpha_t = alpha * 6 / (pi^2 t^2)",
587        "the interval is valid for every n simultaneously with probability at least confidence, at the cost of a wider width than a fixed-n interval",
588    ];
589    let value = record(vec![
590        ("estimate", float_value(estimate)?),
591        ("lower", float_value(lower)?),
592        ("upper", float_value(upper)?),
593        ("half_width", float_value(half_width)?),
594        ("n", integer_value(n)),
595        ("method", text(method)),
596        ("confidence", float_value(confidence)?),
597        ("assumptions", assumptions_value(&assumptions)),
598    ]);
599    Ok(attach_assumptions(
600        Outcome::approximate(value),
601        "confidence_sequence_proportion",
602        &assumptions,
603    ))
604}
605
606// ---------------------------------------------------------------------------
607// Registration
608// ---------------------------------------------------------------------------
609
610pub fn functions() -> Vec<Arc<dyn bicmath_core::contract::Function>> {
611    vec![
612        SimpleFunction::arc(sprt_descriptor(), invoke_sprt),
613        SimpleFunction::arc(
614            confidence_sequence_mean_descriptor(),
615            invoke_confidence_sequence_mean,
616        ),
617        SimpleFunction::arc(
618            confidence_sequence_proportion_descriptor(),
619            invoke_confidence_sequence_proportion,
620        ),
621    ]
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use bicmath_core::value::Value;
628    use std::collections::BTreeMap;
629
630    fn call(id: &str, raw: serde_json::Value) -> Result<Outcome, EngineError> {
631        let module = crate::module();
632        let function = module
633            .functions
634            .iter()
635            .find(|f| f.descriptor().id == id)
636            .expect("function exists");
637        let ctx = ExecContext::scientific();
638        let args_json = raw.as_object().expect("object args");
639        let mut values = BTreeMap::new();
640        for (name, value) in args_json {
641            let param = function
642                .descriptor()
643                .parameter(name)
644                .expect("parameter exists");
645            values.insert(
646                name.clone(),
647                param
648                    .schema
649                    .coerce(value, name, &ctx.limits, true)
650                    .expect("argument coerces"),
651            );
652        }
653        function.invoke(&Args::new(values), &ctx)
654    }
655
656    fn record_of(outcome: &Outcome) -> &BTreeMap<String, Value> {
657        match &outcome.value {
658            Value::Record(fields) => fields,
659            other => panic!("expected record, got {other:?}"),
660        }
661    }
662
663    fn field_f64(fields: &BTreeMap<String, Value>, name: &str) -> f64 {
664        match fields.get(name) {
665            Some(Value::Number(number)) => number.to_f64().expect("number"),
666            other => panic!("expected numeric field {name}, got {other:?}"),
667        }
668    }
669
670    fn field_text(fields: &BTreeMap<String, Value>, name: &str) -> String {
671        match fields.get(name) {
672            Some(Value::Text(text)) => text.clone(),
673            other => panic!("expected text field {name}, got {other:?}"),
674        }
675    }
676
677    fn close(actual: f64, expected: f64, tolerance: f64) {
678        assert!(
679            (actual - expected).abs() <= tolerance,
680            "expected {expected}, got {actual} (tolerance {tolerance})"
681        );
682    }
683
684    #[test]
685    fn sprt_decision_flips_with_the_observed_difference() {
686        // Provenance: Python 3.14 stdlib values for p0 = 0.2, p1 = 0.4,
687        // alpha = 0.05, beta = 0.1: upper = ln(18) = 2.8903717578961645,
688        // lower = ln(0.1 / 0.95) = -2.251291798606495. With 2 successes in
689        // 20 trials the LLR is -3.7919829430121688 (accept_h0); with 18 in 20
690        // it is 11.901285105175454 (accept_h1); with 5 in 20 it is
691        // -0.8494951839769898 (continue).
692        let small = call(
693            "statistics.sprt_bernoulli",
694            serde_json::json!({
695                "successes_a": 1, "n_a": 10,
696                "successes_b": 1, "n_b": 10,
697                "p0": 0.2, "p1": 0.4,
698                "alpha": 0.05, "beta": 0.1
699            }),
700        )
701        .unwrap();
702        let small_fields = record_of(&small);
703        assert_eq!(field_text(small_fields, "decision"), "accept_h0");
704        close(
705            field_f64(small_fields, "log_likelihood_ratio"),
706            -3.791_982_943_012_168_8,
707            1e-12,
708        );
709        close(
710            field_f64(small_fields, "threshold_upper"),
711            2.890_371_757_896_164_5,
712            1e-12,
713        );
714        close(
715            field_f64(small_fields, "threshold_lower"),
716            -2.251_291_798_606_495,
717            1e-12,
718        );
719
720        let large = call(
721            "statistics.sprt_bernoulli",
722            serde_json::json!({
723                "successes_a": 9, "n_a": 10,
724                "successes_b": 9, "n_b": 10,
725                "p0": 0.2, "p1": 0.4,
726                "alpha": 0.05, "beta": 0.1
727            }),
728        )
729        .unwrap();
730        let large_fields = record_of(&large);
731        assert_eq!(field_text(large_fields, "decision"), "accept_h1");
732        close(
733            field_f64(large_fields, "log_likelihood_ratio"),
734            11.901_285_105_175_454,
735            1e-12,
736        );
737
738        let middle = call(
739            "statistics.sprt_bernoulli",
740            serde_json::json!({
741                "successes_a": 3, "n_a": 10,
742                "successes_b": 2, "n_b": 10,
743                "p0": 0.2, "p1": 0.4,
744                "alpha": 0.05, "beta": 0.1
745            }),
746        )
747        .unwrap();
748        let middle_fields = record_of(&middle);
749        assert_eq!(field_text(middle_fields, "decision"), "continue");
750        close(
751            field_f64(middle_fields, "log_likelihood_ratio"),
752            -0.849_495_183_976_989_8,
753            1e-12,
754        );
755    }
756
757    #[test]
758    fn sprt_rejects_invalid_hypotheses_and_counts() {
759        let error = call(
760            "statistics.sprt_bernoulli",
761            serde_json::json!({
762                "successes_a": 1, "n_a": 10,
763                "successes_b": 1, "n_b": 10,
764                "p0": 0.4, "p1": 0.2
765            }),
766        )
767        .unwrap_err();
768        assert_eq!(error.code, ErrorCode::DomainViolation);
769        let error = call(
770            "statistics.sprt_bernoulli",
771            serde_json::json!({
772                "successes_a": 11, "n_a": 10,
773                "successes_b": 1, "n_b": 10,
774                "p0": 0.2, "p1": 0.4
775            }),
776        )
777        .unwrap_err();
778        assert_eq!(error.code, ErrorCode::DomainViolation);
779    }
780
781    #[test]
782    fn confidence_sequence_mean_width_shrinks_and_covers() {
783        // Provenance: Python 3.14 stdlib evaluation of
784        // (upper - lower) * sqrt(ln(2 / alpha_t) / (2 t)) with
785        // alpha_t = 0.05 * 6 / (pi^2 t^2) for values 0.1, ..., 1.0 on the
786        // declared range [0, 1]: half_width(10) = 0.6630139494223621.
787        let outcome = call(
788            "statistics.confidence_sequence_mean",
789            serde_json::json!({
790                "values": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
791                "lower": 0,
792                "upper": 1
793            }),
794        )
795        .unwrap();
796        let fields = record_of(&outcome);
797        close(field_f64(fields, "estimate"), 0.55, 1e-12);
798        close(
799            field_f64(fields, "half_width"),
800            0.663_013_949_422_362_1,
801            1e-12,
802        );
803        assert!(field_f64(fields, "lower") <= field_f64(fields, "estimate"));
804        assert!(field_f64(fields, "upper") >= field_f64(fields, "estimate"));
805
806        let short = call(
807            "statistics.confidence_sequence_mean",
808            serde_json::json!({
809                "values": [0.1, 0.2, 0.3, 0.4, 0.5],
810                "lower": 0,
811                "upper": 1
812            }),
813        )
814        .unwrap();
815        let long = call(
816            "statistics.confidence_sequence_mean",
817            serde_json::json!({
818                "values": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
819                "lower": 0,
820                "upper": 1
821            }),
822        )
823        .unwrap();
824        assert!(
825            field_f64(record_of(&long), "half_width") < field_f64(record_of(&short), "half_width"),
826            "the sequence width must shrink as n grows"
827        );
828    }
829
830    #[test]
831    fn confidence_sequence_mean_empirical_bernstein_width_shrinks() {
832        // Provenance: Python 3.14 stdlib evaluation of
833        // sqrt(2 v ln(6 / alpha_t) / t) + 3 R ln(6 / alpha_t) / t for the
834        // alternating values [0.1, 0.9]: for t = 10 the half width is
835        // 2.936262789405273 and for t = 20 it is 1.7779652039242477; the
836        // bound shrinks with t because the schedule grows only
837        // logarithmically.
838        let short_values: Vec<f64> = std::iter::repeat_n([0.1, 0.9], 5).flatten().collect();
839        let long_values: Vec<f64> = std::iter::repeat_n([0.1, 0.9], 10).flatten().collect();
840        let short = call(
841            "statistics.confidence_sequence_mean",
842            serde_json::json!({"values": short_values, "method": "empirical_bernstein"}),
843        )
844        .unwrap();
845        let long = call(
846            "statistics.confidence_sequence_mean",
847            serde_json::json!({"values": long_values, "method": "empirical_bernstein"}),
848        )
849        .unwrap();
850        close(
851            field_f64(record_of(&short), "half_width"),
852            2.936_262_789_405_273,
853            1e-12,
854        );
855        close(
856            field_f64(record_of(&long), "half_width"),
857            1.777_965_203_924_247_7,
858            1e-12,
859        );
860        assert!(
861            field_f64(record_of(&long), "half_width") < field_f64(record_of(&short), "half_width"),
862            "the empirical Bernstein sequence width must shrink as n grows"
863        );
864        assert_eq!(
865            field_text(record_of(&long), "variance_source"),
866            "observed_range"
867        );
868    }
869
870    #[test]
871    fn confidence_sequence_mean_rejects_missing_bounds_and_out_of_range_values() {
872        let error = call(
873            "statistics.confidence_sequence_mean",
874            serde_json::json!({"values": [1, 2, 3]}),
875        )
876        .unwrap_err();
877        assert_eq!(error.code, ErrorCode::DomainViolation);
878        let error = call(
879            "statistics.confidence_sequence_mean",
880            serde_json::json!({"values": [0, 5], "lower": 0, "upper": 4}),
881        )
882        .unwrap_err();
883        assert_eq!(error.code, ErrorCode::DomainViolation);
884    }
885
886    #[test]
887    fn confidence_sequence_proportion_shrinks_and_covers() {
888        let outcome = call(
889            "statistics.confidence_sequence_proportion",
890            serde_json::json!({"successes": 3, "n": 10}),
891        )
892        .unwrap();
893        let fields = record_of(&outcome);
894        close(field_f64(fields, "estimate"), 0.3, 1e-12);
895        assert!(field_f64(fields, "lower") <= 0.3);
896        assert!(field_f64(fields, "upper") >= 0.3);
897        assert_eq!(field_text(fields, "method"), "hoeffding");
898
899        let short = call(
900            "statistics.confidence_sequence_proportion",
901            serde_json::json!({"successes": 3, "n": 10}),
902        )
903        .unwrap();
904        let long = call(
905            "statistics.confidence_sequence_proportion",
906            serde_json::json!({"successes": 12, "n": 40}),
907        )
908        .unwrap();
909        assert!(
910            field_f64(record_of(&long), "half_width") < field_f64(record_of(&short), "half_width"),
911            "the proportion sequence width must shrink as n grows"
912        );
913    }
914}