Skip to main content

bicmath_statistics/
bayesian.rs

1//! Bayesian conjugate updates and posterior tail probabilities.
2//!
3//! Every function in this module is explicitly Bayesian: the prior is supplied
4//! by the caller, the posterior has the stated conjugate form, and the reported
5//! interval is an equal-tailed posterior credible interval. No posterior
6//! quantity is relabelled as a frequentist confidence interval, and no
7//! posterior probability is relabelled as a p-value. Every record carries that
8//! statement in its assumptions.
9
10use 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
55// ---------------------------------------------------------------------------
56// beta_binomial_update
57// ---------------------------------------------------------------------------
58
59fn 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
205// ---------------------------------------------------------------------------
206// normal_normal_update
207// ---------------------------------------------------------------------------
208
209fn 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
347// ---------------------------------------------------------------------------
348// gamma_poisson_update
349// ---------------------------------------------------------------------------
350
351fn 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
483// ---------------------------------------------------------------------------
484// beta_posterior_probability_gt
485// ---------------------------------------------------------------------------
486
487fn 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
594// ---------------------------------------------------------------------------
595// normal_posterior_probability_gt
596// ---------------------------------------------------------------------------
597
598fn 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
698// ---------------------------------------------------------------------------
699// Registration
700// ---------------------------------------------------------------------------
701
702pub 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        // Provenance: Python 3.14 stdlib evaluation of the Beta(4, 8)
795        // posterior after 3 successes in 10 trials with a Beta(1, 1) prior:
796        // mean 4 / 12 = 0.3333333333333333, mode (4 - 1) / (4 + 8 - 2) = 0.3,
797        // and equal-tailed 95% interval [0.10926344381909811,
798        // 0.6097425595724211].
799        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        // Provenance: Python 3.14 stdlib evaluation of the conjugate update
823        // with prior N(0, 1), sample mean 2, n = 4, known sigma = 1:
824        // posterior precision 5, mean 1.6, variance 0.2, and equal-tailed
825        // 95% interval [0.7234774594234186, 2.476522540576582].
826        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        // Provenance: Python 3.14 stdlib evaluation of the Gamma(7, 3)
854        // posterior after 5 events over 2 units of exposure with a Gamma(2, 1)
855        // prior: mean 7 / 3 = 2.3333333333333335 and equal-tailed 95%
856        // interval [0.9381210171732888, 4.353158007506227].
857        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        // Provenance: Python 3.14 stdlib math.erfc: P(Z > 1.96) =
892        // 0.5 * erfc(1.96 / sqrt(2)) = 0.02499789514822043.
893        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}