#![forbid(unsafe_code)]
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use fsci_conformance::{ArmCounts, CompareLedger};
use fsci_stats::{ContinuousDistribution, Logistic, Lomax, Pareto};
use serde::{Deserialize, Serialize};
const PACKET_ID: &str = "FSCI-P2C-007";
const ABS_TOL: f64 = 1.0e-10;
const REL_TOL: f64 = 1.0e-10;
const REQUIRE_SCIPY_ENV: &str = "FSCI_REQUIRE_SCIPY_ORACLE";
const ARMS: [&str; 9] = [
"logistic.pdf",
"logistic.cdf",
"logistic.sf",
"pareto.pdf",
"pareto.cdf",
"pareto.sf",
"lomax.pdf",
"lomax.cdf",
"lomax.sf",
];
#[derive(Debug, Clone, Serialize)]
struct DistCase {
case_id: String,
dist: String,
method: String,
params: Vec<f64>,
x: f64,
}
#[derive(Debug, Clone, Deserialize)]
struct DistOracleResult {
case_id: String,
value: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
struct CaseDiff {
case_id: String,
dist: String,
method: String,
rust: f64,
scipy: f64,
abs_diff: f64,
rel_diff: f64,
pass: bool,
}
#[derive(Debug, Clone, Serialize)]
struct DiffLog {
test_id: String,
category: String,
case_count: usize,
compared: BTreeMap<String, ArmCounts>,
max_abs_diff: f64,
max_rel_diff: f64,
abs_tol: f64,
rel_tol: f64,
pass: bool,
timestamp_ms: u128,
duration_ns: u128,
cases: Vec<CaseDiff>,
}
fn output_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("fixtures/artifacts/{PACKET_ID}/diff"))
}
fn ensure_output_dir() {
fs::create_dir_all(output_dir()).expect("create distributions diff output dir");
}
fn timestamp_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
}
fn emit_log(log: &DiffLog) {
ensure_output_dir();
let path = output_dir().join(format!("{}.json", log.test_id));
let json = serde_json::to_string_pretty(log).expect("serialize distributions diff log");
fs::write(path, json).expect("write distributions diff log");
}
fn generate_cases() -> Vec<DistCase> {
let mut cases = Vec::new();
let logistic_params = [(0.0_f64, 1.0), (-1.0, 0.5), (2.0, 3.0)];
let logistic_xs = [-5.0_f64, -1.0, 0.0, 1.0, 5.0, 20.0, 40.0];
for (pi, &(loc, scale)) in logistic_params.iter().enumerate() {
for (xi, &x) in logistic_xs.iter().enumerate() {
for method in ["pdf", "cdf", "sf"] {
cases.push(DistCase {
case_id: format!("logistic_p{pi}_x{xi}_{method}"),
dist: "logistic".into(),
method: method.into(),
params: vec![loc, scale],
x,
});
}
}
}
let pareto_params = [(2.0_f64, 1.0), (3.5, 1.0), (1.5, 2.0)];
let pareto_xs = [1.0_f64, 1.5, 2.0, 5.0, 10.0, 100.0, 1.0e6];
for (pi, &(b, scale)) in pareto_params.iter().enumerate() {
for (xi, &x) in pareto_xs.iter().enumerate() {
if x < scale {
continue;
}
for method in ["pdf", "cdf", "sf"] {
cases.push(DistCase {
case_id: format!("pareto_p{pi}_x{xi}_{method}"),
dist: "pareto".into(),
method: method.into(),
params: vec![b, scale],
x,
});
}
}
}
let lomax_params = [0.5_f64, 2.0, 3.5, 7.0];
let lomax_xs = [0.0_f64, 0.5, 1.0, 5.0, 100.0, 1.0e6];
for (pi, &c) in lomax_params.iter().enumerate() {
for (xi, &x) in lomax_xs.iter().enumerate() {
for method in ["pdf", "cdf", "sf"] {
cases.push(DistCase {
case_id: format!("lomax_p{pi}_x{xi}_{method}"),
dist: "lomax".into(),
method: method.into(),
params: vec![c],
x,
});
}
}
}
cases
}
fn rust_eval(case: &DistCase) -> f64 {
match case.dist.as_str() {
"logistic" => {
let d = Logistic::new(case.params[0], case.params[1]);
match case.method.as_str() {
"pdf" => d.pdf(case.x),
"cdf" => d.cdf(case.x),
"sf" => d.sf(case.x),
other => panic!("unknown method {other}"),
}
}
"pareto" => {
let d = Pareto::new(case.params[0], case.params[1]);
match case.method.as_str() {
"pdf" => d.pdf(case.x),
"cdf" => d.cdf(case.x),
"sf" => d.sf(case.x),
other => panic!("unknown method {other}"),
}
}
"lomax" => {
let d = Lomax::new(case.params[0]);
match case.method.as_str() {
"pdf" => d.pdf(case.x),
"cdf" => d.cdf(case.x),
"sf" => d.sf(case.x),
other => panic!("unknown method {other}"),
}
}
other => panic!("unknown dist {other}"),
}
}
fn scipy_oracle_or_skip(cases: &[DistCase]) -> Vec<DistOracleResult> {
let script = r#"
import json
import sys
from scipy import stats
cases = json.load(sys.stdin)
results = []
for c in cases:
cid = c["case_id"]
dist = c["dist"]
method = c["method"]
params = c["params"]
x = c["x"]
try:
if dist == "logistic":
loc, scale = params
d = stats.logistic(loc=loc, scale=scale)
elif dist == "pareto":
b, scale = params
d = stats.pareto(b, scale=scale)
elif dist == "lomax":
(c_shape,) = params
d = stats.lomax(c_shape)
else:
results.append({"case_id": cid, "value": None})
continue
fn = getattr(d, method)
val = float(fn(x))
results.append({"case_id": cid, "value": val})
except Exception:
results.append({"case_id": cid, "value": None})
print(json.dumps(results))
"#;
let cases_json = serde_json::to_string(cases).expect("serialize distribution cases");
let mut child = match fsci_conformance::scipy_oracle_command()
.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"failed to spawn python3 for distributions oracle: {e}"
);
eprintln!("skipping distributions oracle: python3 not available ({e})");
return Vec::new();
}
};
{
let stdin = child
.stdin
.as_mut()
.expect("open distributions oracle stdin");
if let Err(err) = stdin.write_all(cases_json.as_bytes()) {
let output = child.wait_with_output().expect("wait for failed oracle");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"distributions oracle stdin write failed: {err}; stderr: {stderr}"
);
eprintln!("skipping distributions oracle: stdin write failed ({err})\n{stderr}");
return Vec::new();
}
}
let output = child
.wait_with_output()
.expect("wait for distributions oracle");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"distributions oracle failed: {stderr}"
);
eprintln!("skipping distributions oracle: scipy not available\n{stderr}");
return Vec::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).expect("parse distributions oracle JSON")
}
#[test]
fn diff_stats_distributions() {
let cases = generate_cases();
let oracle_results = scipy_oracle_or_skip(&cases);
if oracle_results.is_empty() {
return;
}
assert_eq!(
oracle_results.len(),
cases.len(),
"SciPy distributions oracle returned partial coverage"
);
let oracle_map: HashMap<String, DistOracleResult> = oracle_results
.into_iter()
.map(|r| (r.case_id.clone(), r))
.collect();
let start = Instant::now();
let mut diffs = Vec::new();
let mut max_abs_overall = 0.0_f64;
let mut max_rel_overall = 0.0_f64;
let mut ledger = CompareLedger::new("diff_stats_distributions", &ARMS);
for case in &cases {
let arm = format!("{}.{}", case.dist, case.method);
let scipy = oracle_map.get(&case.case_id).and_then(|r| r.value);
let Some((scipy_value, rust_value)) =
ledger.pair(&arm, &case.case_id, scipy, Some(rust_eval(case)))
else {
continue;
};
let abs_diff = (rust_value - scipy_value).abs();
let rel_diff = abs_diff / scipy_value.abs().max(1.0);
let pass = abs_diff <= ABS_TOL || rel_diff <= REL_TOL;
max_abs_overall = max_abs_overall.max(abs_diff);
max_rel_overall = max_rel_overall.max(rel_diff);
ledger.compared(&arm, &case.case_id, pass);
diffs.push(CaseDiff {
case_id: case.case_id.clone(),
dist: case.dist.clone(),
method: case.method.clone(),
rust: rust_value,
scipy: scipy_value,
abs_diff,
rel_diff,
pass,
});
}
let all_pass = diffs.iter().all(|d| d.pass);
let log = DiffLog {
test_id: "diff_stats_distributions".into(),
category: "scipy.stats.{logistic, pareto, lomax}".into(),
case_count: diffs.len(),
compared: ledger.counts().clone(),
max_abs_diff: max_abs_overall,
max_rel_diff: max_rel_overall,
abs_tol: ABS_TOL,
rel_tol: REL_TOL,
pass: all_pass,
timestamp_ms: timestamp_ms(),
duration_ns: start.elapsed().as_nanos(),
cases: diffs.clone(),
};
emit_log(&log);
for d in &diffs {
if !d.pass {
eprintln!(
"distribution mismatch: {} {} {} rust={} scipy={} abs={} rel={}",
d.dist, d.method, d.case_id, d.rust, d.scipy, d.abs_diff, d.rel_diff
);
}
}
assert!(
all_pass,
"scipy.stats distribution conformance failed: {} cases, max_abs={}, max_rel={}",
diffs.len(),
max_abs_overall,
max_rel_overall
);
let min_per_arm = ARMS
.iter()
.map(|arm| {
cases
.iter()
.filter(|c| format!("{}.{}", c.dist, c.method) == *arm)
.count()
})
.min()
.expect("ARMS is non-empty");
ledger.finish(min_per_arm);
}