Skip to main content

fdars_core/
helpers.rs

1//! Helper functions for numerical integration and common operations.
2
3/// Small epsilon for numerical comparisons (e.g., avoiding division by zero).
4pub const NUMERICAL_EPS: f64 = 1e-10;
5
6/// Default convergence tolerance for iterative algorithms.
7pub const DEFAULT_CONVERGENCE_TOL: f64 = 1e-6;
8
9/// Sort a slice using total ordering that treats NaN as equal.
10pub fn sort_nan_safe(slice: &mut [f64]) {
11    slice.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
12}
13
14/// Per-thread deterministic RNG seeding — the single home for the parallel-loop
15/// determinism contract (CONS-02).
16///
17/// Given a base `seed` and a per-iteration index `k` (the loop's `i` / `b` /
18/// `rep` / `p`), returns an `StdRng` seeded at `seed + k`. Using
19/// `wrapping_add` makes the offset total (no overflow panic in debug builds)
20/// while remaining **bit-identical** to the plain `seed + k as u64` form for all
21/// non-overflowing inputs — so every hand-rolled thread-offset site migrates to
22/// this helper without changing a single RNG draw.
23///
24/// The body is EXACTLY `StdRng::seed_from_u64(seed.wrapping_add(k as u64))` and
25/// must stay that way: it is the determinism contract that keeps bootstrap CIs,
26/// tolerance bands, outlier detection, and generative alignment reproducible.
27#[inline]
28pub(crate) fn seed_for_thread(seed: u64, k: usize) -> rand::rngs::StdRng {
29    use rand::SeedableRng;
30    rand::rngs::StdRng::seed_from_u64(seed.wrapping_add(k as u64))
31}
32
33/// Extract curves from column-major data matrix.
34///
35/// Converts a flat column-major matrix into a vector of curve vectors,
36/// where each curve contains all evaluation points for one observation.
37///
38/// # Arguments
39/// * `data` - Functional data matrix (n x m)
40///
41/// # Returns
42/// Vector of n curves, each containing m values
43pub fn extract_curves(data: &crate::matrix::FdMatrix) -> Vec<Vec<f64>> {
44    data.rows()
45}
46
47/// Compute L2 distance between two curves using integration weights.
48///
49/// # Arguments
50/// * `curve1` - First curve values
51/// * `curve2` - Second curve values
52/// * `weights` - Integration weights
53///
54/// # Returns
55/// L2 distance between the curves
56pub fn l2_distance(curve1: &[f64], curve2: &[f64], weights: &[f64]) -> f64 {
57    let mut dist_sq = 0.0;
58    for i in 0..curve1.len() {
59        let diff = curve1[i] - curve2[i];
60        dist_sq += diff * diff * weights[i];
61    }
62    dist_sq.sqrt()
63}
64
65/// Compute Simpson's 1/3 rule integration weights for a grid.
66///
67/// For odd n (even number of intervals): standard composite Simpson's 1/3 rule.
68/// For even n: Simpson's 1/3 for first n-1 points, trapezoidal for last interval.
69/// For non-uniform grids: generalized Simpson's weights per sub-interval pair.
70///
71/// # Arguments
72/// * `argvals` - Grid points (evaluation points)
73///
74/// # Returns
75/// Vector of integration weights
76pub fn simpsons_weights(argvals: &[f64]) -> Vec<f64> {
77    let n = argvals.len();
78    if n < 2 {
79        return vec![1.0; n];
80    }
81
82    let mut weights = vec![0.0; n];
83
84    if n == 2 {
85        // Trapezoidal rule
86        let h = argvals[1] - argvals[0];
87        weights[0] = h / 2.0;
88        weights[1] = h / 2.0;
89        return weights;
90    }
91
92    // Check if grid is uniform
93    let h0 = argvals[1] - argvals[0];
94    let is_uniform = argvals
95        .windows(2)
96        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs());
97
98    if is_uniform {
99        simpsons_weights_uniform(&mut weights, n, h0);
100    } else {
101        simpsons_weights_nonuniform(&mut weights, argvals, n);
102    }
103
104    weights
105}
106
107/// Uniform grid Simpson's 1/3 weights.
108fn simpsons_weights_uniform(weights: &mut [f64], n: usize, h0: f64) {
109    let n_intervals = n - 1;
110    if n_intervals % 2 == 0 {
111        // Even number of intervals (odd n): pure Simpson's
112        weights[0] = h0 / 3.0;
113        weights[n - 1] = h0 / 3.0;
114        for i in 1..n - 1 {
115            weights[i] = if i % 2 == 1 {
116                4.0 * h0 / 3.0
117            } else {
118                2.0 * h0 / 3.0
119            };
120        }
121    } else {
122        // Odd number of intervals (even n): Simpson's + trapezoidal for last
123        let n_simp = n - 1;
124        weights[0] = h0 / 3.0;
125        weights[n_simp - 1] = h0 / 3.0;
126        for i in 1..n_simp - 1 {
127            weights[i] = if i % 2 == 1 {
128                4.0 * h0 / 3.0
129            } else {
130                2.0 * h0 / 3.0
131            };
132        }
133        weights[n_simp - 1] += h0 / 2.0;
134        weights[n - 1] += h0 / 2.0;
135    }
136}
137
138/// Non-uniform grid generalized Simpson's weights.
139fn simpsons_weights_nonuniform(weights: &mut [f64], argvals: &[f64], n: usize) {
140    let n_intervals = n - 1;
141    let n_pairs = n_intervals / 2;
142
143    for k in 0..n_pairs {
144        let i0 = 2 * k;
145        let i1 = i0 + 1;
146        let i2 = i0 + 2;
147        let h1 = argvals[i1] - argvals[i0];
148        let h2 = argvals[i2] - argvals[i1];
149        let h_sum = h1 + h2;
150
151        weights[i0] += (2.0 * h1 - h2) * h_sum / (6.0 * h1);
152        weights[i1] += h_sum * h_sum * h_sum / (6.0 * h1 * h2);
153        weights[i2] += (2.0 * h2 - h1) * h_sum / (6.0 * h2);
154    }
155
156    if n_intervals % 2 == 1 {
157        let h_last = argvals[n - 1] - argvals[n - 2];
158        weights[n - 2] += h_last / 2.0;
159        weights[n - 1] += h_last / 2.0;
160    }
161}
162
163/// Compute 2D integration weights using tensor product of 1D weights.
164///
165/// Returns a flattened vector of weights for an m1 x m2 grid.
166///
167/// # Arguments
168/// * `argvals_s` - Grid points in s direction
169/// * `argvals_t` - Grid points in t direction
170///
171/// # Returns
172/// Flattened vector of integration weights (column-major: s-varies-fastest, matching FdMatrix surface layout)
173pub fn simpsons_weights_2d(argvals_s: &[f64], argvals_t: &[f64]) -> Vec<f64> {
174    let weights_s = simpsons_weights(argvals_s);
175    let weights_t = simpsons_weights(argvals_t);
176    let m1 = argvals_s.len();
177    let m2 = argvals_t.len();
178
179    let mut weights = vec![0.0; m1 * m2];
180    for i in 0..m1 {
181        for j in 0..m2 {
182            weights[i + j * m1] = weights_s[i] * weights_t[j];
183        }
184    }
185    weights
186}
187
188/// Linear interpolation at point `t` using binary search.
189///
190/// Clamps to boundary values outside the domain of `x`.
191pub fn linear_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
192    if t <= x[0] {
193        return y[0];
194    }
195    let last = x.len() - 1;
196    if t >= x[last] {
197        return y[last];
198    }
199
200    let idx = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
201        Ok(i) => return y[i],
202        Err(i) => i,
203    };
204
205    let t0 = x[idx - 1];
206    let t1 = x[idx];
207    let y0 = y[idx - 1];
208    let y1 = y[idx];
209    y0 + (y1 - y0) * (t - t0) / (t1 - t0)
210}
211
212/// Cumulative integration using Simpson's rule where possible.
213///
214/// For pairs of intervals uses Simpson's 1/3 rule for higher accuracy.
215/// Falls back to trapezoidal for the last interval if n is even.
216pub fn cumulative_trapz(y: &[f64], x: &[f64]) -> Vec<f64> {
217    let n = y.len();
218    let mut out = vec![0.0; n];
219    if n < 2 {
220        return out;
221    }
222
223    // Process pairs of intervals with Simpson's rule
224    let mut k = 1;
225    while k + 1 < n {
226        let h1 = x[k] - x[k - 1];
227        let h2 = x[k + 1] - x[k];
228        let h_sum = h1 + h2;
229
230        // Generalized Simpson's for this pair of intervals
231        let integral = h_sum / 6.0
232            * (y[k - 1] * (2.0 * h1 - h2) / h1
233                + y[k] * h_sum * h_sum / (h1 * h2)
234                + y[k + 1] * (2.0 * h2 - h1) / h2);
235
236        out[k] = out[k - 1] + {
237            // First sub-interval: use trapezoidal for the intermediate value
238            0.5 * (y[k] + y[k - 1]) * h1
239        };
240        out[k + 1] = out[k - 1] + integral;
241        k += 2;
242    }
243
244    // If there's a remaining interval, use trapezoidal
245    if k < n {
246        out[k] = out[k - 1] + 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
247    }
248
249    out
250}
251
252/// Trapezoidal integration of `y` over `x`.
253pub fn trapz(y: &[f64], x: &[f64]) -> f64 {
254    let mut sum = 0.0;
255    for k in 1..y.len() {
256        sum += 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
257    }
258    sum
259}
260
261/// Gaussian kernel: K(d, h) = exp(-d² / (2h²)).
262///
263/// This is the un-normalized version used by Nadaraya-Watson regression
264/// and kernel classification. For density estimation with normalization,
265/// see the smoothing module.
266pub fn gaussian_kernel(d: f64, h: f64) -> f64 {
267    if h < 1e-15 {
268        return 0.0;
269    }
270    (-d * d / (2.0 * h * h)).exp()
271}
272
273/// Extract bandwidth candidates from a flat n×n distance matrix.
274///
275/// Collects the upper-triangle nonzero distances, sorts them, and returns
276/// `n_quantiles` evenly-spaced quantile values. Used for LOO-CV bandwidth
277/// grid search in kernel regression and classification.
278pub fn bandwidth_candidates_from_dists(dists: &[f64], n: usize, n_quantiles: usize) -> Vec<f64> {
279    let mut nonzero: Vec<f64> = (0..n)
280        .flat_map(|i| ((i + 1)..n).map(move |j| dists[i * n + j]))
281        .filter(|&d| d > 0.0)
282        .collect();
283    sort_nan_safe(&mut nonzero);
284
285    if nonzero.is_empty() {
286        return Vec::new();
287    }
288
289    (1..=n_quantiles)
290        .map(|q| {
291            let p = q as f64 / (n_quantiles + 1) as f64;
292            let idx = ((nonzero.len() as f64 * p) as usize).min(nonzero.len() - 1);
293            nonzero[idx]
294        })
295        .filter(|&h| h > 1e-15)
296        .collect()
297}
298
299/// Compute a quantile from a sorted slice.
300///
301/// `p` should be in [0, 1]. Uses linear interpolation between adjacent values.
302pub fn quantile_sorted(sorted: &[f64], p: f64) -> f64 {
303    if sorted.is_empty() {
304        return f64::NAN;
305    }
306    if sorted.len() == 1 || p <= 0.0 {
307        return sorted[0];
308    }
309    if p >= 1.0 {
310        return sorted[sorted.len() - 1];
311    }
312    let pos = p * (sorted.len() - 1) as f64;
313    let lo = pos.floor() as usize;
314    let hi = (lo + 1).min(sorted.len() - 1);
315    let frac = pos - lo as f64;
316    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
317}
318
319/// Compute R² (coefficient of determination).
320pub fn r_squared(y_true: &[f64], residuals: &[f64]) -> f64 {
321    let n = y_true.len();
322    if n == 0 {
323        return f64::NAN;
324    }
325    let mean = y_true.iter().sum::<f64>() / n as f64;
326    let ss_tot: f64 = y_true.iter().map(|&y| (y - mean).powi(2)).sum();
327    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
328    if ss_tot > 1e-15 {
329        1.0 - ss_res / ss_tot
330    } else {
331        0.0
332    }
333}
334
335/// Compute adjusted R².
336pub fn r_squared_adj(y_true: &[f64], residuals: &[f64], p: usize) -> f64 {
337    let n = y_true.len();
338    let r2 = r_squared(y_true, residuals);
339    if n <= p + 1 {
340        return r2;
341    }
342    1.0 - (1.0 - r2) * (n - 1) as f64 / (n - p - 1) as f64
343}
344
345/// Compute AIC from residual sum of squares.
346///
347/// AIC = n * ln(RSS/n) + 2p
348pub fn aic(n: usize, rss: f64, p: usize) -> f64 {
349    let nf = n as f64;
350    nf * (rss / nf).ln() + 2.0 * p as f64
351}
352
353/// Compute BIC from residual sum of squares.
354///
355/// BIC = n * ln(RSS/n) + ln(n) * p
356pub fn bic(n: usize, rss: f64, p: usize) -> f64 {
357    let nf = n as f64;
358    nf * (rss / nf).ln() + nf.ln() * p as f64
359}
360
361/// Interpolation method for resampling functional data.
362#[derive(Debug, Clone, Copy, PartialEq)]
363#[non_exhaustive]
364pub enum InterpolationMethod {
365    /// Linear interpolation between adjacent points.
366    Linear,
367    /// Cubic Hermite interpolation (monotone, C1 continuous).
368    CubicHermite,
369}
370
371/// Interpolate functional data to a new grid.
372///
373/// Resamples each curve from `data` evaluated at `argvals` to the new
374/// evaluation points `new_argvals`.
375///
376/// # Arguments
377/// * `data` - Functional data matrix (n x m)
378/// * `argvals` - Original evaluation points (length m, must be sorted)
379/// * `new_argvals` - New evaluation points (length m_new, must be sorted, within original domain)
380/// * `method` - Interpolation method
381///
382/// # Returns
383/// Interpolated matrix (n x m_new)
384#[must_use]
385pub fn fdata_interpolate(
386    data: &crate::matrix::FdMatrix,
387    argvals: &[f64],
388    new_argvals: &[f64],
389    method: InterpolationMethod,
390) -> crate::matrix::FdMatrix {
391    let (n, m) = data.shape();
392    let m_new = new_argvals.len();
393    if n == 0 || m < 2 || m_new == 0 {
394        return crate::matrix::FdMatrix::zeros(n.max(1), m_new.max(1));
395    }
396
397    let mut result = crate::matrix::FdMatrix::zeros(n, m_new);
398
399    for i in 0..n {
400        let y: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
401        for (j, &t) in new_argvals.iter().enumerate() {
402            result[(i, j)] = match method {
403                InterpolationMethod::Linear => linear_interp(argvals, &y, t),
404                InterpolationMethod::CubicHermite => cubic_hermite_interp(argvals, &y, t),
405            };
406        }
407    }
408
409    result
410}
411
412/// Fit an order-k B-spline interpolant per curve and evaluate at arbitrary query points.
413///
414/// For each curve in `data` (sampled at `argvals`), fits a B-spline of the given `order`
415/// using least-squares via SVD pseudoinverse, then evaluates at `query_points` using the
416/// same knot vector. Returns a new `FdMatrix` with shape `(n, query_points.len())`.
417///
418/// Uses the fit-then-evaluate pattern from the existing B-spline basis system
419/// (`basis::bspline`), without P-spline smoothing — this is interpolation, not smoothing.
420///
421/// # Arguments
422/// * `data`         — Functional data matrix (`n × m`)
423/// * `argvals`      — Original evaluation points (`length m`, must be sorted)
424/// * `query_points` — Points to evaluate at (must lie within `[argvals[0], argvals[m-1]]`)
425/// * `order`        — B-spline order (1 = linear, 2 = quadratic, 4 = cubic, …); must be in `[1, m)`
426///
427/// # Returns
428/// Interpolated `FdMatrix` of shape `(n, query_points.len())`
429///
430/// # Errors
431/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()` or `query_points` is empty
432/// * `FdarError::InvalidParameter` — `order` is 0 or ≥ `m`, or any query point is outside
433///   `[argvals[0], argvals[m-1]]`
434/// * `FdarError::ComputationFailed` — SVD pseudoinverse could not be computed
435pub fn spline_interpolate(
436    data: &crate::matrix::FdMatrix,
437    argvals: &[f64],
438    query_points: &[f64],
439    order: usize,
440) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
441    let (n, m) = data.shape();
442
443    // --- Input validation ---
444    if argvals.len() != m {
445        return Err(crate::FdarError::InvalidDimension {
446            parameter: "argvals",
447            expected: format!("{m}"),
448            actual: format!("{}", argvals.len()),
449        });
450    }
451    if query_points.is_empty() {
452        return Err(crate::FdarError::InvalidDimension {
453            parameter: "query_points",
454            expected: ">= 1".to_string(),
455            actual: "0".to_string(),
456        });
457    }
458    if order == 0 || order >= m {
459        return Err(crate::FdarError::InvalidParameter {
460            parameter: "order",
461            message: format!("must be in [1, {m}), got {order}"),
462        });
463    }
464    let t_min = argvals[0];
465    let t_max = argvals[m - 1];
466    for &q in query_points {
467        if q < t_min || q > t_max {
468            return Err(crate::FdarError::InvalidParameter {
469                parameter: "query_points",
470                message: format!(
471                    "all query points must lie in [{t_min}, {t_max}]; found {q} which is outside the interpolation domain"
472                ),
473            });
474        }
475    }
476
477    // --- Build knot vector and basis matrix on argvals ---
478    // nknots chosen so nbasis = nknots + order ≈ m (interpolating system)
479    let nknots = m.saturating_sub(order).max(2);
480    let knots = crate::basis::bspline::construct_bspline_knots(t_min, t_max, nknots, order);
481
482    // basis_vals: column-major, length m * nbasis; layout: basis[ti + k*m] = B_k(argvals[ti])
483    let basis_vals = crate::basis::bspline::bspline_basis(argvals, nknots, order);
484    let nbasis = basis_vals.len() / m;
485
486    // Form B (m × nbasis) — same layout as pspline.rs:86-87
487    let b_mat = nalgebra::DMatrix::from_column_slice(m, nbasis, &basis_vals);
488
489    // Compute pseudoinverse of B once via SVD and reuse across all n curves.
490    // Mirrors the pattern in basis/helpers.rs:svd_pseudoinverse (which is pub(super)).
491    let tol = NUMERICAL_EPS * b_mat.nrows().max(b_mat.ncols()) as f64;
492    let svd = nalgebra::SVD::new(b_mat.clone(), true, true);
493    let pinv = svd
494        .pseudo_inverse(tol)
495        .map_err(|e| crate::FdarError::ComputationFailed {
496            operation: "spline_interpolate SVD pseudoinverse",
497            detail: e.to_string(),
498        })?;
499    // pinv: nbasis × m
500
501    // --- Build query basis on same knots ---
502    // basis_query: column-major, length m_q * nbasis; layout: basis_query[j + k*m_q] = B_k(query[j])
503    let m_q = query_points.len();
504    let basis_query = crate::basis::bspline::bspline_basis_from_knots(query_points, &knots, order);
505
506    // --- Evaluate per curve ---
507    let mut out = crate::matrix::FdMatrix::zeros(n, m_q);
508    for i in 0..n {
509        // Gather curve i as a column vector (length m)
510        let y_vec: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
511        let y_col = nalgebra::DVector::from_vec(y_vec);
512
513        // Solve: coefs = pinv * y  (shape: nbasis × 1)
514        let coefs = &pinv * y_col;
515
516        // Evaluate: out[i, j] = sum_k coefs[k] * basis_query[j + k*m_q]
517        for j in 0..m_q {
518            let mut val = 0.0;
519            for k in 0..nbasis {
520                val += coefs[k] * basis_query[j + k * m_q];
521            }
522            out[(i, j)] = val;
523        }
524    }
525
526    Ok(out)
527}
528
529/// Interpolate functional data to a new grid using B-splines with explicit extrapolation control.
530///
531/// Like [`spline_interpolate`] but applies `policy` for any query point that falls outside the
532/// domain `[argvals[0], argvals[m-1]]` instead of always returning an error. In-range queries
533/// produce identical values to the [`spline_interpolate`] path.
534///
535/// # Arguments
536/// * `data`         — Functional data matrix (`n × m`)
537/// * `argvals`      — Original evaluation points (length `m`, must be sorted)
538/// * `query_points` — Points to evaluate at (may include out-of-range values)
539/// * `order`        — B-spline order (1 = linear, 2 = quadratic, 4 = cubic, …); must be in `[1, m)`
540/// * `policy`       — Extrapolation policy for out-of-range query points
541///
542/// # Returns
543/// Interpolated `FdMatrix` of shape `(n, query_points.len())`
544///
545/// # Errors
546/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()` or `query_points` is empty
547/// * `FdarError::InvalidParameter` — `order` is 0 or ≥ `m`; or `policy == Exception` and any
548///   query point is outside `[argvals[0], argvals[m-1]]`; or `policy == Periodic` and the domain
549///   length is zero
550/// * `FdarError::ComputationFailed` — SVD pseudoinverse could not be computed
551///
552/// # Policy Semantics
553/// * `Boundary` — clamp OOB query to the nearest boundary (`t_min` or `t_max`) before spline eval
554/// * `Exception` — return `Err(FdarError::InvalidParameter { parameter: "query_points", .. })` on first OOB
555/// * `Fill(v)` — set OOB output cells to constant `v`; in-range cells use spline interpolation
556/// * `Periodic` — wrap OOB query modulo the domain length (same recipe as in
557///   [`fdata_interpolate_with_policy`]); requires `argvals[0] < argvals[m-1]`
558pub fn spline_interpolate_with_policy(
559    data: &crate::matrix::FdMatrix,
560    argvals: &[f64],
561    query_points: &[f64],
562    order: usize,
563    policy: ExtrapolationPolicy,
564) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
565    let (n, m) = data.shape();
566
567    // --- Input validation (mirrors spline_interpolate) ---
568    if argvals.len() != m {
569        return Err(crate::FdarError::InvalidDimension {
570            parameter: "argvals",
571            expected: format!("{m}"),
572            actual: format!("{}", argvals.len()),
573        });
574    }
575    if query_points.is_empty() {
576        return Err(crate::FdarError::InvalidDimension {
577            parameter: "query_points",
578            expected: ">= 1".to_string(),
579            actual: "0".to_string(),
580        });
581    }
582    if order == 0 || order >= m {
583        return Err(crate::FdarError::InvalidParameter {
584            parameter: "order",
585            message: format!("must be in [1, {m}), got {order}"),
586        });
587    }
588
589    let t_min = argvals[0];
590    let t_max = argvals[m - 1];
591    let domain_len = t_max - t_min;
592
593    // Periodic requires a positive domain length.
594    if domain_len <= 0.0 && matches!(policy, ExtrapolationPolicy::Periodic) {
595        return Err(crate::FdarError::InvalidParameter {
596            parameter: "argvals",
597            message: "Periodic extrapolation requires a positive domain length \
598                      (argvals[0] < argvals[m-1])"
599                .to_string(),
600        });
601    }
602
603    let m_q = query_points.len();
604
605    // --- Map each query point to an effective in-range point (or mark as Fill) ---
606    // `effective[j]` holds the remapped query to pass to the spline; `fill_mask[j]` is true
607    // when the output should be the Fill constant instead.
608    let mut effective = Vec::with_capacity(m_q);
609    let mut fill_mask = vec![false; m_q];
610
611    for (j, &q) in query_points.iter().enumerate() {
612        let in_range = q >= t_min && q <= t_max;
613        if in_range {
614            effective.push(q);
615        } else {
616            match &policy {
617                ExtrapolationPolicy::Boundary => effective.push(q.clamp(t_min, t_max)),
618                ExtrapolationPolicy::Exception => {
619                    return Err(crate::FdarError::InvalidParameter {
620                        parameter: "query_points",
621                        message: format!("query {q} is outside domain [{t_min}, {t_max}]"),
622                    });
623                }
624                ExtrapolationPolicy::Fill(_) => {
625                    // Placeholder; we will overwrite this column from the fill value.
626                    fill_mask[j] = true;
627                    effective.push(t_min); // dummy in-range value — will be discarded
628                }
629                ExtrapolationPolicy::Periodic => {
630                    let wrapped = t_min + ((q - t_min) % domain_len + domain_len) % domain_len;
631                    effective.push(wrapped);
632                }
633            }
634        }
635    }
636
637    // --- Run the core spline logic on the effective (all in-range) query points ---
638    // Reuse the same SVD-based evaluation as spline_interpolate.
639    let nknots = m.saturating_sub(order).max(2);
640    let knots = crate::basis::bspline::construct_bspline_knots(t_min, t_max, nknots, order);
641    let basis_vals = crate::basis::bspline::bspline_basis(argvals, nknots, order);
642    let nbasis = basis_vals.len() / m;
643    let b_mat = nalgebra::DMatrix::from_column_slice(m, nbasis, &basis_vals);
644    let tol = NUMERICAL_EPS * b_mat.nrows().max(b_mat.ncols()) as f64;
645    let svd = nalgebra::SVD::new(b_mat.clone(), true, true);
646    let pinv = svd
647        .pseudo_inverse(tol)
648        .map_err(|e| crate::FdarError::ComputationFailed {
649            operation: "spline_interpolate_with_policy SVD pseudoinverse",
650            detail: e.to_string(),
651        })?;
652    let basis_query = crate::basis::bspline::bspline_basis_from_knots(&effective, &knots, order);
653
654    let mut out = crate::matrix::FdMatrix::zeros(n, m_q);
655    for i in 0..n {
656        let y_vec: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
657        let y_col = nalgebra::DVector::from_vec(y_vec);
658        let coefs = &pinv * y_col;
659
660        for j in 0..m_q {
661            if fill_mask[j] {
662                // Fill policy: overwrite with constant.
663                if let ExtrapolationPolicy::Fill(v) = policy {
664                    out[(i, j)] = v;
665                }
666            } else {
667                let mut val = 0.0_f64;
668                for k in 0..nbasis {
669                    val += coefs[k] * basis_query[j + k * m_q];
670                }
671                out[(i, j)] = val;
672            }
673        }
674    }
675
676    Ok(out)
677}
678
679/// Cubic Hermite interpolation at a single point.
680///
681/// Uses Fritsch-Carlson monotone slopes for C1 interpolation.
682fn cubic_hermite_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
683    let n = x.len();
684    if n < 2 {
685        return if n == 1 { y[0] } else { 0.0 };
686    }
687
688    // Clamp to domain
689    if t <= x[0] {
690        return y[0];
691    }
692    if t >= x[n - 1] {
693        return y[n - 1];
694    }
695
696    // Find interval via binary search
697    let k = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
698        Ok(i) => return y[i],
699        Err(i) => {
700            if i == 0 {
701                0
702            } else {
703                i - 1
704            }
705        }
706    };
707
708    // Compute slopes (Fritsch-Carlson)
709    let slopes: Vec<f64> = x
710        .windows(2)
711        .zip(y.windows(2))
712        .map(|(xw, yw)| (yw[1] - yw[0]) / (xw[1] - xw[0]))
713        .collect();
714
715    // Tangents at each point
716    let mut tangents = vec![0.0; n];
717    tangents[0] = slopes[0];
718    tangents[n - 1] = slopes[n - 2];
719    for i in 1..n - 1 {
720        if slopes[i - 1].signum() != slopes[i].signum() {
721            tangents[i] = 0.0;
722        } else {
723            tangents[i] = (slopes[i - 1] + slopes[i]) / 2.0;
724        }
725    }
726
727    // Hermite basis
728    let h = x[k + 1] - x[k];
729    let s = (t - x[k]) / h;
730    let s2 = s * s;
731    let s3 = s2 * s;
732
733    let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
734    let h10 = s3 - 2.0 * s2 + s;
735    let h01 = -2.0 * s3 + 3.0 * s2;
736    let h11 = s3 - s2;
737
738    h00 * y[k] + h10 * h * tangents[k] + h01 * y[k + 1] + h11 * h * tangents[k + 1]
739}
740
741/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
742///
743/// Interior points use the 5-point central difference:
744///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
745///
746/// Near-boundary points use appropriate forward/backward formulas.
747pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
748    let n = y.len();
749    let mut g = vec![0.0; n];
750    if n < 2 {
751        return g;
752    }
753    if n == 2 {
754        g[0] = (y[1] - y[0]) / h;
755        g[1] = (y[1] - y[0]) / h;
756        return g;
757    }
758    if n == 3 {
759        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
760        g[1] = (y[2] - y[0]) / (2.0 * h);
761        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
762        return g;
763    }
764    if n == 4 {
765        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
766        g[1] = (y[2] - y[0]) / (2.0 * h);
767        g[2] = (y[3] - y[1]) / (2.0 * h);
768        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
769        return g;
770    }
771
772    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
773    // Left boundary: O(h³) forward formula
774    g[0] = (-25.0 * y[0] + 48.0 * y[1] - 36.0 * y[2] + 16.0 * y[3] - 3.0 * y[4]) / (12.0 * h);
775    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
776
777    // Interior: 5-point central difference O(h⁴)
778    for i in 2..n - 2 {
779        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
780    }
781
782    // Right boundary: O(h³) backward formula
783    g[n - 2] = (-y[n - 5] + 6.0 * y[n - 4] - 18.0 * y[n - 3] + 10.0 * y[n - 2] + 3.0 * y[n - 1])
784        / (12.0 * h);
785    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
786        + 25.0 * y[n - 1])
787        / (12.0 * h);
788    g
789}
790
791/// Numerical gradient for non-uniform grids using 3-point Lagrange derivative.
792///
793/// At interior points uses the three-point formula:
794///   `g[i] = y[i-1]*h_r/(-h_l*(h_l+h_r)) + y[i]*(h_r-h_l)/(h_l*h_r) + y[i+1]*h_l/(h_r*(h_l+h_r))`
795/// where `h_l = t[i]-t[i-1]` and `h_r = t[i+1]-t[i]`.
796///
797/// Boundary points use forward/backward 3-point formulas.
798pub fn gradient_nonuniform(y: &[f64], t: &[f64]) -> Vec<f64> {
799    let n = y.len();
800    assert_eq!(n, t.len(), "y and t must have the same length");
801    let mut g = vec![0.0; n];
802    if n < 2 {
803        return g;
804    }
805    if n == 2 {
806        let h = t[1] - t[0];
807        if h.abs() < 1e-15 {
808            return g;
809        }
810        g[0] = (y[1] - y[0]) / h;
811        g[1] = g[0];
812        return g;
813    }
814
815    // Left boundary: 3-point forward Lagrange derivative
816    let h0 = t[1] - t[0];
817    let h1 = t[2] - t[0];
818    if h0.abs() > 1e-15 && h1.abs() > 1e-15 && (h1 - h0).abs() > 1e-15 {
819        g[0] = y[0] * (-h1 - h0) / (h0 * h1) + y[1] * h1 / (h0 * (h1 - h0))
820            - y[2] * h0 / (h1 * (h1 - h0));
821    } else {
822        g[0] = (y[1] - y[0]) / h0.max(1e-15);
823    }
824
825    // Interior: 3-point Lagrange central formula
826    for i in 1..n - 1 {
827        let h_l = t[i] - t[i - 1];
828        let h_r = t[i + 1] - t[i];
829        let h_sum = h_l + h_r;
830        if h_l.abs() < 1e-15 || h_r.abs() < 1e-15 || h_sum.abs() < 1e-15 {
831            g[i] = 0.0;
832            continue;
833        }
834        g[i] = -y[i - 1] * h_r / (h_l * h_sum)
835            + y[i] * (h_r - h_l) / (h_l * h_r)
836            + y[i + 1] * h_l / (h_r * h_sum);
837    }
838
839    // Right boundary: 3-point backward Lagrange derivative
840    let h_last = t[n - 1] - t[n - 2];
841    let h_prev = t[n - 1] - t[n - 3];
842    let h_mid = t[n - 2] - t[n - 3];
843    if h_last.abs() > 1e-15 && h_prev.abs() > 1e-15 && h_mid.abs() > 1e-15 {
844        g[n - 1] = y[n - 3] * h_last / (h_mid * h_prev) - y[n - 2] * h_prev / (h_mid * h_last)
845            + y[n - 1] * (h_prev + h_last) / (h_prev * h_last);
846    } else {
847        g[n - 1] = (y[n - 1] - y[n - 2]) / h_last.max(1e-15);
848    }
849
850    g
851}
852
853/// Numerical gradient that auto-detects uniform vs non-uniform grids.
854///
855/// If the grid `t` is uniformly spaced (max|Δt_i − Δt_0| < ε), dispatches to
856/// [`gradient_uniform`] for optimal accuracy. Otherwise falls back to
857/// [`gradient_nonuniform`].
858pub fn gradient(y: &[f64], t: &[f64]) -> Vec<f64> {
859    let n = t.len();
860    if n < 2 {
861        return vec![0.0; y.len()];
862    }
863
864    let h0 = t[1] - t[0];
865    let is_uniform = t
866        .windows(2)
867        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs().max(1.0));
868
869    if is_uniform {
870        gradient_uniform(y, h0)
871    } else {
872        gradient_nonuniform(y, t)
873    }
874}
875
876/// Extrapolation policy controlling behavior when a query point falls
877/// outside the domain of `argvals`.
878///
879/// Used with [`fdata_interpolate_with_policy`] to give callers explicit control
880/// over out-of-range query handling instead of the silent boundary clamp that
881/// [`fdata_interpolate`] applies.
882#[derive(Debug, Clone, PartialEq)]
883#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
884pub enum ExtrapolationPolicy {
885    /// Clamp the query point to the nearest boundary value.
886    ///
887    /// A query at `t < t_min` returns the interpolated value at `t_min`;
888    /// a query at `t > t_max` returns the interpolated value at `t_max`.
889    Boundary,
890    /// Return an error for any out-of-range query point.
891    ///
892    /// Returns `Err(FdarError::InvalidParameter { parameter: "new_argvals", .. })`
893    /// when a query point lies outside `[argvals[0], argvals[m-1]]`.
894    Exception,
895    /// Fill out-of-range cells with a constant value.
896    Fill(f64),
897    /// Wrap query points modulo the domain length (periodic extension).
898    ///
899    /// A query at `t_min - delta` returns the same value as `t_max - delta`.
900    /// Uses the guarded-modulo recipe `((t - t_min) % L + L) % L` to handle
901    /// negative remainders for `t < t_min`.
902    Periodic,
903}
904
905/// Interpolate functional data to a new grid with explicit extrapolation control.
906///
907/// Like [`fdata_interpolate`] but applies `policy` for any query point that falls
908/// outside the domain `[argvals[0], argvals[m-1]]` instead of silently clamping.
909/// In-range queries produce identical values to the [`fdata_interpolate`] path.
910///
911/// # Arguments
912/// * `data`       — Functional data matrix (`n × m`)
913/// * `argvals`    — Original evaluation points (length `m`, must be sorted)
914/// * `new_argvals`— New evaluation points (length `m_new`)
915/// * `method`     — Interpolation method for in-range (and `Boundary`/`Periodic`) points
916/// * `policy`     — Extrapolation policy for out-of-range query points
917///
918/// # Returns
919/// Interpolated matrix `(n × m_new)`
920///
921/// # Errors
922/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()`
923/// * `FdarError::InvalidParameter` — a query point is out of range and `policy ==
924///   ExtrapolationPolicy::Exception`
925pub fn fdata_interpolate_with_policy(
926    data: &crate::matrix::FdMatrix,
927    argvals: &[f64],
928    new_argvals: &[f64],
929    method: InterpolationMethod,
930    policy: ExtrapolationPolicy,
931) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
932    let (n, m) = data.shape();
933    if argvals.len() != m {
934        return Err(crate::FdarError::InvalidDimension {
935            parameter: "argvals",
936            expected: format!("{m}"),
937            actual: format!("{}", argvals.len()),
938        });
939    }
940    let m_new = new_argvals.len();
941    if n == 0 || m < 2 || m_new == 0 {
942        return Ok(crate::matrix::FdMatrix::zeros(n.max(1), m_new.max(1)));
943    }
944    let t_min = argvals[0];
945    let t_max = argvals[m - 1];
946    let domain_len = t_max - t_min;
947
948    // CR-01: Guard degenerate domain before the loop — Periodic wraps via modulo domain_len
949    // and produces NaN when domain_len == 0 (IEEE 754: x % 0.0 = NaN).  Other policies do
950    // not divide by domain_len so only Periodic needs this guard.
951    if domain_len <= 0.0 && matches!(policy, ExtrapolationPolicy::Periodic) {
952        return Err(crate::FdarError::InvalidParameter {
953            parameter: "argvals",
954            message: "Periodic extrapolation requires a positive domain length \
955                      (argvals[0] < argvals[m-1])"
956                .to_string(),
957        });
958    }
959
960    let mut result = crate::matrix::FdMatrix::zeros(n, m_new);
961    for i in 0..n {
962        let y: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
963        for (j, &t) in new_argvals.iter().enumerate() {
964            let in_range = t >= t_min && t <= t_max;
965            result[(i, j)] = if in_range {
966                match method {
967                    InterpolationMethod::Linear => linear_interp(argvals, &y, t),
968                    InterpolationMethod::CubicHermite => cubic_hermite_interp(argvals, &y, t),
969                }
970            } else {
971                match &policy {
972                    ExtrapolationPolicy::Boundary => {
973                        let t_clamped = t.clamp(t_min, t_max);
974                        match method {
975                            InterpolationMethod::Linear => linear_interp(argvals, &y, t_clamped),
976                            InterpolationMethod::CubicHermite => {
977                                cubic_hermite_interp(argvals, &y, t_clamped)
978                            }
979                        }
980                    }
981                    ExtrapolationPolicy::Exception => {
982                        return Err(crate::FdarError::InvalidParameter {
983                            parameter: "new_argvals",
984                            message: format!("query {t} is outside domain [{t_min}, {t_max}]"),
985                        });
986                    }
987                    ExtrapolationPolicy::Fill(v) => *v,
988                    ExtrapolationPolicy::Periodic => {
989                        let wrapped = t_min + ((t - t_min) % domain_len + domain_len) % domain_len;
990                        match method {
991                            InterpolationMethod::Linear => linear_interp(argvals, &y, wrapped),
992                            InterpolationMethod::CubicHermite => {
993                                cubic_hermite_interp(argvals, &y, wrapped)
994                            }
995                        }
996                    }
997                }
998            };
999        }
1000    }
1001    Ok(result)
1002}
1003
1004// ── FEAT-03: NaN imputation ────────────────────────────────────────────────
1005
1006/// Strategy for in-grid NaN imputation in a regular `FdMatrix`.
1007///
1008/// Used with [`impute_missing_values`] to specify how NaN entries are replaced
1009/// in each curve.
1010///
1011/// Leading or trailing NaN values (no neighbor on one side) are filled with
1012/// the nearest valid value (boundary extension) for the `Linear` strategy.
1013#[derive(Debug, Clone, PartialEq)]
1014#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1015pub enum ImputationMethod {
1016    /// Linear interpolation between the nearest non-NaN neighbors.
1017    ///
1018    /// For gaps at the boundary (leading or trailing NaN), the nearest valid
1019    /// value is used as boundary extension.
1020    Linear,
1021    /// Replace each NaN with the curve's mean of its non-NaN values.
1022    Mean,
1023    /// Replace each NaN with a user-supplied constant value.
1024    Constant(f64),
1025}
1026
1027/// Impute NaN values in a regular functional data matrix.
1028///
1029/// Returns a new `FdMatrix` with NaN entries replaced according to `method`.
1030/// Non-NaN entries are copied through unchanged.
1031///
1032/// For `Linear`, gaps at the curve boundary (no neighbor on one side) are
1033/// filled with the nearest valid value (boundary extension).
1034///
1035/// # Arguments
1036/// * `data`    — Functional data matrix (`n × m`) with possible NaN entries
1037/// * `argvals` — Evaluation points (length `m`, must be sorted)
1038/// * `method`  — Imputation strategy
1039///
1040/// # Errors
1041/// * `FdarError::InvalidDimension` if `argvals.len() != data.ncols()`
1042/// * `FdarError::InvalidParameter` if any curve consists entirely of NaN values
1043pub fn impute_missing_values(
1044    data: &crate::matrix::FdMatrix,
1045    argvals: &[f64],
1046    method: ImputationMethod,
1047) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
1048    let (n, m) = data.shape();
1049    if argvals.len() != m {
1050        return Err(crate::FdarError::InvalidDimension {
1051            parameter: "argvals",
1052            expected: format!("{m}"),
1053            actual: format!("{}", argvals.len()),
1054        });
1055    }
1056    // WR-01: A zero-column matrix has no evaluation points and is degenerate.
1057    // Without this guard the per-curve loop would report "curve 0 contains only NaN values",
1058    // which is factually incorrect (the curve has no values at all).
1059    if m == 0 {
1060        return Err(crate::FdarError::InvalidDimension {
1061            parameter: "data",
1062            expected: "m >= 1".to_string(),
1063            actual: "m=0".to_string(),
1064        });
1065    }
1066    let mut out_data = vec![0.0_f64; n * m]; // column-major output buffer
1067    for i in 0..n {
1068        let row: Vec<f64> = data.row(i);
1069        let valid_count = row.iter().filter(|v| !v.is_nan()).count();
1070        if valid_count == 0 {
1071            return Err(crate::FdarError::InvalidParameter {
1072                parameter: "data",
1073                message: format!("curve {i} contains only NaN values"),
1074            });
1075        }
1076        let imputed = impute_row(&row, argvals, &method);
1077        for j in 0..m {
1078            out_data[i + j * n] = imputed[j]; // column-major write
1079        }
1080    }
1081    crate::matrix::FdMatrix::from_column_major(out_data, n, m)
1082}
1083
1084/// Impute a single row (curve) of NaN values using the given strategy.
1085fn impute_row(row: &[f64], argvals: &[f64], method: &ImputationMethod) -> Vec<f64> {
1086    let mut result = row.to_vec();
1087    match method {
1088        ImputationMethod::Mean => {
1089            let sum: f64 = row.iter().filter(|v| !v.is_nan()).sum();
1090            let count = row.iter().filter(|v| !v.is_nan()).count();
1091            let mean = sum / count as f64;
1092            for v in &mut result {
1093                if v.is_nan() {
1094                    *v = mean;
1095                }
1096            }
1097        }
1098        ImputationMethod::Constant(c) => {
1099            for v in &mut result {
1100                if v.is_nan() {
1101                    *v = *c;
1102                }
1103            }
1104        }
1105        ImputationMethod::Linear => {
1106            let valid_idxs: Vec<usize> = (0..row.len()).filter(|&j| !row[j].is_nan()).collect();
1107            for j in 0..row.len() {
1108                if result[j].is_nan() {
1109                    let left = valid_idxs.iter().rev().find(|&&k| k < j).copied();
1110                    let right = valid_idxs.iter().find(|&&k| k > j).copied();
1111                    result[j] = match (left, right) {
1112                        (Some(l), Some(r)) => {
1113                            linear_interp(&[argvals[l], argvals[r]], &[row[l], row[r]], argvals[j])
1114                        }
1115                        (Some(l), None) => row[l], // boundary fill (trailing NaN)
1116                        (None, Some(r)) => row[r], // boundary fill (leading NaN)
1117                        (None, None) => unreachable!(), // all-NaN already rejected
1118                    };
1119                }
1120            }
1121        }
1122    }
1123    result
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129
1130    #[test]
1131    fn test_simpsons_weights_uniform() {
1132        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1133        let weights = simpsons_weights(&argvals);
1134        let sum: f64 = weights.iter().sum();
1135        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
1136    }
1137
1138    #[test]
1139    fn test_simpsons_weights_2d() {
1140        let argvals_s = vec![0.0, 0.5, 1.0];
1141        let argvals_t = vec![0.0, 0.5, 1.0];
1142        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
1143        let sum: f64 = weights.iter().sum();
1144        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
1145    }
1146
1147    #[test]
1148    fn test_extract_curves() {
1149        // Column-major data: 2 observations, 3 points
1150        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
1151        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
1152        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
1153        let curves = extract_curves(&mat);
1154        assert_eq!(curves.len(), 2);
1155        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
1156        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
1157    }
1158
1159    #[test]
1160    fn test_l2_distance_identical() {
1161        let curve = vec![1.0, 2.0, 3.0];
1162        let weights = vec![0.25, 0.5, 0.25];
1163        let dist = l2_distance(&curve, &curve, &weights);
1164        assert!(dist.abs() < NUMERICAL_EPS);
1165    }
1166
1167    #[test]
1168    fn test_l2_distance_different() {
1169        let curve1 = vec![0.0, 0.0, 0.0];
1170        let curve2 = vec![1.0, 1.0, 1.0];
1171        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
1172        let dist = l2_distance(&curve1, &curve2, &weights);
1173        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
1174        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
1175    }
1176
1177    #[test]
1178    fn test_n1_weights() {
1179        // Single point: fallback weight is 1.0 (degenerate case)
1180        let w = simpsons_weights(&[0.5]);
1181        assert_eq!(w.len(), 1);
1182        assert!((w[0] - 1.0).abs() < 1e-12);
1183    }
1184
1185    #[test]
1186    fn test_n2_weights() {
1187        let w = simpsons_weights(&[0.0, 1.0]);
1188        assert_eq!(w.len(), 2);
1189        // Trapezoidal: each weight should be 0.5
1190        assert!((w[0] - 0.5).abs() < 1e-12);
1191        assert!((w[1] - 0.5).abs() < 1e-12);
1192    }
1193
1194    #[test]
1195    fn test_mismatched_l2_distance() {
1196        // Mismatched lengths should not panic but may give garbage
1197        let a = vec![1.0, 2.0, 3.0];
1198        let b = vec![1.0, 2.0, 3.0];
1199        let w = vec![0.5, 0.5, 0.5];
1200        let d = l2_distance(&a, &b, &w);
1201        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
1202    }
1203
1204    // ── trapz ──
1205
1206    #[test]
1207    fn test_trapz_sine() {
1208        // ∫₀^π sin(x) dx = 2
1209        let m = 1000;
1210        let x: Vec<f64> = (0..m)
1211            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
1212            .collect();
1213        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
1214        let result = trapz(&y, &x);
1215        assert!(
1216            (result - 2.0).abs() < 1e-4,
1217            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
1218        );
1219    }
1220
1221    // ── cumulative_trapz ──
1222
1223    #[test]
1224    fn test_cumulative_trapz_matches_final() {
1225        let m = 100;
1226        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1227        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
1228        let cum = cumulative_trapz(&y, &x);
1229        let total = trapz(&y, &x);
1230        assert!(
1231            (cum[m - 1] - total).abs() < 1e-12,
1232            "Final cumulative value should match trapz"
1233        );
1234    }
1235
1236    // ── linear_interp ──
1237
1238    #[test]
1239    fn test_linear_interp_boundary_clamp() {
1240        let x = vec![0.0, 0.5, 1.0];
1241        let y = vec![10.0, 20.0, 30.0];
1242        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
1243        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
1244        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
1245    }
1246
1247    // ── gradient_uniform ──
1248
1249    #[test]
1250    fn test_gradient_uniform_linear() {
1251        // f(x) = 3x → f'(x) = 3 everywhere
1252        let m = 50;
1253        let h = 1.0 / (m - 1) as f64;
1254        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
1255        let g = gradient_uniform(&y, h);
1256        for i in 0..m {
1257            assert!(
1258                (g[i] - 3.0).abs() < 1e-10,
1259                "gradient of 3x should be 3 at i={i}, got {}",
1260                g[i]
1261            );
1262        }
1263    }
1264
1265    // ── fdata_interpolate ──
1266
1267    #[test]
1268    fn test_gaussian_kernel() {
1269        assert!((gaussian_kernel(0.0, 1.0) - 1.0).abs() < 1e-12);
1270        assert!(gaussian_kernel(3.0, 1.0) < 0.02); // far from center
1271        assert!((gaussian_kernel(1.0, 0.0)).abs() < 1e-12); // zero bandwidth
1272    }
1273
1274    #[test]
1275    fn test_bandwidth_candidates() {
1276        let n = 5;
1277        let mut dists = vec![0.0; n * n];
1278        for i in 0..n {
1279            for j in 0..n {
1280                dists[i * n + j] = (i as f64 - j as f64).abs();
1281            }
1282        }
1283        let cands = bandwidth_candidates_from_dists(&dists, n, 10);
1284        assert!(!cands.is_empty());
1285        assert!(cands.iter().all(|&h| h > 0.0));
1286        // Should be sorted
1287        for w in cands.windows(2) {
1288            assert!(w[1] >= w[0]);
1289        }
1290    }
1291
1292    #[test]
1293    fn test_quantile_sorted() {
1294        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1295        assert!((quantile_sorted(&data, 0.0) - 1.0).abs() < 1e-12);
1296        assert!((quantile_sorted(&data, 1.0) - 5.0).abs() < 1e-12);
1297        assert!((quantile_sorted(&data, 0.5) - 3.0).abs() < 1e-12);
1298        assert!((quantile_sorted(&data, 0.25) - 2.0).abs() < 1e-12);
1299    }
1300
1301    #[test]
1302    fn test_r_squared_perfect() {
1303        let y = vec![1.0, 2.0, 3.0, 4.0];
1304        let resid = vec![0.0, 0.0, 0.0, 0.0];
1305        assert!((r_squared(&y, &resid) - 1.0).abs() < 1e-12);
1306    }
1307
1308    #[test]
1309    fn test_r_squared_mean_model() {
1310        let y = vec![1.0, 2.0, 3.0, 4.0];
1311        let mean = 2.5;
1312        let resid: Vec<f64> = y.iter().map(|&yi| yi - mean).collect();
1313        assert!(r_squared(&y, &resid).abs() < 1e-12); // R²=0 for mean model
1314    }
1315
1316    #[test]
1317    fn test_aic_bic() {
1318        let a = aic(100, 50.0, 5);
1319        let b = bic(100, 50.0, 5);
1320        assert!(a.is_finite());
1321        assert!(b.is_finite());
1322        assert!(b > a); // BIC penalizes more for n > ~8
1323    }
1324
1325    #[test]
1326    fn fdata_interpolate_linear_identity() {
1327        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
1328        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
1329        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1330        let result = fdata_interpolate(&data, &t, &t, InterpolationMethod::Linear);
1331        for j in 0..20 {
1332            assert!((result[(0, j)] - data[(0, j)]).abs() < 1e-12);
1333        }
1334    }
1335
1336    #[test]
1337    fn fdata_interpolate_cubic_hermite_smooth() {
1338        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
1339        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
1340        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1341
1342        let t_fine: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
1343        let result = fdata_interpolate(&data, &t, &t_fine, InterpolationMethod::CubicHermite);
1344
1345        // Values should approximate sin(t) well
1346        for (j, &tj) in t_fine.iter().enumerate() {
1347            assert!(
1348                (result[(0, j)] - tj.sin()).abs() < 0.02,
1349                "at t={tj:.2}: got {:.4}, expected {:.4}",
1350                result[(0, j)],
1351                tj.sin()
1352            );
1353        }
1354    }
1355
1356    #[test]
1357    fn fdata_interpolate_multiple_curves() {
1358        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
1359        let n = 5;
1360        let m = 30;
1361        // Build column-major data: n curves, each sin((i+1)*x)
1362        let mut col_major = vec![0.0; n * m];
1363        for i in 0..n {
1364            for j in 0..m {
1365                col_major[i + j * n] = ((i + 1) as f64 * t[j]).sin();
1366            }
1367        }
1368        let data = crate::matrix::FdMatrix::from_column_major(col_major, n, m).unwrap();
1369
1370        let t_new: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
1371        let result = fdata_interpolate(&data, &t, &t_new, InterpolationMethod::Linear);
1372        assert_eq!(result.shape(), (n, 50));
1373        // All values should be finite
1374        for i in 0..n {
1375            for j in 0..50 {
1376                assert!(result[(i, j)].is_finite());
1377            }
1378        }
1379    }
1380
1381    // ── spline_interpolate ──
1382
1383    #[test]
1384    fn spline_interpolate_reproduces_argvals() {
1385        use crate::test_helpers::uniform_grid;
1386        let t = uniform_grid(20);
1387        let vals: Vec<f64> = t.iter().map(|&x| x.powi(3)).collect();
1388        // column-major: 1 row, 20 columns
1389        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1390        let result = spline_interpolate(&data, &t, &t, 4).unwrap();
1391        for j in 0..20 {
1392            assert!(
1393                (result[(0, j)] - data[(0, j)]).abs() < 1e-10,
1394                "at j={j}: got {}, expected {}",
1395                result[(0, j)],
1396                data[(0, j)]
1397            );
1398        }
1399    }
1400
1401    #[test]
1402    fn spline_interpolate_cubic_offgrid() {
1403        // A cubic polynomial y = 2t^3 - t^2 + 0.5t - 0.1 lies exactly in the
1404        // order-4 B-spline space; an order-4 interpolant should reproduce it
1405        // within 1e-10 at off-grid midpoints.
1406        use crate::test_helpers::uniform_grid;
1407        let t = uniform_grid(20); // 20 evaluation points in [0, 1]
1408        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
1409        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
1410        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1411
1412        // Query at off-grid midpoints between consecutive t values
1413        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
1414        let result = spline_interpolate(&data, &t, &q, 4).unwrap();
1415
1416        for (j, &qj) in q.iter().enumerate() {
1417            let expected = poly(qj);
1418            let got = result[(0, j)];
1419            assert!(
1420                (got - expected).abs() < 1e-10,
1421                "off-grid at q={qj:.4}: got {got}, expected {expected}"
1422            );
1423        }
1424    }
1425
1426    #[test]
1427    fn spline_interpolate_rejects_out_of_range() {
1428        use crate::test_helpers::uniform_grid;
1429        let t = uniform_grid(20);
1430        let vals: Vec<f64> = t.to_vec();
1431        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1432
1433        // Query point below argvals[0]
1434        let q_below = vec![-0.1_f64];
1435        let err = spline_interpolate(&data, &t, &q_below, 4).unwrap_err();
1436        assert!(
1437            matches!(
1438                err,
1439                crate::FdarError::InvalidParameter {
1440                    parameter: "query_points",
1441                    ..
1442                }
1443            ),
1444            "expected InvalidParameter for query below domain, got {err:?}"
1445        );
1446
1447        // Query point above argvals[m-1]
1448        let q_above = vec![1.1_f64];
1449        let err2 = spline_interpolate(&data, &t, &q_above, 4).unwrap_err();
1450        assert!(
1451            matches!(
1452                err2,
1453                crate::FdarError::InvalidParameter {
1454                    parameter: "query_points",
1455                    ..
1456                }
1457            ),
1458            "expected InvalidParameter for query above domain, got {err2:?}"
1459        );
1460    }
1461
1462    #[test]
1463    fn spline_interpolate_rejects_bad_order() {
1464        use crate::test_helpers::uniform_grid;
1465        let t = uniform_grid(20);
1466        let vals: Vec<f64> = t.to_vec();
1467        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1468        let q = vec![0.5_f64];
1469
1470        // order == 0
1471        let err = spline_interpolate(&data, &t, &q, 0).unwrap_err();
1472        assert!(
1473            matches!(
1474                err,
1475                crate::FdarError::InvalidParameter {
1476                    parameter: "order",
1477                    ..
1478                }
1479            ),
1480            "expected InvalidParameter for order=0, got {err:?}"
1481        );
1482
1483        // order >= m (m=20)
1484        let err2 = spline_interpolate(&data, &t, &q, 20).unwrap_err();
1485        assert!(
1486            matches!(
1487                err2,
1488                crate::FdarError::InvalidParameter {
1489                    parameter: "order",
1490                    ..
1491                }
1492            ),
1493            "expected InvalidParameter for order=20 (>=m=20), got {err2:?}"
1494        );
1495    }
1496
1497    #[test]
1498    fn spline_interpolate_rejects_dim_mismatch() {
1499        use crate::test_helpers::uniform_grid;
1500        let t = uniform_grid(20);
1501        let vals: Vec<f64> = t.to_vec();
1502        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1503
1504        // argvals.len() != data.ncols()
1505        let bad_argvals: Vec<f64> = (0..15).map(|i| i as f64 / 14.0).collect();
1506        let q = vec![0.5_f64];
1507        let err = spline_interpolate(&data, &bad_argvals, &q, 4).unwrap_err();
1508        assert!(
1509            matches!(
1510                err,
1511                crate::FdarError::InvalidDimension {
1512                    parameter: "argvals",
1513                    ..
1514                }
1515            ),
1516            "expected InvalidDimension for argvals mismatch, got {err:?}"
1517        );
1518
1519        // empty query_points
1520        let err2 = spline_interpolate(&data, &t, &[], 4).unwrap_err();
1521        assert!(
1522            matches!(
1523                err2,
1524                crate::FdarError::InvalidDimension {
1525                    parameter: "query_points",
1526                    ..
1527                }
1528            ),
1529            "expected InvalidDimension for empty query_points, got {err2:?}"
1530        );
1531    }
1532
1533    // ── spline_interpolate_with_policy tests ──────────────────────────────
1534
1535    #[test]
1536    fn test_spline_with_policy_in_range_matches_spline() {
1537        // In-range queries must match spline_interpolate exactly regardless of policy.
1538        use crate::test_helpers::uniform_grid;
1539        let t = uniform_grid(20);
1540        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
1541        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
1542        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1543        // Off-grid midpoints (all in-range)
1544        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
1545        let expected = spline_interpolate(&data, &t, &q, 4).unwrap();
1546        let actual =
1547            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Boundary)
1548                .unwrap();
1549        for j in 0..q.len() {
1550            assert!(
1551                (actual[(0, j)] - expected[(0, j)]).abs() < 1e-10,
1552                "in-range mismatch at j={j}: policy={} vs plain={}",
1553                actual[(0, j)],
1554                expected[(0, j)]
1555            );
1556        }
1557    }
1558
1559    #[test]
1560    fn test_spline_with_policy_boundary() {
1561        // OOB queries clamped to nearest boundary value.
1562        use crate::test_helpers::uniform_grid;
1563        let t = uniform_grid(20); // [0, 1]
1564        let vals: Vec<f64> = t.iter().map(|&x| x.powi(2)).collect(); // y = x^2
1565        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1566        // Query below (should clamp to t_min=0 → y=0) and above (clamp to t_max=1 → y≈1).
1567        let q = vec![-0.5_f64, 0.5, 1.5];
1568        let result =
1569            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Boundary)
1570                .unwrap();
1571        // Clamped to 0 → y = 0^2 = 0 (within spline tolerance)
1572        assert!(
1573            result[(0, 0)].abs() < 1e-9,
1574            "below boundary should clamp, got {}",
1575            result[(0, 0)]
1576        );
1577        // Clamped to 1 → y = 1^2 = 1 (within spline tolerance)
1578        assert!(
1579            (result[(0, 2)] - 1.0).abs() < 1e-9,
1580            "above boundary should clamp, got {}",
1581            result[(0, 2)]
1582        );
1583        // In-range 0.5 → y ≈ 0.25
1584        assert!(
1585            (result[(0, 1)] - 0.25).abs() < 1e-9,
1586            "in-range should be ~0.25, got {}",
1587            result[(0, 1)]
1588        );
1589    }
1590
1591    #[test]
1592    fn test_spline_with_policy_exception() {
1593        // Exception policy errors on OOB, matches spline_interpolate behavior.
1594        use crate::test_helpers::uniform_grid;
1595        let t = uniform_grid(20);
1596        let vals: Vec<f64> = t.to_vec();
1597        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1598        let q_oob = vec![1.5_f64];
1599        let err =
1600            spline_interpolate_with_policy(&data, &t, &q_oob, 4, ExtrapolationPolicy::Exception)
1601                .unwrap_err();
1602        assert!(
1603            matches!(
1604                err,
1605                crate::FdarError::InvalidParameter {
1606                    parameter: "query_points",
1607                    ..
1608                }
1609            ),
1610            "Exception policy should error on OOB, got {err:?}"
1611        );
1612        // In-range queries with Exception policy should succeed.
1613        let q_ok = vec![0.0_f64, 0.5, 1.0];
1614        let ok =
1615            spline_interpolate_with_policy(&data, &t, &q_ok, 4, ExtrapolationPolicy::Exception);
1616        assert!(
1617            ok.is_ok(),
1618            "Exception policy should succeed for in-range queries"
1619        );
1620    }
1621
1622    #[test]
1623    fn test_spline_with_policy_fill() {
1624        // Fill policy: OOB cells get constant fill value; in-range cells use spline.
1625        use crate::test_helpers::uniform_grid;
1626        let t = uniform_grid(20); // [0, 1]
1627        let vals: Vec<f64> = t.iter().map(|&x| x.powi(2)).collect();
1628        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1629        let fill_val = 42.0_f64;
1630        let q = vec![-0.5_f64, 0.5, 2.0];
1631        let result =
1632            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Fill(fill_val))
1633                .unwrap();
1634        assert!(
1635            (result[(0, 0)] - fill_val).abs() < 1e-10,
1636            "OOB below should be fill value, got {}",
1637            result[(0, 0)]
1638        );
1639        assert!(
1640            (result[(0, 2)] - fill_val).abs() < 1e-10,
1641            "OOB above should be fill value, got {}",
1642            result[(0, 2)]
1643        );
1644        // In-range: y ≈ 0.25
1645        assert!(
1646            (result[(0, 1)] - 0.25).abs() < 1e-9,
1647            "in-range should be ~0.25, got {}",
1648            result[(0, 1)]
1649        );
1650    }
1651
1652    #[test]
1653    fn test_spline_with_policy_periodic() {
1654        // Periodic policy: OOB queries wrap modulo domain length.
1655        // Use y = x (linear) on [0, 1]; a query at 1.3 should wrap to 0.3.
1656        use crate::test_helpers::uniform_grid;
1657        let t = uniform_grid(20); // [0, 1]
1658        let vals: Vec<f64> = t.to_vec(); // y = x
1659        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1660        let q = vec![1.3_f64];
1661        let result =
1662            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Periodic)
1663                .unwrap();
1664        // Expected: wrap 1.3 → 0.3, spline of y=x at 0.3 ≈ 0.3
1665        assert!(
1666            (result[(0, 0)] - 0.3).abs() < 1e-9,
1667            "Periodic wrap of 1.3 should give ~0.3, got {}",
1668            result[(0, 0)]
1669        );
1670        // t = -0.2 → wrap to 0.8
1671        let q2 = vec![-0.2_f64];
1672        let result2 =
1673            spline_interpolate_with_policy(&data, &t, &q2, 4, ExtrapolationPolicy::Periodic)
1674                .unwrap();
1675        assert!(
1676            (result2[(0, 0)] - 0.8).abs() < 1e-9,
1677            "Periodic wrap of -0.2 should give ~0.8, got {}",
1678            result2[(0, 0)]
1679        );
1680    }
1681
1682    #[test]
1683    fn test_spline_with_policy_periodic_zero_length_domain_errors() {
1684        // Periodic + zero-length domain must error (same guard as fdata_interpolate_with_policy).
1685        let argvals = vec![3.0_f64, 3.0, 3.0];
1686        let vals = vec![1.0_f64, 1.0, 1.0];
1687        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 3).unwrap();
1688        let q = vec![4.0_f64]; // OOB
1689        let err =
1690            spline_interpolate_with_policy(&data, &argvals, &q, 1, ExtrapolationPolicy::Periodic)
1691                .unwrap_err();
1692        assert!(
1693            matches!(
1694                err,
1695                crate::FdarError::InvalidParameter {
1696                    parameter: "argvals",
1697                    ..
1698                }
1699            ),
1700            "Periodic + zero-length domain should error, got {err:?}"
1701        );
1702    }
1703
1704    // ── ExtrapolationPolicy tests ──────────────────────────────────────────
1705
1706    /// Build a 1-curve FdMatrix: y = x on [0, 1] with `n_pts` points.
1707    fn make_linear_curve(n_pts: usize) -> (crate::matrix::FdMatrix, Vec<f64>) {
1708        use crate::test_helpers::uniform_grid;
1709        let t = uniform_grid(n_pts);
1710        let vals: Vec<f64> = t.to_vec();
1711        let mat = crate::matrix::FdMatrix::from_column_major(vals, 1, n_pts).unwrap();
1712        (mat, t)
1713    }
1714
1715    #[test]
1716    fn test_extrapolation_boundary() {
1717        let (data, t) = make_linear_curve(11); // y=x on [0,1]
1718                                               // Query at t=-0.2 (below) and t=1.3 (above)
1719        let q = vec![-0.2_f64, 0.5, 1.3];
1720        let result = fdata_interpolate_with_policy(
1721            &data,
1722            &t,
1723            &q,
1724            InterpolationMethod::Linear,
1725            ExtrapolationPolicy::Boundary,
1726        )
1727        .unwrap();
1728        // Clamped to t_min=0.0 → y=0.0
1729        assert!(
1730            (result[(0, 0)] - 0.0).abs() < 1e-10,
1731            "below boundary should clamp to 0"
1732        );
1733        // In-range: y=0.5
1734        assert!(
1735            (result[(0, 1)] - 0.5).abs() < 1e-10,
1736            "in-range should interpolate correctly"
1737        );
1738        // Clamped to t_max=1.0 → y=1.0
1739        assert!(
1740            (result[(0, 2)] - 1.0).abs() < 1e-10,
1741            "above boundary should clamp to 1"
1742        );
1743    }
1744
1745    #[test]
1746    fn test_extrapolation_exception() {
1747        let (data, t) = make_linear_curve(11);
1748        let q_bad = vec![1.5_f64]; // out of range
1749        let err = fdata_interpolate_with_policy(
1750            &data,
1751            &t,
1752            &q_bad,
1753            InterpolationMethod::Linear,
1754            ExtrapolationPolicy::Exception,
1755        )
1756        .unwrap_err();
1757        assert!(
1758            matches!(
1759                err,
1760                crate::FdarError::InvalidParameter {
1761                    parameter: "new_argvals",
1762                    ..
1763                }
1764            ),
1765            "expected InvalidParameter for OOB query, got {err:?}"
1766        );
1767
1768        // In-range should still work with Exception policy
1769        let q_ok = vec![0.0_f64, 0.5, 1.0];
1770        let result = fdata_interpolate_with_policy(
1771            &data,
1772            &t,
1773            &q_ok,
1774            InterpolationMethod::Linear,
1775            ExtrapolationPolicy::Exception,
1776        )
1777        .unwrap();
1778        assert!((result[(0, 1)] - 0.5).abs() < 1e-10);
1779    }
1780
1781    #[test]
1782    fn test_extrapolation_fill() {
1783        let (data, t) = make_linear_curve(11);
1784        let fill_val = 99.0_f64;
1785        let q = vec![-0.5_f64, 0.5, 2.0];
1786        let result = fdata_interpolate_with_policy(
1787            &data,
1788            &t,
1789            &q,
1790            InterpolationMethod::Linear,
1791            ExtrapolationPolicy::Fill(fill_val),
1792        )
1793        .unwrap();
1794        assert!(
1795            (result[(0, 0)] - fill_val).abs() < 1e-10,
1796            "below range should be fill value"
1797        );
1798        assert!(
1799            (result[(0, 1)] - 0.5).abs() < 1e-10,
1800            "in-range should interpolate"
1801        );
1802        assert!(
1803            (result[(0, 2)] - fill_val).abs() < 1e-10,
1804            "above range should be fill value"
1805        );
1806    }
1807
1808    #[test]
1809    fn test_extrapolation_periodic() {
1810        let (data, t) = make_linear_curve(11); // y=x on [0,1], domain_len=1
1811                                               // t = -0.1 should wrap to 0.9 (y=0.9)
1812        let q = vec![-0.1_f64, 0.5, 1.1];
1813        let result = fdata_interpolate_with_policy(
1814            &data,
1815            &t,
1816            &q,
1817            InterpolationMethod::Linear,
1818            ExtrapolationPolicy::Periodic,
1819        )
1820        .unwrap();
1821        // wrapped: ((−0.1 − 0) % 1 + 1) % 1 = (−0.1 + 1) % 1 = 0.9 % 1 = 0.9
1822        assert!(
1823            (result[(0, 0)] - 0.9).abs() < 1e-9,
1824            "t=-0.1 should wrap to 0.9, got {}",
1825            result[(0, 0)]
1826        );
1827        assert!(
1828            (result[(0, 1)] - 0.5).abs() < 1e-10,
1829            "in-range point unchanged"
1830        );
1831        // t=1.1 → ((1.1 − 0) % 1 + 1) % 1 = (0.1 + 1) % 1 = 0.1
1832        assert!(
1833            (result[(0, 2)] - 0.1).abs() < 1e-9,
1834            "t=1.1 should wrap to 0.1, got {}",
1835            result[(0, 2)]
1836        );
1837    }
1838
1839    #[test]
1840    fn test_extrapolation_in_range_equivalence() {
1841        // In-range queries must match fdata_interpolate exactly
1842        let (data, t) = make_linear_curve(21);
1843        let q: Vec<f64> = (0..=10).map(|i| i as f64 / 10.0).collect();
1844        let expected = fdata_interpolate(&data, &t, &q, InterpolationMethod::Linear);
1845        let actual = fdata_interpolate_with_policy(
1846            &data,
1847            &t,
1848            &q,
1849            InterpolationMethod::Linear,
1850            ExtrapolationPolicy::Boundary,
1851        )
1852        .unwrap();
1853        let (_, m_new) = actual.shape();
1854        for j in 0..m_new {
1855            assert!(
1856                (actual[(0, j)] - expected[(0, j)]).abs() < 1e-12,
1857                "in-range mismatch at j={j}: policy={} vs plain={}",
1858                actual[(0, j)],
1859                expected[(0, j)]
1860            );
1861        }
1862    }
1863
1864    #[test]
1865    fn test_extrapolation_policy_dim_guard() {
1866        let (data, _t) = make_linear_curve(11);
1867        let bad_argvals: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect(); // len=5, ncols=11
1868        let q = vec![0.5_f64];
1869        let err = fdata_interpolate_with_policy(
1870            &data,
1871            &bad_argvals,
1872            &q,
1873            InterpolationMethod::Linear,
1874            ExtrapolationPolicy::Boundary,
1875        )
1876        .unwrap_err();
1877        assert!(
1878            matches!(
1879                err,
1880                crate::FdarError::InvalidDimension {
1881                    parameter: "argvals",
1882                    ..
1883                }
1884            ),
1885            "expected InvalidDimension for argvals mismatch, got {err:?}"
1886        );
1887    }
1888
1889    // ── ImputationMethod / impute_missing_values tests ────────────────────
1890
1891    /// Build a 1-curve FdMatrix from given values and a uniform grid.
1892    fn make_curve_with_vals(vals: Vec<f64>) -> (crate::matrix::FdMatrix, Vec<f64>) {
1893        let m = vals.len();
1894        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1895        let mat = crate::matrix::FdMatrix::from_column_major(vals, 1, m).unwrap();
1896        (mat, argvals)
1897    }
1898
1899    #[test]
1900    fn test_impute_linear() {
1901        // Curve: [0.0, NaN, 1.0] on argvals [0.0, 0.5, 1.0]
1902        // Linear between (0,0.0) and (1.0,1.0): at t=0.5 → 0.5
1903        let (data, argvals) = make_curve_with_vals(vec![0.0_f64, f64::NAN, 1.0]);
1904        let result = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap();
1905        // Hand-computed: linear_interp([0.0,1.0],[0.0,1.0],0.5) = 0.5
1906        assert!(
1907            (result[(0, 1)] - 0.5).abs() < 1e-10,
1908            "linear imputation should give 0.5, got {}",
1909            result[(0, 1)]
1910        );
1911        // Non-NaN entries unchanged
1912        assert!((result[(0, 0)] - 0.0).abs() < 1e-10);
1913        assert!((result[(0, 2)] - 1.0).abs() < 1e-10);
1914    }
1915
1916    #[test]
1917    fn test_impute_mean() {
1918        // Curve: [1.0, NaN, 3.0] → mean of non-NaN = 2.0
1919        let (data, argvals) = make_curve_with_vals(vec![1.0_f64, f64::NAN, 3.0]);
1920        let result = impute_missing_values(&data, &argvals, ImputationMethod::Mean).unwrap();
1921        assert!(
1922            (result[(0, 1)] - 2.0).abs() < 1e-10,
1923            "mean imputation should give 2.0, got {}",
1924            result[(0, 1)]
1925        );
1926    }
1927
1928    #[test]
1929    fn test_impute_constant() {
1930        // Curve: [1.0, NaN, 3.0] → constant 99.0
1931        let (data, argvals) = make_curve_with_vals(vec![1.0_f64, f64::NAN, 3.0]);
1932        let result =
1933            impute_missing_values(&data, &argvals, ImputationMethod::Constant(99.0)).unwrap();
1934        assert!(
1935            (result[(0, 1)] - 99.0).abs() < 1e-10,
1936            "constant imputation should give 99.0, got {}",
1937            result[(0, 1)]
1938        );
1939        // Non-NaN entries unchanged
1940        assert!((result[(0, 0)] - 1.0).abs() < 1e-10);
1941        assert!((result[(0, 2)] - 3.0).abs() < 1e-10);
1942    }
1943
1944    #[test]
1945    fn test_impute_all_nan() {
1946        // An all-NaN curve should return Err(InvalidParameter)
1947        let (data, argvals) = make_curve_with_vals(vec![f64::NAN, f64::NAN, f64::NAN]);
1948        let err = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap_err();
1949        assert!(
1950            matches!(
1951                err,
1952                crate::FdarError::InvalidParameter {
1953                    parameter: "data",
1954                    ..
1955                }
1956            ),
1957            "expected InvalidParameter for all-NaN curve, got {err:?}"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_impute_boundary_nan() {
1963        // Curve: [NaN, 0.5, 1.0] → leading NaN → boundary fill with 0.5
1964        let (data, argvals) = make_curve_with_vals(vec![f64::NAN, 0.5_f64, 1.0]);
1965        let result = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap();
1966        assert!(
1967            (result[(0, 0)] - 0.5).abs() < 1e-10,
1968            "leading NaN should be filled with nearest valid (0.5), got {}",
1969            result[(0, 0)]
1970        );
1971
1972        // Curve: [0.0, 0.5, NaN] → trailing NaN → boundary fill with 0.5
1973        let (data2, argvals2) = make_curve_with_vals(vec![0.0_f64, 0.5, f64::NAN]);
1974        let result2 = impute_missing_values(&data2, &argvals2, ImputationMethod::Linear).unwrap();
1975        assert!(
1976            (result2[(0, 2)] - 0.5).abs() < 1e-10,
1977            "trailing NaN should be filled with nearest valid (0.5), got {}",
1978            result2[(0, 2)]
1979        );
1980    }
1981
1982    // ── CR-01: Periodic + zero-length domain must error (not produce NaN) ────
1983
1984    #[test]
1985    fn test_extrapolation_periodic_zero_length_domain_errors() {
1986        // Domain [5.0, 5.0] has length 0 — Periodic would compute x % 0.0 = NaN without the guard.
1987        let degenerate_argvals = vec![5.0_f64, 5.0, 5.0];
1988        let vals = vec![1.0_f64, 1.0, 1.0];
1989        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 3).unwrap();
1990        // Any OOB query with Periodic on a zero-length domain must return Err.
1991        let q = vec![6.0_f64]; // outside [5.0, 5.0]
1992        let err = fdata_interpolate_with_policy(
1993            &data,
1994            &degenerate_argvals,
1995            &q,
1996            InterpolationMethod::Linear,
1997            ExtrapolationPolicy::Periodic,
1998        )
1999        .unwrap_err();
2000        assert!(
2001            matches!(
2002                err,
2003                crate::FdarError::InvalidParameter {
2004                    parameter: "argvals",
2005                    ..
2006                }
2007            ),
2008            "expected InvalidParameter for zero-length domain + Periodic, got {err:?}"
2009        );
2010    }
2011
2012    // ── WR-01: m=0 guard in impute_missing_values ─────────────────────────
2013
2014    #[test]
2015    fn test_impute_zero_columns_errors() {
2016        // A matrix with m=0 columns is degenerate; should return InvalidDimension, not "all-NaN".
2017        let data = crate::matrix::FdMatrix::zeros(2, 0);
2018        let argvals: Vec<f64> = vec![];
2019        let err = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap_err();
2020        assert!(
2021            matches!(
2022                err,
2023                crate::FdarError::InvalidDimension {
2024                    parameter: "data",
2025                    ..
2026                }
2027            ),
2028            "expected InvalidDimension for m=0 matrix, got {err:?}"
2029        );
2030    }
2031
2032    #[test]
2033    fn test_impute_dim_mismatch() {
2034        // argvals length != ncols
2035        let (data, _argvals) = make_curve_with_vals(vec![1.0, 2.0, 3.0]);
2036        let bad_argvals = vec![0.0_f64, 1.0]; // len=2, ncols=3
2037        let err = impute_missing_values(&data, &bad_argvals, ImputationMethod::Linear).unwrap_err();
2038        assert!(
2039            matches!(
2040                err,
2041                crate::FdarError::InvalidDimension {
2042                    parameter: "argvals",
2043                    ..
2044                }
2045            ),
2046            "expected InvalidDimension for argvals mismatch, got {err:?}"
2047        );
2048    }
2049}