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(crate) 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#[non_exhaustive]
883#[derive(Debug, Clone, PartialEq)]
884#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
885pub enum ExtrapolationPolicy {
886    /// Clamp the query point to the nearest boundary value.
887    ///
888    /// A query at `t < t_min` returns the interpolated value at `t_min`;
889    /// a query at `t > t_max` returns the interpolated value at `t_max`.
890    Boundary,
891    /// Return an error for any out-of-range query point.
892    ///
893    /// Returns `Err(FdarError::InvalidParameter { parameter: "new_argvals", .. })`
894    /// when a query point lies outside `[argvals[0], argvals[m-1]]`.
895    Exception,
896    /// Fill out-of-range cells with a constant value.
897    Fill(f64),
898    /// Wrap query points modulo the domain length (periodic extension).
899    ///
900    /// A query at `t_min - delta` returns the same value as `t_max - delta`.
901    /// Uses the guarded-modulo recipe `((t - t_min) % L + L) % L` to handle
902    /// negative remainders for `t < t_min`.
903    Periodic,
904}
905
906/// Interpolate functional data to a new grid with explicit extrapolation control.
907///
908/// Like [`fdata_interpolate`] but applies `policy` for any query point that falls
909/// outside the domain `[argvals[0], argvals[m-1]]` instead of silently clamping.
910/// In-range queries produce identical values to the [`fdata_interpolate`] path.
911///
912/// # Arguments
913/// * `data`       — Functional data matrix (`n × m`)
914/// * `argvals`    — Original evaluation points (length `m`, must be sorted)
915/// * `new_argvals`— New evaluation points (length `m_new`)
916/// * `method`     — Interpolation method for in-range (and `Boundary`/`Periodic`) points
917/// * `policy`     — Extrapolation policy for out-of-range query points
918///
919/// # Returns
920/// Interpolated matrix `(n × m_new)`
921///
922/// # Errors
923/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()`
924/// * `FdarError::InvalidParameter` — a query point is out of range and `policy ==
925///   ExtrapolationPolicy::Exception`
926pub fn fdata_interpolate_with_policy(
927    data: &crate::matrix::FdMatrix,
928    argvals: &[f64],
929    new_argvals: &[f64],
930    method: InterpolationMethod,
931    policy: ExtrapolationPolicy,
932) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
933    let (n, m) = data.shape();
934    if argvals.len() != m {
935        return Err(crate::FdarError::InvalidDimension {
936            parameter: "argvals",
937            expected: format!("{m}"),
938            actual: format!("{}", argvals.len()),
939        });
940    }
941    let m_new = new_argvals.len();
942    if n == 0 || m < 2 || m_new == 0 {
943        return Ok(crate::matrix::FdMatrix::zeros(n.max(1), m_new.max(1)));
944    }
945    let t_min = argvals[0];
946    let t_max = argvals[m - 1];
947    let domain_len = t_max - t_min;
948
949    // CR-01: Guard degenerate domain before the loop — Periodic wraps via modulo domain_len
950    // and produces NaN when domain_len == 0 (IEEE 754: x % 0.0 = NaN).  Other policies do
951    // not divide by domain_len so only Periodic needs this guard.
952    if domain_len <= 0.0 && matches!(policy, ExtrapolationPolicy::Periodic) {
953        return Err(crate::FdarError::InvalidParameter {
954            parameter: "argvals",
955            message: "Periodic extrapolation requires a positive domain length \
956                      (argvals[0] < argvals[m-1])"
957                .to_string(),
958        });
959    }
960
961    let mut result = crate::matrix::FdMatrix::zeros(n, m_new);
962    for i in 0..n {
963        let y: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
964        for (j, &t) in new_argvals.iter().enumerate() {
965            let in_range = t >= t_min && t <= t_max;
966            result[(i, j)] = if in_range {
967                match method {
968                    InterpolationMethod::Linear => linear_interp(argvals, &y, t),
969                    InterpolationMethod::CubicHermite => cubic_hermite_interp(argvals, &y, t),
970                }
971            } else {
972                match &policy {
973                    ExtrapolationPolicy::Boundary => {
974                        let t_clamped = t.clamp(t_min, t_max);
975                        match method {
976                            InterpolationMethod::Linear => linear_interp(argvals, &y, t_clamped),
977                            InterpolationMethod::CubicHermite => {
978                                cubic_hermite_interp(argvals, &y, t_clamped)
979                            }
980                        }
981                    }
982                    ExtrapolationPolicy::Exception => {
983                        return Err(crate::FdarError::InvalidParameter {
984                            parameter: "new_argvals",
985                            message: format!("query {t} is outside domain [{t_min}, {t_max}]"),
986                        });
987                    }
988                    ExtrapolationPolicy::Fill(v) => *v,
989                    ExtrapolationPolicy::Periodic => {
990                        let wrapped = t_min + ((t - t_min) % domain_len + domain_len) % domain_len;
991                        match method {
992                            InterpolationMethod::Linear => linear_interp(argvals, &y, wrapped),
993                            InterpolationMethod::CubicHermite => {
994                                cubic_hermite_interp(argvals, &y, wrapped)
995                            }
996                        }
997                    }
998                }
999            };
1000        }
1001    }
1002    Ok(result)
1003}
1004
1005// ── FEAT-03: NaN imputation ────────────────────────────────────────────────
1006
1007/// Strategy for in-grid NaN imputation in a regular `FdMatrix`.
1008///
1009/// Used with [`impute_missing_values`] to specify how NaN entries are replaced
1010/// in each curve.
1011///
1012/// Leading or trailing NaN values (no neighbor on one side) are filled with
1013/// the nearest valid value (boundary extension) for the `Linear` strategy.
1014#[non_exhaustive]
1015#[derive(Debug, Clone, PartialEq)]
1016#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1017pub enum ImputationMethod {
1018    /// Linear interpolation between the nearest non-NaN neighbors.
1019    ///
1020    /// For gaps at the boundary (leading or trailing NaN), the nearest valid
1021    /// value is used as boundary extension.
1022    Linear,
1023    /// Replace each NaN with the curve's mean of its non-NaN values.
1024    Mean,
1025    /// Replace each NaN with a user-supplied constant value.
1026    Constant(f64),
1027}
1028
1029/// Impute NaN values in a regular functional data matrix.
1030///
1031/// Returns a new `FdMatrix` with NaN entries replaced according to `method`.
1032/// Non-NaN entries are copied through unchanged.
1033///
1034/// For `Linear`, gaps at the curve boundary (no neighbor on one side) are
1035/// filled with the nearest valid value (boundary extension).
1036///
1037/// # Arguments
1038/// * `data`    — Functional data matrix (`n × m`) with possible NaN entries
1039/// * `argvals` — Evaluation points (length `m`, must be sorted)
1040/// * `method`  — Imputation strategy
1041///
1042/// # Errors
1043/// * `FdarError::InvalidDimension` if `argvals.len() != data.ncols()`
1044/// * `FdarError::InvalidParameter` if any curve consists entirely of NaN values
1045pub fn impute_missing_values(
1046    data: &crate::matrix::FdMatrix,
1047    argvals: &[f64],
1048    method: ImputationMethod,
1049) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
1050    let (n, m) = data.shape();
1051    if argvals.len() != m {
1052        return Err(crate::FdarError::InvalidDimension {
1053            parameter: "argvals",
1054            expected: format!("{m}"),
1055            actual: format!("{}", argvals.len()),
1056        });
1057    }
1058    // WR-01: A zero-column matrix has no evaluation points and is degenerate.
1059    // Without this guard the per-curve loop would report "curve 0 contains only NaN values",
1060    // which is factually incorrect (the curve has no values at all).
1061    if m == 0 {
1062        return Err(crate::FdarError::InvalidDimension {
1063            parameter: "data",
1064            expected: "m >= 1".to_string(),
1065            actual: "m=0".to_string(),
1066        });
1067    }
1068    let mut out_data = vec![0.0_f64; n * m]; // column-major output buffer
1069    for i in 0..n {
1070        let row: Vec<f64> = data.row(i);
1071        let valid_count = row.iter().filter(|v| !v.is_nan()).count();
1072        if valid_count == 0 {
1073            return Err(crate::FdarError::InvalidParameter {
1074                parameter: "data",
1075                message: format!("curve {i} contains only NaN values"),
1076            });
1077        }
1078        let imputed = impute_row(&row, argvals, &method);
1079        for j in 0..m {
1080            out_data[i + j * n] = imputed[j]; // column-major write
1081        }
1082    }
1083    crate::matrix::FdMatrix::from_column_major(out_data, n, m)
1084}
1085
1086/// Impute a single row (curve) of NaN values using the given strategy.
1087fn impute_row(row: &[f64], argvals: &[f64], method: &ImputationMethod) -> Vec<f64> {
1088    let mut result = row.to_vec();
1089    match method {
1090        ImputationMethod::Mean => {
1091            let sum: f64 = row.iter().filter(|v| !v.is_nan()).sum();
1092            let count = row.iter().filter(|v| !v.is_nan()).count();
1093            let mean = sum / count as f64;
1094            for v in &mut result {
1095                if v.is_nan() {
1096                    *v = mean;
1097                }
1098            }
1099        }
1100        ImputationMethod::Constant(c) => {
1101            for v in &mut result {
1102                if v.is_nan() {
1103                    *v = *c;
1104                }
1105            }
1106        }
1107        ImputationMethod::Linear => {
1108            let valid_idxs: Vec<usize> = (0..row.len()).filter(|&j| !row[j].is_nan()).collect();
1109            for j in 0..row.len() {
1110                if result[j].is_nan() {
1111                    let left = valid_idxs.iter().rev().find(|&&k| k < j).copied();
1112                    let right = valid_idxs.iter().find(|&&k| k > j).copied();
1113                    result[j] = match (left, right) {
1114                        (Some(l), Some(r)) => {
1115                            linear_interp(&[argvals[l], argvals[r]], &[row[l], row[r]], argvals[j])
1116                        }
1117                        (Some(l), None) => row[l], // boundary fill (trailing NaN)
1118                        (None, Some(r)) => row[r], // boundary fill (leading NaN)
1119                        (None, None) => unreachable!(), // all-NaN already rejected
1120                    };
1121                }
1122            }
1123        }
1124    }
1125    result
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131
1132    #[test]
1133    fn test_simpsons_weights_uniform() {
1134        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1135        let weights = simpsons_weights(&argvals);
1136        let sum: f64 = weights.iter().sum();
1137        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
1138    }
1139
1140    #[test]
1141    fn test_simpsons_weights_2d() {
1142        let argvals_s = vec![0.0, 0.5, 1.0];
1143        let argvals_t = vec![0.0, 0.5, 1.0];
1144        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
1145        let sum: f64 = weights.iter().sum();
1146        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
1147    }
1148
1149    #[test]
1150    fn test_extract_curves() {
1151        // Column-major data: 2 observations, 3 points
1152        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
1153        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
1154        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
1155        let curves = extract_curves(&mat);
1156        assert_eq!(curves.len(), 2);
1157        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
1158        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
1159    }
1160
1161    #[test]
1162    fn test_l2_distance_identical() {
1163        let curve = vec![1.0, 2.0, 3.0];
1164        let weights = vec![0.25, 0.5, 0.25];
1165        let dist = l2_distance(&curve, &curve, &weights);
1166        assert!(dist.abs() < NUMERICAL_EPS);
1167    }
1168
1169    #[test]
1170    fn test_l2_distance_different() {
1171        let curve1 = vec![0.0, 0.0, 0.0];
1172        let curve2 = vec![1.0, 1.0, 1.0];
1173        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
1174        let dist = l2_distance(&curve1, &curve2, &weights);
1175        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
1176        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
1177    }
1178
1179    #[test]
1180    fn test_n1_weights() {
1181        // Single point: fallback weight is 1.0 (degenerate case)
1182        let w = simpsons_weights(&[0.5]);
1183        assert_eq!(w.len(), 1);
1184        assert!((w[0] - 1.0).abs() < 1e-12);
1185    }
1186
1187    #[test]
1188    fn test_n2_weights() {
1189        let w = simpsons_weights(&[0.0, 1.0]);
1190        assert_eq!(w.len(), 2);
1191        // Trapezoidal: each weight should be 0.5
1192        assert!((w[0] - 0.5).abs() < 1e-12);
1193        assert!((w[1] - 0.5).abs() < 1e-12);
1194    }
1195
1196    #[test]
1197    fn test_mismatched_l2_distance() {
1198        // Mismatched lengths should not panic but may give garbage
1199        let a = vec![1.0, 2.0, 3.0];
1200        let b = vec![1.0, 2.0, 3.0];
1201        let w = vec![0.5, 0.5, 0.5];
1202        let d = l2_distance(&a, &b, &w);
1203        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
1204    }
1205
1206    // ── trapz ──
1207
1208    #[test]
1209    fn test_trapz_sine() {
1210        // ∫₀^π sin(x) dx = 2
1211        let m = 1000;
1212        let x: Vec<f64> = (0..m)
1213            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
1214            .collect();
1215        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
1216        let result = trapz(&y, &x);
1217        assert!(
1218            (result - 2.0).abs() < 1e-4,
1219            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
1220        );
1221    }
1222
1223    // ── cumulative_trapz ──
1224
1225    #[test]
1226    fn test_cumulative_trapz_matches_final() {
1227        let m = 100;
1228        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1229        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
1230        let cum = cumulative_trapz(&y, &x);
1231        let total = trapz(&y, &x);
1232        assert!(
1233            (cum[m - 1] - total).abs() < 1e-12,
1234            "Final cumulative value should match trapz"
1235        );
1236    }
1237
1238    // ── linear_interp ──
1239
1240    #[test]
1241    fn test_linear_interp_boundary_clamp() {
1242        let x = vec![0.0, 0.5, 1.0];
1243        let y = vec![10.0, 20.0, 30.0];
1244        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
1245        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
1246        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
1247    }
1248
1249    // ── gradient_uniform ──
1250
1251    #[test]
1252    fn test_gradient_uniform_linear() {
1253        // f(x) = 3x → f'(x) = 3 everywhere
1254        let m = 50;
1255        let h = 1.0 / (m - 1) as f64;
1256        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
1257        let g = gradient_uniform(&y, h);
1258        for i in 0..m {
1259            assert!(
1260                (g[i] - 3.0).abs() < 1e-10,
1261                "gradient of 3x should be 3 at i={i}, got {}",
1262                g[i]
1263            );
1264        }
1265    }
1266
1267    // ── fdata_interpolate ──
1268
1269    #[test]
1270    fn test_gaussian_kernel() {
1271        assert!((gaussian_kernel(0.0, 1.0) - 1.0).abs() < 1e-12);
1272        assert!(gaussian_kernel(3.0, 1.0) < 0.02); // far from center
1273        assert!((gaussian_kernel(1.0, 0.0)).abs() < 1e-12); // zero bandwidth
1274    }
1275
1276    #[test]
1277    fn test_bandwidth_candidates() {
1278        let n = 5;
1279        let mut dists = vec![0.0; n * n];
1280        for i in 0..n {
1281            for j in 0..n {
1282                dists[i * n + j] = (i as f64 - j as f64).abs();
1283            }
1284        }
1285        let cands = bandwidth_candidates_from_dists(&dists, n, 10);
1286        assert!(!cands.is_empty());
1287        assert!(cands.iter().all(|&h| h > 0.0));
1288        // Should be sorted
1289        for w in cands.windows(2) {
1290            assert!(w[1] >= w[0]);
1291        }
1292    }
1293
1294    #[test]
1295    fn test_quantile_sorted() {
1296        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1297        assert!((quantile_sorted(&data, 0.0) - 1.0).abs() < 1e-12);
1298        assert!((quantile_sorted(&data, 1.0) - 5.0).abs() < 1e-12);
1299        assert!((quantile_sorted(&data, 0.5) - 3.0).abs() < 1e-12);
1300        assert!((quantile_sorted(&data, 0.25) - 2.0).abs() < 1e-12);
1301    }
1302
1303    #[test]
1304    fn test_r_squared_perfect() {
1305        let y = vec![1.0, 2.0, 3.0, 4.0];
1306        let resid = vec![0.0, 0.0, 0.0, 0.0];
1307        assert!((r_squared(&y, &resid) - 1.0).abs() < 1e-12);
1308    }
1309
1310    #[test]
1311    fn test_r_squared_mean_model() {
1312        let y = vec![1.0, 2.0, 3.0, 4.0];
1313        let mean = 2.5;
1314        let resid: Vec<f64> = y.iter().map(|&yi| yi - mean).collect();
1315        assert!(r_squared(&y, &resid).abs() < 1e-12); // R²=0 for mean model
1316    }
1317
1318    #[test]
1319    fn test_aic_bic() {
1320        let a = aic(100, 50.0, 5);
1321        let b = bic(100, 50.0, 5);
1322        assert!(a.is_finite());
1323        assert!(b.is_finite());
1324        assert!(b > a); // BIC penalizes more for n > ~8
1325    }
1326
1327    #[test]
1328    fn fdata_interpolate_linear_identity() {
1329        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
1330        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
1331        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1332        let result = fdata_interpolate(&data, &t, &t, InterpolationMethod::Linear);
1333        for j in 0..20 {
1334            assert!((result[(0, j)] - data[(0, j)]).abs() < 1e-12);
1335        }
1336    }
1337
1338    #[test]
1339    fn fdata_interpolate_cubic_hermite_smooth() {
1340        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
1341        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
1342        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1343
1344        let t_fine: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
1345        let result = fdata_interpolate(&data, &t, &t_fine, InterpolationMethod::CubicHermite);
1346
1347        // Values should approximate sin(t) well
1348        for (j, &tj) in t_fine.iter().enumerate() {
1349            assert!(
1350                (result[(0, j)] - tj.sin()).abs() < 0.02,
1351                "at t={tj:.2}: got {:.4}, expected {:.4}",
1352                result[(0, j)],
1353                tj.sin()
1354            );
1355        }
1356    }
1357
1358    #[test]
1359    fn fdata_interpolate_multiple_curves() {
1360        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
1361        let n = 5;
1362        let m = 30;
1363        // Build column-major data: n curves, each sin((i+1)*x)
1364        let mut col_major = vec![0.0; n * m];
1365        for i in 0..n {
1366            for j in 0..m {
1367                col_major[i + j * n] = ((i + 1) as f64 * t[j]).sin();
1368            }
1369        }
1370        let data = crate::matrix::FdMatrix::from_column_major(col_major, n, m).unwrap();
1371
1372        let t_new: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
1373        let result = fdata_interpolate(&data, &t, &t_new, InterpolationMethod::Linear);
1374        assert_eq!(result.shape(), (n, 50));
1375        // All values should be finite
1376        for i in 0..n {
1377            for j in 0..50 {
1378                assert!(result[(i, j)].is_finite());
1379            }
1380        }
1381    }
1382
1383    // ── spline_interpolate ──
1384
1385    #[test]
1386    fn spline_interpolate_reproduces_argvals() {
1387        use crate::test_helpers::uniform_grid;
1388        let t = uniform_grid(20);
1389        let vals: Vec<f64> = t.iter().map(|&x| x.powi(3)).collect();
1390        // column-major: 1 row, 20 columns
1391        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1392        let result = spline_interpolate(&data, &t, &t, 4).unwrap();
1393        for j in 0..20 {
1394            assert!(
1395                (result[(0, j)] - data[(0, j)]).abs() < 1e-10,
1396                "at j={j}: got {}, expected {}",
1397                result[(0, j)],
1398                data[(0, j)]
1399            );
1400        }
1401    }
1402
1403    #[test]
1404    fn spline_interpolate_cubic_offgrid() {
1405        // A cubic polynomial y = 2t^3 - t^2 + 0.5t - 0.1 lies exactly in the
1406        // order-4 B-spline space; an order-4 interpolant should reproduce it
1407        // within 1e-10 at off-grid midpoints.
1408        use crate::test_helpers::uniform_grid;
1409        let t = uniform_grid(20); // 20 evaluation points in [0, 1]
1410        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
1411        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
1412        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1413
1414        // Query at off-grid midpoints between consecutive t values
1415        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
1416        let result = spline_interpolate(&data, &t, &q, 4).unwrap();
1417
1418        for (j, &qj) in q.iter().enumerate() {
1419            let expected = poly(qj);
1420            let got = result[(0, j)];
1421            assert!(
1422                (got - expected).abs() < 1e-10,
1423                "off-grid at q={qj:.4}: got {got}, expected {expected}"
1424            );
1425        }
1426    }
1427
1428    #[test]
1429    fn spline_interpolate_rejects_out_of_range() {
1430        use crate::test_helpers::uniform_grid;
1431        let t = uniform_grid(20);
1432        let vals: Vec<f64> = t.to_vec();
1433        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1434
1435        // Query point below argvals[0]
1436        let q_below = vec![-0.1_f64];
1437        let err = spline_interpolate(&data, &t, &q_below, 4).unwrap_err();
1438        assert!(
1439            matches!(
1440                err,
1441                crate::FdarError::InvalidParameter {
1442                    parameter: "query_points",
1443                    ..
1444                }
1445            ),
1446            "expected InvalidParameter for query below domain, got {err:?}"
1447        );
1448
1449        // Query point above argvals[m-1]
1450        let q_above = vec![1.1_f64];
1451        let err2 = spline_interpolate(&data, &t, &q_above, 4).unwrap_err();
1452        assert!(
1453            matches!(
1454                err2,
1455                crate::FdarError::InvalidParameter {
1456                    parameter: "query_points",
1457                    ..
1458                }
1459            ),
1460            "expected InvalidParameter for query above domain, got {err2:?}"
1461        );
1462    }
1463
1464    #[test]
1465    fn spline_interpolate_rejects_bad_order() {
1466        use crate::test_helpers::uniform_grid;
1467        let t = uniform_grid(20);
1468        let vals: Vec<f64> = t.to_vec();
1469        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1470        let q = vec![0.5_f64];
1471
1472        // order == 0
1473        let err = spline_interpolate(&data, &t, &q, 0).unwrap_err();
1474        assert!(
1475            matches!(
1476                err,
1477                crate::FdarError::InvalidParameter {
1478                    parameter: "order",
1479                    ..
1480                }
1481            ),
1482            "expected InvalidParameter for order=0, got {err:?}"
1483        );
1484
1485        // order >= m (m=20)
1486        let err2 = spline_interpolate(&data, &t, &q, 20).unwrap_err();
1487        assert!(
1488            matches!(
1489                err2,
1490                crate::FdarError::InvalidParameter {
1491                    parameter: "order",
1492                    ..
1493                }
1494            ),
1495            "expected InvalidParameter for order=20 (>=m=20), got {err2:?}"
1496        );
1497    }
1498
1499    #[test]
1500    fn spline_interpolate_rejects_dim_mismatch() {
1501        use crate::test_helpers::uniform_grid;
1502        let t = uniform_grid(20);
1503        let vals: Vec<f64> = t.to_vec();
1504        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1505
1506        // argvals.len() != data.ncols()
1507        let bad_argvals: Vec<f64> = (0..15).map(|i| i as f64 / 14.0).collect();
1508        let q = vec![0.5_f64];
1509        let err = spline_interpolate(&data, &bad_argvals, &q, 4).unwrap_err();
1510        assert!(
1511            matches!(
1512                err,
1513                crate::FdarError::InvalidDimension {
1514                    parameter: "argvals",
1515                    ..
1516                }
1517            ),
1518            "expected InvalidDimension for argvals mismatch, got {err:?}"
1519        );
1520
1521        // empty query_points
1522        let err2 = spline_interpolate(&data, &t, &[], 4).unwrap_err();
1523        assert!(
1524            matches!(
1525                err2,
1526                crate::FdarError::InvalidDimension {
1527                    parameter: "query_points",
1528                    ..
1529                }
1530            ),
1531            "expected InvalidDimension for empty query_points, got {err2:?}"
1532        );
1533    }
1534
1535    // ── spline_interpolate_with_policy tests ──────────────────────────────
1536
1537    #[test]
1538    fn test_spline_with_policy_in_range_matches_spline() {
1539        // In-range queries must match spline_interpolate exactly regardless of policy.
1540        use crate::test_helpers::uniform_grid;
1541        let t = uniform_grid(20);
1542        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
1543        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
1544        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1545        // Off-grid midpoints (all in-range)
1546        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
1547        let expected = spline_interpolate(&data, &t, &q, 4).unwrap();
1548        let actual =
1549            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Boundary)
1550                .unwrap();
1551        for j in 0..q.len() {
1552            assert!(
1553                (actual[(0, j)] - expected[(0, j)]).abs() < 1e-10,
1554                "in-range mismatch at j={j}: policy={} vs plain={}",
1555                actual[(0, j)],
1556                expected[(0, j)]
1557            );
1558        }
1559    }
1560
1561    #[test]
1562    fn test_spline_with_policy_boundary() {
1563        // OOB queries clamped to nearest boundary value.
1564        use crate::test_helpers::uniform_grid;
1565        let t = uniform_grid(20); // [0, 1]
1566        let vals: Vec<f64> = t.iter().map(|&x| x.powi(2)).collect(); // y = x^2
1567        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1568        // Query below (should clamp to t_min=0 → y=0) and above (clamp to t_max=1 → y≈1).
1569        let q = vec![-0.5_f64, 0.5, 1.5];
1570        let result =
1571            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Boundary)
1572                .unwrap();
1573        // Clamped to 0 → y = 0^2 = 0 (within spline tolerance)
1574        assert!(
1575            result[(0, 0)].abs() < 1e-9,
1576            "below boundary should clamp, got {}",
1577            result[(0, 0)]
1578        );
1579        // Clamped to 1 → y = 1^2 = 1 (within spline tolerance)
1580        assert!(
1581            (result[(0, 2)] - 1.0).abs() < 1e-9,
1582            "above boundary should clamp, got {}",
1583            result[(0, 2)]
1584        );
1585        // In-range 0.5 → y ≈ 0.25
1586        assert!(
1587            (result[(0, 1)] - 0.25).abs() < 1e-9,
1588            "in-range should be ~0.25, got {}",
1589            result[(0, 1)]
1590        );
1591    }
1592
1593    #[test]
1594    fn test_spline_with_policy_exception() {
1595        // Exception policy errors on OOB, matches spline_interpolate behavior.
1596        use crate::test_helpers::uniform_grid;
1597        let t = uniform_grid(20);
1598        let vals: Vec<f64> = t.to_vec();
1599        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1600        let q_oob = vec![1.5_f64];
1601        let err =
1602            spline_interpolate_with_policy(&data, &t, &q_oob, 4, ExtrapolationPolicy::Exception)
1603                .unwrap_err();
1604        assert!(
1605            matches!(
1606                err,
1607                crate::FdarError::InvalidParameter {
1608                    parameter: "query_points",
1609                    ..
1610                }
1611            ),
1612            "Exception policy should error on OOB, got {err:?}"
1613        );
1614        // In-range queries with Exception policy should succeed.
1615        let q_ok = vec![0.0_f64, 0.5, 1.0];
1616        let ok =
1617            spline_interpolate_with_policy(&data, &t, &q_ok, 4, ExtrapolationPolicy::Exception);
1618        assert!(
1619            ok.is_ok(),
1620            "Exception policy should succeed for in-range queries"
1621        );
1622    }
1623
1624    #[test]
1625    fn test_spline_with_policy_fill() {
1626        // Fill policy: OOB cells get constant fill value; in-range cells use spline.
1627        use crate::test_helpers::uniform_grid;
1628        let t = uniform_grid(20); // [0, 1]
1629        let vals: Vec<f64> = t.iter().map(|&x| x.powi(2)).collect();
1630        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1631        let fill_val = 42.0_f64;
1632        let q = vec![-0.5_f64, 0.5, 2.0];
1633        let result =
1634            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Fill(fill_val))
1635                .unwrap();
1636        assert!(
1637            (result[(0, 0)] - fill_val).abs() < 1e-10,
1638            "OOB below should be fill value, got {}",
1639            result[(0, 0)]
1640        );
1641        assert!(
1642            (result[(0, 2)] - fill_val).abs() < 1e-10,
1643            "OOB above should be fill value, got {}",
1644            result[(0, 2)]
1645        );
1646        // In-range: y ≈ 0.25
1647        assert!(
1648            (result[(0, 1)] - 0.25).abs() < 1e-9,
1649            "in-range should be ~0.25, got {}",
1650            result[(0, 1)]
1651        );
1652    }
1653
1654    #[test]
1655    fn test_spline_with_policy_periodic() {
1656        // Periodic policy: OOB queries wrap modulo domain length.
1657        // Use y = x (linear) on [0, 1]; a query at 1.3 should wrap to 0.3.
1658        use crate::test_helpers::uniform_grid;
1659        let t = uniform_grid(20); // [0, 1]
1660        let vals: Vec<f64> = t.to_vec(); // y = x
1661        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1662        let q = vec![1.3_f64];
1663        let result =
1664            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Periodic)
1665                .unwrap();
1666        // Expected: wrap 1.3 → 0.3, spline of y=x at 0.3 ≈ 0.3
1667        assert!(
1668            (result[(0, 0)] - 0.3).abs() < 1e-9,
1669            "Periodic wrap of 1.3 should give ~0.3, got {}",
1670            result[(0, 0)]
1671        );
1672        // t = -0.2 → wrap to 0.8
1673        let q2 = vec![-0.2_f64];
1674        let result2 =
1675            spline_interpolate_with_policy(&data, &t, &q2, 4, ExtrapolationPolicy::Periodic)
1676                .unwrap();
1677        assert!(
1678            (result2[(0, 0)] - 0.8).abs() < 1e-9,
1679            "Periodic wrap of -0.2 should give ~0.8, got {}",
1680            result2[(0, 0)]
1681        );
1682    }
1683
1684    #[test]
1685    fn test_spline_with_policy_periodic_zero_length_domain_errors() {
1686        // Periodic + zero-length domain must error (same guard as fdata_interpolate_with_policy).
1687        let argvals = vec![3.0_f64, 3.0, 3.0];
1688        let vals = vec![1.0_f64, 1.0, 1.0];
1689        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 3).unwrap();
1690        let q = vec![4.0_f64]; // OOB
1691        let err =
1692            spline_interpolate_with_policy(&data, &argvals, &q, 1, ExtrapolationPolicy::Periodic)
1693                .unwrap_err();
1694        assert!(
1695            matches!(
1696                err,
1697                crate::FdarError::InvalidParameter {
1698                    parameter: "argvals",
1699                    ..
1700                }
1701            ),
1702            "Periodic + zero-length domain should error, got {err:?}"
1703        );
1704    }
1705
1706    // ── ExtrapolationPolicy tests ──────────────────────────────────────────
1707
1708    /// Build a 1-curve FdMatrix: y = x on [0, 1] with `n_pts` points.
1709    fn make_linear_curve(n_pts: usize) -> (crate::matrix::FdMatrix, Vec<f64>) {
1710        use crate::test_helpers::uniform_grid;
1711        let t = uniform_grid(n_pts);
1712        let vals: Vec<f64> = t.to_vec();
1713        let mat = crate::matrix::FdMatrix::from_column_major(vals, 1, n_pts).unwrap();
1714        (mat, t)
1715    }
1716
1717    #[test]
1718    fn test_extrapolation_boundary() {
1719        let (data, t) = make_linear_curve(11); // y=x on [0,1]
1720                                               // Query at t=-0.2 (below) and t=1.3 (above)
1721        let q = vec![-0.2_f64, 0.5, 1.3];
1722        let result = fdata_interpolate_with_policy(
1723            &data,
1724            &t,
1725            &q,
1726            InterpolationMethod::Linear,
1727            ExtrapolationPolicy::Boundary,
1728        )
1729        .unwrap();
1730        // Clamped to t_min=0.0 → y=0.0
1731        assert!(
1732            (result[(0, 0)] - 0.0).abs() < 1e-10,
1733            "below boundary should clamp to 0"
1734        );
1735        // In-range: y=0.5
1736        assert!(
1737            (result[(0, 1)] - 0.5).abs() < 1e-10,
1738            "in-range should interpolate correctly"
1739        );
1740        // Clamped to t_max=1.0 → y=1.0
1741        assert!(
1742            (result[(0, 2)] - 1.0).abs() < 1e-10,
1743            "above boundary should clamp to 1"
1744        );
1745    }
1746
1747    #[test]
1748    fn test_extrapolation_exception() {
1749        let (data, t) = make_linear_curve(11);
1750        let q_bad = vec![1.5_f64]; // out of range
1751        let err = fdata_interpolate_with_policy(
1752            &data,
1753            &t,
1754            &q_bad,
1755            InterpolationMethod::Linear,
1756            ExtrapolationPolicy::Exception,
1757        )
1758        .unwrap_err();
1759        assert!(
1760            matches!(
1761                err,
1762                crate::FdarError::InvalidParameter {
1763                    parameter: "new_argvals",
1764                    ..
1765                }
1766            ),
1767            "expected InvalidParameter for OOB query, got {err:?}"
1768        );
1769
1770        // In-range should still work with Exception policy
1771        let q_ok = vec![0.0_f64, 0.5, 1.0];
1772        let result = fdata_interpolate_with_policy(
1773            &data,
1774            &t,
1775            &q_ok,
1776            InterpolationMethod::Linear,
1777            ExtrapolationPolicy::Exception,
1778        )
1779        .unwrap();
1780        assert!((result[(0, 1)] - 0.5).abs() < 1e-10);
1781    }
1782
1783    #[test]
1784    fn test_extrapolation_fill() {
1785        let (data, t) = make_linear_curve(11);
1786        let fill_val = 99.0_f64;
1787        let q = vec![-0.5_f64, 0.5, 2.0];
1788        let result = fdata_interpolate_with_policy(
1789            &data,
1790            &t,
1791            &q,
1792            InterpolationMethod::Linear,
1793            ExtrapolationPolicy::Fill(fill_val),
1794        )
1795        .unwrap();
1796        assert!(
1797            (result[(0, 0)] - fill_val).abs() < 1e-10,
1798            "below range should be fill value"
1799        );
1800        assert!(
1801            (result[(0, 1)] - 0.5).abs() < 1e-10,
1802            "in-range should interpolate"
1803        );
1804        assert!(
1805            (result[(0, 2)] - fill_val).abs() < 1e-10,
1806            "above range should be fill value"
1807        );
1808    }
1809
1810    #[test]
1811    fn test_extrapolation_periodic() {
1812        let (data, t) = make_linear_curve(11); // y=x on [0,1], domain_len=1
1813                                               // t = -0.1 should wrap to 0.9 (y=0.9)
1814        let q = vec![-0.1_f64, 0.5, 1.1];
1815        let result = fdata_interpolate_with_policy(
1816            &data,
1817            &t,
1818            &q,
1819            InterpolationMethod::Linear,
1820            ExtrapolationPolicy::Periodic,
1821        )
1822        .unwrap();
1823        // wrapped: ((−0.1 − 0) % 1 + 1) % 1 = (−0.1 + 1) % 1 = 0.9 % 1 = 0.9
1824        assert!(
1825            (result[(0, 0)] - 0.9).abs() < 1e-9,
1826            "t=-0.1 should wrap to 0.9, got {}",
1827            result[(0, 0)]
1828        );
1829        assert!(
1830            (result[(0, 1)] - 0.5).abs() < 1e-10,
1831            "in-range point unchanged"
1832        );
1833        // t=1.1 → ((1.1 − 0) % 1 + 1) % 1 = (0.1 + 1) % 1 = 0.1
1834        assert!(
1835            (result[(0, 2)] - 0.1).abs() < 1e-9,
1836            "t=1.1 should wrap to 0.1, got {}",
1837            result[(0, 2)]
1838        );
1839    }
1840
1841    #[test]
1842    fn test_extrapolation_in_range_equivalence() {
1843        // In-range queries must match fdata_interpolate exactly
1844        let (data, t) = make_linear_curve(21);
1845        let q: Vec<f64> = (0..=10).map(|i| i as f64 / 10.0).collect();
1846        let expected = fdata_interpolate(&data, &t, &q, InterpolationMethod::Linear);
1847        let actual = fdata_interpolate_with_policy(
1848            &data,
1849            &t,
1850            &q,
1851            InterpolationMethod::Linear,
1852            ExtrapolationPolicy::Boundary,
1853        )
1854        .unwrap();
1855        let (_, m_new) = actual.shape();
1856        for j in 0..m_new {
1857            assert!(
1858                (actual[(0, j)] - expected[(0, j)]).abs() < 1e-12,
1859                "in-range mismatch at j={j}: policy={} vs plain={}",
1860                actual[(0, j)],
1861                expected[(0, j)]
1862            );
1863        }
1864    }
1865
1866    #[test]
1867    fn test_extrapolation_policy_dim_guard() {
1868        let (data, _t) = make_linear_curve(11);
1869        let bad_argvals: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect(); // len=5, ncols=11
1870        let q = vec![0.5_f64];
1871        let err = fdata_interpolate_with_policy(
1872            &data,
1873            &bad_argvals,
1874            &q,
1875            InterpolationMethod::Linear,
1876            ExtrapolationPolicy::Boundary,
1877        )
1878        .unwrap_err();
1879        assert!(
1880            matches!(
1881                err,
1882                crate::FdarError::InvalidDimension {
1883                    parameter: "argvals",
1884                    ..
1885                }
1886            ),
1887            "expected InvalidDimension for argvals mismatch, got {err:?}"
1888        );
1889    }
1890
1891    // ── ImputationMethod / impute_missing_values tests ────────────────────
1892
1893    /// Build a 1-curve FdMatrix from given values and a uniform grid.
1894    fn make_curve_with_vals(vals: Vec<f64>) -> (crate::matrix::FdMatrix, Vec<f64>) {
1895        let m = vals.len();
1896        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1897        let mat = crate::matrix::FdMatrix::from_column_major(vals, 1, m).unwrap();
1898        (mat, argvals)
1899    }
1900
1901    #[test]
1902    fn test_impute_linear() {
1903        // Curve: [0.0, NaN, 1.0] on argvals [0.0, 0.5, 1.0]
1904        // Linear between (0,0.0) and (1.0,1.0): at t=0.5 → 0.5
1905        let (data, argvals) = make_curve_with_vals(vec![0.0_f64, f64::NAN, 1.0]);
1906        let result = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap();
1907        // Hand-computed: linear_interp([0.0,1.0],[0.0,1.0],0.5) = 0.5
1908        assert!(
1909            (result[(0, 1)] - 0.5).abs() < 1e-10,
1910            "linear imputation should give 0.5, got {}",
1911            result[(0, 1)]
1912        );
1913        // Non-NaN entries unchanged
1914        assert!((result[(0, 0)] - 0.0).abs() < 1e-10);
1915        assert!((result[(0, 2)] - 1.0).abs() < 1e-10);
1916    }
1917
1918    #[test]
1919    fn test_impute_mean() {
1920        // Curve: [1.0, NaN, 3.0] → mean of non-NaN = 2.0
1921        let (data, argvals) = make_curve_with_vals(vec![1.0_f64, f64::NAN, 3.0]);
1922        let result = impute_missing_values(&data, &argvals, ImputationMethod::Mean).unwrap();
1923        assert!(
1924            (result[(0, 1)] - 2.0).abs() < 1e-10,
1925            "mean imputation should give 2.0, got {}",
1926            result[(0, 1)]
1927        );
1928    }
1929
1930    #[test]
1931    fn test_impute_constant() {
1932        // Curve: [1.0, NaN, 3.0] → constant 99.0
1933        let (data, argvals) = make_curve_with_vals(vec![1.0_f64, f64::NAN, 3.0]);
1934        let result =
1935            impute_missing_values(&data, &argvals, ImputationMethod::Constant(99.0)).unwrap();
1936        assert!(
1937            (result[(0, 1)] - 99.0).abs() < 1e-10,
1938            "constant imputation should give 99.0, got {}",
1939            result[(0, 1)]
1940        );
1941        // Non-NaN entries unchanged
1942        assert!((result[(0, 0)] - 1.0).abs() < 1e-10);
1943        assert!((result[(0, 2)] - 3.0).abs() < 1e-10);
1944    }
1945
1946    #[test]
1947    fn test_impute_all_nan() {
1948        // An all-NaN curve should return Err(InvalidParameter)
1949        let (data, argvals) = make_curve_with_vals(vec![f64::NAN, f64::NAN, f64::NAN]);
1950        let err = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap_err();
1951        assert!(
1952            matches!(
1953                err,
1954                crate::FdarError::InvalidParameter {
1955                    parameter: "data",
1956                    ..
1957                }
1958            ),
1959            "expected InvalidParameter for all-NaN curve, got {err:?}"
1960        );
1961    }
1962
1963    #[test]
1964    fn test_impute_boundary_nan() {
1965        // Curve: [NaN, 0.5, 1.0] → leading NaN → boundary fill with 0.5
1966        let (data, argvals) = make_curve_with_vals(vec![f64::NAN, 0.5_f64, 1.0]);
1967        let result = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap();
1968        assert!(
1969            (result[(0, 0)] - 0.5).abs() < 1e-10,
1970            "leading NaN should be filled with nearest valid (0.5), got {}",
1971            result[(0, 0)]
1972        );
1973
1974        // Curve: [0.0, 0.5, NaN] → trailing NaN → boundary fill with 0.5
1975        let (data2, argvals2) = make_curve_with_vals(vec![0.0_f64, 0.5, f64::NAN]);
1976        let result2 = impute_missing_values(&data2, &argvals2, ImputationMethod::Linear).unwrap();
1977        assert!(
1978            (result2[(0, 2)] - 0.5).abs() < 1e-10,
1979            "trailing NaN should be filled with nearest valid (0.5), got {}",
1980            result2[(0, 2)]
1981        );
1982    }
1983
1984    // ── CR-01: Periodic + zero-length domain must error (not produce NaN) ────
1985
1986    #[test]
1987    fn test_extrapolation_periodic_zero_length_domain_errors() {
1988        // Domain [5.0, 5.0] has length 0 — Periodic would compute x % 0.0 = NaN without the guard.
1989        let degenerate_argvals = vec![5.0_f64, 5.0, 5.0];
1990        let vals = vec![1.0_f64, 1.0, 1.0];
1991        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 3).unwrap();
1992        // Any OOB query with Periodic on a zero-length domain must return Err.
1993        let q = vec![6.0_f64]; // outside [5.0, 5.0]
1994        let err = fdata_interpolate_with_policy(
1995            &data,
1996            &degenerate_argvals,
1997            &q,
1998            InterpolationMethod::Linear,
1999            ExtrapolationPolicy::Periodic,
2000        )
2001        .unwrap_err();
2002        assert!(
2003            matches!(
2004                err,
2005                crate::FdarError::InvalidParameter {
2006                    parameter: "argvals",
2007                    ..
2008                }
2009            ),
2010            "expected InvalidParameter for zero-length domain + Periodic, got {err:?}"
2011        );
2012    }
2013
2014    // ── WR-01: m=0 guard in impute_missing_values ─────────────────────────
2015
2016    #[test]
2017    fn test_impute_zero_columns_errors() {
2018        // A matrix with m=0 columns is degenerate; should return InvalidDimension, not "all-NaN".
2019        let data = crate::matrix::FdMatrix::zeros(2, 0);
2020        let argvals: Vec<f64> = vec![];
2021        let err = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap_err();
2022        assert!(
2023            matches!(
2024                err,
2025                crate::FdarError::InvalidDimension {
2026                    parameter: "data",
2027                    ..
2028                }
2029            ),
2030            "expected InvalidDimension for m=0 matrix, got {err:?}"
2031        );
2032    }
2033
2034    #[test]
2035    fn test_impute_dim_mismatch() {
2036        // argvals length != ncols
2037        let (data, _argvals) = make_curve_with_vals(vec![1.0, 2.0, 3.0]);
2038        let bad_argvals = vec![0.0_f64, 1.0]; // len=2, ncols=3
2039        let err = impute_missing_values(&data, &bad_argvals, ImputationMethod::Linear).unwrap_err();
2040        assert!(
2041            matches!(
2042                err,
2043                crate::FdarError::InvalidDimension {
2044                    parameter: "argvals",
2045                    ..
2046                }
2047            ),
2048            "expected InvalidDimension for argvals mismatch, got {err:?}"
2049        );
2050    }
2051}