1use std::sync::Arc;
11
12use bicmath_core::context::ExecContext;
13use bicmath_core::contract::{
14 Args, Assumption, CostClass, Example, FunctionDescriptor, Outcome, ParamDescriptor,
15 SimpleFunction, require_mode,
16};
17use bicmath_core::error::{EngineError, ErrorCode};
18use bicmath_core::schema::ValueSchema;
19use bicmath_core::value::Value;
20
21use crate::common::*;
22use crate::mathfn::{beta_quantile, beta_sf, gamma_quantile, normal_quantile, normal_sf, sqrt};
23
24const POSTERIOR_NOT_P_VALUE: &str = "the reported probability is a posterior probability under \
25 the supplied prior and model, not a p-value, and the reported interval is a credible \
26 interval, not a frequentist confidence interval";
27
28fn credible_interval_schema() -> ValueSchema {
29 record_schema(
30 vec![
31 field("lower", float64_schema()),
32 field("upper", float64_schema()),
33 ],
34 false,
35 )
36}
37
38fn credible_interval_value(lower: f64, upper: f64) -> Result<Value, EngineError> {
39 Ok(record(vec![
40 ("lower", float_value(lower)?),
41 ("upper", float_value(upper)?),
42 ]))
43}
44
45fn attach_assumptions(mut outcome: Outcome, prefix: &str, statements: &[&str]) -> Outcome {
46 for (index, statement) in statements.iter().enumerate() {
47 outcome = outcome.with_assumption(Assumption::unverified(
48 format!("{prefix}_{index}"),
49 *statement,
50 ));
51 }
52 outcome
53}
54
55fn beta_binomial_descriptor() -> FunctionDescriptor {
60 FunctionDescriptor::new(
61 "statistics.beta_binomial_update",
62 "statistics",
63 "1.0.0",
64 "Beta-binomial conjugate update",
65 "Posterior summary for a Bernoulli proportion under a Beta prior.",
66 )
67 .with_description(
68 "Given successes in trials and a Beta(prior_alpha, prior_beta) prior, the posterior is \
69 Beta(prior_alpha + successes, prior_beta + trials - successes). Returns the posterior \
70 alpha and beta, the posterior mean, the posterior mode (null when both posterior \
71 shape parameters are at most 1 and the mode is not unique), and the equal-tailed \
72 credible interval at the requested confidence level, computed from the beta quantile \
73 function. method = \"beta_binomial_conjugate\". successes must be an integer in \
74 0..=trials, trials must be at least 1, and the prior shape parameters must be \
75 strictly positive. The record states that the interval and any probability are \
76 posterior statements under the supplied prior and binomial model, not p-values.",
77 )
78 .with_parameters(vec![
79 ParamDescriptor::required(
80 "successes",
81 "Observed successes; integer in 0..=trials.",
82 integer_schema(),
83 ),
84 ParamDescriptor::required("trials", "Observed trials; integer >= 1.", integer_schema()),
85 ParamDescriptor::required(
86 "prior_alpha",
87 "Prior Beta shape alpha; strictly positive.",
88 any_number_schema(),
89 ),
90 ParamDescriptor::required(
91 "prior_beta",
92 "Prior Beta shape beta; strictly positive.",
93 any_number_schema(),
94 ),
95 ParamDescriptor::optional(
96 "confidence",
97 "Credible level in (0, 1); default 0.95.",
98 any_number_schema(),
99 ),
100 ])
101 .with_output(
102 record_schema(
103 vec![
104 field("posterior_alpha", float64_schema()),
105 field("posterior_beta", float64_schema()),
106 field("posterior_mean", float64_schema()),
107 field("posterior_mode", ValueSchema::Any),
108 field("credible_interval", credible_interval_schema()),
109 field("confidence", float64_schema()),
110 field("method", text_schema()),
111 field("assumptions", array_schema(text_schema())),
112 ],
113 false,
114 ),
115 "Beta-binomial posterior record with an equal-tailed credible interval.",
116 )
117 .with_modes(inferential_modes())
118 .with_cost(CostClass::Constant)
119 .with_method_ref("docs/methods/statistics.md#beta_binomial_update")
120 .with_examples(vec![
121 Example::new(
122 "uniform prior after three successes in ten trials",
123 example_args(&[
124 ("successes", serde_json::json!(3)),
125 ("trials", serde_json::json!(10)),
126 ("prior_alpha", serde_json::json!(1)),
127 ("prior_beta", serde_json::json!(1)),
128 ]),
129 )
130 .with_contains("beta_binomial_conjugate"),
131 Example::new(
132 "successes exceed trials",
133 example_args(&[
134 ("successes", serde_json::json!(11)),
135 ("trials", serde_json::json!(10)),
136 ("prior_alpha", serde_json::json!(1)),
137 ("prior_beta", serde_json::json!(1)),
138 ]),
139 )
140 .with_error(ErrorCode::DomainViolation),
141 ])
142}
143
144fn invoke_beta_binomial(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
145 require_mode(ctx, &inferential_modes(), "statistics.beta_binomial_update")?;
146 let successes = non_negative_u64(&args.integer("successes")?, "successes")?;
147 let trials = non_negative_u64(&args.integer("trials")?, "trials")?;
148 if trials == 0 {
149 return Err(
150 EngineError::domain("trials must be at least 1").with_path("trials".to_string())
151 );
152 }
153 if successes > trials {
154 return Err(
155 EngineError::domain("successes must be between 0 and trials inclusive")
156 .with_path("successes".to_string()),
157 );
158 }
159 let prior_alpha = scalar_f64(args, "prior_alpha")?;
160 let prior_beta = scalar_f64(args, "prior_beta")?;
161 if prior_alpha <= 0.0 || prior_beta <= 0.0 {
162 return Err(EngineError::domain(
163 "prior_alpha and prior_beta must be strictly positive",
164 ));
165 }
166 let confidence = confidence_param(args)?;
167 let posterior_alpha = prior_alpha + successes as f64;
168 let posterior_beta = prior_beta + (trials - successes) as f64;
169 let posterior_mean = posterior_alpha / (posterior_alpha + posterior_beta);
170 let posterior_mode = if posterior_alpha > 1.0 && posterior_beta > 1.0 {
171 float_value((posterior_alpha - 1.0) / (posterior_alpha + posterior_beta - 2.0))?
172 } else if posterior_alpha <= 1.0 && posterior_beta > 1.0 {
173 float_value(0.0)?
174 } else if posterior_alpha > 1.0 && posterior_beta <= 1.0 {
175 float_value(1.0)?
176 } else {
177 Value::Null
178 };
179 let tail = (1.0 - confidence) / 2.0;
180 let lower = beta_quantile(tail, posterior_alpha, posterior_beta)?;
181 let upper = beta_quantile(1.0 - tail, posterior_alpha, posterior_beta)?;
182 let assumptions = [
183 "the observations are independent Bernoulli draws with a constant success probability",
184 "the prior is Beta(prior_alpha, prior_beta) and the posterior is Beta(prior_alpha + successes, prior_beta + trials - successes)",
185 "the interval is an equal-tailed posterior credible interval at the requested confidence level",
186 POSTERIOR_NOT_P_VALUE,
187 ];
188 let value = record(vec![
189 ("posterior_alpha", float_value(posterior_alpha)?),
190 ("posterior_beta", float_value(posterior_beta)?),
191 ("posterior_mean", float_value(posterior_mean)?),
192 ("posterior_mode", posterior_mode),
193 ("credible_interval", credible_interval_value(lower, upper)?),
194 ("confidence", float_value(confidence)?),
195 ("method", text("beta_binomial_conjugate")),
196 ("assumptions", assumptions_value(&assumptions)),
197 ]);
198 Ok(attach_assumptions(
199 Outcome::approximate(value),
200 "beta_binomial",
201 &assumptions,
202 ))
203}
204
205fn normal_normal_descriptor() -> FunctionDescriptor {
210 FunctionDescriptor::new(
211 "statistics.normal_normal_update",
212 "statistics",
213 "1.0.0",
214 "Normal-normal conjugate update",
215 "Posterior summary for a normal mean with known sampling variance.",
216 )
217 .with_description(
218 "Given a sample mean from sample_n observations with known standard deviation \
219 known_sigma and a N(prior_mean, prior_sigma^2) prior, the posterior precision is \
220 1 / prior_sigma^2 + sample_n / known_sigma^2, the posterior mean is the \
221 precision-weighted average of the prior mean and the sample mean, and the posterior \
222 variance is the reciprocal of the posterior precision. Returns posterior_mean, \
223 posterior_variance, posterior_sd, the equal-tailed credible interval at the \
224 requested confidence level, method = \"normal_normal_conjugate\", and the \
225 assumptions. sample_n must be an integer at least 1 and both standard deviations \
226 must be strictly positive. The interval is a posterior credible interval under the \
227 supplied prior and model, not a confidence interval.",
228 )
229 .with_parameters(vec![
230 ParamDescriptor::required("sample_mean", "Observed sample mean.", any_number_schema()),
231 ParamDescriptor::required(
232 "sample_n",
233 "Number of observations in the sample; integer >= 1.",
234 integer_schema(),
235 ),
236 ParamDescriptor::required(
237 "known_sigma",
238 "Known sampling standard deviation; strictly positive.",
239 any_number_schema(),
240 ),
241 ParamDescriptor::required("prior_mean", "Prior mean.", any_number_schema()),
242 ParamDescriptor::required(
243 "prior_sigma",
244 "Prior standard deviation; strictly positive.",
245 any_number_schema(),
246 ),
247 ParamDescriptor::optional(
248 "confidence",
249 "Credible level in (0, 1); default 0.95.",
250 any_number_schema(),
251 ),
252 ])
253 .with_output(
254 record_schema(
255 vec![
256 field("posterior_mean", float64_schema()),
257 field("posterior_variance", float64_schema()),
258 field("posterior_sd", float64_schema()),
259 field("credible_interval", credible_interval_schema()),
260 field("confidence", float64_schema()),
261 field("method", text_schema()),
262 field("assumptions", array_schema(text_schema())),
263 ],
264 false,
265 ),
266 "Normal-normal posterior record with an equal-tailed credible interval.",
267 )
268 .with_modes(inferential_modes())
269 .with_cost(CostClass::Constant)
270 .with_method_ref("docs/methods/statistics.md#normal_normal_update")
271 .with_examples(vec![
272 Example::new(
273 "precision-weighted update",
274 example_args(&[
275 ("sample_mean", serde_json::json!(2.0)),
276 ("sample_n", serde_json::json!(4)),
277 ("known_sigma", serde_json::json!(1.0)),
278 ("prior_mean", serde_json::json!(0.0)),
279 ("prior_sigma", serde_json::json!(1.0)),
280 ]),
281 )
282 .with_contains("normal_normal_conjugate"),
283 Example::new(
284 "zero observations",
285 example_args(&[
286 ("sample_mean", serde_json::json!(2.0)),
287 ("sample_n", serde_json::json!(0)),
288 ("known_sigma", serde_json::json!(1.0)),
289 ("prior_mean", serde_json::json!(0.0)),
290 ("prior_sigma", serde_json::json!(1.0)),
291 ]),
292 )
293 .with_error(ErrorCode::DomainViolation),
294 ])
295}
296
297fn invoke_normal_normal(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
298 require_mode(ctx, &inferential_modes(), "statistics.normal_normal_update")?;
299 let sample_mean = scalar_f64(args, "sample_mean")?;
300 let sample_n = non_negative_u64(&args.integer("sample_n")?, "sample_n")?;
301 if sample_n == 0 {
302 return Err(
303 EngineError::domain("sample_n must be at least 1").with_path("sample_n".to_string())
304 );
305 }
306 let known_sigma = scalar_f64(args, "known_sigma")?;
307 let prior_mean = scalar_f64(args, "prior_mean")?;
308 let prior_sigma = scalar_f64(args, "prior_sigma")?;
309 if known_sigma <= 0.0 || prior_sigma <= 0.0 {
310 return Err(EngineError::domain(
311 "known_sigma and prior_sigma must be strictly positive",
312 ));
313 }
314 let confidence = confidence_param(args)?;
315 let prior_precision = 1.0 / (prior_sigma * prior_sigma);
316 let data_precision = sample_n as f64 / (known_sigma * known_sigma);
317 let posterior_precision = prior_precision + data_precision;
318 let posterior_mean =
319 (prior_mean * prior_precision + sample_mean * data_precision) / posterior_precision;
320 let posterior_variance = 1.0 / posterior_precision;
321 let posterior_sd = sqrt(posterior_variance);
322 let critical = normal_quantile(1.0 - (1.0 - confidence) / 2.0, 0.0, 1.0)?;
323 let lower = posterior_mean - critical * posterior_sd;
324 let upper = posterior_mean + critical * posterior_sd;
325 let assumptions = [
326 "the sample mean is normal with known sampling standard deviation known_sigma, so the sampling variance of the mean is known_sigma^2 / sample_n",
327 "the prior is normal with mean prior_mean and standard deviation prior_sigma",
328 "the posterior is normal with the precision-weighted mean and reciprocal-precision variance reported here",
329 POSTERIOR_NOT_P_VALUE,
330 ];
331 let value = record(vec![
332 ("posterior_mean", float_value(posterior_mean)?),
333 ("posterior_variance", float_value(posterior_variance)?),
334 ("posterior_sd", float_value(posterior_sd)?),
335 ("credible_interval", credible_interval_value(lower, upper)?),
336 ("confidence", float_value(confidence)?),
337 ("method", text("normal_normal_conjugate")),
338 ("assumptions", assumptions_value(&assumptions)),
339 ]);
340 Ok(attach_assumptions(
341 Outcome::approximate(value),
342 "normal_normal",
343 &assumptions,
344 ))
345}
346
347fn gamma_poisson_descriptor() -> FunctionDescriptor {
352 FunctionDescriptor::new(
353 "statistics.gamma_poisson_update",
354 "statistics",
355 "1.0.0",
356 "Gamma-Poisson conjugate update",
357 "Posterior summary for a Poisson rate under a Gamma prior.",
358 )
359 .with_description(
360 "Given total_count events observed over exposure units and a Gamma(prior_shape, \
361 prior_rate) prior in the rate parameterization, the posterior is \
362 Gamma(prior_shape + total_count, prior_rate + exposure). Returns the posterior shape \
363 and rate, the posterior mean, the equal-tailed credible interval at the requested \
364 confidence level, method = \"gamma_poisson_conjugate\", and the assumptions. \
365 total_count must be a non-negative integer, exposure must be strictly positive, and \
366 the prior shape and rate must be strictly positive. The interval is a posterior \
367 credible interval under the supplied prior and Poisson model, not a confidence \
368 interval.",
369 )
370 .with_parameters(vec![
371 ParamDescriptor::required(
372 "total_count",
373 "Observed event count; integer >= 0.",
374 integer_schema(),
375 ),
376 ParamDescriptor::required(
377 "exposure",
378 "Total exposure; strictly positive.",
379 any_number_schema(),
380 ),
381 ParamDescriptor::required(
382 "prior_shape",
383 "Prior Gamma shape; strictly positive.",
384 any_number_schema(),
385 ),
386 ParamDescriptor::required(
387 "prior_rate",
388 "Prior Gamma rate; strictly positive.",
389 any_number_schema(),
390 ),
391 ParamDescriptor::optional(
392 "confidence",
393 "Credible level in (0, 1); default 0.95.",
394 any_number_schema(),
395 ),
396 ])
397 .with_output(
398 record_schema(
399 vec![
400 field("posterior_shape", float64_schema()),
401 field("posterior_rate", float64_schema()),
402 field("posterior_mean", float64_schema()),
403 field("credible_interval", credible_interval_schema()),
404 field("confidence", float64_schema()),
405 field("method", text_schema()),
406 field("assumptions", array_schema(text_schema())),
407 ],
408 false,
409 ),
410 "Gamma-Poisson posterior record with an equal-tailed credible interval.",
411 )
412 .with_modes(inferential_modes())
413 .with_cost(CostClass::Constant)
414 .with_method_ref("docs/methods/statistics.md#gamma_poisson_update")
415 .with_examples(vec![
416 Example::new(
417 "five events over two units of exposure",
418 example_args(&[
419 ("total_count", serde_json::json!(5)),
420 ("exposure", serde_json::json!(2)),
421 ("prior_shape", serde_json::json!(2)),
422 ("prior_rate", serde_json::json!(1)),
423 ]),
424 )
425 .with_contains("gamma_poisson_conjugate"),
426 Example::new(
427 "zero exposure",
428 example_args(&[
429 ("total_count", serde_json::json!(5)),
430 ("exposure", serde_json::json!(0)),
431 ("prior_shape", serde_json::json!(2)),
432 ("prior_rate", serde_json::json!(1)),
433 ]),
434 )
435 .with_error(ErrorCode::DomainViolation),
436 ])
437}
438
439fn invoke_gamma_poisson(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
440 require_mode(ctx, &inferential_modes(), "statistics.gamma_poisson_update")?;
441 let total_count = non_negative_u64(&args.integer("total_count")?, "total_count")?;
442 let exposure = scalar_f64(args, "exposure")?;
443 let prior_shape = scalar_f64(args, "prior_shape")?;
444 let prior_rate = scalar_f64(args, "prior_rate")?;
445 if exposure <= 0.0 {
446 return Err(EngineError::domain("exposure must be strictly positive")
447 .with_path("exposure".to_string()));
448 }
449 if prior_shape <= 0.0 || prior_rate <= 0.0 {
450 return Err(EngineError::domain(
451 "prior_shape and prior_rate must be strictly positive",
452 ));
453 }
454 let confidence = confidence_param(args)?;
455 let posterior_shape = prior_shape + total_count as f64;
456 let posterior_rate = prior_rate + exposure;
457 let posterior_mean = posterior_shape / posterior_rate;
458 let tail = (1.0 - confidence) / 2.0;
459 let lower = gamma_quantile(tail, posterior_shape, posterior_rate)?;
460 let upper = gamma_quantile(1.0 - tail, posterior_shape, posterior_rate)?;
461 let assumptions = [
462 "the total count is Poisson with rate lambda * exposure for a constant rate lambda",
463 "the prior is Gamma(prior_shape, prior_rate) in the rate parameterization",
464 "the posterior is Gamma(prior_shape + total_count, prior_rate + exposure)",
465 POSTERIOR_NOT_P_VALUE,
466 ];
467 let value = record(vec![
468 ("posterior_shape", float_value(posterior_shape)?),
469 ("posterior_rate", float_value(posterior_rate)?),
470 ("posterior_mean", float_value(posterior_mean)?),
471 ("credible_interval", credible_interval_value(lower, upper)?),
472 ("confidence", float_value(confidence)?),
473 ("method", text("gamma_poisson_conjugate")),
474 ("assumptions", assumptions_value(&assumptions)),
475 ]);
476 Ok(attach_assumptions(
477 Outcome::approximate(value),
478 "gamma_poisson",
479 &assumptions,
480 ))
481}
482
483fn beta_probability_descriptor() -> FunctionDescriptor {
488 FunctionDescriptor::new(
489 "statistics.beta_posterior_probability_gt",
490 "statistics",
491 "1.0.0",
492 "Beta posterior tail probability",
493 "Posterior probability that a Beta-distributed parameter exceeds a threshold.",
494 )
495 .with_description(
496 "Returns P(parameter > threshold) for a Beta(alpha, beta) posterior, computed from \
497 the regularized incomplete beta function as I_{1 - threshold}(beta, alpha). The \
498 threshold may be any finite number; probabilities outside [0, 1] are handled by the \
499 support of the distribution. method = \"beta_posterior_probability_gt\". The result \
500 states that the probability is a posterior probability under the supplied prior and \
501 model, not a p-value.",
502 )
503 .with_parameters(vec![
504 ParamDescriptor::required(
505 "alpha",
506 "Posterior Beta shape alpha; strictly positive.",
507 any_number_schema(),
508 ),
509 ParamDescriptor::required(
510 "beta",
511 "Posterior Beta shape beta; strictly positive.",
512 any_number_schema(),
513 ),
514 ParamDescriptor::required(
515 "threshold",
516 "Threshold; the returned probability is P(parameter > threshold).",
517 any_number_schema(),
518 ),
519 ])
520 .with_output(
521 record_schema(
522 vec![
523 field("probability", float64_schema()),
524 field("alpha", float64_schema()),
525 field("beta", float64_schema()),
526 field("threshold", float64_schema()),
527 field("method", text_schema()),
528 field("assumptions", array_schema(text_schema())),
529 ],
530 false,
531 ),
532 "Beta posterior tail probability record.",
533 )
534 .with_modes(inferential_modes())
535 .with_cost(CostClass::Constant)
536 .with_method_ref("docs/methods/statistics.md#beta_posterior_probability_gt")
537 .with_examples(vec![
538 Example::new(
539 "uniform posterior above one half",
540 example_args(&[
541 ("alpha", serde_json::json!(1)),
542 ("beta", serde_json::json!(1)),
543 ("threshold", serde_json::json!(0.5)),
544 ]),
545 )
546 .with_contains("beta_posterior_probability_gt"),
547 Example::new(
548 "non-positive shape",
549 example_args(&[
550 ("alpha", serde_json::json!(0)),
551 ("beta", serde_json::json!(1)),
552 ("threshold", serde_json::json!(0.5)),
553 ]),
554 )
555 .with_error(ErrorCode::DomainViolation),
556 ])
557}
558
559fn invoke_beta_probability(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
560 require_mode(
561 ctx,
562 &inferential_modes(),
563 "statistics.beta_posterior_probability_gt",
564 )?;
565 let alpha = scalar_f64(args, "alpha")?;
566 let beta = scalar_f64(args, "beta")?;
567 let threshold = scalar_f64(args, "threshold")?;
568 if alpha <= 0.0 || beta <= 0.0 {
569 return Err(EngineError::domain(
570 "alpha and beta must be strictly positive",
571 ));
572 }
573 let probability = beta_sf(threshold, alpha, beta)?;
574 let assumptions = [
575 "the parameter follows the Beta(alpha, beta) posterior distribution",
576 "the threshold is compared with the parameter itself, not with a test statistic",
577 POSTERIOR_NOT_P_VALUE,
578 ];
579 let value = record(vec![
580 ("probability", float_value(probability)?),
581 ("alpha", float_value(alpha)?),
582 ("beta", float_value(beta)?),
583 ("threshold", float_value(threshold)?),
584 ("method", text("beta_posterior_probability_gt")),
585 ("assumptions", assumptions_value(&assumptions)),
586 ]);
587 Ok(attach_assumptions(
588 Outcome::approximate(value),
589 "beta_probability",
590 &assumptions,
591 ))
592}
593
594fn normal_probability_descriptor() -> FunctionDescriptor {
599 FunctionDescriptor::new(
600 "statistics.normal_posterior_probability_gt",
601 "statistics",
602 "1.0.0",
603 "Normal posterior tail probability",
604 "Posterior probability that a normal parameter exceeds a threshold.",
605 )
606 .with_description(
607 "Returns P(parameter > threshold) for a N(mean, sd^2) posterior, computed from the \
608 complementary error function as 0.5 * erfc((threshold - mean) / (sd * sqrt(2))). \
609 sd must be strictly positive. method = \"normal_posterior_probability_gt\". The \
610 result states that the probability is a posterior probability under the supplied \
611 prior and model, not a p-value.",
612 )
613 .with_parameters(vec![
614 ParamDescriptor::required("mean", "Posterior mean.", any_number_schema()),
615 ParamDescriptor::required(
616 "sd",
617 "Posterior standard deviation; strictly positive.",
618 any_number_schema(),
619 ),
620 ParamDescriptor::required(
621 "threshold",
622 "Threshold; the returned probability is P(parameter > threshold).",
623 any_number_schema(),
624 ),
625 ])
626 .with_output(
627 record_schema(
628 vec![
629 field("probability", float64_schema()),
630 field("mean", float64_schema()),
631 field("sd", float64_schema()),
632 field("threshold", float64_schema()),
633 field("method", text_schema()),
634 field("assumptions", array_schema(text_schema())),
635 ],
636 false,
637 ),
638 "Normal posterior tail probability record.",
639 )
640 .with_modes(inferential_modes())
641 .with_cost(CostClass::Constant)
642 .with_method_ref("docs/methods/statistics.md#normal_posterior_probability_gt")
643 .with_examples(vec![
644 Example::new(
645 "one point nine six standard deviations above the mean",
646 example_args(&[
647 ("mean", serde_json::json!(0)),
648 ("sd", serde_json::json!(1)),
649 ("threshold", serde_json::json!(1.96)),
650 ]),
651 )
652 .with_contains("normal_posterior_probability_gt"),
653 Example::new(
654 "non-positive standard deviation",
655 example_args(&[
656 ("mean", serde_json::json!(0)),
657 ("sd", serde_json::json!(0)),
658 ("threshold", serde_json::json!(1.96)),
659 ]),
660 )
661 .with_error(ErrorCode::DomainViolation),
662 ])
663}
664
665fn invoke_normal_probability(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
666 require_mode(
667 ctx,
668 &inferential_modes(),
669 "statistics.normal_posterior_probability_gt",
670 )?;
671 let mean = scalar_f64(args, "mean")?;
672 let sd = scalar_f64(args, "sd")?;
673 let threshold = scalar_f64(args, "threshold")?;
674 if sd <= 0.0 {
675 return Err(EngineError::domain("sd must be strictly positive").with_path("sd".to_string()));
676 }
677 let probability = normal_sf(threshold, mean, sd)?;
678 let assumptions = [
679 "the parameter follows the N(mean, sd^2) posterior distribution",
680 "the threshold is compared with the parameter itself, not with a test statistic",
681 POSTERIOR_NOT_P_VALUE,
682 ];
683 let value = record(vec![
684 ("probability", float_value(probability)?),
685 ("mean", float_value(mean)?),
686 ("sd", float_value(sd)?),
687 ("threshold", float_value(threshold)?),
688 ("method", text("normal_posterior_probability_gt")),
689 ("assumptions", assumptions_value(&assumptions)),
690 ]);
691 Ok(attach_assumptions(
692 Outcome::approximate(value),
693 "normal_probability",
694 &assumptions,
695 ))
696}
697
698pub fn functions() -> Vec<Arc<dyn bicmath_core::contract::Function>> {
703 vec![
704 SimpleFunction::arc(beta_binomial_descriptor(), invoke_beta_binomial),
705 SimpleFunction::arc(normal_normal_descriptor(), invoke_normal_normal),
706 SimpleFunction::arc(gamma_poisson_descriptor(), invoke_gamma_poisson),
707 SimpleFunction::arc(beta_probability_descriptor(), invoke_beta_probability),
708 SimpleFunction::arc(normal_probability_descriptor(), invoke_normal_probability),
709 ]
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use std::collections::BTreeMap;
716
717 fn call(id: &str, raw: serde_json::Value) -> Result<Outcome, EngineError> {
718 let module = crate::module();
719 let function = module
720 .functions
721 .iter()
722 .find(|f| f.descriptor().id == id)
723 .expect("function exists");
724 let ctx = ExecContext::scientific();
725 let args_json = raw.as_object().expect("object args");
726 let mut values = BTreeMap::new();
727 for (name, value) in args_json {
728 let param = function
729 .descriptor()
730 .parameter(name)
731 .expect("parameter exists");
732 values.insert(
733 name.clone(),
734 param
735 .schema
736 .coerce(value, name, &ctx.limits, true)
737 .expect("argument coerces"),
738 );
739 }
740 function.invoke(&Args::new(values), &ctx)
741 }
742
743 fn record_of(outcome: &Outcome) -> &BTreeMap<String, Value> {
744 match &outcome.value {
745 Value::Record(fields) => fields,
746 other => panic!("expected record, got {other:?}"),
747 }
748 }
749
750 fn field_f64(fields: &BTreeMap<String, Value>, name: &str) -> f64 {
751 match fields.get(name) {
752 Some(Value::Number(number)) => number.to_f64().expect("number"),
753 other => panic!("expected numeric field {name}, got {other:?}"),
754 }
755 }
756
757 fn field_record<'a>(
758 fields: &'a BTreeMap<String, Value>,
759 name: &str,
760 ) -> &'a BTreeMap<String, Value> {
761 match fields.get(name) {
762 Some(Value::Record(record)) => record,
763 other => panic!("expected record field {name}, got {other:?}"),
764 }
765 }
766
767 fn assumptions_of(outcome: &Outcome) -> Vec<String> {
768 outcome
769 .assumptions
770 .iter()
771 .map(|assumption| assumption.statement.clone())
772 .collect()
773 }
774
775 fn assert_posterior_statement(outcome: &Outcome) {
776 let statements = assumptions_of(outcome);
777 assert!(
778 statements
779 .iter()
780 .any(|statement| statement.contains("not a p-value")),
781 "the record must state that the probability is not a p-value: {statements:?}"
782 );
783 }
784
785 fn close(actual: f64, expected: f64, tolerance: f64) {
786 assert!(
787 (actual - expected).abs() <= tolerance,
788 "expected {expected}, got {actual} (tolerance {tolerance})"
789 );
790 }
791
792 #[test]
793 fn beta_binomial_posterior_matches_the_conjugate_form() {
794 let outcome = call(
800 "statistics.beta_binomial_update",
801 serde_json::json!({
802 "successes": 3,
803 "trials": 10,
804 "prior_alpha": 1,
805 "prior_beta": 1
806 }),
807 )
808 .unwrap();
809 let fields = record_of(&outcome);
810 close(field_f64(fields, "posterior_alpha"), 4.0, 0.0);
811 close(field_f64(fields, "posterior_beta"), 8.0, 0.0);
812 close(field_f64(fields, "posterior_mean"), 4.0 / 12.0, 1e-15);
813 close(field_f64(fields, "posterior_mode"), 0.3, 1e-12);
814 let interval = field_record(fields, "credible_interval");
815 close(field_f64(interval, "lower"), 0.109_263_443_819_098_11, 1e-9);
816 close(field_f64(interval, "upper"), 0.609_742_559_572_421_1, 1e-9);
817 assert_posterior_statement(&outcome);
818 }
819
820 #[test]
821 fn normal_normal_posterior_mean_lies_between_prior_and_sample() {
822 let outcome = call(
827 "statistics.normal_normal_update",
828 serde_json::json!({
829 "sample_mean": 2.0,
830 "sample_n": 4,
831 "known_sigma": 1.0,
832 "prior_mean": 0.0,
833 "prior_sigma": 1.0
834 }),
835 )
836 .unwrap();
837 let fields = record_of(&outcome);
838 let posterior_mean = field_f64(fields, "posterior_mean");
839 assert!(
840 posterior_mean > 0.0 && posterior_mean < 2.0,
841 "the posterior mean must lie between the prior mean and the sample mean"
842 );
843 close(posterior_mean, 1.6, 1e-15);
844 close(field_f64(fields, "posterior_variance"), 0.2, 1e-15);
845 let interval = field_record(fields, "credible_interval");
846 close(field_f64(interval, "lower"), 0.723_477_459_423_418_6, 1e-9);
847 close(field_f64(interval, "upper"), 2.476_522_540_576_582, 1e-9);
848 assert_posterior_statement(&outcome);
849 }
850
851 #[test]
852 fn gamma_poisson_posterior_shape_adds_the_count() {
853 let outcome = call(
858 "statistics.gamma_poisson_update",
859 serde_json::json!({
860 "total_count": 5,
861 "exposure": 2,
862 "prior_shape": 2,
863 "prior_rate": 1
864 }),
865 )
866 .unwrap();
867 let fields = record_of(&outcome);
868 close(field_f64(fields, "posterior_shape"), 7.0, 1e-15);
869 close(field_f64(fields, "posterior_rate"), 3.0, 1e-15);
870 close(field_f64(fields, "posterior_mean"), 7.0 / 3.0, 1e-15);
871 let interval = field_record(fields, "credible_interval");
872 close(field_f64(interval, "lower"), 0.938_121_017_173_288_8, 1e-9);
873 close(field_f64(interval, "upper"), 4.353_158_007_506_227, 1e-9);
874 assert_posterior_statement(&outcome);
875 }
876
877 #[test]
878 fn beta_posterior_probability_above_one_half_is_one_half() {
879 let outcome = call(
880 "statistics.beta_posterior_probability_gt",
881 serde_json::json!({"alpha": 1, "beta": 1, "threshold": 0.5}),
882 )
883 .unwrap();
884 let fields = record_of(&outcome);
885 close(field_f64(fields, "probability"), 0.5, 1e-12);
886 assert_posterior_statement(&outcome);
887 }
888
889 #[test]
890 fn normal_posterior_probability_matches_the_erfc_tail() {
891 let outcome = call(
894 "statistics.normal_posterior_probability_gt",
895 serde_json::json!({"mean": 0, "sd": 1, "threshold": 1.96}),
896 )
897 .unwrap();
898 let fields = record_of(&outcome);
899 close(
900 field_f64(fields, "probability"),
901 0.024_997_895_148_220_43,
902 1e-15,
903 );
904 assert_posterior_statement(&outcome);
905 }
906
907 #[test]
908 fn bayesian_updates_reject_invalid_inputs() {
909 let error = call(
910 "statistics.beta_binomial_update",
911 serde_json::json!({
912 "successes": 11, "trials": 10,
913 "prior_alpha": 1, "prior_beta": 1
914 }),
915 )
916 .unwrap_err();
917 assert_eq!(error.code, ErrorCode::DomainViolation);
918 let error = call(
919 "statistics.gamma_poisson_update",
920 serde_json::json!({
921 "total_count": 5, "exposure": 0,
922 "prior_shape": 2, "prior_rate": 1
923 }),
924 )
925 .unwrap_err();
926 assert_eq!(error.code, ErrorCode::DomainViolation);
927 let error = call(
928 "statistics.normal_posterior_probability_gt",
929 serde_json::json!({"mean": 0, "sd": -1, "threshold": 0}),
930 )
931 .unwrap_err();
932 assert_eq!(error.code, ErrorCode::DomainViolation);
933 }
934}