#![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_interpolate::{splantider, splder, splev, splint, splrep, sproot};
use serde::{Deserialize, Serialize};
const PACKET_ID: &str = "FSCI-P2C-007";
const TCK_TOL: f64 = 1.0e-9;
const VAL_TOL: f64 = 1.0e-9;
const REQUIRE_SCIPY_ENV: &str = "FSCI_REQUIRE_SCIPY_ORACLE";
#[derive(Debug, Clone, Serialize)]
struct SplCase {
case_id: String,
x: Vec<f64>,
y: Vec<f64>,
k: usize,
eval: Vec<f64>,
integ_a: f64,
integ_b: f64,
do_sproot: bool,
}
#[derive(Debug, Clone, Serialize)]
struct OracleQuery {
cases: Vec<SplCase>,
fsci_tcks: Vec<TckOut>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TckOut {
case_id: String,
t: Vec<f64>,
c: Vec<f64>,
k: usize,
}
#[derive(Debug, Clone, Deserialize)]
struct ScipyArm {
case_id: String,
der_t: Option<Vec<f64>>,
der_c: Option<Vec<f64>>,
der_k: Option<usize>,
der_eval: Option<Vec<f64>>,
anti_t: Option<Vec<f64>>,
anti_c: Option<Vec<f64>>,
anti_k: Option<usize>,
anti_eval: Option<Vec<f64>>,
splint_value: Option<f64>,
sproot: Option<Vec<f64>>,
}
#[derive(Debug, Clone, Deserialize)]
struct OracleResult {
arms: Vec<ScipyArm>,
}
#[derive(Debug, Clone, Serialize)]
struct CaseDiff {
case_id: String,
op: String,
abs_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,
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 spl_ops diff 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 log");
fs::write(path, json).expect("write log");
}
fn generate_cases() -> Vec<SplCase> {
let x_dense: Vec<f64> = (0..15).map(|i| (i as f64) * 0.5).collect();
let y_quadratic: Vec<f64> = x_dense.iter().map(|x| x * x).collect();
let y_sin: Vec<f64> = x_dense.iter().map(|x| x.sin()).collect();
let x_smooth: Vec<f64> = (0..20).map(|i| (i as f64) * 0.3).collect();
let y_cubic: Vec<f64> = x_smooth.iter().map(|x| x * x * x - 2.0 * x).collect();
let eval_pts_dense: Vec<f64> = (1..=12).map(|i| (i as f64) * 0.5).collect();
let eval_pts_smooth: Vec<f64> = (1..=18).map(|i| (i as f64) * 0.3).collect();
vec![
SplCase {
case_id: "quadratic".into(),
x: x_dense.clone(),
y: y_quadratic,
k: 3,
eval: eval_pts_dense.clone(),
integ_a: 1.0,
integ_b: 5.0,
do_sproot: false,
},
SplCase {
case_id: "sin".into(),
x: x_dense,
y: y_sin,
k: 3,
eval: eval_pts_dense,
integ_a: 0.5,
integ_b: 6.0,
do_sproot: true, },
SplCase {
case_id: "cubic_minus_2x".into(),
x: x_smooth,
y: y_cubic,
k: 3,
eval: eval_pts_smooth,
integ_a: 0.6,
integ_b: 5.0,
do_sproot: true, },
]
}
fn build_fsci_tcks(cases: &[SplCase]) -> Vec<TckOut> {
let mut out = Vec::with_capacity(cases.len());
for case in cases {
let Ok((t, c, k)) = splrep(&case.x, &case.y, case.k, 0.0) else {
continue;
};
out.push(TckOut {
case_id: case.case_id.clone(),
t,
c,
k,
});
}
out
}
fn scipy_oracle_or_skip(query: &OracleQuery) -> Option<OracleResult> {
let script = r#"
import json
import math
import sys
import numpy as np
from scipy import interpolate as si
def finite_or_none(arr):
out = []
for v in arr:
if not math.isfinite(float(v)):
return None
out.append(float(v))
return out
q = json.load(sys.stdin)
fsci_tcks = {tck["case_id"]: tck for tck in q["fsci_tcks"]}
arms = []
for case in q["cases"]:
cid = case["case_id"]
if cid not in fsci_tcks:
arms.append({"case_id": cid, "der_t": None, "der_c": None, "der_k": None, "der_eval": None,
"anti_t": None, "anti_c": None, "anti_k": None, "anti_eval": None,
"splint_value": None, "sproot": None})
continue
tck_in = fsci_tcks[cid]
t = np.array(tck_in["t"], dtype=float)
c = np.array(tck_in["c"], dtype=float)
k = int(tck_in["k"])
eval_pts = np.array(case["eval"], dtype=float)
a = float(case["integ_a"]); b = float(case["integ_b"])
do_sproot = bool(case["do_sproot"])
arm = {"case_id": cid}
try:
# splder returns (t, c, k)
dt, dc, dk = si.splder((t, c, k))
arm["der_t"] = finite_or_none(dt.tolist())
arm["der_c"] = finite_or_none(dc.tolist())
arm["der_k"] = int(dk)
arm["der_eval"] = finite_or_none(si.splev(eval_pts, (dt, dc, dk)).tolist())
except Exception as e:
sys.stderr.write(f"splder {cid}: {e}\n")
arm["der_t"] = None; arm["der_c"] = None; arm["der_k"] = None; arm["der_eval"] = None
try:
at, ac, ak = si.splantider((t, c, k))
arm["anti_t"] = finite_or_none(at.tolist())
arm["anti_c"] = finite_or_none(ac.tolist())
arm["anti_k"] = int(ak)
arm["anti_eval"] = finite_or_none(si.splev(eval_pts, (at, ac, ak)).tolist())
except Exception as e:
sys.stderr.write(f"splantider {cid}: {e}\n")
arm["anti_t"] = None; arm["anti_c"] = None; arm["anti_k"] = None; arm["anti_eval"] = None
try:
sv = float(si.splint(a, b, (t, c, k)))
arm["splint_value"] = sv if math.isfinite(sv) else None
except Exception as e:
sys.stderr.write(f"splint {cid}: {e}\n")
arm["splint_value"] = None
if do_sproot and k == 3:
try:
roots = si.sproot((t, c, k)).tolist()
arm["sproot"] = sorted([float(r) for r in roots if math.isfinite(r)])
except Exception as e:
sys.stderr.write(f"sproot {cid}: {e}\n")
arm["sproot"] = None
else:
arm["sproot"] = None
arms.append(arm)
print(json.dumps({"arms": arms}))
"#;
let query_json = serde_json::to_string(query).expect("serialize query");
let mut child = match fsci_conformance::scipy_oracle_command()
.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"failed to spawn python3 for spl_ops oracle: {e}"
);
eprintln!("skipping spl_ops oracle: python3 not available ({e})");
return None;
}
};
{
let stdin = child.stdin.as_mut().expect("open oracle stdin");
if let Err(err) = stdin.write_all(query_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(),
"spl_ops oracle stdin write failed: {err}; stderr: {stderr}"
);
eprintln!("skipping spl_ops oracle: stdin write failed ({err})\n{stderr}");
return None;
}
}
let output = child.wait_with_output().expect("wait for spl_ops oracle");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"spl_ops oracle failed: {stderr}"
);
eprintln!("skipping spl_ops oracle: scipy not available\n{stderr}");
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
Some(serde_json::from_str(&stdout).expect("parse spl_ops oracle JSON"))
}
fn vec_max_diff(a: &[f64], b: &[f64]) -> f64 {
if a.len() != b.len() {
return f64::INFINITY;
}
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).abs())
.fold(0.0_f64, f64::max)
}
fn scipy_tck<'a>(
t: Option<&'a [f64]>,
c: Option<&'a [f64]>,
k: Option<usize>,
) -> Option<(&'a [f64], &'a [f64], usize)> {
Some((t?, c?, k?))
}
#[test]
fn diff_interpolate_spl_ops() {
let cases = generate_cases();
let fsci_tcks = build_fsci_tcks(&cases);
let query = OracleQuery {
cases: cases.clone(),
fsci_tcks: fsci_tcks.clone(),
};
let Some(oracle) = scipy_oracle_or_skip(&query) else {
return;
};
assert_eq!(oracle.arms.len(), cases.len());
let arm_map: HashMap<String, ScipyArm> = oracle
.arms
.into_iter()
.map(|a| (a.case_id.clone(), a))
.collect();
let tck_map: HashMap<String, TckOut> = fsci_tcks
.into_iter()
.map(|t| (t.case_id.clone(), t))
.collect();
let start = Instant::now();
let mut diffs = Vec::new();
let mut max_overall = 0.0_f64;
let mut ledger = CompareLedger::new(
"diff_interpolate_spl_ops",
&[
"splder_tck",
"splder_eval",
"splantider_tck",
"splantider_eval",
"splint",
"sproot",
],
);
for case in &cases {
let arm = arm_map.get(&case.case_id).expect("validated oracle");
let tck = tck_map
.get(&case.case_id)
.expect("fsci splrep builds the tck both sides operate on");
let fsci_tck = (tck.t.clone(), tck.c.clone(), tck.k);
let der = splder(&fsci_tck).ok();
if let Some(((et, ec, ek), (dt, dc, dk))) = ledger.both(
"splder_tck",
&case.case_id,
scipy_tck(arm.der_t.as_deref(), arm.der_c.as_deref(), arm.der_k),
der.as_ref(),
) {
let t_diff = vec_max_diff(dt, et);
let c_diff = vec_max_diff(dc, ec);
let k_diff = if *dk == ek { 0.0 } else { f64::INFINITY };
let abs_d = t_diff.max(c_diff).max(k_diff);
let no_nan = dt.iter().chain(dc).all(|v| !v.is_nan());
let pass = abs_d <= TCK_TOL && no_nan;
ledger.compared("splder_tck", &case.case_id, pass);
max_overall = max_overall.max(abs_d);
diffs.push(CaseDiff {
case_id: format!("{}_splder_tck", case.case_id),
op: "splder_tck".into(),
abs_diff: abs_d,
pass,
});
}
let der_eval = der.as_ref().and_then(|d| splev(&case.eval, d).ok());
if let Some((ev_exp, ev)) = ledger.slices(
"splder_eval",
&case.case_id,
arm.der_eval.as_deref(),
der_eval.as_deref(),
) {
let abs_d = vec_max_diff(ev, ev_exp);
max_overall = max_overall.max(abs_d);
ledger.compared("splder_eval", &case.case_id, abs_d <= VAL_TOL);
diffs.push(CaseDiff {
case_id: format!("{}_splder_eval", case.case_id),
op: "splder_eval".into(),
abs_diff: abs_d,
pass: abs_d <= VAL_TOL,
});
}
let anti = splantider(&fsci_tck).ok();
if let Some(((et, ec, ek), (at, ac, ak))) = ledger.both(
"splantider_tck",
&case.case_id,
scipy_tck(arm.anti_t.as_deref(), arm.anti_c.as_deref(), arm.anti_k),
anti.as_ref(),
) {
let t_diff = vec_max_diff(at, et);
let c_diff = vec_max_diff(ac, ec);
let k_diff = if *ak == ek { 0.0 } else { f64::INFINITY };
let abs_d = t_diff.max(c_diff).max(k_diff);
let no_nan = at.iter().chain(ac).all(|v| !v.is_nan());
let pass = abs_d <= TCK_TOL && no_nan;
ledger.compared("splantider_tck", &case.case_id, pass);
max_overall = max_overall.max(abs_d);
diffs.push(CaseDiff {
case_id: format!("{}_splantider_tck", case.case_id),
op: "splantider_tck".into(),
abs_diff: abs_d,
pass,
});
}
let anti_eval = anti.as_ref().and_then(|a| splev(&case.eval, a).ok());
if let Some((ev_exp, ev)) = ledger.slices(
"splantider_eval",
&case.case_id,
arm.anti_eval.as_deref(),
anti_eval.as_deref(),
) {
let off_actual = ev[0];
let off_expected = ev_exp[0];
let shifted_actual: Vec<f64> = ev.iter().map(|v| v - off_actual).collect();
let shifted_expected: Vec<f64> = ev_exp.iter().map(|v| v - off_expected).collect();
let abs_d = vec_max_diff(&shifted_actual, &shifted_expected);
max_overall = max_overall.max(abs_d);
ledger.compared("splantider_eval", &case.case_id, abs_d <= VAL_TOL);
diffs.push(CaseDiff {
case_id: format!("{}_splantider_eval_shifted", case.case_id),
op: "splantider_eval".into(),
abs_diff: abs_d,
pass: abs_d <= VAL_TOL,
});
}
if let Some((expected, value)) = ledger.pair(
"splint",
&case.case_id,
arm.splint_value,
splint(case.integ_a, case.integ_b, &fsci_tck).ok(),
) {
let abs_d = (value - expected).abs();
max_overall = max_overall.max(abs_d);
ledger.compared("splint", &case.case_id, abs_d <= VAL_TOL);
diffs.push(CaseDiff {
case_id: format!("{}_splint", case.case_id),
op: "splint".into(),
abs_diff: abs_d,
pass: abs_d <= VAL_TOL,
});
}
if case.do_sproot {
let found = sproot(&fsci_tck).ok().map(|mut found| {
found.sort_by(f64::total_cmp);
found
});
if let Some((expected, found)) = ledger.slices(
"sproot",
&case.case_id,
arm.sproot.as_deref(),
found.as_deref(),
) {
let abs_d = vec_max_diff(found, expected);
max_overall = max_overall.max(abs_d);
ledger.compared("sproot", &case.case_id, abs_d <= VAL_TOL);
diffs.push(CaseDiff {
case_id: format!("{}_sproot", case.case_id),
op: "sproot".into(),
abs_diff: abs_d,
pass: abs_d <= VAL_TOL,
});
}
}
}
let all_pass = diffs.iter().all(|d| d.pass);
let log = DiffLog {
test_id: "diff_interpolate_spl_ops".into(),
category: "fsci_interpolate splder/splantider vs scipy.interpolate".into(),
case_count: diffs.len(),
compared: ledger.counts().clone(),
max_abs_diff: max_overall,
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!("{} mismatch: {} abs_diff={}", d.op, d.case_id, d.abs_diff);
}
}
assert!(
all_pass,
"spl_ops conformance failed: {} cases, max_diff={}",
diffs.len(),
max_overall
);
ledger.finish(cases.iter().filter(|c| c.do_sproot).count());
}