1use std::sync::Arc;
9
10use bicmath_core::context::ExecContext;
11use bicmath_core::contract::{
12 Args, CostClass, Example, FunctionDescriptor, Outcome, ParamDescriptor, SimpleFunction,
13 require_mode,
14};
15use bicmath_core::error::{EngineError, ErrorCode};
16use bicmath_core::schema::ValueSchema;
17use bicmath_core::value::Value;
18
19use crate::common::*;
20use crate::mathfn::{f_sf, sqrt, student_t_cdf, student_t_quantile, student_t_sf};
21
22fn collect_f64(args: &Args, name: &str) -> Result<Vec<f64>, EngineError> {
23 let numbers = collect_numbers(args, name)?;
24 if numbers.is_empty() {
25 return Err(insufficient(format!("{name} must not be empty")));
26 }
27 numbers
28 .iter()
29 .enumerate()
30 .map(|(index, number)| {
31 number_to_f64(number).map_err(|error| error.with_path(format!("{name}[{index}]")))
32 })
33 .collect()
34}
35
36fn float_array(values: &[f64]) -> Result<Value, EngineError> {
37 values
38 .iter()
39 .map(|value| float_value(*value))
40 .collect::<Result<Vec<_>, _>>()
41 .map(array_value)
42}
43
44fn anova_descriptor() -> FunctionDescriptor {
49 FunctionDescriptor::new(
50 "statistics.anova_one_way",
51 "statistics",
52 "1.0.0",
53 "One-way analysis of variance",
54 "Classical one-way fixed-effects ANOVA F test.",
55 )
56 .with_description(
57 "groups must contain at least two non-empty arrays of observations. Computes the \
58 between-group and within-group sums of squares, the F statistic (mean square ratio), \
59 and the upper-tail p-value from the F distribution. At least one residual degree of \
60 freedom is required. Returns f_statistic, df_between, df_within, p_value, \
61 ss_between, ss_within, group_means, and method = \"one_way_anova_f\". A zero \
62 within-group sum of squares with a positive between-group sum of squares makes the F \
63 statistic unbounded and is rejected as a domain violation.",
64 )
65 .with_parameters(vec![ParamDescriptor::required(
66 "groups",
67 "Groups of observations: an array of non-empty numeric arrays.",
68 ValueSchema::Any,
69 )])
70 .with_output(
71 record_schema(
72 vec![
73 field("f_statistic", float64_schema()),
74 field("df_between", float64_schema()),
75 field("df_within", float64_schema()),
76 field("p_value", float64_schema()),
77 field("ss_between", float64_schema()),
78 field("ss_within", float64_schema()),
79 field("group_means", array_schema(float64_schema())),
80 field("method", text_schema()),
81 ],
82 false,
83 ),
84 "One-way ANOVA record.",
85 )
86 .with_modes(inferential_modes())
87 .with_cost(CostClass::Linear)
88 .with_method_ref("docs/methods/statistics.md#anova_one_way")
89 .with_examples(vec![
90 Example::new(
91 "two shifted groups",
92 example_args(&[("groups", serde_json::json!([[1, 2, 3], [4, 5, 6]]))]),
93 )
94 .with_contains("one_way_anova_f"),
95 Example::new(
96 "a single group",
97 example_args(&[("groups", serde_json::json!([[1, 2, 3]]))]),
98 )
99 .with_error(ErrorCode::DomainViolation),
100 ])
101}
102
103fn invoke_anova_one_way(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
104 require_mode(ctx, &inferential_modes(), "statistics.anova_one_way")?;
105 let groups_value = args.require("groups")?;
106 let groups_array = groups_value
107 .as_array()
108 .map_err(|error| error.with_path("groups".to_string()))?;
109 if groups_array.len() < 2 {
110 return Err(
111 EngineError::domain("anova_one_way requires at least two groups")
112 .with_path("groups".to_string()),
113 );
114 }
115 let mut groups: Vec<Vec<f64>> = Vec::with_capacity(groups_array.len());
116 for (group_index, group) in groups_array.iter().enumerate() {
117 let items = group
118 .as_array()
119 .map_err(|error| error.with_path(format!("groups[{group_index}]")))?;
120 if items.is_empty() {
121 return Err(
122 EngineError::domain("each group must contain at least one observation")
123 .with_path(format!("groups[{group_index}]")),
124 );
125 }
126 let mut values = Vec::with_capacity(items.len());
127 for (value_index, item) in items.iter().enumerate() {
128 let number = item.as_number().map_err(|error| {
129 error.with_path(format!("groups[{group_index}][{value_index}]"))
130 })?;
131 values.push(number_to_f64(number).map_err(|error| {
132 error.with_path(format!("groups[{group_index}][{value_index}]"))
133 })?);
134 }
135 groups.push(values);
136 }
137 let group_count = groups.len();
138 let total: usize = groups.iter().map(Vec::len).sum();
139 if total <= group_count {
140 return Err(insufficient(
141 "anova_one_way requires at least one residual degree of freedom",
142 ));
143 }
144 let all: Vec<f64> = groups.iter().flatten().copied().collect();
145 let grand_mean = mean_f64(&all);
146 let mut ss_between = 0.0f64;
147 let mut ss_within = 0.0f64;
148 let mut group_means = Vec::with_capacity(group_count);
149 for group in &groups {
150 let mean = mean_f64(group);
151 group_means.push(mean);
152 ss_between += group.len() as f64 * (mean - grand_mean) * (mean - grand_mean);
153 for value in group {
154 ss_within += (value - mean) * (value - mean);
155 }
156 }
157 if ss_within == 0.0 {
158 return Err(EngineError::domain(
159 "the F statistic is unbounded because the within-group variance is zero",
160 ));
161 }
162 let df_between = (group_count - 1) as f64;
163 let df_within = (total - group_count) as f64;
164 let f_statistic = (ss_between / df_between) / (ss_within / df_within);
165 let p_value = f_sf(f_statistic, df_between, df_within)?;
166 let value = record(vec![
167 ("f_statistic", float_value(f_statistic)?),
168 ("df_between", float_value(df_between)?),
169 ("df_within", float_value(df_within)?),
170 ("p_value", float_value(p_value)?),
171 ("ss_between", float_value(ss_between)?),
172 ("ss_within", float_value(ss_within)?),
173 ("group_means", float_array(&group_means)?),
174 ("method", text("one_way_anova_f")),
175 ]);
176 Ok(Outcome::approximate(value))
177}
178
179const ADJUSTMENT_METHODS: [&str; 4] = ["bonferroni", "holm", "hochberg", "bh"];
184
185fn p_adjust_descriptor() -> FunctionDescriptor {
186 FunctionDescriptor::new(
187 "statistics.p_adjust",
188 "statistics",
189 "1.0.0",
190 "Multiple-comparison p-value adjustment",
191 "Adjust p-values for multiple testing.",
192 )
193 .with_description(
194 "Returns the adjusted p-values in the same order as the input. bonferroni multiplies \
195 each p-value by the number of tests m and caps at 1. holm is the step-down \
196 Bonferroni-Holm method. hochberg is the step-up Hochberg method. bh is the \
197 Benjamini-Hochberg false discovery rate procedure. All p-values must lie in [0, 1] \
198 and the array must not be empty.",
199 )
200 .with_parameters(vec![
201 ParamDescriptor::required(
202 "p_values",
203 "Raw p-values in [0, 1].",
204 array_schema(any_number_schema()),
205 ),
206 ParamDescriptor::required(
207 "method",
208 "Adjustment method: bonferroni, holm, hochberg, or bh.",
209 ValueSchema::Enum {
210 variants: ADJUSTMENT_METHODS
211 .iter()
212 .map(|method| (*method).to_string())
213 .collect(),
214 },
215 ),
216 ])
217 .with_output(
218 record_schema(
219 vec![
220 field("adjusted", array_schema(float64_schema())),
221 field("method", text_schema()),
222 ],
223 false,
224 ),
225 "Adjusted p-values in input order plus the method label.",
226 )
227 .with_modes(inferential_modes())
228 .with_cost(CostClass::Linear)
229 .with_method_ref("docs/methods/statistics.md#p_adjust")
230 .with_examples(vec![
231 Example::new(
232 "holm adjustment",
233 example_args(&[
234 ("p_values", serde_json::json!([0.01, 0.02, 0.03, 0.04])),
235 ("method", serde_json::json!("holm")),
236 ]),
237 )
238 .with_contains("holm"),
239 Example::new(
240 "p-value above one",
241 example_args(&[
242 ("p_values", serde_json::json!([0.5, 1.5])),
243 ("method", serde_json::json!("bonferroni")),
244 ]),
245 )
246 .with_error(ErrorCode::DomainViolation),
247 ])
248}
249
250fn invoke_p_adjust(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
251 require_mode(ctx, &inferential_modes(), "statistics.p_adjust")?;
252 let method = args.text("method")?.to_string();
253 if !ADJUSTMENT_METHODS.contains(&method.as_str()) {
254 return Err(EngineError::domain(format!(
255 "unknown method {method:?}; expected one of {ADJUSTMENT_METHODS:?}"
256 ))
257 .with_path("method".to_string()));
258 }
259 let values = collect_f64(args, "p_values")?;
260 for (index, value) in values.iter().enumerate() {
261 if !(0.0..=1.0).contains(value) {
262 return Err(EngineError::domain("p-values must lie in [0, 1]")
263 .with_path(format!("p_values[{index}]")));
264 }
265 }
266 let m = values.len();
267 let mut order: Vec<usize> = (0..m).collect();
268 order.sort_by(|a, b| values[*a].total_cmp(&values[*b]));
269 let mut adjusted = vec![0.0f64; m];
270 match method.as_str() {
271 "bonferroni" => {
272 let factor = m as f64;
273 for (index, value) in values.iter().enumerate() {
274 adjusted[index] = (factor * value).min(1.0);
275 }
276 }
277 "holm" => {
278 let mut running = 0.0f64;
279 for (rank, index) in order.iter().enumerate() {
280 let factor = (m - rank) as f64;
281 running = running.max(factor * values[*index]);
282 adjusted[*index] = running.min(1.0);
283 }
284 }
285 "hochberg" => {
286 let mut running = 1.0f64;
287 for (rank, index) in order.iter().enumerate().rev() {
288 let factor = (m - rank) as f64;
289 running = running.min(factor * values[*index]);
290 adjusted[*index] = running.min(1.0);
291 }
292 }
293 "bh" => {
294 let mut running = 1.0f64;
295 for (rank, index) in order.iter().enumerate().rev() {
296 let factor = m as f64 / (rank + 1) as f64;
297 running = running.min(factor * values[*index]);
298 adjusted[*index] = running.min(1.0);
299 }
300 }
301 other => {
302 return Err(EngineError::domain(format!(
303 "unknown method {other:?}; expected one of {ADJUSTMENT_METHODS:?}"
304 ))
305 .with_path("method".to_string()));
306 }
307 }
308 let value = record(vec![
309 ("adjusted", float_array(&adjusted)?),
310 ("method", text(method)),
311 ]);
312 Ok(Outcome::approximate(value))
313}
314
315fn tost_descriptor() -> FunctionDescriptor {
320 FunctionDescriptor::new(
321 "statistics.tost_two_means",
322 "statistics",
323 "1.0.0",
324 "Two one-sided tests for equivalence of means",
325 "TOST equivalence test for two independent means (Welch).",
326 )
327 .with_description(
328 "Tests H0: |mean_a - mean_b| >= margin against the two one-sided alternatives at \
329 alpha = 1 - confidence, using the Welch standard error and Welch-Satterthwaite \
330 degrees of freedom. The reported p_value is the larger of the two one-sided \
331 p-values; equivalence holds when p_value < 1 - confidence. ci_lower and ci_upper \
332 are the two-sided confidence interval for the difference with the same alpha. Each \
333 sample needs at least two observations, margin must be strictly positive, and the \
334 Welch standard error must be positive.",
335 )
336 .with_parameters(vec![
337 ParamDescriptor::required(
338 "sample_a",
339 "First sample.",
340 array_schema(any_number_schema()),
341 ),
342 ParamDescriptor::required(
343 "sample_b",
344 "Second sample.",
345 array_schema(any_number_schema()),
346 ),
347 ParamDescriptor::required(
348 "margin",
349 "Equivalence margin; must be > 0.",
350 any_number_schema(),
351 ),
352 ParamDescriptor::optional(
353 "confidence",
354 "Confidence level in (0, 1); default 0.95.",
355 any_number_schema(),
356 ),
357 ])
358 .with_output(
359 record_schema(
360 vec![
361 field("p_value", float64_schema()),
362 field("ci_lower", float64_schema()),
363 field("ci_upper", float64_schema()),
364 field("equivalent", bool_schema()),
365 field("method", text_schema()),
366 field("df", float64_schema()),
367 ],
368 false,
369 ),
370 "TOST equivalence record.",
371 )
372 .with_modes(inferential_modes())
373 .with_cost(CostClass::Linear)
374 .with_method_ref("docs/methods/statistics.md#tost_two_means")
375 .with_examples(vec![
376 Example::new(
377 "equivalent samples",
378 example_args(&[
379 ("sample_a", serde_json::json!([1, 2, 3, 4])),
380 ("sample_b", serde_json::json!([1.1, 2.1, 3.1, 4.1])),
381 ("margin", serde_json::json!(2)),
382 ]),
383 )
384 .with_contains("tost_welch"),
385 Example::new(
386 "zero margin",
387 example_args(&[
388 ("sample_a", serde_json::json!([1, 2, 3])),
389 ("sample_b", serde_json::json!([2, 3, 4])),
390 ("margin", serde_json::json!(0)),
391 ]),
392 )
393 .with_error(ErrorCode::DomainViolation),
394 ])
395}
396
397fn invoke_tost_two_means(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
398 require_mode(ctx, &inferential_modes(), "statistics.tost_two_means")?;
399 let margin = scalar_f64(args, "margin")?;
400 if margin <= 0.0 {
401 return Err(
402 EngineError::domain("margin must be strictly positive").with_path("margin".to_string())
403 );
404 }
405 let confidence = confidence_param(args)?;
406 let sample_a = classify_series(args, "sample_a", ctx)?.to_f64_vec()?;
407 let sample_b = classify_series(args, "sample_b", ctx)?.to_f64_vec()?;
408 if sample_a.len() < 2 || sample_b.len() < 2 {
409 return Err(insufficient(
410 "tost_two_means requires at least two observations per sample",
411 ));
412 }
413 let mean_a = mean_f64(&sample_a);
414 let mean_b = mean_f64(&sample_b);
415 let variance_a = variance_f64(&sample_a, 1)?;
416 let variance_b = variance_f64(&sample_b, 1)?;
417 let n_a = sample_a.len() as f64;
418 let n_b = sample_b.len() as f64;
419 let component_a = variance_a / n_a;
420 let component_b = variance_b / n_b;
421 let standard_error = sqrt(component_a + component_b);
422 if standard_error <= 0.0 {
423 return Err(EngineError::domain(
424 "the TOST is undefined when both samples are constant (zero standard error)",
425 ));
426 }
427 let df = (component_a + component_b).powi(2)
428 / (component_a.powi(2) / (n_a - 1.0) + component_b.powi(2) / (n_b - 1.0));
429 if !df.is_finite() || df <= 0.0 {
430 return Err(EngineError::domain(
431 "Welch-Satterthwaite degrees of freedom are undefined",
432 ));
433 }
434 let difference = mean_a - mean_b;
435 let lower_test = student_t_sf((difference + margin) / standard_error, df)?;
436 let upper_test = student_t_cdf((difference - margin) / standard_error, df)?;
437 let p_value = lower_test.max(upper_test).min(1.0);
438 let critical = student_t_quantile(confidence, df)?;
439 let ci_lower = difference - critical * standard_error;
440 let ci_upper = difference + critical * standard_error;
441 let equivalent = p_value < 1.0 - confidence;
442 let value = record(vec![
443 ("p_value", float_value(p_value)?),
444 ("ci_lower", float_value(ci_lower)?),
445 ("ci_upper", float_value(ci_upper)?),
446 ("equivalent", bool_value(equivalent)),
447 ("method", text("tost_welch")),
448 ("df", float_value(df)?),
449 ]);
450 Ok(Outcome::approximate(value))
451}
452
453pub fn functions() -> Vec<Arc<dyn bicmath_core::contract::Function>> {
458 vec![
459 SimpleFunction::arc(anova_descriptor(), invoke_anova_one_way),
460 SimpleFunction::arc(p_adjust_descriptor(), invoke_p_adjust),
461 SimpleFunction::arc(tost_descriptor(), invoke_tost_two_means),
462 ]
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use std::collections::BTreeMap;
469
470 fn call(id: &str, raw: serde_json::Value) -> Result<Outcome, EngineError> {
471 let module = crate::module();
472 let function = module
473 .functions
474 .iter()
475 .find(|f| f.descriptor().id == id)
476 .expect("function exists");
477 let ctx = ExecContext::scientific();
478 let args_json = raw.as_object().expect("object args");
479 let mut values = BTreeMap::new();
480 for (name, value) in args_json {
481 let param = function
482 .descriptor()
483 .parameter(name)
484 .expect("parameter exists");
485 values.insert(
486 name.clone(),
487 param
488 .schema
489 .coerce(value, name, &ctx.limits, true)
490 .expect("argument coerces"),
491 );
492 }
493 function.invoke(&Args::new(values), &ctx)
494 }
495
496 fn record_of(outcome: &Outcome) -> &BTreeMap<String, Value> {
497 match &outcome.value {
498 Value::Record(fields) => fields,
499 other => panic!("expected record, got {other:?}"),
500 }
501 }
502
503 fn field_f64(fields: &BTreeMap<String, Value>, name: &str) -> f64 {
504 match fields.get(name) {
505 Some(Value::Number(number)) => number.to_f64().expect("number"),
506 other => panic!("expected numeric field {name}, got {other:?}"),
507 }
508 }
509
510 fn field_numbers(fields: &BTreeMap<String, Value>, name: &str) -> Vec<f64> {
511 match fields.get(name) {
512 Some(Value::Array(items)) => items
513 .iter()
514 .map(|item| item.as_number().expect("number").to_f64().expect("f64"))
515 .collect(),
516 other => panic!("expected array field {name}, got {other:?}"),
517 }
518 }
519
520 fn close(actual: f64, expected: f64, tolerance: f64) {
521 assert!(
522 (actual - expected).abs() <= tolerance,
523 "expected {expected}, got {actual} (tolerance {tolerance})"
524 );
525 }
526
527 fn close_slice(actual: &[f64], expected: &[f64], tolerance: f64) {
528 assert_eq!(actual.len(), expected.len());
529 for (a, e) in actual.iter().zip(expected.iter()) {
530 close(*a, *e, tolerance);
531 }
532 }
533
534 #[test]
535 fn anova_reference_values() {
536 let outcome = call(
541 "statistics.anova_one_way",
542 serde_json::json!({"groups": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]}),
543 )
544 .unwrap();
545 let fields = record_of(&outcome);
546 close(field_f64(fields, "f_statistic"), 27.0, 1e-12);
547 close(field_f64(fields, "df_between"), 2.0, 0.0);
548 close(field_f64(fields, "df_within"), 6.0, 0.0);
549 close(field_f64(fields, "ss_between"), 54.0, 1e-12);
550 close(field_f64(fields, "ss_within"), 6.0, 1e-12);
551 close(field_f64(fields, "p_value"), 0.001, 1e-9);
552 close_slice(
553 &field_numbers(fields, "group_means"),
554 &[2.0, 5.0, 8.0],
555 1e-12,
556 );
557 }
558
559 #[test]
560 fn anova_rejects_bad_inputs() {
561 let error = call(
562 "statistics.anova_one_way",
563 serde_json::json!({"groups": [[1, 2, 3]]}),
564 )
565 .unwrap_err();
566 assert_eq!(error.code, ErrorCode::DomainViolation);
567 let error = call(
568 "statistics.anova_one_way",
569 serde_json::json!({"groups": [[1, 2, 3], []]}),
570 )
571 .unwrap_err();
572 assert_eq!(error.code, ErrorCode::DomainViolation);
573 }
574
575 #[test]
576 fn p_adjust_reference_values() {
577 let cases = [
580 ("bonferroni", [0.04, 0.08, 0.12, 0.16]),
581 ("holm", [0.04, 0.06, 0.06, 0.06]),
582 ("hochberg", [0.04, 0.04, 0.04, 0.04]),
583 ("bh", [0.04, 0.04, 0.04, 0.04]),
584 ];
585 for (method, expected) in cases {
586 let outcome = call(
587 "statistics.p_adjust",
588 serde_json::json!({"p_values": [0.01, 0.02, 0.03, 0.04], "method": method}),
589 )
590 .unwrap();
591 let fields = record_of(&outcome);
592 close_slice(&field_numbers(fields, "adjusted"), &expected, 1e-12);
593 }
594 }
595
596 #[test]
597 fn p_adjust_preserves_input_order() {
598 let outcome = call(
599 "statistics.p_adjust",
600 serde_json::json!({"p_values": [0.04, 0.01, 0.03, 0.02], "method": "holm"}),
601 )
602 .unwrap();
603 let fields = record_of(&outcome);
604 close_slice(
605 &field_numbers(fields, "adjusted"),
606 &[0.06, 0.04, 0.06, 0.06],
607 1e-12,
608 );
609 }
610
611 #[test]
612 fn p_adjust_rejects_out_of_range_values() {
613 let error = call(
614 "statistics.p_adjust",
615 serde_json::json!({"p_values": [0.5, 1.5], "method": "holm"}),
616 )
617 .unwrap_err();
618 assert_eq!(error.code, ErrorCode::DomainViolation);
619 }
620
621 #[test]
622 fn tost_reference_values() {
623 let outcome = call(
628 "statistics.tost_two_means",
629 serde_json::json!({
630 "sample_a": [1, 2, 3, 4],
631 "sample_b": [1.1, 2.1, 3.1, 4.1],
632 "margin": 2
633 }),
634 )
635 .unwrap();
636 let fields = record_of(&outcome);
637 close(field_f64(fields, "p_value"), 0.041_290_456_824_054_02, 1e-9);
638 close(field_f64(fields, "df"), 6.0, 1e-9);
639 close(field_f64(fields, "ci_lower"), -1.873_872_788_229_078, 1e-9);
640 close(field_f64(fields, "ci_upper"), 1.673_872_788_229_077_8, 1e-9);
641 match fields.get("equivalent") {
642 Some(Value::Bool(true)) => {}
643 other => panic!("expected equivalent = true, got {other:?}"),
644 }
645 }
646
647 #[test]
648 fn tost_nearly_identical_samples_are_equivalent() {
649 let outcome = call(
652 "statistics.tost_two_means",
653 serde_json::json!({
654 "sample_a": [5.0, 5.1, 5.2],
655 "sample_b": [5.01, 5.11, 5.21],
656 "margin": 1.0
657 }),
658 )
659 .unwrap();
660 let fields = record_of(&outcome);
661 close(
662 field_f64(fields, "p_value"),
663 0.000_132_726_104_254_924_9,
664 1e-9,
665 );
666 close(field_f64(fields, "df"), 4.0, 1e-9);
667 match fields.get("equivalent") {
668 Some(Value::Bool(true)) => {}
669 other => panic!("expected equivalent = true, got {other:?}"),
670 }
671 }
672
673 #[test]
674 fn tost_rejects_non_equivalent_samples() {
675 let outcome = call(
676 "statistics.tost_two_means",
677 serde_json::json!({
678 "sample_a": [1, 2, 3, 4],
679 "sample_b": [1.1, 2.1, 3.1, 4.1],
680 "margin": 0.05
681 }),
682 )
683 .unwrap();
684 let fields = record_of(&outcome);
685 match fields.get("equivalent") {
686 Some(Value::Bool(false)) => {}
687 other => panic!("expected equivalent = false, got {other:?}"),
688 }
689 }
690
691 #[test]
692 fn tost_rejects_invalid_inputs() {
693 let error = call(
694 "statistics.tost_two_means",
695 serde_json::json!({
696 "sample_a": [1, 2],
697 "sample_b": [1, 2],
698 "margin": 0
699 }),
700 )
701 .unwrap_err();
702 assert_eq!(error.code, ErrorCode::DomainViolation);
703 let error = call(
704 "statistics.tost_two_means",
705 serde_json::json!({
706 "sample_a": [1],
707 "sample_b": [1, 2],
708 "margin": 1
709 }),
710 )
711 .unwrap_err();
712 assert_eq!(error.code, ErrorCode::InsufficientObservations);
713 }
714}