mod common;
use std::sync::LazyLock;
use bigdecimal::BigDecimal;
use chrono::{DateTime, TimeDelta, Utc};
use common::{Rng, epoch, exact, exact_with_lebesgue, to_f64};
use rayon::prelude::*;
use splimes::{Backend, Interpolator, Point, PointKind, Precision, Resolution, Spline};
const fn bound(precision: Precision, _degree: usize) -> f64 {
match precision {
Precision::F64 => 1e-13,
_ => 1e-5,
}
}
const SPLINES: [Spline; 6] = [Spline::Linear, Spline::Quadratic, Spline::Cubic, Spline::Polynomial(5, None), Spline::Polynomial(4, Some(0.5)), Spline::Polynomial(8, None)];
const GRID_POINTS: i32 = 300;
struct Fixture {
label: String,
spline: Spline,
points: Vec<Point>,
start: DateTime<Utc>,
end: DateTime<Utc>,
resolution: Resolution,
expected: Vec<(f64, f64)>,
range: f64,
}
static FIXTURES: LazyLock<Vec<Fixture>> = LazyLock::new(|| {
let mut jobs = Vec::new();
for (label, points) in series() {
let knots = common::distinct(&points);
let n = knots.len();
let o: Vec<i128> = knots.iter().map(|p| common::nanos_between(p.timestamp, knots[0].timestamp)).collect();
let first = knots[0].timestamp;
let last = knots[n - 1].timestamp;
let min_gap = o.windows(2).map(|w| w[1] - w[0]).min().unwrap_or(1_000_000_000).max(20);
let resolution = Resolution::ALL.iter().copied().rev().find(|r| i128::from(r.step_nanos()) * 20 <= min_gap).unwrap_or(Resolution::Nanoseconds);
let step = resolution.step();
let width = step * (GRID_POINTS - 1);
let span = o[n - 1].max(1_000);
let middle = first + TimeDelta::nanoseconds((span / 2) as i64);
let densest = o.windows(2).enumerate().min_by_key(|(_, w)| w[1] - w[0]).map_or(first, |(i, _)| knots[i].timestamp);
let (min, max) = common::value_range(&knots);
let range = to_f64(&(&max - &min));
let range = if range > 0.0 { range } else { to_f64(&max).abs().max(f64::MIN_POSITIVE) };
for spline in SPLINES {
let m = spline.fallback_for(n).min_points().min(n);
let edge_pad = |a: i128, b: i128| TimeDelta::nanoseconds(if m >= 2 { (2 * (b - a) / (m as i128 - 1)) as i64 } else { 2_000 });
let (pad_start, pad_end) = (edge_pad(o[0], o[m - 1]), edge_pad(o[n - m], o[n - 1]));
let far = last + pad_end * 50;
for (start, end) in [(first - pad_start, first - pad_start + width), (middle, middle + width), (densest, densest + width), (last + pad_end - width, last + pad_end), (far, far + width)] {
jobs.push((label.clone(), spline, points.clone(), start, end, resolution, range));
}
}
}
jobs.into_par_iter()
.map(|(label, spline, points, start, end, resolution, range)| {
let expected = (0..GRID_POINTS)
.map(|k| {
let (value, lebesgue) = exact_with_lebesgue(&points, spline, start + resolution.step() * k);
(to_f64(&value), lebesgue)
})
.collect();
Fixture { label, spline, points, start, end, resolution, expected, range }
})
.collect()
});
fn series() -> Vec<(String, Vec<Point>)> {
let mut rng = Rng::new(0x5EED);
let spacings: [(&str, i64); 4] = [("µs", 1_000), ("s", 1_000_000_000), ("h", 3_600_000_000_000), ("30d", 2_592_000_000_000_000)];
let mut out = Vec::new();
for &(spacing_name, spacing) in &spacings {
for n in [1, 2, 3, 4, 5, 9, 40, 2_000] {
for shape in ["walk", "offset", "tiny", "huge"] {
for irregular in [false, true] {
let mut t = epoch();
let mut v = 0.0_f64;
let points = (0..n)
.map(|_| {
let gap = if irregular { (spacing as f64 * 2.95_f64.mul_add(rng.unit(), 0.05)) as i64 } else { spacing };
t += TimeDelta::nanoseconds(gap.max(1));
v += rng.unit() - 0.5;
let value = match shape {
"walk" => v,
"offset" => v.mul_add(1e-3, 1e9),
"tiny" => v * 1e-200,
_ => v * 1e200,
};
Point::new(t, format!("{value:e}").parse().expect("finite"))
})
.collect();
out.push((format!("{spacing_name}/{n}/{shape}/{}", if irregular { "irregular" } else { "regular" }), points));
}
}
}
}
let at = |secs: f64| epoch() + TimeDelta::nanoseconds((secs * 1e9) as i64);
let point = |secs: f64, value: f64| Point::new(at(secs), format!("{value:e}").parse().expect("finite"));
let mut t = 0.0;
let burst = (0..40)
.map(|i| {
t += if i < 32 { 3.0 } else { 0.05 };
point(t, (t / 10.0).sin() * 10.0 + t)
})
.collect();
out.push(("burst/smooth".to_owned(), burst));
let mut t = 0.0;
let geometric = [0.0, 216_000.0, 3_600.0, 60.0, 1.0, 33.9, 1.0, 1.0, 1.0]
.iter()
.enumerate()
.map(|(i, gap)| {
t += gap;
point(t, if i % 2 == 0 { 1.0 } else { -1.0 })
})
.collect();
out.push(("geometric/alternating".to_owned(), geometric));
out
}
#[derive(Default, Clone, Copy)]
struct Worst {
inside: f64,
outside: f64,
}
fn sweep(backend: Backend, precision: Precision, f64_api: bool) -> Vec<(Spline, Worst)> {
let mut worst = [Worst::default(); SPLINES.len()];
for f in FIXTURES.iter() {
let interpolator = Interpolator::new(f.spline, f.resolution).backend(backend).gpu_precision(precision);
let overflowed = |timestamp| {
let exact = to_f64(&exact(&f.points, f.spline, timestamp));
assert!(!exact.is_finite() || exact.abs() > 0.999 * f64::MAX, "{} {}: NonFiniteResult at {timestamp}, but the exact value {exact} is finite", f.label, f.spline);
};
let (got, kinds): (Vec<f64>, Vec<PointKind>) = if f64_api {
let ts: Vec<_> = f.points.iter().map(|p| p.timestamp).collect();
let vs: Vec<_> = f.points.iter().map(|p| to_f64(&p.value)).collect();
match interpolator.run_f64(&ts, &vs, f.start, f.end) {
Ok(r) => (r.values().to_vec(), r.kinds().to_vec()),
Err(splimes::Error::NonFiniteResult { timestamp }) => {
overflowed(timestamp);
continue;
}
Err(e) => panic!("{} {}: {e}", f.label, f.spline),
}
} else {
match interpolator.run(&f.points, f.start, f.end) {
Ok(r) => {
for ((t, v), k) in r.timestamps().iter().zip(r.values()).zip(r.kinds()) {
if *k == PointKind::Raw {
let input = &common::distinct(&f.points).into_iter().find(|p| p.timestamp == *t).expect("raw point has an input").value;
assert_eq!(v, input, "{} {}: raw point at {t} must be the input, exactly", f.label, f.spline);
}
}
(r.values().iter().map(to_f64).collect(), r.kinds().to_vec())
}
Err(splimes::Error::NonFiniteResult { timestamp }) => {
overflowed(timestamp);
continue;
}
Err(e) => panic!("{} {}: {e}", f.label, f.spline),
}
};
assert_eq!(got.len(), f.expected.len(), "{} {}: grid length", f.label, f.spline);
let w = &mut worst[SPLINES.iter().position(|s| *s == f.spline).expect("known spline")];
for ((&g, &(e, lebesgue)), &kind) in got.iter().zip(&f.expected).zip(&kinds) {
assert!(g.is_finite(), "{} {}: a non-finite value came back as a value", f.label, f.spline);
let err = ((g - e).abs() - f64::EPSILON * e.abs()).max(0.0) / (f.range * lebesgue);
assert!(!err.is_nan(), "{} {}: NaN error against {e}", f.label, f.spline);
let slot = match kind {
PointKind::Raw => continue,
PointKind::Interpolated => &mut w.inside,
PointKind::Extrapolated => &mut w.outside,
};
if err > *slot {
*slot = err;
if std::env::var_os("CONTRACT_WORST").is_some() {
eprintln!("worst so far {} {} {kind:?}: {err:.2e} (Λ {lebesgue:.2e}, got {g}, exact {e})", f.label, f.spline);
}
}
}
}
SPLINES.into_iter().zip(worst).collect()
}
fn report(name: &str, rows: &[(Spline, Worst)], precision: Precision) {
println!("\n{name}");
println!("{:<44} {:>10} {:>10} {:>10}", "method", "inside", "outside", "bound");
for (spline, w) in rows {
println!("{:<44} {:>10.2e} {:>10.2e} {:>10.0e}", spline.to_string(), w.inside, w.outside, bound(precision, spline.degree()));
}
for (spline, w) in rows {
let bound = bound(precision, spline.degree());
assert!(w.inside <= bound, "{name}: {spline} interpolation error {:e} exceeds the published bound {bound:e}", w.inside);
assert!(w.outside <= bound, "{name}: {spline} extrapolation error {:e} exceeds the published bound {bound:e}", w.outside);
}
}
#[test]
fn cpu_meets_the_contract() {
report("Cpu, BigDecimal", &sweep(Backend::Cpu, Precision::F64, false), Precision::F64);
}
#[test]
fn parallel_meets_the_contract() {
report("Parallel, f64", &sweep(Backend::Parallel, Precision::F64, true), Precision::F64);
}
#[test]
fn gpu_f64_meets_the_contract() {
let Some(info) = common::gpu_or_skip("gpu_f64_meets_the_contract") else { return };
if !info.supports_f64 {
eprintln!("gpu_f64_meets_the_contract: skipped, {} has no f64 support", info.name);
return;
}
report(&format!("Gpu f64 on {} ({})", info.name, info.api), &sweep(Backend::Gpu, Precision::F64, true), Precision::F64);
}
#[test]
fn gpu_f32_meets_the_contract() {
let Some(info) = common::gpu_or_skip("gpu_f32_meets_the_contract") else { return };
report(&format!("Gpu f32 on {} ({})", info.name, info.api), &sweep(Backend::Gpu, Precision::F32, true), Precision::F32);
}
#[test]
fn long_series_keep_the_bound() {
const N: i64 = 2_000_000;
let timestamps: Vec<DateTime<Utc>> = (0..N).map(|i| epoch() + TimeDelta::seconds(i)).collect();
let values: Vec<f64> = (0..N).map(|i| (i % 2) as f64).collect();
let points: Vec<Point> = timestamps.iter().zip(&values).map(|(&t, &v)| Point::new(t, BigDecimal::from(v as i64))).collect();
let tail = &points[points.len() - 40..];
let (start, end) = (timestamps[timestamps.len() - 4], timestamps[timestamps.len() - 1] + TimeDelta::seconds(1));
let mut configs = vec![(Backend::Cpu, Precision::F64), (Backend::Parallel, Precision::F64)];
if let Some(info) = common::gpu_or_skip("long_series_keep_the_bound (GPU part)") {
if info.supports_f64 {
configs.push((Backend::Gpu, Precision::F64));
}
configs.push((Backend::Gpu, Precision::F32));
}
for spline in [Spline::Linear, Spline::Cubic, Spline::Polynomial(5, None)] {
for &(backend, precision) in &configs {
let out = Interpolator::new(spline, Resolution::Milliseconds).backend(backend).gpu_precision(precision).run_f64(×tamps, &values, start, end).expect("runs");
let mut worst = 0.0_f64;
for ((&t, &got), kind) in out.timestamps().iter().zip(out.values()).zip(out.kinds()) {
assert!(got.is_finite(), "{spline} on {backend} {precision}: non-finite value at {t}");
if *kind == PointKind::Interpolated {
let expected = to_f64(&exact(tail, spline, t));
worst = worst.max(((got - expected).abs() - f64::EPSILON * expected.abs()).max(0.0));
}
}
let bound = bound(precision, spline.degree());
println!("{N} knots, {spline} on {backend} {precision}: {worst:.2e} (bound {bound:.0e})");
assert!(worst <= bound, "{spline} on {backend} {precision}: error {worst:e} exceeds {bound:e} two million knots in");
}
}
}
#[test]
fn nearly_coincident_knots_stay_distinct() {
let day = TimeDelta::days(1);
let t0 = epoch();
let timestamps = vec![t0, t0 + day * 200, t0 + day * 200 + TimeDelta::nanoseconds(1), t0 + day * 400, t0 + day * 600];
let values = vec![0.0, 1.0, 1.0, 0.0, 1.0];
let points: Vec<Point> = timestamps.iter().zip(&values).map(|(&t, &v)| Point::new(t, BigDecimal::from(v as i64))).collect();
let mut configs = vec![(Backend::Cpu, Precision::F64), (Backend::Parallel, Precision::F64)];
if let Some(info) = common::gpu_or_skip("nearly_coincident_knots_stay_distinct (GPU part)")
&& info.supports_f64
{
configs.push((Backend::Gpu, Precision::F64));
}
for (backend, precision) in configs {
let run = |spline| Interpolator::new(spline, Resolution::Hours).backend(backend).gpu_precision(precision).run_f64(×tamps, &values, t0, t0 + day * 600);
let linear = run(Spline::Linear).expect("linear runs");
for ((&t, &got), kind) in linear.timestamps().iter().zip(linear.values()).zip(linear.kinds()) {
if *kind != PointKind::Raw {
let expected = to_f64(&exact(&points, Spline::Linear, t));
assert!((got - expected).abs() <= 1e-11, "linear on {backend} at {t}: {got} vs {expected}");
}
}
let cubic = run(Spline::Cubic).expect("cubic runs");
assert!(cubic.values().iter().all(|v| v.is_finite()), "cubic on {backend}");
}
}
#[test]
fn far_bounded_extrapolation_lands_on_the_right_bound() {
let t0 = epoch();
let timestamps: Vec<DateTime<Utc>> = (0..9).map(|i| t0 + TimeDelta::seconds(i)).collect();
let rising: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.5];
let falling: Vec<f64> = rising.iter().map(|v| -v).collect();
let mut configs = vec![(Backend::Cpu, Precision::F64)];
if let Some(info) = common::gpu_or_skip("far_bounded_extrapolation_lands_on_the_right_bound (GPU part)") {
if info.supports_f64 {
configs.push((Backend::Gpu, Precision::F64));
}
configs.push((Backend::Gpu, Precision::F32));
}
for (values, lo, hi) in [(&rising, -4.25, 12.75), (&falling, -12.75, 4.25)] {
for far in [TimeDelta::days(3), TimeDelta::days(7), TimeDelta::days(365)] {
let at = timestamps[8] + far;
let points: Vec<Point> = timestamps.iter().zip(values.iter()).map(|(&t, &v)| Point::new(t, format!("{v:e}").parse().expect("finite"))).collect();
let (exact_free, lebesgue) = exact_with_lebesgue(&points, Spline::Polynomial(8, None), at);
let exact_free = to_f64(&exact_free);
for &(backend, precision) in &configs {
let bounded = Interpolator::new(Spline::Polynomial(8, Some(0.5)), Resolution::Seconds).backend(backend).gpu_precision(precision).run_f64(×tamps, values, at, at).expect("runs").values()[0];
let expected = if values[8] > 0.0 { hi } else { lo };
assert_eq!(bounded, expected, "bounded, {far} out, on {backend} {precision}");
assert!(bounded >= lo && bounded <= hi);
let free = Interpolator::new(Spline::Polynomial(8, None), Resolution::Seconds).backend(backend).gpu_precision(precision).run_f64(×tamps, values, at, at).expect("runs").values()[0];
let allowed = bound(precision, 8) * 8.5 * lebesgue + f64::EPSILON * exact_free.abs();
assert!((free - exact_free).abs() <= allowed, "unbounded, {far} out, on {backend} {precision}: {free} vs exact {exact_free}");
assert_eq!(free.signum(), exact_free.signum());
}
}
}
}