Skip to main content

bicmath_statistics/
inference.rs

1//! Statistical inference: confidence intervals, contingency tests, and
2//! normal-approximation sample-size and power planning.
3//!
4//! Every method is named explicitly and every result carries its assumptions.
5//! No equal-variance, normality, or paired-samples assumption is ever applied
6//! silently.
7
8use std::sync::Arc;
9
10use bicmath_core::context::ExecContext;
11use bicmath_core::contract::{
12    Args, Assumption, CostClass, Example, FunctionDescriptor, Outcome, ParamDescriptor,
13    SimpleFunction, Warning, require_mode,
14};
15use bicmath_core::error::{EngineError, ErrorCode};
16use bicmath_core::schema::{FieldSchema, ValueSchema};
17use bicmath_core::value::Value;
18use num_bigint::BigInt;
19
20use crate::common::*;
21use crate::mathfn::{chi_square_sf, normal_cdf, normal_quantile, sqrt, student_t_quantile};
22
23// ---------------------------------------------------------------------------
24// Helpers
25// ---------------------------------------------------------------------------
26
27fn numeric_record_schema(fields: Vec<(&str, ValueSchema)>) -> ValueSchema {
28    record_schema(
29        fields
30            .into_iter()
31            .map(|(name, schema)| FieldSchema::required(name, schema))
32            .collect(),
33        false,
34    )
35}
36
37fn assumptions_value(statements: &[&str]) -> Value {
38    array_value(
39        statements
40            .iter()
41            .map(|statement| text(*statement))
42            .collect(),
43    )
44}
45
46fn normal_critical(confidence: f64) -> Result<f64, EngineError> {
47    normal_quantile(1.0 - (1.0 - confidence) / 2.0, 0.0, 1.0)
48}
49
50fn t_critical(confidence: f64, df: f64) -> Result<f64, EngineError> {
51    student_t_quantile(1.0 - (1.0 - confidence) / 2.0, df)
52}
53
54fn sample_mean_variance(values: &[f64]) -> Result<(f64, f64), EngineError> {
55    if values.len() < 2 {
56        return Err(insufficient(
57            "the method requires at least two observations to estimate the variance",
58        ));
59    }
60    Ok((mean_f64(values), variance_f64(values, 1)?))
61}
62
63fn wilson_interval(successes: f64, n: f64, z: f64) -> (f64, f64) {
64    let p = successes / n;
65    let denominator = 1.0 + z * z / n;
66    let center = (p + z * z / (2.0 * n)) / denominator;
67    let half = z * sqrt(p * (1.0 - p) / n + z * z / (4.0 * n * n)) / denominator;
68    (
69        (center - half).clamp(0.0, 1.0),
70        (center + half).clamp(0.0, 1.0),
71    )
72}
73
74fn wald_proportion_interval(p: f64, n: f64, z: f64) -> (f64, f64) {
75    let standard_error = sqrt(p * (1.0 - p) / n);
76    (
77        (p - z * standard_error).clamp(0.0, 1.0),
78        (p + z * standard_error).clamp(0.0, 1.0),
79    )
80}
81
82fn validate_successes(
83    successes: &BigInt,
84    n: &BigInt,
85    prefix: &str,
86) -> Result<(u64, u64), EngineError> {
87    let n_value = non_negative_u64(n, &format!("{prefix}n"))?;
88    if n_value == 0 {
89        return Err(EngineError::domain("n must be at least 1").with_path(format!("{prefix}n")));
90    }
91    let s_value = non_negative_u64(successes, &format!("{prefix}successes"))?;
92    if s_value > n_value {
93        return Err(
94            EngineError::domain("successes must be between 0 and n inclusive")
95                .with_path(format!("{prefix}successes")),
96        );
97    }
98    Ok((s_value, n_value))
99}
100
101fn parse_count_table(args: &Args, name: &str) -> Result<(usize, usize, Vec<f64>), EngineError> {
102    let value = args.require(name)?;
103    let (rows, cols, data): (usize, usize, Vec<&Value>) = match value {
104        Value::Matrix { rows, cols, data } => {
105            (*rows as usize, *cols as usize, data.iter().collect())
106        }
107        Value::Array(items) => {
108            if items.is_empty() {
109                return Err(
110                    EngineError::domain("the contingency table must not be empty")
111                        .with_path(name.to_string()),
112                );
113            }
114            let mut rows = Vec::new();
115            let mut cols = None;
116            for (index, row) in items.iter().enumerate() {
117                let row = row.as_array().map_err(|e| {
118                    EngineError::malformed(format!(
119                        "row {index} of {name} must be an array: {}",
120                        e.message
121                    ))
122                    .with_path(format!("{name}[{index}]"))
123                })?;
124                if row.is_empty() {
125                    return Err(
126                        EngineError::domain("the contingency table must not be empty")
127                            .with_path(format!("{name}[{index}]")),
128                    );
129                }
130                match cols {
131                    None => cols = Some(row.len()),
132                    Some(expected) if expected != row.len() => {
133                        return Err(EngineError::malformed(format!(
134                            "ragged contingency table: row {index} has {} entries, expected {expected}",
135                            row.len()
136                        ))
137                        .with_path(format!("{name}[{index}]")));
138                    }
139                    Some(_) => {}
140                }
141                rows.push(row.iter().collect::<Vec<_>>());
142            }
143            let cols = cols.unwrap_or(0);
144            (rows.len(), cols, rows.into_iter().flatten().collect())
145        }
146        other => {
147            return Err(EngineError::malformed(format!(
148                "expected a matrix or an array of equal-length arrays at {name}, found {}",
149                other.kind_name()
150            ))
151            .with_path(name.to_string()));
152        }
153    };
154    if rows < 2 || cols < 2 {
155        return Err(EngineError::domain(
156            "the contingency table must have at least two rows and two columns",
157        )
158        .with_path(name.to_string()));
159    }
160    let mut counts = Vec::with_capacity(data.len());
161    for (index, cell) in data.iter().enumerate() {
162        let number = cell.as_number().map_err(|_| {
163            EngineError::malformed(format!("table cell {index} must be a number"))
164                .with_path(format!("{name}[{index}]"))
165        })?;
166        let count = number_to_f64(number).map_err(|e| e.with_path(format!("{name}[{index}]")))?;
167        if count < 0.0 {
168            return Err(
169                EngineError::domain("contingency table counts must be non-negative")
170                    .with_path(format!("{name}[{index}]")),
171            );
172        }
173        counts.push(count);
174    }
175    Ok((rows, cols, counts))
176}
177
178fn integer_from_f64(value: f64, name: &str) -> Result<BigInt, EngineError> {
179    if !value.is_finite() || value < 0.0 {
180        return Err(EngineError::domain(format!(
181            "{name} could not be represented as a non-negative sample size"
182        )));
183    }
184    let rounded = value.ceil();
185    if rounded > u64::MAX as f64 {
186        return Err(EngineError::new(
187            ErrorCode::ResourceLimit,
188            format!("{name} exceeds the supported sample-size range"),
189        ));
190    }
191    Ok(BigInt::from(rounded as u64))
192}
193
194// ---------------------------------------------------------------------------
195// ci_mean
196// ---------------------------------------------------------------------------
197
198fn ci_mean_descriptor() -> FunctionDescriptor {
199    FunctionDescriptor::new(
200        "statistics.ci_mean",
201        "statistics",
202        "1.0.0",
203        "Confidence interval for a mean",
204        "Confidence interval for the mean of one sample.",
205    )
206    .with_description(
207        "method = \"t\" (default) uses the Student-t critical value with n - 1 degrees of \
208         freedom; method = \"z\" uses the normal critical value with the sample standard \
209         deviation (a large-sample approximation). At least two observations are required. \
210         The output reports estimate, lower, upper, standard_error, method, df, and the \
211         assumptions that were applied; no normality or independence assumption is silently \
212         added.",
213    )
214    .with_parameters(vec![
215        ParamDescriptor::required("values", "Observations.", array_schema(any_number_schema())),
216        ParamDescriptor::optional(
217            "confidence",
218            "Confidence level in (0, 1); default 0.95.",
219            any_number_schema(),
220        ),
221        ParamDescriptor::optional(
222            "method",
223            "Critical value method: t (default) or z.",
224            ValueSchema::Enum {
225                variants: vec!["t".to_string(), "z".to_string()],
226            },
227        ),
228    ])
229    .with_output(
230        numeric_record_schema(vec![
231            ("estimate", any_number_schema()),
232            ("lower", any_number_schema()),
233            ("upper", any_number_schema()),
234            ("standard_error", any_number_schema()),
235            ("method", text_schema()),
236            ("df", ValueSchema::Any),
237            ("assumptions", array_schema(text_schema())),
238        ]),
239        "Confidence interval record.",
240    )
241    .with_modes(inferential_modes())
242    .with_cost(CostClass::Linear)
243    .with_method_ref("docs/methods/statistics.md#ci_mean")
244    .with_examples(vec![
245        Example::new(
246            "single observation is not enough",
247            example_args(&[("values", serde_json::json!([1]))]),
248        )
249        .with_error(ErrorCode::InsufficientObservations),
250    ])
251}
252
253fn invoke_ci_mean(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
254    require_mode(ctx, &inferential_modes(), "statistics.ci_mean")?;
255    let confidence = confidence_param(args)?;
256    let method = parse_choice(args, "method", "t", &["t", "z"])?;
257    let values = classify_series(args, "values", ctx)?.to_f64_vec()?;
258    let (estimate, variance) = sample_mean_variance(&values)?;
259    let n = values.len() as f64;
260    let standard_error = sqrt(variance / n);
261    let (critical, df_value, method_assumptions): (f64, Value, &[&str]) = if method == "t" {
262        (
263            t_critical(confidence, n - 1.0)?,
264            float_value(n - 1.0)?,
265            &[
266                "observations are independent and identically distributed",
267                "the sample is representative of the target population",
268                "the population is approximately normal, or the sample is large enough for the t interval",
269            ],
270        )
271    } else {
272        (
273            normal_critical(confidence)?,
274            Value::Null,
275            &[
276                "observations are independent and identically distributed",
277                "the sample is representative of the target population",
278                "the population standard deviation is known; the sample standard deviation is used as a large-sample approximation",
279            ],
280        )
281    };
282    let lower = estimate - critical * standard_error;
283    let upper = estimate + critical * standard_error;
284    let value = record(vec![
285        ("estimate", float_value(estimate)?),
286        ("lower", float_value(lower)?),
287        ("upper", float_value(upper)?),
288        ("standard_error", float_value(standard_error)?),
289        ("method", text(method)),
290        ("df", df_value),
291        ("assumptions", assumptions_value(method_assumptions)),
292    ]);
293    let mut outcome = Outcome::approximate(value);
294    for (index, statement) in method_assumptions.iter().enumerate() {
295        outcome = outcome.with_assumption(Assumption::unverified(
296            format!("ci_mean_{index}"),
297            *statement,
298        ));
299    }
300    Ok(outcome)
301}
302
303// ---------------------------------------------------------------------------
304// ci_proportion
305// ---------------------------------------------------------------------------
306
307fn ci_proportion_descriptor() -> FunctionDescriptor {
308    FunctionDescriptor::new(
309        "statistics.ci_proportion",
310        "statistics",
311        "1.0.0",
312        "Confidence interval for a proportion",
313        "Confidence interval for a binomial proportion.",
314    )
315    .with_description(
316        "method = \"wilson\" (default) uses the Wilson score interval; method = \"wald\" uses \
317         the normal approximation p_hat +/- z * sqrt(p_hat (1 - p_hat) / n) with endpoints \
318         clipped to [0, 1]. Wald intervals emit a warning when n * p_hat < 5 or \
319         n * (1 - p_hat) < 5. successes must be an integer in 0..=n and n must be at least 1.",
320    )
321    .with_parameters(vec![
322        ParamDescriptor::required(
323            "successes",
324            "Number of successes; integer in 0..=n.",
325            integer_schema(),
326        ),
327        ParamDescriptor::required("n", "Number of trials; integer >= 1.", integer_schema()),
328        ParamDescriptor::optional(
329            "confidence",
330            "Confidence level in (0, 1); default 0.95.",
331            any_number_schema(),
332        ),
333        ParamDescriptor::optional(
334            "method",
335            "Interval method: wilson (default) or wald.",
336            ValueSchema::Enum {
337                variants: vec!["wilson".to_string(), "wald".to_string()],
338            },
339        ),
340    ])
341    .with_output(
342        numeric_record_schema(vec![
343            ("estimate", any_number_schema()),
344            ("lower", any_number_schema()),
345            ("upper", any_number_schema()),
346            ("method", text_schema()),
347            ("assumptions", array_schema(text_schema())),
348        ]),
349        "Proportion confidence interval record.",
350    )
351    .with_modes(inferential_modes())
352    .with_cost(CostClass::Constant)
353    .with_method_ref("docs/methods/statistics.md#ci_proportion")
354    .with_examples(vec![
355        Example::new(
356            "zero trials",
357            example_args(&[
358                ("successes", serde_json::json!(0)),
359                ("n", serde_json::json!(0)),
360            ]),
361        )
362        .with_error(ErrorCode::DomainViolation),
363    ])
364}
365
366fn invoke_ci_proportion(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
367    require_mode(ctx, &inferential_modes(), "statistics.ci_proportion")?;
368    let (successes, n) = validate_successes(&args.integer("successes")?, &args.integer("n")?, "")?;
369    let confidence = confidence_param(args)?;
370    let method = parse_choice(args, "method", "wilson", &["wilson", "wald"])?;
371    let z = normal_critical(confidence)?;
372    let successes_f = successes as f64;
373    let n_f = n as f64;
374    let p = successes_f / n_f;
375    let (lower, upper, assumptions, warning) = if method == "wilson" {
376        let (lower, upper) = wilson_interval(successes_f, n_f, z);
377        (
378            lower,
379            upper,
380            vec![
381                "trials are independent Bernoulli draws",
382                "the sample is representative of the target population",
383            ],
384            None,
385        )
386    } else {
387        let (lower, upper) = wald_proportion_interval(p, n_f, z);
388        let small = n_f * p < 5.0 || n_f * (1.0 - p) < 5.0;
389        let warning = small.then(|| {
390            Warning::new(
391                "wald_small_sample",
392                "the Wald interval is unreliable when n * p_hat < 5 or n * (1 - p_hat) < 5",
393            )
394        });
395        (
396            lower,
397            upper,
398            vec![
399                "trials are independent Bernoulli draws",
400                "the sample is representative of the target population",
401                "the normal approximation to the binomial is adequate",
402            ],
403            warning,
404        )
405    };
406    let value = record(vec![
407        ("estimate", float_value(p)?),
408        ("lower", float_value(lower)?),
409        ("upper", float_value(upper)?),
410        ("method", text(method)),
411        ("assumptions", assumptions_value(&assumptions)),
412    ]);
413    let mut outcome = Outcome::approximate(value);
414    if let Some(warning) = warning {
415        outcome = outcome.with_warning(warning);
416    }
417    Ok(outcome)
418}
419
420// ---------------------------------------------------------------------------
421// welch_ci
422// ---------------------------------------------------------------------------
423
424fn welch_ci_descriptor() -> FunctionDescriptor {
425    FunctionDescriptor::new(
426        "statistics.welch_ci",
427        "statistics",
428        "1.0.0",
429        "Welch confidence interval for a mean difference",
430        "Welch interval for mean_a - mean_b without an equal-variance assumption.",
431    )
432    .with_description(
433        "Returns the difference of means with the Welch-Satterthwaite degrees of freedom and \
434         a Student-t interval. The equal-variance assumption of the pooled two-sample interval \
435         is deliberately not applied. Each sample must contain at least two observations and \
436         the standard error must be positive (two constant samples have an undefined interval).",
437    )
438    .with_parameters(vec![
439        ParamDescriptor::required(
440            "sample_a",
441            "First sample.",
442            array_schema(any_number_schema()),
443        ),
444        ParamDescriptor::required(
445            "sample_b",
446            "Second sample.",
447            array_schema(any_number_schema()),
448        ),
449        ParamDescriptor::optional(
450            "confidence",
451            "Confidence level in (0, 1); default 0.95.",
452            any_number_schema(),
453        ),
454    ])
455    .with_output(
456        numeric_record_schema(vec![
457            ("estimate", any_number_schema()),
458            ("lower", any_number_schema()),
459            ("upper", any_number_schema()),
460            ("standard_error", any_number_schema()),
461            ("df", any_number_schema()),
462            ("method", text_schema()),
463            ("assumptions", array_schema(text_schema())),
464        ]),
465        "Welch confidence interval record.",
466    )
467    .with_modes(inferential_modes())
468    .with_cost(CostClass::Linear)
469    .with_method_ref("docs/methods/statistics.md#welch_ci")
470    .with_examples(vec![
471        Example::new(
472            "one observation per sample",
473            example_args(&[
474                ("sample_a", serde_json::json!([1])),
475                ("sample_b", serde_json::json!([2])),
476            ]),
477        )
478        .with_error(ErrorCode::InsufficientObservations),
479    ])
480}
481
482fn invoke_welch_ci(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
483    require_mode(ctx, &inferential_modes(), "statistics.welch_ci")?;
484    let confidence = confidence_param(args)?;
485    let a = classify_series(args, "sample_a", ctx)?.to_f64_vec()?;
486    let b = classify_series(args, "sample_b", ctx)?.to_f64_vec()?;
487    let (mean_a, variance_a) = sample_mean_variance(&a)?;
488    let (mean_b, variance_b) = sample_mean_variance(&b)?;
489    let n_a = a.len() as f64;
490    let n_b = b.len() as f64;
491    let component_a = variance_a / n_a;
492    let component_b = variance_b / n_b;
493    let standard_error = sqrt(component_a + component_b);
494    if standard_error <= 0.0 {
495        return Err(EngineError::domain(
496            "Welch interval is undefined when both samples are constant (zero standard error)",
497        ));
498    }
499    let df = (component_a + component_b).powi(2)
500        / (component_a.powi(2) / (n_a - 1.0) + component_b.powi(2) / (n_b - 1.0));
501    if !df.is_finite() || df <= 0.0 {
502        return Err(EngineError::domain(
503            "Welch-Satterthwaite degrees of freedom are undefined",
504        ));
505    }
506    let critical = t_critical(confidence, df)?;
507    let estimate = mean_a - mean_b;
508    let assumptions = [
509        "the two samples are independent",
510        "each sample is representative of its population",
511        "the sampling distribution of the difference is approximately Student-t (normal populations or large samples)",
512    ];
513    let value = record(vec![
514        ("estimate", float_value(estimate)?),
515        ("lower", float_value(estimate - critical * standard_error)?),
516        ("upper", float_value(estimate + critical * standard_error)?),
517        ("standard_error", float_value(standard_error)?),
518        ("df", float_value(df)?),
519        ("method", text("welch")),
520        ("assumptions", assumptions_value(&assumptions)),
521    ]);
522    let mut outcome = Outcome::approximate(value);
523    for (index, statement) in assumptions.iter().enumerate() {
524        outcome = outcome.with_assumption(Assumption::unverified(
525            format!("welch_ci_{index}"),
526            *statement,
527        ));
528    }
529    Ok(outcome)
530}
531
532// ---------------------------------------------------------------------------
533// proportions_difference
534// ---------------------------------------------------------------------------
535
536fn proportions_difference_descriptor() -> FunctionDescriptor {
537    FunctionDescriptor::new(
538        "statistics.proportions_difference",
539        "statistics",
540        "1.0.0",
541        "Confidence interval for a difference of proportions",
542        "Newcombe or Wald interval for p_a - p_b.",
543    )
544    .with_description(
545        "method = \"newcombe\" (default) combines the two Wilson score intervals with the \
546         square-and-add hybrid; method = \"wald\" uses the normal approximation. Each group \
547         needs n >= 1 and 0 <= successes <= n. The Wald interval emits a small-sample warning \
548         when any expected count is below 5.",
549    )
550    .with_parameters(vec![
551        ParamDescriptor::required(
552            "successes_a",
553            "Successes in group A; integer in 0..=n_a.",
554            integer_schema(),
555        ),
556        ParamDescriptor::required("n_a", "Trials in group A; integer >= 1.", integer_schema()),
557        ParamDescriptor::required(
558            "successes_b",
559            "Successes in group B; integer in 0..=n_b.",
560            integer_schema(),
561        ),
562        ParamDescriptor::required("n_b", "Trials in group B; integer >= 1.", integer_schema()),
563        ParamDescriptor::optional(
564            "confidence",
565            "Confidence level in (0, 1); default 0.95.",
566            any_number_schema(),
567        ),
568        ParamDescriptor::optional(
569            "method",
570            "Interval method: newcombe (default) or wald.",
571            ValueSchema::Enum {
572                variants: vec!["newcombe".to_string(), "wald".to_string()],
573            },
574        ),
575    ])
576    .with_output(
577        numeric_record_schema(vec![
578            ("difference", any_number_schema()),
579            ("lower", any_number_schema()),
580            ("upper", any_number_schema()),
581            ("method", text_schema()),
582            ("assumptions", array_schema(text_schema())),
583        ]),
584        "Difference of proportions confidence interval record.",
585    )
586    .with_modes(inferential_modes())
587    .with_cost(CostClass::Constant)
588    .with_method_ref("docs/methods/statistics.md#proportions_difference")
589    .with_examples(vec![
590        Example::new(
591            "successes exceed trials",
592            example_args(&[
593                ("successes_a", serde_json::json!(5)),
594                ("n_a", serde_json::json!(2)),
595                ("successes_b", serde_json::json!(1)),
596                ("n_b", serde_json::json!(2)),
597            ]),
598        )
599        .with_error(ErrorCode::DomainViolation),
600    ])
601}
602
603fn invoke_proportions_difference(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
604    require_mode(
605        ctx,
606        &inferential_modes(),
607        "statistics.proportions_difference",
608    )?;
609    let (successes_a, n_a) =
610        validate_successes(&args.integer("successes_a")?, &args.integer("n_a")?, "a_")?;
611    let (successes_b, n_b) =
612        validate_successes(&args.integer("successes_b")?, &args.integer("n_b")?, "b_")?;
613    let confidence = confidence_param(args)?;
614    let method = parse_choice(args, "method", "newcombe", &["newcombe", "wald"])?;
615    let z = normal_critical(confidence)?;
616    let p_a = successes_a as f64 / n_a as f64;
617    let p_b = successes_b as f64 / n_b as f64;
618    let difference = p_a - p_b;
619    let (lower, upper, assumptions, warning) = if method == "newcombe" {
620        let (lower_a, upper_a) = wilson_interval(successes_a as f64, n_a as f64, z);
621        let (lower_b, upper_b) = wilson_interval(successes_b as f64, n_b as f64, z);
622        let lower = difference - sqrt((p_a - lower_a).powi(2) + (upper_b - p_b).powi(2));
623        let upper = difference + sqrt((upper_a - p_a).powi(2) + (p_b - lower_b).powi(2));
624        (
625            lower,
626            upper,
627            vec![
628                "the two groups are independent",
629                "each group is a representative Bernoulli sample",
630                "the Newcombe square-and-add hybrid combines two Wilson score intervals",
631            ],
632            None,
633        )
634    } else {
635        let standard_error = sqrt(p_a * (1.0 - p_a) / n_a as f64 + p_b * (1.0 - p_b) / n_b as f64);
636        let small = n_a as f64 * p_a < 5.0
637            || n_a as f64 * (1.0 - p_a) < 5.0
638            || n_b as f64 * p_b < 5.0
639            || n_b as f64 * (1.0 - p_b) < 5.0;
640        let warning = small.then(|| {
641            Warning::new(
642                "wald_small_sample",
643                "the Wald interval is unreliable when any expected count is below 5",
644            )
645        });
646        (
647            difference - z * standard_error,
648            difference + z * standard_error,
649            vec![
650                "the two groups are independent",
651                "each group is a representative Bernoulli sample",
652                "the normal approximation to the binomial is adequate",
653            ],
654            warning,
655        )
656    };
657    let value = record(vec![
658        ("difference", float_value(difference)?),
659        ("lower", float_value(lower)?),
660        ("upper", float_value(upper)?),
661        ("method", text(method)),
662        ("assumptions", assumptions_value(&assumptions)),
663    ]);
664    let mut outcome = Outcome::approximate(value);
665    if let Some(warning) = warning {
666        outcome = outcome.with_warning(warning);
667    }
668    Ok(outcome)
669}
670
671// ---------------------------------------------------------------------------
672// chi_square_contingency
673// ---------------------------------------------------------------------------
674
675fn chi_square_descriptor() -> FunctionDescriptor {
676    FunctionDescriptor::new(
677        "statistics.chi_square_contingency",
678        "statistics",
679        "1.0.0",
680        "Pearson chi-square test of independence",
681        "Pearson chi-square test for a two-way contingency table.",
682    )
683    .with_description(
684        "Accepts a matrix or an array of equal-length arrays of non-negative counts. Returns \
685         the Pearson statistic, degrees of freedom (r - 1)(c - 1), p-value, the expected \
686         count matrix, and diagnostics (minimum expected count, number of cells below 5, and \
687         a warning when expected counts are small). Ragged or empty tables and tables with a \
688         zero margin are rejected.",
689    )
690    .with_parameters(vec![ParamDescriptor::required(
691        "table",
692        "Two-way table of non-negative counts: a matrix or an array of equal-length arrays.",
693        ValueSchema::Any,
694    )])
695    .with_output(
696        numeric_record_schema(vec![
697            ("statistic", any_number_schema()),
698            ("df", any_number_schema()),
699            ("p_value", any_number_schema()),
700            ("expected", ValueSchema::Any),
701            ("method", text_schema()),
702            ("diagnostics", ValueSchema::Any),
703        ]),
704        "Chi-square test record with the expected-count matrix and diagnostics.",
705    )
706    .with_modes(inferential_modes())
707    .with_cost(CostClass::Quadratic)
708    .with_method_ref("docs/methods/statistics.md#chi_square_contingency")
709    .with_examples(vec![
710        Example::new(
711            "zero margins",
712            example_args(&[("table", serde_json::json!([[0, 0], [0, 0]]))]),
713        )
714        .with_error(ErrorCode::DomainViolation),
715    ])
716}
717
718fn invoke_chi_square_contingency(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
719    require_mode(
720        ctx,
721        &inferential_modes(),
722        "statistics.chi_square_contingency",
723    )?;
724    let (rows, cols, counts) = parse_count_table(args, "table")?;
725    let total: f64 = counts.iter().sum();
726    if total <= 0.0 {
727        return Err(EngineError::domain(
728            "the contingency table has a zero grand total",
729        ));
730    }
731    let mut row_totals = vec![0.0f64; rows];
732    let mut col_totals = vec![0.0f64; cols];
733    for row in 0..rows {
734        for col in 0..cols {
735            let value = counts[row * cols + col];
736            row_totals[row] += value;
737            col_totals[col] += value;
738        }
739    }
740    if row_totals.contains(&0.0) {
741        return Err(EngineError::domain(
742            "the contingency table has a zero row margin",
743        ));
744    }
745    if col_totals.contains(&0.0) {
746        return Err(EngineError::domain(
747            "the contingency table has a zero column margin",
748        ));
749    }
750    let mut statistic = 0.0f64;
751    let mut expected = Vec::with_capacity(rows * cols);
752    let mut min_expected = f64::INFINITY;
753    let mut cells_below_5 = 0u64;
754    for row in 0..rows {
755        for col in 0..cols {
756            let expected_value = row_totals[row] * col_totals[col] / total;
757            let observed = counts[row * cols + col];
758            statistic += (observed - expected_value).powi(2) / expected_value;
759            if expected_value < min_expected {
760                min_expected = expected_value;
761            }
762            if expected_value < 5.0 {
763                cells_below_5 += 1;
764            }
765            expected.push(float_value(expected_value)?);
766        }
767    }
768    let df = ((rows - 1) * (cols - 1)) as f64;
769    let p_value = chi_square_sf(statistic, df)?;
770    let expected_value = Value::Matrix {
771        rows: rows as u32,
772        cols: cols as u32,
773        data: expected,
774    };
775    let diagnostics = record(vec![
776        ("min_expected", float_value(min_expected)?),
777        ("cells_below_5", integer_value(cells_below_5)),
778        (
779            "warning",
780            if cells_below_5 > 0 {
781                text(
782                    "one or more expected counts are below 5; the chi-square approximation may be inaccurate",
783                )
784            } else {
785                Value::Null
786            },
787        ),
788    ]);
789    let value = record(vec![
790        ("statistic", float_value(statistic)?),
791        ("df", float_value(df)?),
792        ("p_value", float_value(p_value)?),
793        ("expected", expected_value),
794        ("method", text("pearson_chi_square")),
795        ("diagnostics", diagnostics),
796    ]);
797    let mut outcome = Outcome::approximate(value);
798    if cells_below_5 > 0 {
799        outcome = outcome.with_warning(Warning::new(
800            "chi_square_small_expected",
801            "one or more expected counts are below 5; the chi-square approximation may be inaccurate",
802        ));
803    }
804    Ok(outcome)
805}
806
807// ---------------------------------------------------------------------------
808// Sample size and power (normal approximation)
809// ---------------------------------------------------------------------------
810
811fn planning_assumption_text() -> &'static str {
812    "a power or sample-size figure is a probability under the stated planning assumptions, not a guarantee of the observed result"
813}
814
815fn sample_size_means_descriptor() -> FunctionDescriptor {
816    FunctionDescriptor::new(
817        "statistics.sample_size_two_means",
818        "statistics",
819        "1.0.0",
820        "Sample size for two means",
821        "Per-group sample size for a two-sample mean comparison (normal approximation).",
822    )
823    .with_description(
824        "Uses the normal approximation: n1 = (z_(1-alpha') + z_(1-power))^2 * (1 + 1/r) / d^2 \
825         and n2 = ceil(r * n1), where d is the standardized effect size, r is \
826         allocation_ratio = n2 / n1, and alpha' is alpha / 2 for a two-sided test or alpha for \
827         a one-sided test. Sizes are rounded up. A power figure is a probability under the \
828         planning assumptions, not a guarantee.",
829    )
830    .with_parameters(vec![
831        ParamDescriptor::required(
832            "effect_size",
833            "Standardized effect size d = (mean1 - mean2) / sd; must be > 0.",
834            any_number_schema(),
835        ),
836        ParamDescriptor::optional(
837            "alpha",
838            "Type I error rate; default 0.05.",
839            any_number_schema(),
840        ),
841        ParamDescriptor::optional("power", "Target power; default 0.8.", any_number_schema()),
842        ParamDescriptor::optional(
843            "allocation_ratio",
844            "n2 / n1; default 1.",
845            any_number_schema(),
846        ),
847        ParamDescriptor::optional(
848            "sided",
849            "two (default) or one.",
850            ValueSchema::Enum {
851                variants: vec!["two".to_string(), "one".to_string()],
852            },
853        ),
854    ])
855    .with_output(
856        numeric_record_schema(vec![
857            ("n1", integer_schema()),
858            ("n2", integer_schema()),
859            ("total", integer_schema()),
860            ("allocation_ratio", any_number_schema()),
861            ("effect_size", any_number_schema()),
862            ("alpha", any_number_schema()),
863            ("power", any_number_schema()),
864            ("sided", text_schema()),
865            ("method", text_schema()),
866            ("assumptions", array_schema(text_schema())),
867        ]),
868        "Sample-size plan record.",
869    )
870    .with_modes(inferential_modes())
871    .with_cost(CostClass::Constant)
872    .with_method_ref("docs/methods/statistics.md#sample_size_two_means")
873    .with_examples(vec![
874        Example::new(
875            "zero effect size",
876            example_args(&[("effect_size", serde_json::json!(0))]),
877        )
878        .with_error(ErrorCode::DomainViolation),
879    ])
880}
881
882fn invoke_sample_size_two_means(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
883    require_mode(
884        ctx,
885        &inferential_modes(),
886        "statistics.sample_size_two_means",
887    )?;
888    let effect_size = scalar_f64(args, "effect_size")?;
889    if effect_size <= 0.0 {
890        return Err(EngineError::domain("effect_size must be strictly positive")
891            .with_path("effect_size".to_string()));
892    }
893    let alpha = alpha_param(args)?;
894    let power = power_param(args)?;
895    let ratio = allocation_ratio_param(args)?;
896    let sided = sided_param(args)?;
897    let z_alpha = if sided == "two" {
898        normal_quantile(1.0 - alpha / 2.0, 0.0, 1.0)?
899    } else {
900        normal_quantile(1.0 - alpha, 0.0, 1.0)?
901    };
902    let z_power = normal_quantile(power, 0.0, 1.0)?;
903    let n1_float = (z_alpha + z_power).powi(2) * (1.0 + 1.0 / ratio) / (effect_size * effect_size);
904    let n1 = integer_from_f64(n1_float, "n1")?;
905    let n2 = integer_from_f64(ratio * n1_float, "n2")?;
906    let total = &n1 + &n2;
907    let assumptions = [
908        "the comparison uses the normal approximation to the sampling distribution",
909        "the effect size is a planning value supplied by the caller",
910        planning_assumption_text(),
911    ];
912    let value = record(vec![
913        (
914            "n1",
915            Value::Number(bicmath_core::number::Number::Integer(n1)),
916        ),
917        (
918            "n2",
919            Value::Number(bicmath_core::number::Number::Integer(n2)),
920        ),
921        (
922            "total",
923            Value::Number(bicmath_core::number::Number::Integer(total)),
924        ),
925        ("allocation_ratio", float_value(ratio)?),
926        ("effect_size", float_value(effect_size)?),
927        ("alpha", float_value(alpha)?),
928        ("power", float_value(power)?),
929        ("sided", text(sided)),
930        ("method", text("normal_approximation")),
931        ("assumptions", assumptions_value(&assumptions)),
932    ]);
933    let mut outcome = Outcome::approximate(value);
934    for (index, statement) in assumptions.iter().enumerate() {
935        outcome = outcome.with_assumption(Assumption::unverified(
936            format!("sample_size_two_means_{index}"),
937            *statement,
938        ));
939    }
940    Ok(outcome)
941}
942
943fn sample_size_proportions_descriptor() -> FunctionDescriptor {
944    FunctionDescriptor::new(
945        "statistics.sample_size_two_proportions",
946        "statistics",
947        "1.0.0",
948        "Sample size for two proportions",
949        "Per-group sample size for a two-proportion comparison (normal approximation).",
950    )
951    .with_description(
952        "Uses the normal approximation with pooled variance under the null and unpooled \
953         variance under the alternative: n1 = (z_(1-alpha') * sqrt((1 + 1/r) * pbar * \
954         (1 - pbar)) + z_(1-power) * sqrt(p1 (1 - p1) + p2 (1 - p2) / r))^2 / (p1 - p2)^2, \
955         where pbar = (p1 + r * p2) / (1 + r) and r = allocation_ratio = n2 / n1. p1 and p2 \
956         must differ. A power figure is a probability under the planning assumptions, not a \
957         guarantee.",
958    )
959    .with_parameters(vec![
960        ParamDescriptor::required(
961            "p1",
962            "Proportion in group 1, in [0, 1].",
963            any_number_schema(),
964        ),
965        ParamDescriptor::required(
966            "p2",
967            "Proportion in group 2, in [0, 1].",
968            any_number_schema(),
969        ),
970        ParamDescriptor::optional(
971            "alpha",
972            "Type I error rate; default 0.05.",
973            any_number_schema(),
974        ),
975        ParamDescriptor::optional("power", "Target power; default 0.8.", any_number_schema()),
976        ParamDescriptor::optional(
977            "allocation_ratio",
978            "n2 / n1; default 1.",
979            any_number_schema(),
980        ),
981        ParamDescriptor::optional(
982            "sided",
983            "two (default) or one.",
984            ValueSchema::Enum {
985                variants: vec!["two".to_string(), "one".to_string()],
986            },
987        ),
988    ])
989    .with_output(
990        numeric_record_schema(vec![
991            ("n1", integer_schema()),
992            ("n2", integer_schema()),
993            ("total", integer_schema()),
994            ("allocation_ratio", any_number_schema()),
995            ("p1", any_number_schema()),
996            ("p2", any_number_schema()),
997            ("alpha", any_number_schema()),
998            ("power", any_number_schema()),
999            ("sided", text_schema()),
1000            ("method", text_schema()),
1001            ("assumptions", array_schema(text_schema())),
1002        ]),
1003        "Sample-size plan record.",
1004    )
1005    .with_modes(inferential_modes())
1006    .with_cost(CostClass::Constant)
1007    .with_method_ref("docs/methods/statistics.md#sample_size_two_proportions")
1008    .with_examples(vec![
1009        Example::new(
1010            "equal proportions",
1011            example_args(&[
1012                ("p1", serde_json::json!(0.5)),
1013                ("p2", serde_json::json!(0.5)),
1014            ]),
1015        )
1016        .with_error(ErrorCode::DomainViolation),
1017    ])
1018}
1019
1020fn invoke_sample_size_two_proportions(
1021    args: &Args,
1022    ctx: &ExecContext,
1023) -> Result<Outcome, EngineError> {
1024    require_mode(
1025        ctx,
1026        &inferential_modes(),
1027        "statistics.sample_size_two_proportions",
1028    )?;
1029    let p1 = probability_param(args, "p1", 0.5)?;
1030    let p2 = probability_param(args, "p2", 0.5)?;
1031    if (p1 - p2).abs() == 0.0 {
1032        return Err(EngineError::domain(
1033            "sample_size_two_proportions requires p1 != p2",
1034        ));
1035    }
1036    let alpha = alpha_param(args)?;
1037    let power = power_param(args)?;
1038    let ratio = allocation_ratio_param(args)?;
1039    let sided = sided_param(args)?;
1040    let z_alpha = if sided == "two" {
1041        normal_quantile(1.0 - alpha / 2.0, 0.0, 1.0)?
1042    } else {
1043        normal_quantile(1.0 - alpha, 0.0, 1.0)?
1044    };
1045    let z_power = normal_quantile(power, 0.0, 1.0)?;
1046    let pbar = (p1 + ratio * p2) / (1.0 + ratio);
1047    let pooled = z_alpha * sqrt((1.0 + 1.0 / ratio) * pbar * (1.0 - pbar));
1048    let unpooled = z_power * sqrt(p1 * (1.0 - p1) + p2 * (1.0 - p2) / ratio);
1049    let n1_float = (pooled + unpooled).powi(2) / (p1 - p2).powi(2);
1050    let n1 = integer_from_f64(n1_float, "n1")?;
1051    let n2 = integer_from_f64(ratio * n1_float, "n2")?;
1052    let total = &n1 + &n2;
1053    let assumptions = [
1054        "the comparison uses the normal approximation to the binomial",
1055        "p1 and p2 are planning values supplied by the caller",
1056        planning_assumption_text(),
1057    ];
1058    let value = record(vec![
1059        (
1060            "n1",
1061            Value::Number(bicmath_core::number::Number::Integer(n1)),
1062        ),
1063        (
1064            "n2",
1065            Value::Number(bicmath_core::number::Number::Integer(n2)),
1066        ),
1067        (
1068            "total",
1069            Value::Number(bicmath_core::number::Number::Integer(total)),
1070        ),
1071        ("allocation_ratio", float_value(ratio)?),
1072        ("p1", float_value(p1)?),
1073        ("p2", float_value(p2)?),
1074        ("alpha", float_value(alpha)?),
1075        ("power", float_value(power)?),
1076        ("sided", text(sided)),
1077        ("method", text("normal_approximation")),
1078        ("assumptions", assumptions_value(&assumptions)),
1079    ]);
1080    let mut outcome = Outcome::approximate(value);
1081    for (index, statement) in assumptions.iter().enumerate() {
1082        outcome = outcome.with_assumption(Assumption::unverified(
1083            format!("sample_size_two_proportions_{index}"),
1084            *statement,
1085        ));
1086    }
1087    Ok(outcome)
1088}
1089
1090fn power_means_descriptor() -> FunctionDescriptor {
1091    FunctionDescriptor::new(
1092        "statistics.power_two_means",
1093        "statistics",
1094        "1.0.0",
1095        "Power for two means",
1096        "Achieved power for a two-sample mean comparison (normal approximation).",
1097    )
1098    .with_description(
1099        "Computes power = Phi(d * sqrt(n / 2) - z_(1-alpha')) for equal groups, where d is the \
1100         standardized effect size and alpha' is alpha / 2 for a two-sided test or alpha for a \
1101         one-sided test. The result is a probability under the planning assumptions, not a \
1102         guarantee.",
1103    )
1104    .with_parameters(vec![
1105        ParamDescriptor::required(
1106            "n_per_group",
1107            "Observations per group; integer >= 2.",
1108            integer_schema(),
1109        ),
1110        ParamDescriptor::required(
1111            "effect_size",
1112            "Standardized effect size d >= 0.",
1113            any_number_schema(),
1114        ),
1115        ParamDescriptor::optional(
1116            "alpha",
1117            "Type I error rate; default 0.05.",
1118            any_number_schema(),
1119        ),
1120        ParamDescriptor::optional(
1121            "sided",
1122            "two (default) or one.",
1123            ValueSchema::Enum {
1124                variants: vec!["two".to_string(), "one".to_string()],
1125            },
1126        ),
1127    ])
1128    .with_output(
1129        numeric_record_schema(vec![
1130            ("power", any_number_schema()),
1131            ("n_per_group", integer_schema()),
1132            ("effect_size", any_number_schema()),
1133            ("alpha", any_number_schema()),
1134            ("sided", text_schema()),
1135            ("method", text_schema()),
1136            ("assumptions", array_schema(text_schema())),
1137        ]),
1138        "Achieved power record.",
1139    )
1140    .with_modes(inferential_modes())
1141    .with_cost(CostClass::Constant)
1142    .with_method_ref("docs/methods/statistics.md#power_two_means")
1143    .with_examples(vec![
1144        Example::new(
1145            "one observation per group",
1146            example_args(&[
1147                ("n_per_group", serde_json::json!(1)),
1148                ("effect_size", serde_json::json!(0.5)),
1149            ]),
1150        )
1151        .with_error(ErrorCode::InsufficientObservations),
1152    ])
1153}
1154
1155fn invoke_power_two_means(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
1156    require_mode(ctx, &inferential_modes(), "statistics.power_two_means")?;
1157    let n = args.usize_param("n_per_group")?;
1158    if n < 2 {
1159        return Err(insufficient(
1160            "power_two_means requires at least two observations per group",
1161        ));
1162    }
1163    let effect_size = scalar_f64(args, "effect_size")?;
1164    if effect_size < 0.0 {
1165        return Err(EngineError::domain("effect_size must be non-negative")
1166            .with_path("effect_size".to_string()));
1167    }
1168    let alpha = alpha_param(args)?;
1169    let sided = sided_param(args)?;
1170    let z_alpha = if sided == "two" {
1171        normal_quantile(1.0 - alpha / 2.0, 0.0, 1.0)?
1172    } else {
1173        normal_quantile(1.0 - alpha, 0.0, 1.0)?
1174    };
1175    let z_beta = effect_size * sqrt(n as f64 / 2.0) - z_alpha;
1176    let achieved = normal_cdf(z_beta, 0.0, 1.0)?;
1177    let assumptions = [
1178        "the comparison uses the normal approximation to the sampling distribution",
1179        "equal group sizes and a known planning effect size",
1180        planning_assumption_text(),
1181    ];
1182    let value = record(vec![
1183        ("power", float_value(achieved)?),
1184        ("n_per_group", integer_value(n as u64)),
1185        ("effect_size", float_value(effect_size)?),
1186        ("alpha", float_value(alpha)?),
1187        ("sided", text(sided)),
1188        ("method", text("normal_approximation")),
1189        ("assumptions", assumptions_value(&assumptions)),
1190    ]);
1191    let mut outcome = Outcome::approximate(value);
1192    for (index, statement) in assumptions.iter().enumerate() {
1193        outcome = outcome.with_assumption(Assumption::unverified(
1194            format!("power_two_means_{index}"),
1195            *statement,
1196        ));
1197    }
1198    Ok(outcome)
1199}
1200
1201fn power_proportions_descriptor() -> FunctionDescriptor {
1202    FunctionDescriptor::new(
1203        "statistics.power_two_proportions",
1204        "statistics",
1205        "1.0.0",
1206        "Power for two proportions",
1207        "Achieved power for a two-proportion comparison (normal approximation).",
1208    )
1209    .with_description(
1210        "Computes power = Phi((|p1 - p2| * sqrt(n) - z_(1-alpha') * sqrt(2 * pbar * \
1211         (1 - pbar))) / sqrt(p1 (1 - p1) + p2 (1 - p2))) for equal groups, where pbar = \
1212         (p1 + p2) / 2. The result is a probability under the planning assumptions, not a \
1213         guarantee.",
1214    )
1215    .with_parameters(vec![
1216        ParamDescriptor::required(
1217            "n_per_group",
1218            "Observations per group; integer >= 1.",
1219            integer_schema(),
1220        ),
1221        ParamDescriptor::required(
1222            "p1",
1223            "Proportion in group 1, in [0, 1].",
1224            any_number_schema(),
1225        ),
1226        ParamDescriptor::required(
1227            "p2",
1228            "Proportion in group 2, in [0, 1].",
1229            any_number_schema(),
1230        ),
1231        ParamDescriptor::optional(
1232            "alpha",
1233            "Type I error rate; default 0.05.",
1234            any_number_schema(),
1235        ),
1236        ParamDescriptor::optional(
1237            "sided",
1238            "two (default) or one.",
1239            ValueSchema::Enum {
1240                variants: vec!["two".to_string(), "one".to_string()],
1241            },
1242        ),
1243    ])
1244    .with_output(
1245        numeric_record_schema(vec![
1246            ("power", any_number_schema()),
1247            ("n_per_group", integer_schema()),
1248            ("p1", any_number_schema()),
1249            ("p2", any_number_schema()),
1250            ("alpha", any_number_schema()),
1251            ("sided", text_schema()),
1252            ("method", text_schema()),
1253            ("assumptions", array_schema(text_schema())),
1254        ]),
1255        "Achieved power record.",
1256    )
1257    .with_modes(inferential_modes())
1258    .with_cost(CostClass::Constant)
1259    .with_method_ref("docs/methods/statistics.md#power_two_proportions")
1260    .with_examples(vec![
1261        Example::new(
1262            "zero observations",
1263            example_args(&[
1264                ("n_per_group", serde_json::json!(0)),
1265                ("p1", serde_json::json!(0.4)),
1266                ("p2", serde_json::json!(0.5)),
1267            ]),
1268        )
1269        .with_error(ErrorCode::InsufficientObservations),
1270    ])
1271}
1272
1273fn invoke_power_two_proportions(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
1274    require_mode(
1275        ctx,
1276        &inferential_modes(),
1277        "statistics.power_two_proportions",
1278    )?;
1279    let n = args.usize_param("n_per_group")?;
1280    if n == 0 {
1281        return Err(insufficient(
1282            "power_two_proportions requires at least one observation per group",
1283        ));
1284    }
1285    let p1 = probability_param(args, "p1", 0.5)?;
1286    let p2 = probability_param(args, "p2", 0.5)?;
1287    let alpha = alpha_param(args)?;
1288    let sided = sided_param(args)?;
1289    let z_alpha = if sided == "two" {
1290        normal_quantile(1.0 - alpha / 2.0, 0.0, 1.0)?
1291    } else {
1292        normal_quantile(1.0 - alpha, 0.0, 1.0)?
1293    };
1294    let pbar = (p1 + p2) / 2.0;
1295    let unpooled = sqrt(p1 * (1.0 - p1) + p2 * (1.0 - p2));
1296    let z_beta = if (p1 - p2).abs() == 0.0 {
1297        -z_alpha
1298    } else if unpooled == 0.0 {
1299        f64::INFINITY
1300    } else {
1301        ((p1 - p2).abs() * sqrt(n as f64) - z_alpha * sqrt(2.0 * pbar * (1.0 - pbar))) / unpooled
1302    };
1303    let achieved = normal_cdf(z_beta, 0.0, 1.0)?;
1304    let assumptions = [
1305        "the comparison uses the normal approximation to the binomial",
1306        "equal group sizes and known planning proportions",
1307        planning_assumption_text(),
1308    ];
1309    let value = record(vec![
1310        ("power", float_value(achieved)?),
1311        ("n_per_group", integer_value(n as u64)),
1312        ("p1", float_value(p1)?),
1313        ("p2", float_value(p2)?),
1314        ("alpha", float_value(alpha)?),
1315        ("sided", text(sided)),
1316        ("method", text("normal_approximation")),
1317        ("assumptions", assumptions_value(&assumptions)),
1318    ]);
1319    let mut outcome = Outcome::approximate(value);
1320    for (index, statement) in assumptions.iter().enumerate() {
1321        outcome = outcome.with_assumption(Assumption::unverified(
1322            format!("power_two_proportions_{index}"),
1323            *statement,
1324        ));
1325    }
1326    Ok(outcome)
1327}
1328
1329// ---------------------------------------------------------------------------
1330// Registration
1331// ---------------------------------------------------------------------------
1332
1333pub fn functions() -> Vec<Arc<dyn bicmath_core::contract::Function>> {
1334    vec![
1335        SimpleFunction::arc(ci_mean_descriptor(), invoke_ci_mean),
1336        SimpleFunction::arc(ci_proportion_descriptor(), invoke_ci_proportion),
1337        SimpleFunction::arc(welch_ci_descriptor(), invoke_welch_ci),
1338        SimpleFunction::arc(
1339            proportions_difference_descriptor(),
1340            invoke_proportions_difference,
1341        ),
1342        SimpleFunction::arc(chi_square_descriptor(), invoke_chi_square_contingency),
1343        SimpleFunction::arc(sample_size_means_descriptor(), invoke_sample_size_two_means),
1344        SimpleFunction::arc(
1345            sample_size_proportions_descriptor(),
1346            invoke_sample_size_two_proportions,
1347        ),
1348        SimpleFunction::arc(power_means_descriptor(), invoke_power_two_means),
1349        SimpleFunction::arc(power_proportions_descriptor(), invoke_power_two_proportions),
1350    ]
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::*;
1356    use std::collections::BTreeMap;
1357
1358    fn call(id: &str, raw: serde_json::Value) -> Result<Outcome, EngineError> {
1359        let module = crate::module();
1360        let function = module
1361            .functions
1362            .iter()
1363            .find(|f| f.descriptor().id == id)
1364            .expect("function exists");
1365        let ctx = ExecContext::scientific();
1366        let args_json = raw.as_object().expect("object args");
1367        let mut values = BTreeMap::new();
1368        for (name, value) in args_json {
1369            let param = function
1370                .descriptor()
1371                .parameter(name)
1372                .expect("parameter exists");
1373            values.insert(
1374                name.clone(),
1375                param
1376                    .schema
1377                    .coerce(value, name, &ctx.limits, true)
1378                    .expect("argument coerces"),
1379            );
1380        }
1381        function.invoke(&Args::new(values), &ctx)
1382    }
1383
1384    fn field<'a>(outcome: &'a Outcome, name: &str) -> &'a Value {
1385        match &outcome.value {
1386            Value::Record(fields) => fields.get(name).expect("field exists"),
1387            other => panic!("expected record, got {other:?}"),
1388        }
1389    }
1390
1391    fn as_f64(value: &Value) -> f64 {
1392        match value {
1393            Value::Number(number) => number.to_f64().expect("number"),
1394            other => panic!("expected number, got {other:?}"),
1395        }
1396    }
1397
1398    #[test]
1399    fn ci_mean_matches_reference() {
1400        // values 1..=10: mean 5.5, sample variance 9.1666..., se = 0.9574...
1401        let outcome = call(
1402            "statistics.ci_mean",
1403            serde_json::json!({"values": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}),
1404        )
1405        .unwrap();
1406        assert_eq!(as_f64(field(&outcome, "estimate")), 5.5);
1407        let se = as_f64(field(&outcome, "standard_error"));
1408        assert!((se - (9.166_666_666_666_667f64 / 10.0).sqrt()).abs() < 1e-12);
1409        assert_eq!(as_f64(field(&outcome, "df")), 9.0);
1410        assert_eq!(field(&outcome, "method"), &text("t"));
1411        // Independent reference: t_{0.975, 9} = 2.2621571628540993
1412        let critical = 2.262_157_162_854_099_3;
1413        assert!((as_f64(field(&outcome, "lower")) - (5.5 - critical * se)).abs() < 1e-9);
1414    }
1415
1416    #[test]
1417    fn ci_proportion_wilson_and_wald() {
1418        let outcome = call(
1419            "statistics.ci_proportion",
1420            serde_json::json!({"successes": 5, "n": 10}),
1421        )
1422        .unwrap();
1423        // Wilson interval for 5/10 at 95% is (0.2366, 0.7634).
1424        assert!((as_f64(field(&outcome, "lower")) - 0.236_593).abs() < 1e-5);
1425        assert!((as_f64(field(&outcome, "upper")) - 0.763_407).abs() < 1e-5);
1426        let wald = call(
1427            "statistics.ci_proportion",
1428            serde_json::json!({"successes": 1, "n": 10, "method": "wald"}),
1429        )
1430        .unwrap();
1431        assert!(!wald.warnings.is_empty());
1432    }
1433
1434    #[test]
1435    fn welch_interval_uses_welch_df() {
1436        let outcome = call(
1437            "statistics.welch_ci",
1438            serde_json::json!({
1439                "sample_a": [1, 2, 3, 4, 5],
1440                "sample_b": [2, 4, 6, 8, 10]
1441            }),
1442        )
1443        .unwrap();
1444        assert_eq!(as_f64(field(&outcome, "estimate")), -3.0);
1445        let df = as_f64(field(&outcome, "df"));
1446        assert!(df > 4.0 && df < 8.0, "welch df out of range: {df}");
1447        assert_eq!(field(&outcome, "method"), &text("welch"));
1448    }
1449
1450    #[test]
1451    fn proportions_difference_newcombe() {
1452        let outcome = call(
1453            "statistics.proportions_difference",
1454            serde_json::json!({
1455                "successes_a": 20, "n_a": 100,
1456                "successes_b": 30, "n_b": 100
1457            }),
1458        )
1459        .unwrap();
1460        assert!((as_f64(field(&outcome, "difference")) + 0.1).abs() < 1e-12);
1461        let lower = as_f64(field(&outcome, "lower"));
1462        let upper = as_f64(field(&outcome, "upper"));
1463        assert!(lower < -0.1 && upper > -0.1);
1464    }
1465
1466    #[test]
1467    fn chi_square_reference_table() {
1468        let outcome = call(
1469            "statistics.chi_square_contingency",
1470            serde_json::json!({"table": [[10, 20], [30, 40]]}),
1471        )
1472        .unwrap();
1473        let statistic = as_f64(field(&outcome, "statistic"));
1474        // Pearson statistic for [[10,20],[30,40]] is 50/63 ~ 0.7936507936507937
1475        assert!((statistic - 50.0 / 63.0).abs() < 1e-12);
1476        assert_eq!(as_f64(field(&outcome, "df")), 1.0);
1477        assert!(matches!(field(&outcome, "expected"), Value::Matrix { .. }));
1478    }
1479
1480    #[test]
1481    fn sample_size_and_power_are_consistent() {
1482        let plan = call(
1483            "statistics.sample_size_two_means",
1484            serde_json::json!({"effect_size": "0.5"}),
1485        )
1486        .unwrap();
1487        let n = as_f64(field(&plan, "n1"));
1488        assert!((n - 63.0).abs() < 1.0, "expected ~63 per group, got {n}");
1489        let power = call(
1490            "statistics.power_two_means",
1491            serde_json::json!({"n_per_group": 64, "effect_size": "0.5"}),
1492        )
1493        .unwrap();
1494        assert!((as_f64(field(&power, "power")) - 0.801).abs() < 0.01);
1495    }
1496
1497    #[test]
1498    fn no_probability_true_effect_field() {
1499        let outcome = call(
1500            "statistics.power_two_proportions",
1501            serde_json::json!({"n_per_group": 100, "p1": "0.4", "p2": "0.5"}),
1502        )
1503        .unwrap();
1504        if let Value::Record(fields) = &outcome.value {
1505            assert!(!fields.contains_key("probability_true_effect_positive"));
1506        }
1507    }
1508}