Skip to main content

bicmath_statistics/
common.rs

1//! Shared helpers for the statistics module.
2//!
3//! All numeric conversions are explicit: float64 never silently enters exact
4//! arithmetic, non-finite values are rejected, and non-numeric array entries are
5//! reported with their path.
6
7use std::collections::BTreeMap;
8
9use bicmath_core::context::ExecContext;
10use bicmath_core::contract::Args;
11use bicmath_core::envelope::Exactness;
12use bicmath_core::error::{EngineError, ErrorCode};
13use bicmath_core::number::{Decimal, Number, NumericMode};
14use bicmath_core::schema::{FieldSchema, NumberKind, ValueSchema};
15use bicmath_core::value::Value;
16use num_bigint::BigInt;
17use num_rational::BigRational;
18use num_traits::{One, Signed, ToPrimitive, Zero};
19
20use crate::mathfn::sqrt;
21
22// ---------------------------------------------------------------------------
23// Modes and schemas
24// ---------------------------------------------------------------------------
25
26/// Descriptive statistics support all three numeric modes.
27pub fn all_modes() -> Vec<NumericMode> {
28    vec![
29        NumericMode::Exact,
30        NumericMode::Auto,
31        NumericMode::Scientific,
32    ]
33}
34
35/// Distributions and inference always produce float64 results and therefore
36/// require auto or scientific mode.
37pub fn inferential_modes() -> Vec<NumericMode> {
38    vec![NumericMode::Auto, NumericMode::Scientific]
39}
40
41pub fn any_number_schema() -> ValueSchema {
42    ValueSchema::number(NumberKind::Any)
43}
44
45pub fn float64_schema() -> ValueSchema {
46    ValueSchema::number(NumberKind::Float64)
47}
48
49pub fn integer_schema() -> ValueSchema {
50    ValueSchema::number(NumberKind::Integer)
51}
52
53pub fn text_schema() -> ValueSchema {
54    ValueSchema::text()
55}
56
57pub fn bool_schema() -> ValueSchema {
58    ValueSchema::Bool
59}
60
61pub fn array_schema(items: ValueSchema) -> ValueSchema {
62    ValueSchema::array(items)
63}
64
65pub fn record_schema(fields: Vec<FieldSchema>, allow_extra: bool) -> ValueSchema {
66    ValueSchema::Record {
67        fields,
68        allow_extra,
69    }
70}
71
72pub fn field(name: &str, schema: ValueSchema) -> FieldSchema {
73    FieldSchema::required(name, schema)
74}
75
76pub fn optional_field(name: &str, schema: ValueSchema) -> FieldSchema {
77    FieldSchema::optional(name, schema)
78}
79
80pub fn number_field(name: &str) -> FieldSchema {
81    FieldSchema::required(name, any_number_schema())
82}
83
84// ---------------------------------------------------------------------------
85// Example helpers
86// ---------------------------------------------------------------------------
87
88pub fn parse_value(raw: serde_json::Value) -> Value {
89    serde_json::from_value(raw).expect("example value must be valid")
90}
91
92pub fn example_args(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, Value> {
93    pairs
94        .iter()
95        .map(|(name, raw)| (name.to_string(), parse_value(raw.clone())))
96        .collect()
97}
98
99// ---------------------------------------------------------------------------
100// Value construction
101// ---------------------------------------------------------------------------
102
103pub fn record(entries: Vec<(&str, Value)>) -> Value {
104    Value::Record(
105        entries
106            .into_iter()
107            .map(|(key, value)| (key.to_string(), value))
108            .collect(),
109    )
110}
111
112pub fn text(value: impl Into<String>) -> Value {
113    Value::Text(value.into())
114}
115
116pub fn integer_value(value: u64) -> Value {
117    Value::integer(BigInt::from(value))
118}
119
120pub fn bool_value(value: bool) -> Value {
121    Value::Bool(value)
122}
123
124pub fn array_value(values: Vec<Value>) -> Value {
125    Value::Array(values)
126}
127
128pub fn number_value(value: Number) -> Value {
129    Value::Number(value)
130}
131
132pub fn float_value(value: f64) -> Result<Value, EngineError> {
133    Ok(Value::Number(float_number(value)?))
134}
135
136pub fn rational_value(value: BigRational) -> Value {
137    Value::Number(rational_to_number(value))
138}
139
140pub fn rational_to_number(value: BigRational) -> Number {
141    if value.is_integer() {
142        Number::Integer(value.to_integer())
143    } else {
144        Number::Rational(value)
145    }
146}
147
148pub fn float_number(value: f64) -> Result<Number, EngineError> {
149    if !value.is_finite() {
150        return Err(EngineError::domain(
151            "calculation produced a non-finite float64 result",
152        ));
153    }
154    Number::float(value)
155}
156
157pub fn float_array(values: &[f64]) -> Result<Value, EngineError> {
158    values
159        .iter()
160        .map(|value| float_value(*value))
161        .collect::<Result<Vec<_>, _>>()
162        .map(array_value)
163}
164
165/// A text array of method assumptions, shared by the inference records.
166pub fn assumptions_value(statements: &[&str]) -> Value {
167    array_value(
168        statements
169            .iter()
170            .map(|statement| text(*statement))
171            .collect(),
172    )
173}
174
175// ---------------------------------------------------------------------------
176// Parameter parsing
177// ---------------------------------------------------------------------------
178
179pub fn ensure_finite(number: &Number) -> Result<(), EngineError> {
180    if let Number::Float64(value) = number
181        && !value.get().is_finite()
182    {
183        return Err(EngineError::domain(
184            "non-finite float64 values are not accepted",
185        ));
186    }
187    Ok(())
188}
189
190pub fn number_to_f64(number: &Number) -> Result<f64, EngineError> {
191    ensure_finite(number)?;
192    let value = number.to_f64().ok_or_else(|| {
193        EngineError::domain(format!(
194            "{} value is not representable as float64",
195            number.kind_name()
196        ))
197    })?;
198    if !value.is_finite() {
199        return Err(EngineError::domain("value overflows float64"));
200    }
201    Ok(value)
202}
203
204pub fn scalar_f64(args: &Args, name: &str) -> Result<f64, EngineError> {
205    number_to_f64(args.number(name)?).map_err(|e| e.with_path(name.to_string()))
206}
207
208pub fn optional_f64_param(args: &Args, name: &str) -> Result<Option<f64>, EngineError> {
209    match args.optional_number(name)? {
210        None => Ok(None),
211        Some(number) => number_to_f64(number)
212            .map(Some)
213            .map_err(|e| e.with_path(name.to_string())),
214    }
215}
216
217pub fn confidence_param(args: &Args) -> Result<f64, EngineError> {
218    let value = optional_f64_param(args, "confidence")?.unwrap_or(0.95);
219    if !(0.0..1.0).contains(&value) {
220        return Err(
221            EngineError::domain("confidence must be strictly between 0 and 1")
222                .with_path("confidence".to_string()),
223        );
224    }
225    Ok(value)
226}
227
228pub fn alpha_param(args: &Args) -> Result<f64, EngineError> {
229    let value = optional_f64_param(args, "alpha")?.unwrap_or(0.05);
230    if !(0.0..1.0).contains(&value) {
231        return Err(
232            EngineError::domain("alpha must be strictly between 0 and 1")
233                .with_path("alpha".to_string()),
234        );
235    }
236    Ok(value)
237}
238
239pub fn power_param(args: &Args) -> Result<f64, EngineError> {
240    let value = optional_f64_param(args, "power")?.unwrap_or(0.8);
241    if !(0.0..1.0).contains(&value) {
242        return Err(
243            EngineError::domain("power must be strictly between 0 and 1")
244                .with_path("power".to_string()),
245        );
246    }
247    Ok(value)
248}
249
250pub fn allocation_ratio_param(args: &Args) -> Result<f64, EngineError> {
251    let value = optional_f64_param(args, "allocation_ratio")?.unwrap_or(1.0);
252    if value <= 0.0 {
253        return Err(EngineError::domain("allocation_ratio must be positive")
254            .with_path("allocation_ratio".to_string()));
255    }
256    Ok(value)
257}
258
259pub fn sided_param(args: &Args) -> Result<String, EngineError> {
260    parse_choice(args, "sided", "two", &["two", "one"])
261}
262
263pub fn parse_choice(
264    args: &Args,
265    name: &str,
266    default: &str,
267    variants: &[&str],
268) -> Result<String, EngineError> {
269    match args.optional_text(name)? {
270        None => Ok(default.to_string()),
271        Some(text) => {
272            if variants.contains(&text) {
273                Ok(text.to_string())
274            } else {
275                Err(EngineError::domain(format!(
276                    "unknown {name} {text:?}; expected one of {variants:?}"
277                ))
278                .with_path(name.to_string()))
279            }
280        }
281    }
282}
283
284pub fn probability_param(args: &Args, name: &str, default: f64) -> Result<f64, EngineError> {
285    let value = optional_f64_param(args, name)?.unwrap_or(default);
286    if !(0.0..=1.0).contains(&value) {
287        return Err(
288            EngineError::domain(format!("{name} must be in [0, 1]")).with_path(name.to_string())
289        );
290    }
291    Ok(value)
292}
293
294pub fn non_negative_u64(value: &BigInt, name: &str) -> Result<u64, EngineError> {
295    if value.is_negative() {
296        return Err(
297            EngineError::domain(format!("{name} must be a non-negative integer"))
298                .with_path(name.to_string()),
299        );
300    }
301    value.to_u64().ok_or_else(|| {
302        EngineError::domain(format!("{name} is too large for the supported range"))
303            .with_path(name.to_string())
304    })
305}
306
307pub fn insufficient(message: impl Into<String>) -> EngineError {
308    EngineError::new(ErrorCode::InsufficientObservations, message)
309}
310
311// ---------------------------------------------------------------------------
312// Input collection and exact/float dispatch
313// ---------------------------------------------------------------------------
314
315pub fn collect_numbers(args: &Args, name: &str) -> Result<Vec<Number>, EngineError> {
316    let items = args.array(name)?;
317    let mut out = Vec::with_capacity(items.len());
318    for (index, item) in items.iter().enumerate() {
319        match item {
320            Value::Number(number) => {
321                ensure_finite(number).map_err(|e| e.with_path(format!("{name}[{index}]")))?;
322                out.push(number.clone());
323            }
324            other => {
325                return Err(EngineError::malformed(format!(
326                    "expected a number at {name}[{index}], found {}",
327                    other.kind_name()
328                ))
329                .with_path(format!("{name}[{index}]")));
330            }
331        }
332    }
333    Ok(out)
334}
335
336/// Collect a non-empty numeric array as float64 values, preserving the path of
337/// the offending entry in any conversion error.
338pub fn float_vector(args: &Args, name: &str) -> Result<Vec<f64>, EngineError> {
339    let numbers = collect_numbers(args, name)?;
340    if numbers.is_empty() {
341        return Err(insufficient(format!("{name} must not be empty")));
342    }
343    numbers
344        .iter()
345        .enumerate()
346        .map(|(index, number)| {
347            number_to_f64(number).map_err(|error| error.with_path(format!("{name}[{index}]")))
348        })
349        .collect()
350}
351
352/// A numeric series in the representation chosen for the calculation.
353#[derive(Clone, Debug)]
354pub enum Series {
355    Exact(Vec<BigRational>),
356    Float(Vec<f64>),
357}
358
359impl Series {
360    pub fn len(&self) -> usize {
361        match self {
362            Series::Exact(values) => values.len(),
363            Series::Float(values) => values.len(),
364        }
365    }
366
367    pub fn is_empty(&self) -> bool {
368        self.len() == 0
369    }
370
371    pub fn is_float(&self) -> bool {
372        matches!(self, Series::Float(_))
373    }
374
375    /// Explicit conversion of the series to float64 for inferential methods
376    /// whose output is always float64.
377    pub fn to_f64_vec(&self) -> Result<Vec<f64>, EngineError> {
378        match self {
379            Series::Float(values) => Ok(values.clone()),
380            Series::Exact(values) => values
381                .iter()
382                .map(|value| {
383                    value
384                        .to_f64()
385                        .filter(|v| v.is_finite())
386                        .ok_or_else(|| EngineError::domain("value overflows float64"))
387                })
388                .collect(),
389        }
390    }
391}
392
393/// Convert an input array to either exact rationals or float64 values.
394///
395/// If any element is float64 the whole series is computed in float64 and the
396/// result is approximate. Exact mode rejects float64 input.
397pub fn classify_series(args: &Args, name: &str, ctx: &ExecContext) -> Result<Series, EngineError> {
398    let values = collect_numbers(args, name)?;
399    let has_float = values.iter().any(Number::is_float);
400    if has_float {
401        if ctx.numeric.mode == NumericMode::Exact {
402            return Err(EngineError::new(
403                ErrorCode::UnsupportedNumericMode,
404                format!("{name} contains float64 values, which require auto or scientific mode"),
405            )
406            .with_path(name.to_string()));
407        }
408        let mut out = Vec::with_capacity(values.len());
409        for (index, value) in values.iter().enumerate() {
410            let float =
411                number_to_f64(value).map_err(|e| e.with_path(format!("{name}[{index}]")))?;
412            out.push(float);
413        }
414        Ok(Series::Float(out))
415    } else {
416        let mut out = Vec::with_capacity(values.len());
417        for (index, value) in values.iter().enumerate() {
418            let rational = value.as_exact_rational().ok_or_else(|| {
419                EngineError::internal("exact value could not be converted to a rational")
420                    .with_path(format!("{name}[{index}]"))
421            })?;
422            out.push(rational);
423        }
424        Ok(Series::Exact(out))
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Exact statistics
430// ---------------------------------------------------------------------------
431
432pub fn mean_exact(values: &[BigRational]) -> Result<BigRational, EngineError> {
433    if values.is_empty() {
434        return Err(insufficient("mean requires at least one observation"));
435    }
436    let n = BigInt::from(values.len());
437    let sum: BigRational = values.iter().cloned().sum();
438    Ok(sum / BigRational::from_integer(n))
439}
440
441pub fn variance_exact(values: &[BigRational], ddof: u64) -> Result<BigRational, EngineError> {
442    let n = values.len() as u64;
443    if values.is_empty() {
444        return Err(insufficient("variance requires at least one observation"));
445    }
446    if n <= ddof {
447        return Err(insufficient(format!(
448            "variance with ddof={ddof} requires more than {ddof} observations"
449        )));
450    }
451    let mean = mean_exact(values)?;
452    let mut sum_squares = BigRational::zero();
453    for value in values {
454        let delta = value - &mean;
455        sum_squares += &delta * &delta;
456    }
457    let denominator = BigRational::from_integer(BigInt::from(n - ddof));
458    Ok(sum_squares / denominator)
459}
460
461pub fn covariance_exact(
462    xs: &[BigRational],
463    ys: &[BigRational],
464    ddof: u64,
465) -> Result<BigRational, EngineError> {
466    let n = xs.len() as u64;
467    if xs.is_empty() {
468        return Err(insufficient("covariance requires at least one observation"));
469    }
470    if n <= ddof {
471        return Err(insufficient(format!(
472            "covariance with ddof={ddof} requires more than {ddof} observations"
473        )));
474    }
475    let mean_x = mean_exact(xs)?;
476    let mean_y = mean_exact(ys)?;
477    let mut sum = BigRational::zero();
478    for (x, y) in xs.iter().zip(ys.iter()) {
479        sum += (x - &mean_x) * (y - &mean_y);
480    }
481    let denominator = BigRational::from_integer(BigInt::from(n - ddof));
482    Ok(sum / denominator)
483}
484
485/// Exact square root of a non-negative rational, when one exists.
486pub fn rational_sqrt_exact(value: &BigRational) -> Option<BigRational> {
487    if value.is_negative() {
488        return None;
489    }
490    let numer = value.numer().sqrt();
491    let denom = value.denom().sqrt();
492    if &numer * &numer == *value.numer() && &denom * &denom == *value.denom() {
493        Some(BigRational::new(numer, denom))
494    } else {
495        None
496    }
497}
498
499// ---------------------------------------------------------------------------
500// Float statistics (stable algorithms)
501// ---------------------------------------------------------------------------
502
503/// Neumaier compensated summation.
504pub fn compensated_sum(values: &[f64]) -> f64 {
505    let mut sum = 0.0f64;
506    let mut compensation = 0.0f64;
507    for &value in values {
508        let next = sum + value;
509        if sum.abs() >= value.abs() {
510            compensation += (sum - next) + value;
511        } else {
512            compensation += (value - next) + sum;
513        }
514        sum = next;
515    }
516    sum + compensation
517}
518
519pub fn mean_f64(values: &[f64]) -> f64 {
520    if values.is_empty() {
521        return f64::NAN;
522    }
523    compensated_sum(values) / values.len() as f64
524}
525
526/// Welford's online algorithm for the sample variance.
527pub fn variance_f64(values: &[f64], ddof: u64) -> Result<f64, EngineError> {
528    let n = values.len() as u64;
529    if values.is_empty() {
530        return Err(insufficient("variance requires at least one observation"));
531    }
532    if n <= ddof {
533        return Err(insufficient(format!(
534            "variance with ddof={ddof} requires more than {ddof} observations"
535        )));
536    }
537    let mut count = 0u64;
538    let mut mean = 0.0f64;
539    let mut m2 = 0.0f64;
540    for &value in values {
541        count += 1;
542        let delta = value - mean;
543        mean += delta / count as f64;
544        let delta2 = value - mean;
545        m2 += delta * delta2;
546    }
547    Ok(m2 / (n - ddof) as f64)
548}
549
550/// Two-pass centered covariance with compensated summation.
551pub fn covariance_f64(xs: &[f64], ys: &[f64], ddof: u64) -> Result<f64, EngineError> {
552    let n = xs.len() as u64;
553    if xs.is_empty() {
554        return Err(insufficient("covariance requires at least one observation"));
555    }
556    if n <= ddof {
557        return Err(insufficient(format!(
558            "covariance with ddof={ddof} requires more than {ddof} observations"
559        )));
560    }
561    let mean_x = mean_f64(xs);
562    let mean_y = mean_f64(ys);
563    let mut sum = 0.0f64;
564    let mut compensation = 0.0f64;
565    for (x, y) in xs.iter().zip(ys.iter()) {
566        let term = (x - mean_x) * (y - mean_y);
567        let adjusted = term - compensation;
568        let next = sum + adjusted;
569        compensation = (next - sum) - adjusted;
570        sum = next;
571    }
572    Ok(sum / (n - ddof) as f64)
573}
574
575// ---------------------------------------------------------------------------
576// Moment dispatch
577// ---------------------------------------------------------------------------
578
579/// An exact or approximate statistical moment.
580#[derive(Clone, Debug)]
581pub enum Moment {
582    Exact(BigRational),
583    Float(f64),
584}
585
586pub fn mean_moment(series: &Series) -> Result<Moment, EngineError> {
587    match series {
588        Series::Exact(values) => Ok(Moment::Exact(mean_exact(values)?)),
589        Series::Float(values) => {
590            if values.is_empty() {
591                return Err(insufficient("mean requires at least one observation"));
592            }
593            Ok(Moment::Float(mean_f64(values)))
594        }
595    }
596}
597
598pub fn variance_moment(series: &Series, ddof: u64) -> Result<Moment, EngineError> {
599    match series {
600        Series::Exact(values) => Ok(Moment::Exact(variance_exact(values, ddof)?)),
601        Series::Float(values) => Ok(Moment::Float(variance_f64(values, ddof)?)),
602    }
603}
604
605pub fn covariance_moment(xs: &Series, ys: &Series, ddof: u64) -> Result<Moment, EngineError> {
606    match (xs, ys) {
607        (Series::Exact(xs), Series::Exact(ys)) => {
608            Ok(Moment::Exact(covariance_exact(xs, ys, ddof)?))
609        }
610        (Series::Float(xs), Series::Float(ys)) => Ok(Moment::Float(covariance_f64(xs, ys, ddof)?)),
611        _ => Err(EngineError::internal(
612            "mixed exact/float series reached covariance",
613        )),
614    }
615}
616
617/// Convert a variance moment to a standard deviation under the mode rules:
618/// an exact perfect square stays exact, otherwise exact mode is rejected, auto
619/// mode returns a decimal approximation, and scientific mode returns float64.
620pub fn stddev_moment(
621    moment: &Moment,
622    mode: NumericMode,
623) -> Result<(Number, Exactness), EngineError> {
624    match moment {
625        Moment::Exact(value) => {
626            if value.is_negative() {
627                return Err(EngineError::internal("negative exact variance"));
628            }
629            if let Some(root) = rational_sqrt_exact(value) {
630                return Ok((rational_to_number(root), Exactness::Exact));
631            }
632            match mode {
633                NumericMode::Exact => Err(EngineError::new(
634                    ErrorCode::UnsupportedNumericMode,
635                    "variance is not a perfect square; exact mode cannot represent the \
636                     standard deviation; use auto or scientific mode",
637                )),
638                NumericMode::Auto => {
639                    let approximate = value.to_f64().ok_or_else(|| {
640                        EngineError::domain("variance is not representable as float64")
641                    })?;
642                    let root = sqrt(approximate);
643                    let decimal = Decimal::from_f64_display(root).ok_or_else(|| {
644                        EngineError::internal("could not format decimal approximation")
645                    })?;
646                    Ok((Number::Decimal(decimal), Exactness::Approximate))
647                }
648                NumericMode::Scientific => {
649                    let approximate = value.to_f64().ok_or_else(|| {
650                        EngineError::domain("variance is not representable as float64")
651                    })?;
652                    Ok((float_number(sqrt(approximate))?, Exactness::Approximate))
653                }
654            }
655        }
656        Moment::Float(value) => {
657            if value.is_nan() || *value < 0.0 {
658                return Err(EngineError::domain("negative float64 variance"));
659            }
660            Ok((float_number(sqrt(*value))?, Exactness::Approximate))
661        }
662    }
663}
664
665// ---------------------------------------------------------------------------
666// Order statistics
667// ---------------------------------------------------------------------------
668
669pub fn extreme_number(series: &Series, minimum: bool) -> Result<(Number, Exactness), EngineError> {
670    if series.is_empty() {
671        return Err(insufficient(
672            "minimum and maximum require at least one observation",
673        ));
674    }
675    match series {
676        Series::Exact(values) => {
677            let mut best = values[0].clone();
678            for value in &values[1..] {
679                if (minimum && value < &best) || (!minimum && value > &best) {
680                    best = value.clone();
681                }
682            }
683            Ok((rational_to_number(best), Exactness::Exact))
684        }
685        Series::Float(values) => {
686            let mut best = values[0];
687            for &value in &values[1..] {
688                if (minimum && value < best) || (!minimum && value > best) {
689                    best = value;
690                }
691            }
692            Ok((float_number(best)?, Exactness::Approximate))
693        }
694    }
695}
696
697pub fn median_number(series: &Series) -> Result<(Number, Exactness), EngineError> {
698    if series.is_empty() {
699        return Err(insufficient("median requires at least one observation"));
700    }
701    match series {
702        Series::Exact(values) => {
703            let mut sorted = values.clone();
704            sorted.sort();
705            let n = sorted.len();
706            let median = if n % 2 == 1 {
707                sorted[n / 2].clone()
708            } else {
709                (sorted[n / 2 - 1].clone() + sorted[n / 2].clone())
710                    / BigRational::from_integer(BigInt::from(2))
711            };
712            Ok((rational_to_number(median), Exactness::Exact))
713        }
714        Series::Float(values) => {
715            let mut sorted = values.clone();
716            sorted.sort_by(|a, b| a.total_cmp(b));
717            let n = sorted.len();
718            let median = if n % 2 == 1 {
719                sorted[n / 2]
720            } else {
721                0.5 * (sorted[n / 2 - 1] + sorted[n / 2])
722            };
723            Ok((float_number(median)?, Exactness::Approximate))
724        }
725    }
726}
727
728/// R-7 style quantiles with alternative order-statistic conventions.
729///
730/// With `h = (n - 1) * q`:
731/// - `linear` interpolates `x[floor(h)] + (h - floor(h)) * (x[floor(h)+1] - x[floor(h)])`
732/// - `lower` returns `x[floor(h)]`
733/// - `higher` returns `x[ceil(h)]`
734/// - `midpoint` returns `(x[floor(h)] + x[ceil(h)]) / 2`
735/// - `nearest` returns `x[round_half_to_even(h)]`
736pub fn quantile_number(
737    series: &Series,
738    q: &Number,
739    method: &str,
740) -> Result<(Number, Exactness), EngineError> {
741    if series.is_empty() {
742        return Err(insufficient("quantile requires at least one observation"));
743    }
744    let use_float = series.is_float() || q.is_float();
745    if use_float {
746        let p = number_to_f64(q)?;
747        if !(0.0..=1.0).contains(&p) {
748            return Err(EngineError::domain("q must be in [0, 1]"));
749        }
750        let mut sorted: Vec<f64> = match series {
751            Series::Float(values) => values.clone(),
752            Series::Exact(values) => values
753                .iter()
754                .map(|value| {
755                    value
756                        .to_f64()
757                        .filter(|v| v.is_finite())
758                        .ok_or_else(|| EngineError::domain("value overflows float64"))
759                })
760                .collect::<Result<_, _>>()?,
761        };
762        sorted.sort_by(|a, b| a.total_cmp(b));
763        let n = sorted.len();
764        let h = (n - 1) as f64 * p;
765        let lower = h.floor() as usize;
766        let fraction = h - lower as f64;
767        let upper = if fraction == 0.0 {
768            lower
769        } else {
770            (lower + 1).min(n - 1)
771        };
772        let result = match method {
773            "linear" => sorted[lower] + fraction * (sorted[upper] - sorted[lower]),
774            "lower" => sorted[lower],
775            "higher" => sorted[upper],
776            "midpoint" => 0.5 * (sorted[lower] + sorted[upper]),
777            "nearest" => {
778                let index = h.round_ties_even() as usize;
779                sorted[index.min(n - 1)]
780            }
781            other => {
782                return Err(EngineError::domain(format!(
783                    "unknown quantile method {other:?}"
784                )));
785            }
786        };
787        return Ok((float_number(result)?, Exactness::Approximate));
788    }
789    let p = q
790        .as_exact_rational()
791        .ok_or_else(|| EngineError::internal("exact q could not be converted to a rational"))?;
792    if p.is_negative() || p > BigRational::one() {
793        return Err(EngineError::domain("q must be in [0, 1]"));
794    }
795    let values = match series {
796        Series::Exact(values) => values,
797        Series::Float(_) => {
798            return Err(EngineError::internal(
799                "float series reached the exact quantile path",
800            ));
801        }
802    };
803    let mut sorted = values.clone();
804    sorted.sort();
805    let n = sorted.len();
806    let h = BigRational::from_integer(BigInt::from(n - 1)) * &p;
807    let lower = h
808        .floor()
809        .to_integer()
810        .to_usize()
811        .ok_or_else(|| EngineError::internal("quantile index out of range"))?;
812    let fraction = &h - BigRational::from_integer(h.floor().to_integer());
813    let upper = if fraction.is_zero() {
814        lower
815    } else {
816        (lower + 1).min(n - 1)
817    };
818    let result = match method {
819        "linear" => &sorted[lower] + &fraction * (&sorted[upper] - &sorted[lower]),
820        "lower" => sorted[lower].clone(),
821        "higher" => sorted[upper].clone(),
822        "midpoint" => {
823            (&sorted[lower] + &sorted[upper]) / BigRational::from_integer(BigInt::from(2))
824        }
825        "nearest" => {
826            let twice = &fraction * BigRational::from_integer(BigInt::from(2));
827            let index = match twice.cmp(&BigRational::one()) {
828                std::cmp::Ordering::Less => lower,
829                std::cmp::Ordering::Greater => upper,
830                std::cmp::Ordering::Equal => {
831                    if lower % 2 == 0 {
832                        lower
833                    } else {
834                        upper
835                    }
836                }
837            };
838            sorted[index.min(n - 1)].clone()
839        }
840        other => {
841            return Err(EngineError::domain(format!(
842                "unknown quantile method {other:?}"
843            )));
844        }
845    };
846    Ok((rational_to_number(result), Exactness::Exact))
847}
848
849/// All tied modes and their counts, sorted ascending.
850pub fn mode_numbers(series: &Series) -> Result<(Vec<Number>, Vec<u64>), EngineError> {
851    if series.is_empty() {
852        return Err(insufficient("mode requires at least one observation"));
853    }
854    match series {
855        Series::Exact(values) => {
856            let mut counts: BTreeMap<BigRational, u64> = BTreeMap::new();
857            for value in values {
858                *counts.entry(value.clone()).or_insert(0) += 1;
859            }
860            let max = counts.values().copied().max().unwrap_or(0);
861            let mut modes = Vec::new();
862            let mut mode_counts = Vec::new();
863            for (value, count) in counts {
864                if count == max {
865                    modes.push(rational_to_number(value));
866                    mode_counts.push(count);
867                }
868            }
869            Ok((modes, mode_counts))
870        }
871        Series::Float(values) => {
872            let mut sorted: Vec<f64> = values
873                .iter()
874                .map(|value| if *value == 0.0 { 0.0 } else { *value })
875                .collect();
876            sorted.sort_by(|a, b| a.total_cmp(b));
877            let mut groups: Vec<(f64, u64)> = Vec::new();
878            for value in sorted {
879                match groups.last_mut() {
880                    Some(last) if last.0 == value => last.1 += 1,
881                    _ => groups.push((value, 1)),
882                }
883            }
884            let max = groups.iter().map(|group| group.1).max().unwrap_or(0);
885            let mut modes = Vec::new();
886            let mut mode_counts = Vec::new();
887            for (value, count) in groups {
888                if count == max {
889                    modes.push(float_number(value)?);
890                    mode_counts.push(count);
891                }
892            }
893            Ok((modes, mode_counts))
894        }
895    }
896}