1use 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
27fn 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
66fn 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
254fn 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
487fn 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
606pub 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 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 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 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}