Skip to main content

bicmath_statistics/
descriptive.rs

1//! Descriptive statistics.
2//!
3//! Integer, rational, and decimal inputs are computed exactly; float64 inputs
4//! are computed with stable float algorithms and marked approximate. Input
5//! arrays are never mutated and non-finite or non-numeric entries are rejected.
6
7use bicmath_core::context::ExecContext;
8use bicmath_core::contract::{
9    Args, CostClass, Example, FunctionDescriptor, Outcome, ParamDescriptor, SimpleFunction,
10    require_mode,
11};
12use bicmath_core::envelope::Exactness;
13use bicmath_core::error::{EngineError, ErrorCode};
14use bicmath_core::number::{Number, NumericMode};
15use bicmath_core::schema::ValueSchema;
16use bicmath_core::value::Value;
17use num_bigint::BigInt;
18use num_rational::BigRational;
19use num_traits::Zero;
20use std::sync::Arc;
21
22use crate::common::*;
23use crate::mathfn::sqrt;
24
25fn moment_value(moment: &Moment) -> Result<Value, EngineError> {
26    match moment {
27        Moment::Exact(value) => Ok(rational_value(value.clone())),
28        Moment::Float(value) => float_value(*value),
29    }
30}
31
32// ---------------------------------------------------------------------------
33// count
34// ---------------------------------------------------------------------------
35
36fn count_descriptor() -> FunctionDescriptor {
37    FunctionDescriptor::new(
38        "statistics.count",
39        "statistics",
40        "1.0.0",
41        "Count",
42        "Count the observations in an array.",
43    )
44    .with_description(
45        "Returns the number of entries. Every entry must be a finite number; non-numeric or \
46         non-finite entries are rejected rather than silently skipped.",
47    )
48    .with_parameters(vec![ParamDescriptor::required(
49        "values",
50        "Observations to count.",
51        array_schema(any_number_schema()),
52    )])
53    .with_output(integer_schema(), "Number of observations.")
54    .with_modes(all_modes())
55    .with_cost(CostClass::Linear)
56    .with_method_ref("docs/methods/statistics.md#count")
57    .with_examples(vec![
58        Example::new(
59            "count three values",
60            example_args(&[("values", serde_json::json!([1, 2, 3]))]),
61        )
62        .with_value(parse_value(
63            serde_json::json!({"kind": "integer", "value": "3"}),
64        )),
65    ])
66}
67
68fn invoke_count(args: &Args, _ctx: &ExecContext) -> Result<Outcome, EngineError> {
69    let values = args.array("values")?;
70    for (index, item) in values.iter().enumerate() {
71        match item {
72            Value::Number(number) => {
73                ensure_finite(number).map_err(|e| e.with_path(format!("values[{index}]")))?;
74            }
75            other => {
76                return Err(EngineError::malformed(format!(
77                    "expected a number at values[{index}], found {}",
78                    other.kind_name()
79                ))
80                .with_path(format!("values[{index}]")));
81            }
82        }
83    }
84    Ok(Outcome::exact(Value::integer(BigInt::from(values.len()))))
85}
86
87// ---------------------------------------------------------------------------
88// mean
89// ---------------------------------------------------------------------------
90
91fn mean_descriptor() -> FunctionDescriptor {
92    FunctionDescriptor::new(
93        "statistics.mean",
94        "statistics",
95        "1.0.0",
96        "Arithmetic mean",
97        "Exact arithmetic mean of a non-empty array.",
98    )
99    .with_description(
100        "Integer, rational, and decimal inputs produce an exact integer or rational mean. \
101         Float64 inputs require auto or scientific mode, are computed in binary64, and are \
102         marked approximate. An empty array is rejected with insufficient_observations.",
103    )
104    .with_parameters(vec![ParamDescriptor::required(
105        "values",
106        "Observations.",
107        array_schema(any_number_schema()),
108    )])
109    .with_output(any_number_schema(), "Arithmetic mean.")
110    .with_modes(all_modes())
111    .with_cost(CostClass::Linear)
112    .with_method_ref("docs/methods/statistics.md#mean")
113    .with_examples(vec![
114        Example::new(
115            "exact integer mean",
116            example_args(&[("values", serde_json::json!([1, 2, 3]))]),
117        )
118        .with_value(parse_value(
119            serde_json::json!({"kind": "integer", "value": "2"}),
120        )),
121        Example::new(
122            "exact rational mean",
123            example_args(&[("values", serde_json::json!([1, 2]))]),
124        )
125        .with_value(parse_value(
126            serde_json::json!({"kind": "rational", "numerator": "3", "denominator": "2"}),
127        )),
128        Example::new(
129            "empty array",
130            example_args(&[("values", serde_json::json!([]))]),
131        )
132        .with_error(ErrorCode::InsufficientObservations),
133    ])
134}
135
136fn invoke_mean(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
137    let series = classify_series(args, "values", ctx)?;
138    let moment = mean_moment(&series)?;
139    match moment {
140        Moment::Exact(value) => Ok(Outcome::exact(rational_value(value))),
141        Moment::Float(value) => Ok(Outcome::approximate(float_value(value)?)),
142    }
143}
144
145// ---------------------------------------------------------------------------
146// weighted_mean
147// ---------------------------------------------------------------------------
148
149fn weighted_mean_descriptor() -> FunctionDescriptor {
150    FunctionDescriptor::new(
151        "statistics.weighted_mean",
152        "statistics",
153        "1.0.0",
154        "Weighted mean",
155        "Weighted arithmetic mean with frequency or reliability weights.",
156    )
157    .with_description(
158        "Frequency weights must be non-negative integers and count repeated observations; \
159         reliability weights must be non-negative numbers. Weights may be zero but the total \
160         weight must be positive. Negative weights, length mismatches, and a zero total weight \
161         are rejected. Exact inputs produce an exact rational result; float64 inputs require \
162         auto or scientific mode and are marked approximate.",
163    )
164    .with_parameters(vec![
165        ParamDescriptor::required("values", "Observations.", array_schema(any_number_schema())),
166        ParamDescriptor::required(
167            "weights",
168            "Non-negative weights, one per observation.",
169            array_schema(any_number_schema()),
170        ),
171        ParamDescriptor::optional(
172            "weight_type",
173            "Weight semantics: frequency (default) or reliability.",
174            bicmath_core::schema::ValueSchema::Enum {
175                variants: vec!["frequency".to_string(), "reliability".to_string()],
176            },
177        ),
178    ])
179    .with_output(any_number_schema(), "Weighted mean.")
180    .with_modes(all_modes())
181    .with_cost(CostClass::Linear)
182    .with_method_ref("docs/methods/statistics.md#weighted_mean")
183    .with_examples(vec![
184        Example::new(
185            "frequency weights",
186            example_args(&[
187                ("values", serde_json::json!([1, 2])),
188                ("weights", serde_json::json!([1, 1])),
189            ]),
190        )
191        .with_value(parse_value(
192            serde_json::json!({"kind": "rational", "numerator": "3", "denominator": "2"}),
193        )),
194        Example::new(
195            "negative weight",
196            example_args(&[
197                ("values", serde_json::json!([1, 2])),
198                ("weights", serde_json::json!([1, -1])),
199            ]),
200        )
201        .with_error(ErrorCode::DomainViolation),
202    ])
203}
204
205fn invoke_weighted_mean(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
206    let weight_type = parse_choice(
207        args,
208        "weight_type",
209        "frequency",
210        &["frequency", "reliability"],
211    )?;
212    let values = collect_numbers(args, "values")?;
213    let weights = collect_numbers(args, "weights")?;
214    if values.len() != weights.len() {
215        return Err(EngineError::malformed(format!(
216            "values and weights must have the same length ({} vs {})",
217            values.len(),
218            weights.len()
219        )));
220    }
221    if values.is_empty() {
222        return Err(insufficient(
223            "weighted_mean requires at least one observation",
224        ));
225    }
226    for (index, weight) in weights.iter().enumerate() {
227        if weight.is_negative() {
228            return Err(EngineError::domain("weights must be non-negative")
229                .with_path(format!("weights[{index}]")));
230        }
231        if weight_type == "frequency" {
232            let integral = match weight {
233                Number::Integer(_) => true,
234                Number::Rational(value) => value.is_integer(),
235                Number::Decimal(value) => value.to_bigint_if_integral().is_some(),
236                Number::Float64(value) => value.get().fract() == 0.0,
237            };
238            if !integral {
239                return Err(
240                    EngineError::domain("frequency weights must be non-negative integers")
241                        .with_path(format!("weights[{index}]")),
242                );
243            }
244        }
245    }
246    let has_float = values.iter().any(Number::is_float) || weights.iter().any(Number::is_float);
247    if has_float {
248        if ctx.numeric.mode == NumericMode::Exact {
249            return Err(EngineError::new(
250                ErrorCode::UnsupportedNumericMode,
251                "weighted_mean with float64 values or weights requires auto or scientific mode",
252            ));
253        }
254        let mut numerator = 0.0f64;
255        let mut numerator_compensation = 0.0f64;
256        let mut denominator = 0.0f64;
257        let mut denominator_compensation = 0.0f64;
258        for (value, weight) in values.iter().zip(weights.iter()) {
259            let value = number_to_f64(value)?;
260            let weight = number_to_f64(weight)?;
261            let term = value * weight;
262            let adjusted = term - numerator_compensation;
263            let next = numerator + adjusted;
264            numerator_compensation = (next - numerator) - adjusted;
265            numerator = next;
266
267            let adjusted = weight - denominator_compensation;
268            let next = denominator + adjusted;
269            denominator_compensation = (next - denominator) - adjusted;
270            denominator = next;
271        }
272        if denominator == 0.0 {
273            return Err(EngineError::domain(
274                "total weight must be strictly positive",
275            ));
276        }
277        return Ok(Outcome::approximate(float_value(numerator / denominator)?));
278    }
279    let mut numerator = BigRational::zero();
280    let mut denominator = BigRational::zero();
281    for (value, weight) in values.iter().zip(weights.iter()) {
282        let value = value
283            .as_exact_rational()
284            .ok_or_else(|| EngineError::internal("exact value could not be converted"))?;
285        let weight = weight
286            .as_exact_rational()
287            .ok_or_else(|| EngineError::internal("exact weight could not be converted"))?;
288        numerator += &value * &weight;
289        denominator += weight;
290    }
291    if denominator.is_zero() {
292        return Err(EngineError::domain(
293            "total weight must be strictly positive",
294        ));
295    }
296    Ok(Outcome::exact(rational_value(numerator / denominator)))
297}
298
299// ---------------------------------------------------------------------------
300// median
301// ---------------------------------------------------------------------------
302
303fn median_descriptor() -> FunctionDescriptor {
304    FunctionDescriptor::new(
305        "statistics.median",
306        "statistics",
307        "1.0.0",
308        "Median",
309        "Median of a non-empty array.",
310    )
311    .with_description(
312        "The values are sorted numerically without modifying the input. An odd count returns \
313         the middle value; an even count returns the exact mean of the two middle values. \
314         Exact inputs stay exact (median([1, 2, 10, 100]) = 6); float64 inputs require auto \
315         or scientific mode.",
316    )
317    .with_parameters(vec![ParamDescriptor::required(
318        "values",
319        "Observations.",
320        array_schema(any_number_schema()),
321    )])
322    .with_output(any_number_schema(), "Median value.")
323    .with_modes(all_modes())
324    .with_cost(CostClass::Linear)
325    .with_method_ref("docs/methods/statistics.md#median")
326    .with_examples(vec![
327        Example::new(
328            "even count",
329            example_args(&[("values", serde_json::json!([1, 2, 10, 100]))]),
330        )
331        .with_value(parse_value(
332            serde_json::json!({"kind": "integer", "value": "6"}),
333        )),
334        Example::new(
335            "odd count",
336            example_args(&[("values", serde_json::json!([2, 10, 30]))]),
337        )
338        .with_value(parse_value(
339            serde_json::json!({"kind": "integer", "value": "10"}),
340        )),
341    ])
342}
343
344fn invoke_median(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
345    let series = classify_series(args, "values", ctx)?;
346    let (value, exactness) = median_number(&series)?;
347    Ok(Outcome::new(Value::Number(value), exactness))
348}
349
350// ---------------------------------------------------------------------------
351// mode
352// ---------------------------------------------------------------------------
353
354fn mode_descriptor() -> FunctionDescriptor {
355    FunctionDescriptor::new(
356        "statistics.mode",
357        "statistics",
358        "1.0.0",
359        "Mode",
360        "All tied modes with their counts.",
361    )
362    .with_description(
363        "Returns every value that occurs most often, sorted ascending, with the matching \
364         counts. Ties are reported in full; there is no arbitrary winner. The method label is \
365         always all_modes.",
366    )
367    .with_parameters(vec![ParamDescriptor::required(
368        "values",
369        "Observations.",
370        array_schema(any_number_schema()),
371    )])
372    .with_output(
373        record_schema(
374            vec![
375                field("modes", array_schema(any_number_schema())),
376                field("counts", array_schema(integer_schema())),
377                field("method", text_schema()),
378            ],
379            false,
380        ),
381        "Record with modes, counts, and the method label all_modes.",
382    )
383    .with_modes(all_modes())
384    .with_cost(CostClass::Linear)
385    .with_method_ref("docs/methods/statistics.md#mode")
386    .with_examples(vec![
387        Example::new(
388            "single mode",
389            example_args(&[("values", serde_json::json!([1, 2, 2, 3]))]),
390        )
391        .with_value(parse_value(serde_json::json!({
392            "modes": [2],
393            "counts": [2],
394            "method": "all_modes"
395        }))),
396    ])
397}
398
399fn invoke_mode(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
400    let series = classify_series(args, "values", ctx)?;
401    let (modes, counts) = mode_numbers(&series)?;
402    let value = record(vec![
403        (
404            "modes",
405            array_value(modes.into_iter().map(number_value).collect()),
406        ),
407        (
408            "counts",
409            array_value(
410                counts
411                    .into_iter()
412                    .map(|count| Value::integer(BigInt::from(count)))
413                    .collect(),
414            ),
415        ),
416        ("method", text("all_modes")),
417    ]);
418    let exactness = if series.is_float() {
419        Exactness::Approximate
420    } else {
421        Exactness::Exact
422    };
423    Ok(Outcome::new(value, exactness))
424}
425
426// ---------------------------------------------------------------------------
427// min / max
428// ---------------------------------------------------------------------------
429
430fn minmax_descriptor(id: &str, title: &str, summary: &str, minimum: bool) -> FunctionDescriptor {
431    FunctionDescriptor::new(id, "statistics", "1.0.0", title, summary)
432        .with_description(
433            "Returns the smallest/largest observation without modifying the input. An empty \
434             array is rejected with insufficient_observations. Exact inputs return the exact \
435             selected value; float64 inputs require auto or scientific mode.",
436        )
437        .with_parameters(vec![ParamDescriptor::required(
438            "values",
439            "Observations.",
440            array_schema(any_number_schema()),
441        )])
442        .with_output(any_number_schema(), "Selected extreme value.")
443        .with_modes(all_modes())
444        .with_cost(CostClass::Linear)
445        .with_method_ref("docs/methods/statistics.md#min-max")
446        .with_examples(vec![
447            Example::new(
448                "extreme value",
449                example_args(&[("values", serde_json::json!([3, 1, 2]))]),
450            )
451            .with_value(parse_value(serde_json::json!({
452                "kind": "integer",
453                "value": if minimum { "1" } else { "3" }
454            }))),
455        ])
456}
457
458fn invoke_min(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
459    select_minmax(args, ctx, true)
460}
461
462fn invoke_max(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
463    select_minmax(args, ctx, false)
464}
465
466fn select_minmax(args: &Args, ctx: &ExecContext, minimum: bool) -> Result<Outcome, EngineError> {
467    let series = classify_series(args, "values", ctx)?;
468    let (value, exactness) = extreme_number(&series, minimum)?;
469    Ok(Outcome::new(Value::Number(value), exactness))
470}
471
472// ---------------------------------------------------------------------------
473// quantile
474// ---------------------------------------------------------------------------
475
476fn quantile_descriptor() -> FunctionDescriptor {
477    FunctionDescriptor::new(
478        "statistics.quantile",
479        "statistics",
480        "1.0.0",
481        "Quantile",
482        "Quantile with an explicit interpolation convention.",
483    )
484    .with_description(
485        "Let h = (n - 1) * q. linear (default, R-7) returns \
486         x[floor(h)] + (h - floor(h)) * (x[floor(h)+1] - x[floor(h)]); lower returns \
487         x[floor(h)]; higher returns x[ceil(h)]; midpoint returns the mean of those two order \
488         statistics; nearest returns x[round_half_to_even(h)]. q must be in [0, 1]. Exact \
489         inputs stay exact; float64 inputs require auto or scientific mode.",
490    )
491    .with_parameters(vec![
492        ParamDescriptor::required("values", "Observations.", array_schema(any_number_schema())),
493        ParamDescriptor::required("q", "Quantile level in [0, 1].", any_number_schema()),
494        ParamDescriptor::optional(
495            "method",
496            "Interpolation method: linear (default), lower, higher, midpoint, nearest.",
497            bicmath_core::schema::ValueSchema::Enum {
498                variants: vec![
499                    "linear".to_string(),
500                    "lower".to_string(),
501                    "higher".to_string(),
502                    "midpoint".to_string(),
503                    "nearest".to_string(),
504                ],
505            },
506        ),
507    ])
508    .with_output(any_number_schema(), "Quantile value.")
509    .with_modes(all_modes())
510    .with_cost(CostClass::Linear)
511    .with_method_ref("docs/methods/statistics.md#quantile")
512    .with_examples(vec![
513        Example::new(
514            "linear interpolation",
515            example_args(&[
516                ("values", serde_json::json!([1, 2, 3, 4])),
517                ("q", serde_json::json!(0.5)),
518            ]),
519        )
520        .with_value(parse_value(
521            serde_json::json!({"kind": "rational", "numerator": "5", "denominator": "2"}),
522        )),
523        Example::new(
524            "out of range q",
525            example_args(&[
526                ("values", serde_json::json!([1, 2, 3])),
527                ("q", serde_json::json!(1.5)),
528            ]),
529        )
530        .with_error(ErrorCode::DomainViolation),
531    ])
532}
533
534fn invoke_quantile(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
535    let method = parse_choice(
536        args,
537        "method",
538        "linear",
539        &["linear", "lower", "higher", "midpoint", "nearest"],
540    )?;
541    let q = args.number("q")?.clone();
542    let series = classify_series(args, "values", ctx)?;
543    let (value, exactness) = quantile_number(&series, &q, &method)?;
544    Ok(Outcome::new(Value::Number(value), exactness))
545}
546
547// ---------------------------------------------------------------------------
548// variance / stddev / covariance / correlation
549// ---------------------------------------------------------------------------
550
551fn variance_descriptor() -> FunctionDescriptor {
552    FunctionDescriptor::new(
553        "statistics.variance",
554        "statistics",
555        "1.0.0",
556        "Variance",
557        "Sample or population variance with explicit delta degrees of freedom.",
558    )
559    .with_description(
560        "Computes sum((x - mean)^2) / (n - ddof). ddof must be a non-negative integer and n \
561         must be strictly greater than ddof, otherwise insufficient_observations is returned. \
562         Exact inputs use exact rational arithmetic (variance([1, 2, 3], ddof=0) = 2/3); \
563         float64 inputs use Welford's stable one-pass algorithm and require auto or scientific \
564         mode.",
565    )
566    .with_parameters(vec![
567        ParamDescriptor::required("values", "Observations.", array_schema(any_number_schema())),
568        ParamDescriptor::required(
569            "ddof",
570            "Delta degrees of freedom: 0 for the population variance, 1 for the sample variance.",
571            integer_schema(),
572        ),
573    ])
574    .with_output(any_number_schema(), "Variance.")
575    .with_modes(all_modes())
576    .with_cost(CostClass::Linear)
577    .with_method_ref("docs/methods/statistics.md#variance")
578    .with_examples(vec![
579        Example::new(
580            "population variance",
581            example_args(&[
582                ("values", serde_json::json!([1, 2, 3])),
583                ("ddof", serde_json::json!(0)),
584            ]),
585        )
586        .with_value(parse_value(
587            serde_json::json!({"kind": "rational", "numerator": "2", "denominator": "3"}),
588        )),
589        Example::new(
590            "sample variance",
591            example_args(&[
592                ("values", serde_json::json!([1, 2, 3])),
593                ("ddof", serde_json::json!(1)),
594            ]),
595        )
596        .with_value(parse_value(
597            serde_json::json!({"kind": "integer", "value": "1"}),
598        )),
599        Example::new(
600            "too few observations",
601            example_args(&[
602                ("values", serde_json::json!([1])),
603                ("ddof", serde_json::json!(1)),
604            ]),
605        )
606        .with_error(ErrorCode::InsufficientObservations),
607    ])
608}
609
610fn invoke_variance(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
611    let ddof = non_negative_u64(&args.integer("ddof")?, "ddof")?;
612    let series = classify_series(args, "values", ctx)?;
613    let moment = variance_moment(&series, ddof)?;
614    match moment {
615        Moment::Exact(value) => Ok(Outcome::exact(rational_value(value))),
616        Moment::Float(value) => Ok(Outcome::approximate(float_value(value)?)),
617    }
618}
619
620fn stddev_descriptor() -> FunctionDescriptor {
621    FunctionDescriptor::new(
622        "statistics.stddev",
623        "statistics",
624        "1.0.0",
625        "Standard deviation",
626        "Square root of the variance with explicit exactness handling.",
627    )
628    .with_description(
629        "Computes sqrt(variance(values, ddof)). When the variance is a perfect square in the \
630         selected representation the result is exact. Otherwise exact mode returns \
631         unsupported_numeric_mode, auto mode returns a decimal approximation marked \
632         approximate, and scientific mode returns float64. Float64 inputs require auto or \
633         scientific mode.",
634    )
635    .with_parameters(vec![
636        ParamDescriptor::required("values", "Observations.", array_schema(any_number_schema())),
637        ParamDescriptor::required(
638            "ddof",
639            "Delta degrees of freedom: 0 for the population standard deviation, 1 for the sample.",
640            integer_schema(),
641        ),
642    ])
643    .with_output(any_number_schema(), "Standard deviation.")
644    .with_modes(all_modes())
645    .with_cost(CostClass::Linear)
646    .with_method_ref("docs/methods/statistics.md#stddev")
647    .with_examples(vec![
648        Example::new(
649            "exact perfect square",
650            example_args(&[
651                ("values", serde_json::json!([1, 2, 3])),
652                ("ddof", serde_json::json!(1)),
653            ]),
654        )
655        .with_value(parse_value(
656            serde_json::json!({"kind": "integer", "value": "1"}),
657        )),
658        Example::new(
659            "non-square variance in exact mode",
660            example_args(&[
661                ("values", serde_json::json!([1, 2, 3])),
662                ("ddof", serde_json::json!(0)),
663            ]),
664        )
665        .with_error(ErrorCode::UnsupportedNumericMode),
666    ])
667}
668
669fn invoke_stddev(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
670    let ddof = non_negative_u64(&args.integer("ddof")?, "ddof")?;
671    let series = classify_series(args, "values", ctx)?;
672    let moment = variance_moment(&series, ddof)?;
673    let (value, exactness) = stddev_moment(&moment, ctx.numeric.mode)?;
674    Ok(Outcome::new(Value::Number(value), exactness))
675}
676
677fn covariance_descriptor() -> FunctionDescriptor {
678    FunctionDescriptor::new(
679        "statistics.covariance",
680        "statistics",
681        "1.0.0",
682        "Covariance",
683        "Sample or population covariance of two paired series.",
684    )
685    .with_description(
686        "Computes sum((x - mean_x) * (y - mean_y)) / (n - ddof) for paired observations. The \
687         two arrays must have equal length; n must be strictly greater than ddof. Exact inputs \
688         use exact rational arithmetic; float64 inputs use a stable two-pass centered \
689         algorithm and require auto or scientific mode.",
690    )
691    .with_parameters(vec![
692        ParamDescriptor::required("xs", "First series.", array_schema(any_number_schema())),
693        ParamDescriptor::required(
694            "ys",
695            "Second series, paired with xs.",
696            array_schema(any_number_schema()),
697        ),
698        ParamDescriptor::required("ddof", "Delta degrees of freedom.", integer_schema()),
699    ])
700    .with_output(any_number_schema(), "Covariance.")
701    .with_modes(all_modes())
702    .with_cost(CostClass::Linear)
703    .with_method_ref("docs/methods/statistics.md#covariance")
704    .with_examples(vec![
705        Example::new(
706            "covariance of identical series",
707            example_args(&[
708                ("xs", serde_json::json!([1, 2, 3])),
709                ("ys", serde_json::json!([1, 2, 3])),
710                ("ddof", serde_json::json!(0)),
711            ]),
712        )
713        .with_value(parse_value(
714            serde_json::json!({"kind": "rational", "numerator": "2", "denominator": "3"}),
715        )),
716        Example::new(
717            "length mismatch",
718            example_args(&[
719                ("xs", serde_json::json!([1, 2, 3])),
720                ("ys", serde_json::json!([1, 2])),
721                ("ddof", serde_json::json!(0)),
722            ]),
723        )
724        .with_error(ErrorCode::MalformedInput),
725    ])
726}
727
728fn invoke_covariance(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
729    let ddof = non_negative_u64(&args.integer("ddof")?, "ddof")?;
730    let xs = classify_series(args, "xs", ctx)?;
731    let ys = classify_series(args, "ys", ctx)?;
732    if xs.len() != ys.len() {
733        return Err(EngineError::malformed(format!(
734            "xs and ys must have the same length ({} vs {})",
735            xs.len(),
736            ys.len()
737        )));
738    }
739    let moment = covariance_moment(&xs, &ys, ddof)?;
740    match moment {
741        Moment::Exact(value) => Ok(Outcome::exact(rational_value(value))),
742        Moment::Float(value) => Ok(Outcome::approximate(float_value(value)?)),
743    }
744}
745
746fn correlation_descriptor() -> FunctionDescriptor {
747    FunctionDescriptor::new(
748        "statistics.correlation",
749        "statistics",
750        "1.0.0",
751        "Pearson correlation",
752        "Pearson product-moment correlation coefficient.",
753    )
754    .with_description(
755        "Returns the Pearson correlation r = cov(x, y) / (sd(x) * sd(y)) as float64. If either \
756         series has zero variance the result is undefined and a domain_violation is returned \
757         instead of NaN. At least two paired observations are required. The output is always \
758         approximate, so exact mode is not supported.",
759    )
760    .with_parameters(vec![
761        ParamDescriptor::required("xs", "First series.", array_schema(any_number_schema())),
762        ParamDescriptor::required(
763            "ys",
764            "Second series, paired with xs.",
765            array_schema(any_number_schema()),
766        ),
767    ])
768    .with_output(float64_schema(), "Pearson correlation coefficient.")
769    .with_modes(inferential_modes())
770    .with_cost(CostClass::Linear)
771    .with_method_ref("docs/methods/statistics.md#correlation")
772    .with_examples(vec![
773        Example::new(
774            "perfect positive correlation",
775            example_args(&[
776                ("xs", serde_json::json!([1, 2, 3])),
777                ("ys", serde_json::json!([2, 4, 6])),
778            ]),
779        )
780        .with_value(parse_value(
781            serde_json::json!({"kind": "float64", "value": "1"}),
782        )),
783        Example::new(
784            "zero variance",
785            example_args(&[
786                ("xs", serde_json::json!([1, 1, 1])),
787                ("ys", serde_json::json!([1, 2, 3])),
788            ]),
789        )
790        .with_error(ErrorCode::DomainViolation),
791    ])
792}
793
794fn invoke_correlation(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
795    require_mode(ctx, &inferential_modes(), "statistics.correlation")?;
796    let xs = collect_numbers(args, "xs")?;
797    let ys = collect_numbers(args, "ys")?;
798    if xs.len() != ys.len() {
799        return Err(EngineError::malformed(format!(
800            "xs and ys must have the same length ({} vs {})",
801            xs.len(),
802            ys.len()
803        )));
804    }
805    if xs.len() < 2 {
806        return Err(insufficient(
807            "correlation requires at least two paired observations",
808        ));
809    }
810    let xs: Vec<f64> = xs
811        .iter()
812        .enumerate()
813        .map(|(index, value)| number_to_f64(value).map_err(|e| e.with_path(format!("xs[{index}]"))))
814        .collect::<Result<_, _>>()?;
815    let ys: Vec<f64> = ys
816        .iter()
817        .enumerate()
818        .map(|(index, value)| number_to_f64(value).map_err(|e| e.with_path(format!("ys[{index}]"))))
819        .collect::<Result<_, _>>()?;
820    let n = xs.len();
821    let mean_x = mean_f64(&xs);
822    let mean_y = mean_f64(&ys);
823    let mut sxx = 0.0f64;
824    let mut syy = 0.0f64;
825    let mut sxy = 0.0f64;
826    for index in 0..n {
827        let dx = xs[index] - mean_x;
828        let dy = ys[index] - mean_y;
829        sxx += dx * dx;
830        syy += dy * dy;
831        sxy += dx * dy;
832    }
833    if sxx <= 0.0 || syy <= 0.0 {
834        return Err(EngineError::domain(
835            "correlation is undefined when either series has zero variance",
836        ));
837    }
838    let denominator = sqrt(sxx * syy);
839    if denominator <= 0.0 {
840        return Err(EngineError::domain(
841            "correlation is undefined when either series has zero variance",
842        ));
843    }
844    let r = (sxy / denominator).clamp(-1.0, 1.0);
845    Ok(Outcome::approximate(float_value(r)?))
846}
847
848// ---------------------------------------------------------------------------
849// summary
850// ---------------------------------------------------------------------------
851
852fn summary_descriptor() -> FunctionDescriptor {
853    FunctionDescriptor::new(
854        "statistics.summary",
855        "statistics",
856        "1.0.0",
857        "Summary statistics",
858        "One-call descriptive summary with quantiles.",
859    )
860    .with_description(
861        "Returns count, mean, min, max, median, sample variance (ddof = 1), sample standard \
862         deviation, and the linear quantiles 0.25, 0.5, and 0.75. At least two observations \
863         are required. Exact inputs stay exact wherever the representation allows; the \
864         standard deviation follows the stddev exactness rules.",
865    )
866    .with_parameters(vec![ParamDescriptor::required(
867        "values",
868        "Observations.",
869        array_schema(any_number_schema()),
870    )])
871    .with_output(
872        record_schema(
873            vec![
874                field("count", integer_schema()),
875                field("mean", any_number_schema()),
876                field("min", any_number_schema()),
877                field("max", any_number_schema()),
878                field("median", any_number_schema()),
879                field("variance", any_number_schema()),
880                field("stddev", any_number_schema()),
881                field("quantiles", ValueSchema::Any),
882            ],
883            false,
884        ),
885        "Record with count, mean, min, max, median, variance, stddev, and quantiles.",
886    )
887    .with_modes(all_modes())
888    .with_cost(CostClass::Linear)
889    .with_method_ref("docs/methods/statistics.md#summary")
890    .with_examples(vec![
891        Example::new(
892            "too few observations",
893            example_args(&[("values", serde_json::json!([1]))]),
894        )
895        .with_error(ErrorCode::InsufficientObservations),
896    ])
897}
898
899fn invoke_summary(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
900    let series = classify_series(args, "values", ctx)?;
901    if series.len() < 2 {
902        return Err(insufficient(
903            "summary requires at least two observations because variance uses ddof = 1",
904        ));
905    }
906    let mean = mean_moment(&series)?;
907    let variance = variance_moment(&series, 1)?;
908    let (stddev, stddev_exactness) = stddev_moment(&variance, ctx.numeric.mode)?;
909    let (minimum, _) = extreme_number(&series, true)?;
910    let (maximum, _) = extreme_number(&series, false)?;
911    let (median, _) = median_number(&series)?;
912    let quarter = rational_to_number(BigRational::new(BigInt::from(1), BigInt::from(4)));
913    let half = rational_to_number(BigRational::new(BigInt::from(1), BigInt::from(2)));
914    let three_quarters = rational_to_number(BigRational::new(BigInt::from(3), BigInt::from(4)));
915    let (q25, _) = quantile_number(&series, &quarter, "linear")?;
916    let (q50, _) = quantile_number(&series, &half, "linear")?;
917    let (q75, _) = quantile_number(&series, &three_quarters, "linear")?;
918    let value = record(vec![
919        ("count", integer_value(series.len() as u64)),
920        ("mean", moment_value(&mean)?),
921        ("min", Value::Number(minimum)),
922        ("max", Value::Number(maximum)),
923        ("median", Value::Number(median)),
924        ("variance", moment_value(&variance)?),
925        ("stddev", Value::Number(stddev)),
926        (
927            "quantiles",
928            record(vec![
929                ("0.25", Value::Number(q25)),
930                ("0.5", Value::Number(q50)),
931                ("0.75", Value::Number(q75)),
932            ]),
933        ),
934    ]);
935    let exactness = Exactness::Exact.combine(stddev_exactness);
936    Ok(Outcome::new(value, exactness))
937}
938
939// ---------------------------------------------------------------------------
940// Registration
941// ---------------------------------------------------------------------------
942
943pub fn functions() -> Vec<Arc<dyn bicmath_core::contract::Function>> {
944    vec![
945        SimpleFunction::arc(count_descriptor(), invoke_count),
946        SimpleFunction::arc(mean_descriptor(), invoke_mean),
947        SimpleFunction::arc(weighted_mean_descriptor(), invoke_weighted_mean),
948        SimpleFunction::arc(median_descriptor(), invoke_median),
949        SimpleFunction::arc(mode_descriptor(), invoke_mode),
950        SimpleFunction::arc(
951            minmax_descriptor(
952                "statistics.min",
953                "Minimum",
954                "Smallest observation in a non-empty array.",
955                true,
956            ),
957            invoke_min,
958        ),
959        SimpleFunction::arc(
960            minmax_descriptor(
961                "statistics.max",
962                "Maximum",
963                "Largest observation in a non-empty array.",
964                false,
965            ),
966            invoke_max,
967        ),
968        SimpleFunction::arc(quantile_descriptor(), invoke_quantile),
969        SimpleFunction::arc(variance_descriptor(), invoke_variance),
970        SimpleFunction::arc(stddev_descriptor(), invoke_stddev),
971        SimpleFunction::arc(covariance_descriptor(), invoke_covariance),
972        SimpleFunction::arc(correlation_descriptor(), invoke_correlation),
973        SimpleFunction::arc(summary_descriptor(), invoke_summary),
974    ]
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use std::collections::BTreeMap;
981
982    fn call(id: &str, raw: serde_json::Value) -> Result<Outcome, EngineError> {
983        call_with_mode(id, raw, NumericMode::Auto)
984    }
985
986    fn call_with_mode(
987        id: &str,
988        raw: serde_json::Value,
989        mode: NumericMode,
990    ) -> Result<Outcome, EngineError> {
991        let module = crate::module();
992        let function = module
993            .functions
994            .iter()
995            .find(|f| f.descriptor().id == id)
996            .expect("function exists");
997        let ctx = ExecContext {
998            numeric: bicmath_core::number::NumericContext {
999                mode,
1000                ..bicmath_core::number::NumericContext::default()
1001            },
1002            ..ExecContext::conservative()
1003        };
1004        let args_json = raw.as_object().expect("object args");
1005        let mut values = BTreeMap::new();
1006        for (name, value) in args_json {
1007            let param = function
1008                .descriptor()
1009                .parameter(name)
1010                .expect("parameter exists");
1011            values.insert(
1012                name.clone(),
1013                param
1014                    .schema
1015                    .coerce(value, name, &ctx.limits, true)
1016                    .expect("argument coerces"),
1017            );
1018        }
1019        function.invoke(&Args::new(values), &ctx)
1020    }
1021
1022    fn text_of(outcome: &Outcome) -> String {
1023        match &outcome.value {
1024            Value::Number(number) => number.to_string(),
1025            other => panic!("expected number, got {other:?}"),
1026        }
1027    }
1028
1029    #[test]
1030    fn exact_descriptive_values() {
1031        assert_eq!(
1032            text_of(&call("statistics.mean", serde_json::json!({"values": [1, 2, 3]})).unwrap()),
1033            "2"
1034        );
1035        assert_eq!(
1036            text_of(&call("statistics.mean", serde_json::json!({"values": [1, 2]})).unwrap()),
1037            "3/2"
1038        );
1039        assert_eq!(
1040            text_of(
1041                &call(
1042                    "statistics.median",
1043                    serde_json::json!({"values": [2, 10, 30]})
1044                )
1045                .unwrap()
1046            ),
1047            "10"
1048        );
1049        assert_eq!(
1050            text_of(
1051                &call(
1052                    "statistics.median",
1053                    serde_json::json!({"values": [1, 2, 10, 100]})
1054                )
1055                .unwrap()
1056            ),
1057            "6"
1058        );
1059        assert_eq!(
1060            text_of(
1061                &call(
1062                    "statistics.variance",
1063                    serde_json::json!({"values": [1, 2, 3], "ddof": 0})
1064                )
1065                .unwrap()
1066            ),
1067            "2/3"
1068        );
1069        assert_eq!(
1070            text_of(
1071                &call(
1072                    "statistics.variance",
1073                    serde_json::json!({"values": [1, 2, 3], "ddof": 1})
1074                )
1075                .unwrap()
1076            ),
1077            "1"
1078        );
1079    }
1080
1081    #[test]
1082    fn original_input_is_not_mutated() {
1083        let raw = serde_json::json!({"values": [3, 1, 2]});
1084        let outcome = call("statistics.median", raw.clone()).unwrap();
1085        assert_eq!(text_of(&outcome), "2");
1086        let args = raw.as_object().unwrap();
1087        assert_eq!(args["values"], serde_json::json!([3, 1, 2]));
1088    }
1089
1090    #[test]
1091    fn float_inputs_are_approximate() {
1092        let outcome = call(
1093            "statistics.mean",
1094            serde_json::json!({"values": [
1095                {"kind": "float64", "value": "1"},
1096                {"kind": "float64", "value": "2"}
1097            ]}),
1098        )
1099        .unwrap();
1100        assert_eq!(outcome.exactness, Exactness::Approximate);
1101        assert_eq!(text_of(&outcome), "1.5");
1102    }
1103
1104    #[test]
1105    fn exact_mode_rejects_float_inputs() {
1106        let err = call_with_mode(
1107            "statistics.mean",
1108            serde_json::json!({"values": [{"kind": "float64", "value": "1"}]}),
1109            NumericMode::Exact,
1110        )
1111        .unwrap_err();
1112        assert_eq!(err.code, ErrorCode::UnsupportedNumericMode);
1113    }
1114
1115    #[test]
1116    fn non_numeric_entries_are_rejected() {
1117        let module = crate::module();
1118        let function = module
1119            .functions
1120            .iter()
1121            .find(|f| f.descriptor().id == "statistics.mean")
1122            .expect("function exists");
1123        let mut values = BTreeMap::new();
1124        values.insert(
1125            "values".to_string(),
1126            Value::Array(vec![
1127                Value::Number(Number::Integer(BigInt::from(1))),
1128                Value::text("not a number"),
1129            ]),
1130        );
1131        let err = function
1132            .invoke(&Args::new(values), &ExecContext::conservative())
1133            .unwrap_err();
1134        assert_eq!(err.code, ErrorCode::MalformedInput);
1135    }
1136
1137    #[test]
1138    fn mode_returns_all_ties() {
1139        let outcome = call(
1140            "statistics.mode",
1141            serde_json::json!({"values": [1, 2, 2, 3, 3]}),
1142        )
1143        .unwrap();
1144        let Value::Record(fields) = &outcome.value else {
1145            panic!("expected record");
1146        };
1147        assert_eq!(
1148            fields.get("modes"),
1149            Some(&Value::Array(vec![
1150                Value::Number(Number::Integer(BigInt::from(2))),
1151                Value::Number(Number::Integer(BigInt::from(3))),
1152            ]))
1153        );
1154    }
1155
1156    #[test]
1157    fn weighted_mean_rejects_bad_weights() {
1158        let err = call(
1159            "statistics.weighted_mean",
1160            serde_json::json!({"values": [1, 2], "weights": [1, -1]}),
1161        )
1162        .unwrap_err();
1163        assert_eq!(err.code, ErrorCode::DomainViolation);
1164        let err = call(
1165            "statistics.weighted_mean",
1166            serde_json::json!({"values": [1, 2], "weights": [0, 0]}),
1167        )
1168        .unwrap_err();
1169        assert_eq!(err.code, ErrorCode::DomainViolation);
1170        let err = call(
1171            "statistics.weighted_mean",
1172            serde_json::json!({"values": [1, 2], "weights": [1]}),
1173        )
1174        .unwrap_err();
1175        assert_eq!(err.code, ErrorCode::MalformedInput);
1176    }
1177
1178    #[test]
1179    fn stddev_exact_and_approximate_paths() {
1180        let exact = call(
1181            "statistics.stddev",
1182            serde_json::json!({"values": [1, 2, 3], "ddof": 1}),
1183        )
1184        .unwrap();
1185        assert_eq!(exact.exactness, Exactness::Exact);
1186        let err = call_with_mode(
1187            "statistics.stddev",
1188            serde_json::json!({"values": [1, 2, 3], "ddof": 0}),
1189            NumericMode::Exact,
1190        )
1191        .unwrap_err();
1192        assert_eq!(err.code, ErrorCode::UnsupportedNumericMode);
1193        let approximate = call(
1194            "statistics.stddev",
1195            serde_json::json!({"values": [1, 2, 3], "ddof": 0}),
1196        )
1197        .unwrap();
1198        assert_eq!(approximate.exactness, Exactness::Approximate);
1199    }
1200
1201    #[test]
1202    fn correlation_detects_zero_variance() {
1203        let err = call(
1204            "statistics.correlation",
1205            serde_json::json!({"xs": [1, 1, 1], "ys": [1, 2, 3]}),
1206        )
1207        .unwrap_err();
1208        assert_eq!(err.code, ErrorCode::DomainViolation);
1209    }
1210
1211    #[test]
1212    fn quantile_methods() {
1213        let lower = call(
1214            "statistics.quantile",
1215            serde_json::json!({"values": [1, 2, 3, 4], "q": "0.5", "method": "lower"}),
1216        )
1217        .unwrap();
1218        assert_eq!(text_of(&lower), "2");
1219        let higher = call(
1220            "statistics.quantile",
1221            serde_json::json!({"values": [1, 2, 3, 4], "q": "0.5", "method": "higher"}),
1222        )
1223        .unwrap();
1224        assert_eq!(text_of(&higher), "3");
1225        // h = 1.5 rounds half-to-even to index 2, so the nearest value is 3.
1226        let nearest = call(
1227            "statistics.quantile",
1228            serde_json::json!({"values": [1, 2, 3, 4], "q": "0.5", "method": "nearest"}),
1229        )
1230        .unwrap();
1231        assert_eq!(text_of(&nearest), "3");
1232    }
1233
1234    #[test]
1235    fn summary_exact_record() {
1236        let outcome = call(
1237            "statistics.summary",
1238            serde_json::json!({"values": [1, 2, 3]}),
1239        )
1240        .unwrap();
1241        assert_eq!(outcome.exactness, Exactness::Exact);
1242        let Value::Record(fields) = &outcome.value else {
1243            panic!("expected record");
1244        };
1245        assert_eq!(
1246            fields.get("variance"),
1247            Some(&Value::Number(Number::Integer(BigInt::from(1))))
1248        );
1249    }
1250}