Skip to main content

cjc_runtime/
interpolate.rs

1//! Interpolation & Curve Fitting
2//!
3//! Provides deterministic interpolation and polynomial fitting primitives:
4//! - Piecewise linear interpolation
5//! - Nearest-neighbor interpolation
6//! - Least-squares polynomial fitting (via Vandermonde + QR)
7//! - Polynomial evaluation (Horner's method)
8//! - Natural cubic spline interpolation
9//!
10//! # Determinism Contract
11//!
12//! All summation reductions use `BinnedAccumulatorF64` for bit-identical results
13//! across platforms and execution orders. No `HashMap` or non-deterministic
14//! iteration is used.
15
16use crate::accumulator::BinnedAccumulatorF64;
17
18// ---------------------------------------------------------------------------
19// Helpers
20// ---------------------------------------------------------------------------
21
22/// Binary search for the interval containing `xq` in a sorted slice `xs`.
23/// Returns index `i` such that `xs[i] <= xq < xs[i+1]`, clamped to valid range.
24fn find_interval(xs: &[f64], xq: f64) -> usize {
25    debug_assert!(xs.len() >= 2);
26    if xq <= xs[0] {
27        return 0;
28    }
29    if xq >= xs[xs.len() - 1] {
30        return xs.len() - 2;
31    }
32    // Standard binary search for the left boundary.
33    let mut lo = 0usize;
34    let mut hi = xs.len() - 1;
35    while lo + 1 < hi {
36        let mid = lo + (hi - lo) / 2;
37        if xs[mid] <= xq {
38            lo = mid;
39        } else {
40            hi = mid;
41        }
42    }
43    lo
44}
45
46/// Validate that x_data and y_data are non-empty, same length, and x_data is
47/// sorted in strictly ascending order.
48fn validate_sorted_data(x_data: &[f64], y_data: &[f64]) -> Result<(), String> {
49    if x_data.is_empty() {
50        return Err("interpolation requires at least one data point".to_string());
51    }
52    if x_data.len() != y_data.len() {
53        return Err(format!(
54            "x_data length ({}) must equal y_data length ({})",
55            x_data.len(),
56            y_data.len()
57        ));
58    }
59    if x_data.len() < 2 {
60        return Err("interpolation requires at least two data points".to_string());
61    }
62    for i in 1..x_data.len() {
63        if x_data[i] <= x_data[i - 1] {
64            return Err(format!(
65                "x_data must be strictly ascending; x_data[{}] = {} <= x_data[{}] = {}",
66                i,
67                x_data[i],
68                i - 1,
69                x_data[i - 1]
70            ));
71        }
72    }
73    Ok(())
74}
75
76// ---------------------------------------------------------------------------
77// 1. Linear Interpolation
78// ---------------------------------------------------------------------------
79
80/// Piecewise linear interpolation.
81///
82/// `x_data` must be sorted in strictly ascending order. Values outside the data
83/// range are extrapolated as constant (clamped to the boundary y-values).
84pub fn interp1d_linear(
85    x_data: &[f64],
86    y_data: &[f64],
87    x_query: &[f64],
88) -> Result<Vec<f64>, String> {
89    validate_sorted_data(x_data, y_data)?;
90
91    let n = x_data.len();
92    let result: Vec<f64> = x_query
93        .iter()
94        .map(|&xq| {
95            if xq <= x_data[0] {
96                return y_data[0];
97            }
98            if xq >= x_data[n - 1] {
99                return y_data[n - 1];
100            }
101            let i = find_interval(x_data, xq);
102            let dx = x_data[i + 1] - x_data[i];
103            if dx.abs() < 1e-300 {
104                return y_data[i];
105            }
106            let t = (xq - x_data[i]) / dx;
107            // Linear blend: y_i * (1 - t) + y_{i+1} * t
108            y_data[i] + t * (y_data[i + 1] - y_data[i])
109        })
110        .collect();
111    Ok(result)
112}
113
114// ---------------------------------------------------------------------------
115// 2. Nearest-Neighbor Interpolation
116// ---------------------------------------------------------------------------
117
118/// Nearest-neighbor interpolation.
119///
120/// `x_data` must be sorted in strictly ascending order. For query points exactly
121/// at the midpoint between two data points, the lower-index point is chosen
122/// (deterministic tie-breaking).
123pub fn interp1d_nearest(
124    x_data: &[f64],
125    y_data: &[f64],
126    x_query: &[f64],
127) -> Result<Vec<f64>, String> {
128    validate_sorted_data(x_data, y_data)?;
129
130    let n = x_data.len();
131    let result: Vec<f64> = x_query
132        .iter()
133        .map(|&xq| {
134            if xq <= x_data[0] {
135                return y_data[0];
136            }
137            if xq >= x_data[n - 1] {
138                return y_data[n - 1];
139            }
140            let i = find_interval(x_data, xq);
141            let d_left = (xq - x_data[i]).abs();
142            let d_right = (x_data[i + 1] - xq).abs();
143            // Tie-break: prefer lower index (<=)
144            if d_left <= d_right {
145                y_data[i]
146            } else {
147                y_data[i + 1]
148            }
149        })
150        .collect();
151    Ok(result)
152}
153
154// ---------------------------------------------------------------------------
155// 3. Polynomial Fitting (Vandermonde + QR)
156// ---------------------------------------------------------------------------
157
158/// Least-squares polynomial fit of given degree.
159///
160/// Returns coefficients `[a0, a1, ..., a_degree]` such that
161/// `p(x) = a0 + a1*x + a2*x^2 + ... + a_degree*x^degree`.
162///
163/// Internally constructs the Vandermonde matrix and solves via QR decomposition
164/// (Modified Gram-Schmidt) followed by back-substitution. All reductions use
165/// `BinnedAccumulatorF64`.
166pub fn polyfit(x: &[f64], y: &[f64], degree: usize) -> Result<Vec<f64>, String> {
167    if x.len() != y.len() {
168        return Err(format!(
169            "x length ({}) must equal y length ({})",
170            x.len(),
171            y.len()
172        ));
173    }
174    let m = x.len(); // number of data points (rows)
175    let n = degree + 1; // number of coefficients (columns)
176    if m < n {
177        return Err(format!(
178            "need at least {} data points for degree {} fit, got {}",
179            n, degree, m
180        ));
181    }
182
183    // Build Vandermonde matrix V (m x n), column-major for QR convenience.
184    // V[i][j] = x[i]^j
185    let mut v_cols: Vec<Vec<f64>> = Vec::with_capacity(n);
186    for j in 0..n {
187        let col: Vec<f64> = x
188            .iter()
189            .map(|&xi| {
190                if j == 0 {
191                    1.0
192                } else {
193                    // Compute xi^j iteratively (no pow for determinism)
194                    let mut val = 1.0;
195                    for _ in 0..j {
196                        val *= xi;
197                    }
198                    val
199                }
200            })
201            .collect();
202        v_cols.push(col);
203    }
204
205    // QR decomposition via Modified Gram-Schmidt (column-major).
206    // q_cols will hold the orthonormal columns, r is upper triangular (n x n).
207    let mut q_cols = v_cols; // we modify in place
208    let mut r = vec![0.0f64; n * n]; // row-major: r[i*n + j]
209
210    for j in 0..n {
211        // Orthogonalize column j against previous columns
212        for i in 0..j {
213            let dot = {
214                let mut acc = BinnedAccumulatorF64::new();
215                for k in 0..m {
216                    acc.add(q_cols[i][k] * q_cols[j][k]);
217                }
218                acc.finalize()
219            };
220            r[i * n + j] = dot;
221            for k in 0..m {
222                q_cols[j][k] -= dot * q_cols[i][k];
223            }
224        }
225        // Compute norm of column j
226        let norm = {
227            let mut acc = BinnedAccumulatorF64::new();
228            for k in 0..m {
229                acc.add(q_cols[j][k] * q_cols[j][k]);
230            }
231            acc.finalize()
232        }
233        .sqrt();
234
235        r[j * n + j] = norm;
236        if norm < 1e-15 {
237            return Err("polyfit: Vandermonde matrix is rank-deficient".to_string());
238        }
239        for k in 0..m {
240            q_cols[j][k] /= norm;
241        }
242    }
243
244    // Compute Q^T * y
245    let mut qty = vec![0.0f64; n];
246    for j in 0..n {
247        let mut acc = BinnedAccumulatorF64::new();
248        for k in 0..m {
249            acc.add(q_cols[j][k] * y[k]);
250        }
251        qty[j] = acc.finalize();
252    }
253
254    // Back-substitution: R * coeffs = Q^T * y
255    let mut coeffs = vec![0.0f64; n];
256    for j in (0..n).rev() {
257        let mut acc = BinnedAccumulatorF64::new();
258        for k in (j + 1)..n {
259            acc.add(r[j * n + k] * coeffs[k]);
260        }
261        coeffs[j] = (qty[j] - acc.finalize()) / r[j * n + j];
262    }
263
264    Ok(coeffs)
265}
266
267// ---------------------------------------------------------------------------
268// 4. Polynomial Evaluation (Horner's Method)
269// ---------------------------------------------------------------------------
270
271/// Evaluate polynomial at given points using Horner's method.
272///
273/// `coeffs` = `[a0, a1, ..., an]` where `p(x) = a0 + a1*x + ... + an*x^n`.
274/// Horner form: `p(x) = a0 + x*(a1 + x*(a2 + ... + x*an))`.
275pub fn polyval(coeffs: &[f64], x: &[f64]) -> Vec<f64> {
276    if coeffs.is_empty() {
277        return vec![0.0; x.len()];
278    }
279    x.iter()
280        .map(|&xi| {
281            // Horner's: start from highest degree coefficient
282            let mut result = coeffs[coeffs.len() - 1];
283            for k in (0..coeffs.len() - 1).rev() {
284                result = result * xi + coeffs[k];
285            }
286            result
287        })
288        .collect()
289}
290
291// ---------------------------------------------------------------------------
292// 5. Cubic Spline
293// ---------------------------------------------------------------------------
294
295/// Natural cubic spline representation.
296///
297/// For each interval `[x[i], x[i+1]]` the spline is:
298/// ```text
299/// S_i(t) = a[i] + b[i]*t + c[i]*t^2 + d[i]*t^3
300/// ```
301/// where `t = x_query - x[i]`.
302pub struct CubicSpline {
303    /// Knot positions (sorted, length n).
304    pub x: Vec<f64>,
305    /// Constant coefficients (= y values at knots), length n-1.
306    pub a: Vec<f64>,
307    /// Linear coefficients, length n-1.
308    pub b: Vec<f64>,
309    /// Quadratic coefficients, length n-1.
310    pub c: Vec<f64>,
311    /// Cubic coefficients, length n-1.
312    pub d: Vec<f64>,
313}
314
315/// Construct a natural cubic spline (second derivative = 0 at boundaries).
316///
317/// Solves the tridiagonal system for the second-derivative values using the
318/// Thomas algorithm. All reductions use `BinnedAccumulatorF64`.
319pub fn spline_cubic_natural(x: &[f64], y: &[f64]) -> Result<CubicSpline, String> {
320    validate_sorted_data(x, y)?;
321
322    let n = x.len(); // number of data points
323    let nm1 = n - 1; // number of intervals
324
325    // Step 1: Compute interval widths h[i] and divided differences
326    let h: Vec<f64> = (0..nm1).map(|i| x[i + 1] - x[i]).collect();
327
328    // Step 2: Set up the tridiagonal system for second derivatives (c_i).
329    // For natural spline: c[0] = 0, c[n-1] = 0.
330    // Interior equations (i = 1..n-2):
331    //   h[i-1]*c[i-1] + 2*(h[i-1]+h[i])*c[i] + h[i]*c[i+1]
332    //     = 3*((y[i+1]-y[i])/h[i] - (y[i]-y[i-1])/h[i-1])
333
334    // We only solve for c[1..n-2] (the interior points).
335    // With natural BC: c[0] = 0 and c[n-1] = 0.
336
337    let mut c_all = vec![0.0f64; n]; // second-derivative-like values
338
339    if n > 2 {
340        let interior = n - 2; // number of interior unknowns
341
342        // Build tridiagonal: lower (a_tri), diagonal (b_tri), upper (c_tri), rhs (d_tri)
343        let mut a_tri = vec![0.0f64; interior]; // sub-diagonal
344        let mut b_tri = vec![0.0f64; interior]; // main diagonal
345        let mut c_tri = vec![0.0f64; interior]; // super-diagonal
346        let mut d_tri = vec![0.0f64; interior]; // right-hand side
347
348        for i in 0..interior {
349            let ii = i + 1; // index into original arrays
350            a_tri[i] = h[ii - 1];
351            b_tri[i] = 2.0 * (h[ii - 1] + h[ii]);
352            c_tri[i] = h[ii];
353            d_tri[i] = 3.0 * ((y[ii + 1] - y[ii]) / h[ii] - (y[ii] - y[ii - 1]) / h[ii - 1]);
354        }
355
356        // Thomas algorithm (tridiagonal solver)
357        // Forward sweep
358        for i in 1..interior {
359            if b_tri[i - 1].abs() < 1e-300 {
360                return Err("spline_cubic_natural: zero pivot in Thomas algorithm".to_string());
361            }
362            let w = a_tri[i] / b_tri[i - 1];
363            b_tri[i] -= w * c_tri[i - 1];
364            d_tri[i] -= w * d_tri[i - 1];
365        }
366
367        // Back substitution
368        if b_tri[interior - 1].abs() < 1e-300 {
369            return Err("spline_cubic_natural: zero pivot in Thomas algorithm".to_string());
370        }
371        c_all[interior] = d_tri[interior - 1] / b_tri[interior - 1]; // c_all[n-2]
372        for i in (0..interior - 1).rev() {
373            c_all[i + 1] = (d_tri[i] - c_tri[i] * c_all[i + 2]) / b_tri[i];
374        }
375    }
376    // c_all[0] = 0 and c_all[n-1] = 0 (natural boundary conditions)
377
378    // Step 3: Compute spline coefficients for each interval.
379    let mut a_coeff = Vec::with_capacity(nm1);
380    let mut b_coeff = Vec::with_capacity(nm1);
381    let mut c_coeff = Vec::with_capacity(nm1);
382    let mut d_coeff = Vec::with_capacity(nm1);
383
384    for i in 0..nm1 {
385        a_coeff.push(y[i]);
386        b_coeff.push(
387            (y[i + 1] - y[i]) / h[i] - h[i] * (2.0 * c_all[i] + c_all[i + 1]) / 3.0,
388        );
389        c_coeff.push(c_all[i]);
390        d_coeff.push((c_all[i + 1] - c_all[i]) / (3.0 * h[i]));
391    }
392
393    Ok(CubicSpline {
394        x: x.to_vec(),
395        a: a_coeff,
396        b: b_coeff,
397        c: c_coeff,
398        d: d_coeff,
399    })
400}
401
402/// Evaluate a cubic spline at query points.
403///
404/// Uses binary search to locate the interval for each query point. Points
405/// outside the data range are clamped to the boundary intervals.
406pub fn spline_eval(spline: &CubicSpline, x_query: &[f64]) -> Result<Vec<f64>, String> {
407    if spline.x.len() < 2 {
408        return Err("spline must have at least two knots".to_string());
409    }
410
411    let result: Vec<f64> = x_query
412        .iter()
413        .map(|&xq| {
414            let i = find_interval(&spline.x, xq);
415            let t = xq - spline.x[i];
416            // Horner-like evaluation: a + t*(b + t*(c + t*d))
417            spline.a[i] + t * (spline.b[i] + t * (spline.c[i] + t * spline.d[i]))
418        })
419        .collect();
420    Ok(result)
421}
422
423// ---------------------------------------------------------------------------
424// Tests
425// ---------------------------------------------------------------------------
426
427#[cfg(test)]
428mod interpolate_tests {
429    use super::*;
430
431    const EPS: f64 = 1e-12;
432
433    // --- Linear Interpolation ---
434
435    #[test]
436    fn linear_interp_exact_at_data_points() {
437        // y = 2x + 1 at x = [0, 1, 2, 3, 4]
438        let x_data = vec![0.0, 1.0, 2.0, 3.0, 4.0];
439        let y_data: Vec<f64> = x_data.iter().map(|&x| 2.0 * x + 1.0).collect();
440        let result = interp1d_linear(&x_data, &y_data, &x_data).unwrap();
441        for (i, &r) in result.iter().enumerate() {
442            assert!(
443                (r - y_data[i]).abs() < EPS,
444                "at x={}, expected {}, got {}",
445                x_data[i],
446                y_data[i],
447                r
448            );
449        }
450    }
451
452    #[test]
453    fn linear_interp_between_points() {
454        let x_data = vec![0.0, 1.0, 2.0, 3.0, 4.0];
455        let y_data: Vec<f64> = x_data.iter().map(|&x| 2.0 * x + 1.0).collect();
456        let x_query = vec![0.5, 1.5, 2.5, 3.5];
457        let result = interp1d_linear(&x_data, &y_data, &x_query).unwrap();
458        for (i, &xq) in x_query.iter().enumerate() {
459            let expected = 2.0 * xq + 1.0;
460            assert!(
461                (result[i] - expected).abs() < EPS,
462                "at x={}, expected {}, got {}",
463                xq,
464                expected,
465                result[i]
466            );
467        }
468    }
469
470    #[test]
471    fn linear_interp_extrapolation_clamps() {
472        let x_data = vec![1.0, 2.0, 3.0];
473        let y_data = vec![10.0, 20.0, 30.0];
474        let x_query = vec![-5.0, 0.0, 4.0, 100.0];
475        let result = interp1d_linear(&x_data, &y_data, &x_query).unwrap();
476        assert!((result[0] - 10.0).abs() < EPS); // clamp left
477        assert!((result[1] - 10.0).abs() < EPS); // clamp left
478        assert!((result[2] - 30.0).abs() < EPS); // clamp right
479        assert!((result[3] - 30.0).abs() < EPS); // clamp right
480    }
481
482    #[test]
483    fn linear_interp_validation_errors() {
484        assert!(interp1d_linear(&[], &[], &[1.0]).is_err());
485        assert!(interp1d_linear(&[1.0], &[1.0], &[1.0]).is_err());
486        assert!(interp1d_linear(&[1.0, 2.0], &[1.0], &[1.0]).is_err()); // length mismatch
487        assert!(interp1d_linear(&[2.0, 1.0], &[1.0, 2.0], &[1.0]).is_err()); // not sorted
488    }
489
490    // --- Nearest-Neighbor Interpolation ---
491
492    #[test]
493    fn nearest_interp_snaps_to_closest() {
494        let x_data = vec![0.0, 1.0, 3.0, 5.0];
495        let y_data = vec![10.0, 20.0, 30.0, 40.0];
496        // 0.3 is closer to 0.0 -> 10.0
497        // 0.7 is closer to 1.0 -> 20.0
498        // 2.5 is closer to 3.0 -> 30.0
499        // 4.0 is equidistant between 3.0 and 5.0 -> tie-break to 30.0 (lower index)
500        let x_query = vec![0.3, 0.7, 2.5, 4.0];
501        let result = interp1d_nearest(&x_data, &y_data, &x_query).unwrap();
502        assert!((result[0] - 10.0).abs() < EPS);
503        assert!((result[1] - 20.0).abs() < EPS);
504        assert!((result[2] - 30.0).abs() < EPS);
505        assert!((result[3] - 30.0).abs() < EPS); // tie-break: lower index
506    }
507
508    #[test]
509    fn nearest_interp_at_data_points() {
510        let x_data = vec![0.0, 1.0, 2.0];
511        let y_data = vec![5.0, 15.0, 25.0];
512        let result = interp1d_nearest(&x_data, &y_data, &x_data).unwrap();
513        for (i, &r) in result.iter().enumerate() {
514            assert!((r - y_data[i]).abs() < EPS);
515        }
516    }
517
518    #[test]
519    fn nearest_interp_boundary_clamp() {
520        let x_data = vec![1.0, 2.0, 3.0];
521        let y_data = vec![10.0, 20.0, 30.0];
522        let result = interp1d_nearest(&x_data, &y_data, &[-1.0, 0.5, 3.5, 100.0]).unwrap();
523        assert!((result[0] - 10.0).abs() < EPS);
524        assert!((result[1] - 10.0).abs() < EPS);
525        assert!((result[2] - 30.0).abs() < EPS);
526        assert!((result[3] - 30.0).abs() < EPS);
527    }
528
529    // --- Polynomial Fitting ---
530
531    #[test]
532    fn polyfit_degree1_linear_data() {
533        // y = 3x + 2
534        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
535        let y: Vec<f64> = x.iter().map(|&xi| 3.0 * xi + 2.0).collect();
536        let coeffs = polyfit(&x, &y, 1).unwrap();
537        assert_eq!(coeffs.len(), 2);
538        assert!(
539            (coeffs[0] - 2.0).abs() < 1e-10,
540            "intercept: expected 2.0, got {}",
541            coeffs[0]
542        );
543        assert!(
544            (coeffs[1] - 3.0).abs() < 1e-10,
545            "slope: expected 3.0, got {}",
546            coeffs[1]
547        );
548    }
549
550    #[test]
551    fn polyfit_degree2_quadratic_data() {
552        // y = 1 + 2x + 3x^2
553        let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
554        let y: Vec<f64> = x.iter().map(|&xi| 1.0 + 2.0 * xi + 3.0 * xi * xi).collect();
555        let coeffs = polyfit(&x, &y, 2).unwrap();
556        assert_eq!(coeffs.len(), 3);
557        assert!(
558            (coeffs[0] - 1.0).abs() < 1e-8,
559            "a0: expected 1.0, got {}",
560            coeffs[0]
561        );
562        assert!(
563            (coeffs[1] - 2.0).abs() < 1e-8,
564            "a1: expected 2.0, got {}",
565            coeffs[1]
566        );
567        assert!(
568            (coeffs[2] - 3.0).abs() < 1e-8,
569            "a2: expected 3.0, got {}",
570            coeffs[2]
571        );
572    }
573
574    #[test]
575    fn polyfit_validation_errors() {
576        assert!(polyfit(&[1.0], &[1.0, 2.0], 1).is_err()); // length mismatch
577        assert!(polyfit(&[1.0], &[1.0], 1).is_err()); // too few points for degree 1
578    }
579
580    // --- Polynomial Evaluation ---
581
582    #[test]
583    fn polyval_roundtrip_with_polyfit() {
584        // Fit y = 5 - x + 0.5*x^2 and evaluate at same points
585        let x: Vec<f64> = (0..15).map(|i| i as f64 * 0.3).collect();
586        let y: Vec<f64> = x.iter().map(|&xi| 5.0 - xi + 0.5 * xi * xi).collect();
587        let coeffs = polyfit(&x, &y, 2).unwrap();
588        let y_eval = polyval(&coeffs, &x);
589        for (i, (&ye, &yo)) in y_eval.iter().zip(y.iter()).enumerate() {
590            assert!(
591                (ye - yo).abs() < 1e-8,
592                "at i={}, expected {}, got {}",
593                i,
594                yo,
595                ye
596            );
597        }
598    }
599
600    #[test]
601    fn polyval_empty_coeffs() {
602        let result = polyval(&[], &[1.0, 2.0, 3.0]);
603        assert_eq!(result.len(), 3);
604        for &r in &result {
605            assert!((r - 0.0).abs() < EPS);
606        }
607    }
608
609    #[test]
610    fn polyval_constant() {
611        let result = polyval(&[42.0], &[0.0, 1.0, 100.0]);
612        for &r in &result {
613            assert!((r - 42.0).abs() < EPS);
614        }
615    }
616
617    // --- Cubic Spline ---
618
619    #[test]
620    fn spline_interpolates_data_points_exactly() {
621        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
622        let y = vec![0.0, 1.0, 0.0, 1.0, 0.0];
623        let spline = spline_cubic_natural(&x, &y).unwrap();
624        let result = spline_eval(&spline, &x).unwrap();
625        for (i, (&r, &yi)) in result.iter().zip(y.iter()).enumerate() {
626            assert!(
627                (r - yi).abs() < EPS,
628                "at knot x={}, expected {}, got {}",
629                x[i],
630                yi,
631                r
632            );
633        }
634    }
635
636    #[test]
637    fn spline_linear_data_is_exact() {
638        // For linear data y = 2x + 1, natural cubic spline should reproduce it exactly
639        // (since a cubic with zero higher-order terms IS a line).
640        let x = vec![0.0, 1.0, 2.0, 3.0];
641        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
642        let spline = spline_cubic_natural(&x, &y).unwrap();
643        let x_query = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
644        let result = spline_eval(&spline, &x_query).unwrap();
645        for (i, &xq) in x_query.iter().enumerate() {
646            let expected = 2.0 * xq + 1.0;
647            assert!(
648                (result[i] - expected).abs() < 1e-10,
649                "at x={}, expected {}, got {}",
650                xq,
651                expected,
652                result[i]
653            );
654        }
655    }
656
657    #[test]
658    fn spline_derivative_continuity_at_knots() {
659        // Numerically verify that the first derivative is continuous at interior knots.
660        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
661        let y = vec![0.0, 2.0, 1.0, 3.0, 0.5];
662        let spline = spline_cubic_natural(&x, &y).unwrap();
663
664        let delta = 1e-7;
665        // Check derivative continuity at interior knots (indices 1, 2, 3)
666        for &knot in &[1.0, 2.0, 3.0] {
667            let left = spline_eval(&spline, &[knot - delta]).unwrap()[0];
668            let right = spline_eval(&spline, &[knot + delta]).unwrap()[0];
669            let at = spline_eval(&spline, &[knot]).unwrap()[0];
670            let deriv_left = (at - left) / delta;
671            let deriv_right = (right - at) / delta;
672            let diff = (deriv_left - deriv_right).abs();
673            assert!(
674                diff < 1e-4,
675                "derivative discontinuity at x={}: left={}, right={}, diff={}",
676                knot,
677                deriv_left,
678                deriv_right,
679                diff
680            );
681        }
682    }
683
684    #[test]
685    fn spline_second_derivative_zero_at_boundaries() {
686        // Natural spline: second derivative is zero at x[0] and x[n-1].
687        // S''(x) = 2*c + 6*d*t
688        // At left boundary (t=0): S''(x[0]) = 2*c[0] should be ~0
689        // At right boundary: evaluate in last interval at t = x[n-1] - x[n-2]
690        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
691        let y = vec![1.0, 3.0, 2.0, 5.0, 4.0];
692        let spline = spline_cubic_natural(&x, &y).unwrap();
693
694        // Left boundary
695        assert!(
696            (2.0 * spline.c[0]).abs() < 1e-10,
697            "second derivative at left boundary = {}",
698            2.0 * spline.c[0]
699        );
700
701        // Right boundary: 2*c[n-2] + 6*d[n-2]*h[n-2]
702        let last = spline.a.len() - 1;
703        let h_last = x[last + 1] - x[last];
704        let second_deriv_right = 2.0 * spline.c[last] + 6.0 * spline.d[last] * h_last;
705        assert!(
706            second_deriv_right.abs() < 1e-10,
707            "second derivative at right boundary = {}",
708            second_deriv_right
709        );
710    }
711
712    #[test]
713    fn spline_validation_errors() {
714        assert!(spline_cubic_natural(&[], &[]).is_err());
715        assert!(spline_cubic_natural(&[1.0], &[1.0]).is_err());
716        assert!(spline_cubic_natural(&[2.0, 1.0], &[1.0, 2.0]).is_err()); // not sorted
717    }
718
719    // --- Determinism Tests ---
720
721    #[test]
722    fn determinism_linear_interp() {
723        let x_data = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
724        let y_data = vec![0.0, 0.8, 0.9, 0.1, -0.8, -1.0];
725        let x_query: Vec<f64> = (0..100).map(|i| i as f64 * 0.05).collect();
726        let r1 = interp1d_linear(&x_data, &y_data, &x_query).unwrap();
727        let r2 = interp1d_linear(&x_data, &y_data, &x_query).unwrap();
728        assert_eq!(r1.len(), r2.len());
729        for (a, b) in r1.iter().zip(r2.iter()) {
730            assert_eq!(a.to_bits(), b.to_bits(), "non-deterministic result detected");
731        }
732    }
733
734    #[test]
735    fn determinism_polyfit() {
736        let x: Vec<f64> = (0..50).map(|i| i as f64 * 0.1).collect();
737        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
738        let c1 = polyfit(&x, &y, 5).unwrap();
739        let c2 = polyfit(&x, &y, 5).unwrap();
740        assert_eq!(c1.len(), c2.len());
741        for (a, b) in c1.iter().zip(c2.iter()) {
742            assert_eq!(a.to_bits(), b.to_bits(), "non-deterministic polyfit");
743        }
744    }
745
746    #[test]
747    fn determinism_cubic_spline() {
748        let x = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
749        let y = vec![0.0, 0.48, 0.84, 1.0, 0.91, 0.60, 0.14];
750        let x_query: Vec<f64> = (0..60).map(|i| i as f64 * 0.05).collect();
751        let s1 = spline_cubic_natural(&x, &y).unwrap();
752        let s2 = spline_cubic_natural(&x, &y).unwrap();
753        let r1 = spline_eval(&s1, &x_query).unwrap();
754        let r2 = spline_eval(&s2, &x_query).unwrap();
755        for (a, b) in r1.iter().zip(r2.iter()) {
756            assert_eq!(a.to_bits(), b.to_bits(), "non-deterministic spline");
757        }
758    }
759}