commonstats 0.1.0

WASM-first Rust statistics: descriptives, hypothesis tests, distributions, density estimation, transforms, and resampling — validated against SciPy/R.
Documentation
// tests/density_oracle.rs — oracle tests for src/density.rs
// Run: cargo test --test density_oracle
// Fixtures generated by: python scripts/gen_oracle.py

mod common;
use common::{assert_close, Tol};

use serde::Deserialize;
use commonstats::density::{kde, Kernel, Bandwidth, histogram_auto, Bins, Rule, Norm};

fn load_str(name: &str) -> String {
    let path = format!(
        "{}/tests/fixtures/{name}.json",
        env!("CARGO_MANIFEST_DIR")
    );
    std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("missing fixture {path}: {e} — run scripts/gen_oracle.py"))
}

// ---- KDE oracle ----

#[derive(Deserialize)]
struct KdeRecord {
    label: String,
    data: Vec<f64>,
    bw_method: Option<String>,
    fixed_h: Option<f64>,
    grid: Vec<f64>,
    bandwidth: f64,
    density: Vec<f64>,
}

#[test]
fn kde_oracle_bandwidth_and_evaluate() {
    let txt = load_str("kde");
    let records: Vec<KdeRecord> = serde_json::from_str(&txt).expect("kde.json parse");

    for rec in &records {
        let bw = match (rec.bw_method.as_deref(), rec.fixed_h) {
            (Some("silverman"), _) => Bandwidth::Silverman,
            (Some("scott"), _)     => Bandwidth::Scott,
            (None, Some(h))        => Bandwidth::Fixed(h),
            other => panic!("unexpected bw spec in fixture: {:?}", other),
        };
        let k = kde(&rec.data, Kernel::Gaussian, bw)
            .unwrap_or_else(|e| panic!("kde failed for {}: {e:?}", rec.label));

        // Bandwidth comes from scipy's resolved factor (not mpmath) — keep rel 1e-10.
        assert_close(
            &format!("{}/bandwidth", rec.label),
            k.bandwidth(),
            rec.bandwidth,
            Tol { rel: 1e-10, abs: 1e-15 },
        );

        // Density is mpmath truth; impl evaluated against it.
        let got_vals = k.evaluate(&rec.grid);
        assert_eq!(got_vals.len(), rec.density.len());
        for (i, (&got, &want)) in got_vals.iter().zip(rec.density.iter()).enumerate() {
            assert_close(
                &format!("{}/density[{}]", rec.label, i),
                got,
                want,
                Tol { rel: 1e-12, abs: 1e-15 },
            );
        }
    }
}

// ---- histogram_auto oracle ----

#[derive(Deserialize)]
struct HistRecord {
    rule: String,
    data: Vec<f64>,
    edges: Vec<f64>,
    counts: Vec<u64>,
    density: Vec<f64>,
}

fn rule_from_str(s: &str) -> Rule {
    match s {
        "sturges" => Rule::Sturges,
        "scott"   => Rule::Scott,
        "fd"      => Rule::FreedmanDiaconis,
        "rice"    => Rule::Rice,
        "sqrt"    => Rule::Sqrt,
        other     => panic!("unknown rule in fixture: {other}"),
    }
}

#[test]
fn histogram_auto_oracle_edges_counts_density() {
    let txt = load_str("histogram_auto");
    let records: Vec<HistRecord> = serde_json::from_str(&txt).expect("histogram_auto.json parse");

    for rec in &records {
        let rule = rule_from_str(&rec.rule);

        // ---- Count normalization ----
        let (got_edges, got_counts) =
            histogram_auto(&rec.data, Bins::Rule(rule), Norm::Count)
            .unwrap_or_else(|e| panic!("histogram_auto Count/{}: {e:?}", rec.rule));

        // Number of bins must match numpy.
        assert_eq!(
            got_counts.len(), rec.counts.len(),
            "rule={}: bin count mismatch (got {}, want {})",
            rec.rule, got_counts.len(), rec.counts.len()
        );

        // Edge values match to rel 1e-10.
        assert_eq!(got_edges.len(), rec.edges.len(),
            "rule={}: edge count mismatch", rec.rule);
        for (i, (&g, &w)) in got_edges.iter().zip(rec.edges.iter()).enumerate() {
            assert_close(&format!("{}/edges[{i}]", rec.rule), g, w, Tol { rel: 1e-10, abs: 1e-15 });
        }

        // Counts are exact integers.
        for (i, (&g, &w)) in got_counts.iter().zip(rec.counts.iter()).enumerate() {
            assert_eq!(
                g as u64, w,
                "rule={}/count[{i}]: got {g}, want {w}",
                rec.rule
            );
        }

        // ---- Density normalization ----
        let (_, got_density) =
            histogram_auto(&rec.data, Bins::Rule(rule), Norm::Density)
            .unwrap_or_else(|e| panic!("histogram_auto Density/{}: {e:?}", rec.rule));

        for (i, (&g, &w)) in got_density.iter().zip(rec.density.iter()).enumerate() {
            assert_close(&format!("{}/density[{i}]", rec.rule), g, w, Tol { rel: 1e-12, abs: 1e-15 });
        }
    }
}

// ---- Stub tests from Task 3 (kept for regression) ----
#[test]
fn kde_fixture_exists() {
    let _ = load_str("kde");
}

#[test]
fn histogram_auto_fixture_exists() {
    let _ = load_str("histogram_auto");
}