Skip to main content

bicmath_statistics/
testing.rs

1//! Hypothesis-testing helpers: one-way ANOVA, multiple-comparison p-value
2//! adjustment, and two one-sided tests (TOST) for equivalence of means.
3//!
4//! Every result is a float64 record and requires auto or scientific mode. The
5//! methods are named explicitly in the output so that the assumptions of each
6//! test travel with the result.
7
8use 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
44// ---------------------------------------------------------------------------
45// anova_one_way
46// ---------------------------------------------------------------------------
47
48fn 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
179// ---------------------------------------------------------------------------
180// p_adjust
181// ---------------------------------------------------------------------------
182
183const 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
315// ---------------------------------------------------------------------------
316// tost_two_means
317// ---------------------------------------------------------------------------
318
319fn 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
453// ---------------------------------------------------------------------------
454// Registration
455// ---------------------------------------------------------------------------
456
457pub 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        // Provenance: hand calculation for the shifted groups
537        // [1, 2, 3], [4, 5, 6], [7, 8, 9]: grand mean 5, SS_between 54,
538        // SS_within 6, F(2, 6) = 27. The p-value 0.001 is the exact closed
539        // form for F(2, 6): P(F > 27) = 1 / 1000.
540        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        // Provenance: hand calculation (and Python 3.14 stdlib check) of the
578        // step-wise procedures for p = [0.01, 0.02, 0.03, 0.04].
579        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        // Provenance: Python 3.14 stdlib Welch TOST for the samples below:
624        // difference -0.1, standard error 0.9128709291752768, df 6,
625        // p = 0.04129045682405402, 90% interval [-1.873872788229078,
626        // 1.6738727882290778].
627        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        // Provenance: Python 3.14 stdlib Welch TOST: p = 0.0001327261042549249,
650        // df = 4, difference -0.01, standard error 0.08164965809277268.
651        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}