#![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_special::orthopoly::{
eval_chebyt, eval_chebyu, eval_gegenbauer, eval_genlaguerre, eval_hermite, eval_hermitenorm,
eval_jacobi, eval_laguerre, eval_legendre,
};
use serde::{Deserialize, Serialize};
const PACKET_ID: &str = "FSCI-P2C-006";
const ABS_TOL: f64 = 1.0e-10;
const REL_TOL: f64 = 1.0e-10;
const REQUIRE_SCIPY_ENV: &str = "FSCI_REQUIRE_SCIPY_ORACLE";
const ORTHOPOLY_ARMS: [&str; 9] = [
"eval_legendre",
"eval_chebyt",
"eval_chebyu",
"eval_laguerre",
"eval_hermite",
"eval_hermitenorm",
"eval_genlaguerre",
"eval_gegenbauer",
"eval_jacobi",
];
#[derive(Debug, Clone, Serialize)]
struct SpecialCase {
case_id: String,
op: String,
n: u32,
alpha: Option<f64>,
beta: Option<f64>,
x: f64,
}
#[derive(Debug, Clone, Deserialize)]
struct OracleResult {
case_id: String,
value: f64,
}
#[derive(Debug, Clone, Serialize)]
struct CaseDiff {
case_id: String,
op: String,
n: u32,
alpha: Option<f64>,
beta: Option<f64>,
x: f64,
rust_value: f64,
scipy_value: f64,
abs_diff: f64,
rel_diff: f64,
tolerance_abs: f64,
tolerance_rel: 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,
tolerance_abs: f64,
tolerance_rel: 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 special diff output dir");
}
fn timestamp_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.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 special diff log");
fs::write(path, json).expect("write special diff log");
}
fn push_case(
cases: &mut Vec<SpecialCase>,
op: &str,
n: u32,
alpha: Option<f64>,
beta: Option<f64>,
x: f64,
) {
let alpha_id = alpha.map_or_else(String::new, |value| format!("_a{value:.2}"));
let beta_id = beta.map_or_else(String::new, |value| format!("_b{value:.2}"));
cases.push(SpecialCase {
case_id: format!("{op}_n{n}{alpha_id}{beta_id}_x{x:.2}"),
op: op.to_string(),
n,
alpha,
beta,
x,
});
}
fn special_cases() -> Vec<SpecialCase> {
let mut cases = Vec::new();
let compact_degrees = [0, 1, 2, 3, 5, 8];
let compact_xs = [-0.75, -0.25, 0.0, 0.25, 0.75];
for op in ["eval_legendre", "eval_chebyt", "eval_chebyu"] {
for n in compact_degrees {
for x in compact_xs {
push_case(&mut cases, op, n, None, None, x);
}
}
}
for n in [0, 1, 2, 3, 5] {
for x in [0.0, 0.5, 1.5, 3.0] {
push_case(&mut cases, "eval_laguerre", n, None, None, x);
}
}
for op in ["eval_hermite", "eval_hermitenorm"] {
for n in [0, 1, 2, 3, 4] {
for x in [-1.0, -0.25, 0.5] {
push_case(&mut cases, op, n, None, None, x);
}
}
}
for n in [0, 1, 2, 4] {
for alpha in [0.5, 2.0] {
for x in [0.0, 0.75, 2.5] {
push_case(&mut cases, "eval_genlaguerre", n, Some(alpha), None, x);
}
}
}
for n in [0, 1, 2, 4] {
for alpha in [0.5, 1.5] {
for x in [-0.5, 0.0, 0.5] {
push_case(&mut cases, "eval_gegenbauer", n, Some(alpha), None, x);
}
}
}
for n in [0, 1, 2, 4] {
for (alpha, beta) in [(0.0, 0.0), (0.5, 0.5), (1.0, 2.0)] {
for x in [-0.5, 0.25] {
push_case(&mut cases, "eval_jacobi", n, Some(alpha), Some(beta), x);
}
}
}
cases
}
fn rust_value(case: &SpecialCase) -> Option<f64> {
match case.op.as_str() {
"eval_legendre" => Some(eval_legendre(case.n, case.x)),
"eval_chebyt" => Some(eval_chebyt(case.n, case.x)),
"eval_chebyu" => Some(eval_chebyu(case.n, case.x)),
"eval_laguerre" => Some(eval_laguerre(case.n, case.x)),
"eval_hermite" => Some(eval_hermite(case.n, case.x)),
"eval_hermitenorm" => Some(eval_hermitenorm(case.n, case.x)),
"eval_genlaguerre" => Some(eval_genlaguerre(case.n, case.alpha?, case.x)),
"eval_gegenbauer" => Some(eval_gegenbauer(case.n, case.alpha?, case.x)),
"eval_jacobi" => Some(eval_jacobi(case.n, case.alpha?, case.beta?, case.x)),
_ => None,
}
}
fn run_scipy_oracle(cases: &[SpecialCase]) -> Option<HashMap<String, OracleResult>> {
let python_code = r#"
import json
import math
import sys
try:
from scipy import special
except Exception:
sys.exit(42)
cases = json.loads(sys.stdin.read())
results = []
for case in cases:
op = case["op"]
n = int(case["n"])
x = float(case["x"])
alpha = case.get("alpha")
beta = case.get("beta")
try:
if op == "eval_legendre":
value = special.eval_legendre(n, x)
elif op == "eval_chebyt":
value = special.eval_chebyt(n, x)
elif op == "eval_chebyu":
value = special.eval_chebyu(n, x)
elif op == "eval_laguerre":
value = special.eval_laguerre(n, x)
elif op == "eval_hermite":
value = special.eval_hermite(n, x)
elif op == "eval_hermitenorm":
value = special.eval_hermitenorm(n, x)
elif op == "eval_genlaguerre":
value = special.eval_genlaguerre(n, float(alpha), x)
elif op == "eval_gegenbauer":
value = special.eval_gegenbauer(n, float(alpha), x)
elif op == "eval_jacobi":
value = special.eval_jacobi(n, float(alpha), float(beta), x)
else:
continue
value = float(value)
if not math.isfinite(value):
continue
results.append({"case_id": case["case_id"], "value": value})
except Exception:
continue
print(json.dumps(results))
"#;
let mut child = fsci_conformance::scipy_oracle_command()
.arg("-c")
.arg(python_code)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
let input = serde_json::to_vec(cases).expect("serialize special oracle input");
stdin.write_all(&input).expect("write special oracle input");
}
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let results: Vec<OracleResult> = serde_json::from_slice(&output.stdout).ok()?;
Some(
results
.into_iter()
.map(|result| (result.case_id.clone(), result))
.collect(),
)
}
fn assert_complete_oracle(
test_id: &str,
cases: &[SpecialCase],
oracle: &HashMap<String, OracleResult>,
) {
assert_eq!(
oracle.len(),
cases.len(),
"{test_id} SciPy special oracle returned partial or duplicate coverage"
);
let missing: Vec<&str> = cases
.iter()
.filter(|case| !oracle.contains_key(case.case_id.as_str()))
.map(|case| case.case_id.as_str())
.collect();
assert!(
missing.is_empty(),
"{test_id} missing SciPy special oracle results: {:?}",
missing
);
}
fn close_enough(actual: f64, expected: f64) -> (f64, f64, bool) {
let abs_diff = (actual - expected).abs();
let scale = actual.abs().max(expected.abs()).max(1.0);
let rel_diff = abs_diff / scale;
let pass = abs_diff <= ABS_TOL + REL_TOL * scale;
(abs_diff, rel_diff, pass)
}
#[test]
fn diff_001_special_orthopoly_live_scipy() {
let cases = special_cases();
assert_eq!(
cases.len(),
212,
"special orthopoly diff case inventory changed"
);
let start = Instant::now();
let Some(oracle_results) = run_scipy_oracle(&cases) else {
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"{REQUIRE_SCIPY_ENV}=1 but SciPy special oracle unavailable"
);
eprintln!("skipping special orthopoly diff: scipy oracle not available");
return;
};
assert_complete_oracle(
"diff_001_special_orthopoly_live_scipy",
&cases,
&oracle_results,
);
let mut ledger = CompareLedger::new("diff_001_special_orthopoly_live_scipy", &ORTHOPOLY_ARMS);
let mut case_diffs = Vec::with_capacity(cases.len());
for case in &cases {
let scipy = oracle_results
.get(case.case_id.as_str())
.map(|result| result.value);
let Some((expected, actual)) =
ledger.pair(&case.op, &case.case_id, scipy, rust_value(case))
else {
continue;
};
let (abs_diff, rel_diff, pass) = close_enough(actual, expected);
ledger.compared(&case.op, &case.case_id, pass);
case_diffs.push(CaseDiff {
case_id: case.case_id.clone(),
op: case.op.clone(),
n: case.n,
alpha: case.alpha,
beta: case.beta,
x: case.x,
rust_value: actual,
scipy_value: expected,
abs_diff,
rel_diff,
tolerance_abs: ABS_TOL,
tolerance_rel: REL_TOL,
pass,
});
}
let max_abs_diff = case_diffs
.iter()
.map(|case| case.abs_diff)
.fold(0.0_f64, f64::max);
let max_rel_diff = case_diffs
.iter()
.map(|case| case.rel_diff)
.fold(0.0_f64, f64::max);
let pass = case_diffs.iter().all(|case| case.pass);
let log = DiffLog {
test_id: String::from("diff_001_special_orthopoly_live_scipy"),
category: String::from("live_scipy_differential"),
case_count: cases.len(),
compared: ledger.counts().clone(),
max_abs_diff,
max_rel_diff,
tolerance_abs: ABS_TOL,
tolerance_rel: REL_TOL,
pass,
timestamp_ms: timestamp_ms(),
duration_ns: start.elapsed().as_nanos(),
cases: case_diffs,
};
emit_log(&log);
assert!(
pass,
"special orthopoly live SciPy diff max_abs={max_abs_diff:.3e} max_rel={max_rel_diff:.3e}"
);
let min_per_arm = ORTHOPOLY_ARMS
.iter()
.map(|arm| cases.iter().filter(|c| c.op == *arm).count())
.min()
.expect("ORTHOPOLY_ARMS is non-empty");
ledger.finish(min_per_arm);
}