1use 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
23fn 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
194fn 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
303fn 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
420fn 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
532fn 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
671fn 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
807fn 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
1329pub 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 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 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 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 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}