Skip to main content

fdars_core/
concurrent_regression.rs

1//! Concurrent (varying-coefficient) functional regression.
2//!
3//! Models the relationship:
4//!
5//! `y_i(t) = β₀(t) + Σₖ βₖ(t)·xₖᵢ(t) + εᵢ(t)`
6//!
7//! where all functional curves share the same dense grid of `m` evaluation
8//! points. Estimation follows the **fdaconcur `ptFCReg` → `smPtFCRegCoef`
9//! two-step convention**:
10//!
11//! 1. **Pointwise OLS** at each grid column `j` — build a design matrix
12//!    `[1 | x₁[:,j] | … | xₚ[:,j]]` and solve the normal equations to obtain
13//!    raw coefficient estimates `raw_β₀[j]` and `raw_βₖ[j]` for each predictor.
14//! 2. **Local-linear kernel smoothing** of each raw discrete coefficient
15//!    sequence over `j` via [`crate::smoothing::local_linear`]. The `bandwidth`
16//!    parameter is the sole roughness knob: larger bandwidth → smoother β(t).
17//!
18//! **Boundary behaviour:** the smoother is not edge-corrected; the first and
19//! last few grid points exhibit higher bias. Tests check only interior indices.
20
21use crate::error::FdarError;
22use crate::iter_maybe_parallel;
23use crate::matrix::FdMatrix;
24use crate::smoothing;
25#[cfg(feature = "parallel")]
26use rayon::iter::ParallelIterator;
27
28// ---------------------------------------------------------------------------
29// Result type
30// ---------------------------------------------------------------------------
31
32/// Result of concurrent (varying-coefficient) functional regression.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct ConcurrentRegrResult {
37    /// Smoothed predictor coefficient curves, rows = predictor index, cols = grid points (p × m).
38    pub beta_curve: FdMatrix,
39    /// Smoothed time-varying intercept β₀(t) (length m).
40    pub intercept: Vec<f64>,
41    /// Fitted functional response curves (n × m).
42    pub fitted: FdMatrix,
43    /// Residuals: response − fitted (n × m).
44    pub residuals: FdMatrix,
45    /// Shared grid used for evaluation (length m).
46    pub argvals: Vec<f64>,
47}
48
49// ---------------------------------------------------------------------------
50// Main function
51// ---------------------------------------------------------------------------
52
53/// Concurrent (varying-coefficient) functional regression.
54///
55/// Fits the model `y_i(t) = β₀(t) + Σₖ βₖ(t)·xₖᵢ(t) + εᵢ(t)` for a
56/// dense shared grid of `m` evaluation points and `p ≥ 1` functional
57/// predictors. Estimation follows the **fdaconcur two-step convention**:
58/// pointwise OLS at each grid column, then local-linear kernel smoothing of
59/// the resulting discrete coefficient sequences.
60///
61/// # Arguments
62///
63/// * `response` — Functional response matrix (n × m).
64/// * `predictors` — Slice of functional predictor matrices, each (n × m),
65///   all evaluated on the same shared grid.
66/// * `argvals` — Shared evaluation grid (length m); if `None`, a uniform
67///   0..1 grid is used (project-wide convention).
68/// * `bandwidth` — Kernel bandwidth for β(t) smoothing (must be positive).
69///   Larger values yield smoother coefficient curves.
70/// * `kernel` — Kernel type: `"gaussian"` (default), `"epanechnikov"`,
71///   `"tricube"`. Passed directly to [`crate::smoothing::local_linear`].
72///
73/// # Errors
74///
75/// Returns [`FdarError::InvalidDimension`] if:
76/// - `predictors` is empty,
77/// - `response` has fewer than 2 rows or zero columns,
78/// - `response` has at most as many rows as there are predictors (`n <= p`),
79///   which would make each per-column design matrix underdetermined,
80/// - any predictor's shape does not match `(n, m)`, or
81/// - `argvals` is `Some` with a length different from `m`.
82///
83/// Returns [`FdarError::InvalidParameter`] if `bandwidth` is not strictly
84/// positive (i.e., zero, negative, `f64::NAN`, or `f64::INFINITY`).
85///
86/// # Notes
87///
88/// A small ridge regulariser (`eps = 1e-10 * (n + 1)`) is applied to the
89/// diagonal of each column's normal equations to prevent numerical blow-up
90/// when predictors are collinear at a grid point.
91#[must_use = "expensive computation whose result should not be discarded"]
92pub fn concurrent_regression(
93    response: &FdMatrix,
94    predictors: &[FdMatrix],
95    argvals: Option<&[f64]>,
96    bandwidth: f64,
97    kernel: &str,
98) -> Result<ConcurrentRegrResult, FdarError> {
99    // ------------------------------------------------------------------
100    // 1. Input validation
101    // ------------------------------------------------------------------
102    if predictors.is_empty() {
103        return Err(FdarError::InvalidDimension {
104            parameter: "predictors",
105            expected: "at least 1".to_string(),
106            actual: "0".to_string(),
107        });
108    }
109
110    let (n, m) = response.shape();
111
112    if n < 2 {
113        return Err(FdarError::InvalidDimension {
114            parameter: "response",
115            expected: "at least 2 rows (observations)".to_string(),
116            actual: format!("{n}"),
117        });
118    }
119    if m == 0 {
120        return Err(FdarError::InvalidDimension {
121            parameter: "response",
122            expected: "non-zero columns (grid points)".to_string(),
123            actual: "0".to_string(),
124        });
125    }
126
127    for (k, pred) in predictors.iter().enumerate() {
128        if pred.nrows() != n {
129            return Err(FdarError::InvalidDimension {
130                parameter: "predictors[k]",
131                expected: format!("{n} rows (matching response)"),
132                actual: format!("{} (predictor index {k})", pred.nrows()),
133            });
134        }
135        if pred.ncols() != m {
136            return Err(FdarError::InvalidDimension {
137                parameter: "predictors[k]",
138                expected: format!("{m} columns (matching response)"),
139                actual: format!("{} (predictor index {k})", pred.ncols()),
140            });
141        }
142    }
143
144    // Reject NaN (`is_finite()` is false for NaN), ±Inf, zero and negative
145    // values — all of which would propagate into the kernel smoother and
146    // produce silent all-zero output.  The original `bandwidth <= 0.0` guard
147    // was insufficient because `NaN <= 0.0` evaluates to `false` under
148    // IEEE 754, allowing NaN to bypass the check.
149    if !bandwidth.is_finite() || bandwidth <= 0.0 {
150        return Err(FdarError::InvalidParameter {
151            parameter: "bandwidth",
152            message: format!("must be finite and positive, got {bandwidth}"),
153        });
154    }
155
156    if let Some(av) = argvals {
157        if av.len() != m {
158            return Err(FdarError::InvalidDimension {
159                parameter: "argvals",
160                expected: format!("{m} elements (matching response columns)"),
161                actual: format!("{}", av.len()),
162            });
163        }
164    }
165
166    // ------------------------------------------------------------------
167    // 2. Resolve argvals
168    // ------------------------------------------------------------------
169    let argvals_owned: Vec<f64> = argvals
170        .map(|v| v.to_vec())
171        .unwrap_or_else(|| (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect());
172
173    let p = predictors.len();
174    let q = p + 1; // intercept + p slopes
175
176    // Guard: with n <= p the per-column design matrix X_j ∈ R^{n×(p+1)} is
177    // underdetermined (rank at most n < p+1). The ridge stabiliser is too
178    // small (~3e-10 for n=2) to act as a statistical regulariser, so the
179    // returned coefficients would be driven almost entirely by the ridge
180    // rather than the data with no indication to the caller.
181    if n <= p {
182        return Err(FdarError::InvalidDimension {
183            parameter: "response",
184            expected: format!(
185                "at least {} rows (more observations than predictors p={})",
186                p + 1,
187                p
188            ),
189            actual: format!("{n}"),
190        });
191    }
192
193    // ------------------------------------------------------------------
194    // 3. Step 1 — pointwise OLS at each grid column (parallelised)
195    //
196    // Each closure allocates its own local xtx / xty buffers so there is
197    // no shared mutable state — safe for rayon (mirrors local_polynomial).
198    // Returns (raw_intercept_j, raw_beta_j: Vec<f64> of length p).
199    // ------------------------------------------------------------------
200    let raw_cols: Vec<(f64, Vec<f64>)> = iter_maybe_parallel!(0..m)
201        .map(|j| {
202            let mut xtx = vec![0.0_f64; q * q];
203            let mut xty = vec![0.0_f64; q];
204
205            for i in 0..n {
206                // design row: [1, pred_0[i,j], pred_1[i,j], ...]
207                let mut row = vec![1.0_f64; q];
208                for (k, pred) in predictors.iter().enumerate() {
209                    row[k + 1] = pred[(i, j)];
210                }
211                for a in 0..q {
212                    for b in 0..q {
213                        xtx[a * q + b] += row[a] * row[b];
214                    }
215                    xty[a] += row[a] * response[(i, j)];
216                }
217            }
218
219            // Ridge stabiliser: eps = 1e-10 * (n + 1) (xtx[0] ≈ n for
220            // the intercept column, but use n directly for clarity).
221            let eps = 1e-10 * (xtx[0] + 1.0);
222            for d in 0..q {
223                xtx[d * q + d] += eps;
224            }
225
226            let coef = smoothing::solve_gaussian_pub(&mut xtx, &mut xty, q);
227
228            let raw_intercept_j = coef[0];
229            let raw_beta_j: Vec<f64> = coef[1..q].to_vec();
230            (raw_intercept_j, raw_beta_j)
231        })
232        .collect();
233
234    // Serialise collected results into contiguous buffers.
235    let mut raw_intercept = vec![0.0_f64; m];
236    // raw_beta layout: row-major over (predictor k, grid column j)
237    // raw_beta[k * m + j] = β_k[j]
238    let mut raw_beta = vec![0.0_f64; p * m];
239    for (j, (ic, bk)) in raw_cols.into_iter().enumerate() {
240        raw_intercept[j] = ic;
241        for k in 0..p {
242            raw_beta[k * m + j] = bk[k];
243        }
244    }
245
246    // ------------------------------------------------------------------
247    // 4. Step 2 — smooth each raw coefficient sequence
248    // ------------------------------------------------------------------
249    let intercept = smoothing::local_linear(
250        &argvals_owned,
251        &raw_intercept,
252        &argvals_owned,
253        bandwidth,
254        kernel,
255    )?;
256
257    let mut beta_curve = FdMatrix::zeros(p, m);
258    for k in 0..p {
259        let raw_k: Vec<f64> = (0..m).map(|j| raw_beta[k * m + j]).collect();
260        let smooth_k =
261            smoothing::local_linear(&argvals_owned, &raw_k, &argvals_owned, bandwidth, kernel)?;
262        for j in 0..m {
263            beta_curve[(k, j)] = smooth_k[j];
264        }
265    }
266
267    // ------------------------------------------------------------------
268    // 5. Step 3 — fitted curves and residuals
269    // ------------------------------------------------------------------
270    let mut fitted = FdMatrix::zeros(n, m);
271    let mut residuals = FdMatrix::zeros(n, m);
272    for j in 0..m {
273        for i in 0..n {
274            let mut val = intercept[j];
275            for (k, pred) in predictors.iter().enumerate() {
276                val += beta_curve[(k, j)] * pred[(i, j)];
277            }
278            fitted[(i, j)] = val;
279            residuals[(i, j)] = response[(i, j)] - val;
280        }
281    }
282
283    Ok(ConcurrentRegrResult {
284        beta_curve,
285        intercept,
286        fitted,
287        residuals,
288        argvals: argvals_owned,
289    })
290}
291
292// ---------------------------------------------------------------------------
293// Tests
294// ---------------------------------------------------------------------------
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::test_helpers::uniform_grid;
300
301    // -----------------------------------------------------------------------
302    // Task 1: Smoke / shape test
303    // -----------------------------------------------------------------------
304
305    #[test]
306    fn test_shape_smoke() {
307        let n = 6;
308        let m = 8;
309        let argvals = uniform_grid(m);
310
311        // Build a simple response: y[i,j] = (i as f64) * argvals[j]
312        let mut response = FdMatrix::zeros(n, m);
313        for i in 0..n {
314            for j in 0..m {
315                response[(i, j)] = (i as f64 + 1.0) * argvals[j];
316            }
317        }
318
319        // Build a simple predictor: x[i,j] = (i as f64) + argvals[j]
320        let mut predictor = FdMatrix::zeros(n, m);
321        for i in 0..n {
322            for j in 0..m {
323                predictor[(i, j)] = (i as f64 + 1.0) + argvals[j];
324            }
325        }
326
327        let result = concurrent_regression(&response, &[predictor], None, 0.2, "gaussian");
328        assert!(result.is_ok(), "expected Ok, got {result:?}");
329        let r = result.unwrap();
330
331        assert_eq!(r.beta_curve.shape(), (1, m), "beta_curve shape");
332        assert_eq!(r.intercept.len(), m, "intercept length");
333        assert_eq!(r.fitted.shape(), (n, m), "fitted shape");
334        assert_eq!(r.residuals.shape(), (n, m), "residuals shape");
335        assert_eq!(r.argvals.len(), m, "argvals length");
336    }
337
338    // -----------------------------------------------------------------------
339    // Task 2: Multi-predictor shape test
340    // -----------------------------------------------------------------------
341
342    #[test]
343    fn test_multi_predictor_shape() {
344        let n = 6;
345        let m = 8;
346        let p = 3;
347        let argvals = uniform_grid(m);
348
349        let mut response = FdMatrix::zeros(n, m);
350        for i in 0..n {
351            for j in 0..m {
352                response[(i, j)] = (i as f64 + 1.0) * argvals[j];
353            }
354        }
355
356        let predictors: Vec<FdMatrix> = (0..p)
357            .map(|k| {
358                let mut pred = FdMatrix::zeros(n, m);
359                for i in 0..n {
360                    for j in 0..m {
361                        pred[(i, j)] = (i as f64 + k as f64 + 1.0) * argvals[j] + 0.1;
362                    }
363                }
364                pred
365            })
366            .collect();
367
368        let result = concurrent_regression(&response, &predictors, None, 0.2, "gaussian");
369        assert!(result.is_ok(), "expected Ok for p=3, got {result:?}");
370        let r = result.unwrap();
371        assert_eq!(r.beta_curve.shape(), (p, m), "beta_curve shape for p=3");
372    }
373
374    // -----------------------------------------------------------------------
375    // Task 2: Determinism / parallel-sequential equivalence
376    // -----------------------------------------------------------------------
377
378    #[test]
379    fn test_parallel_sequential_equivalence() {
380        let n = 6;
381        let m = 8;
382        let argvals = uniform_grid(m);
383
384        let mut response = FdMatrix::zeros(n, m);
385        let mut predictor = FdMatrix::zeros(n, m);
386        for i in 0..n {
387            for j in 0..m {
388                response[(i, j)] = (i as f64 + 1.0) * argvals[j] + 0.3;
389                predictor[(i, j)] = argvals[j] * (i as f64 + 0.5);
390            }
391        }
392
393        let r1 = concurrent_regression(&response, &[predictor.clone()], None, 0.2, "gaussian")
394            .expect("first call should succeed");
395        let r2 = concurrent_regression(&response, &[predictor], None, 0.2, "gaussian")
396            .expect("second call should succeed");
397
398        assert_eq!(
399            r1, r2,
400            "two calls on identical inputs must produce equal results"
401        );
402    }
403
404    // -----------------------------------------------------------------------
405    // Task 3: Recovery of known β(t)
406    // -----------------------------------------------------------------------
407
408    /// Simple inline LCG for deterministic noise (no rand crate).
409    fn lcg_noise(seed: u64, n: usize) -> Vec<f64> {
410        let mut state = seed;
411        (0..n)
412            .map(|_| {
413                state = state
414                    .wrapping_mul(6_364_136_223_846_793_005)
415                    .wrapping_add(1_442_695_040_888_963_407);
416                // Map to (-0.5, 0.5)
417                (state >> 33) as f64 / (u32::MAX as f64) - 0.5
418            })
419            .collect()
420    }
421
422    #[test]
423    fn test_recovery_known_beta() {
424        let n = 50usize;
425        let m = 50usize;
426        let argvals = uniform_grid(m);
427
428        // true β₀(t) = 0.5, true β₁(t) = sin(π·t)  (half period, lower curvature than
429        // sin(2πt) so local-linear smoothing recovers it with bandwidth=0.15 within tolerance).
430        let true_beta0 = 0.5_f64;
431        let true_beta: Vec<f64> = argvals
432            .iter()
433            .map(|&t| (std::f64::consts::PI * t).sin())
434            .collect();
435
436        // x[i,j] = sin(2π*(i/n) + argvals[j])
437        let mut predictor = FdMatrix::zeros(n, m);
438        for i in 0..n {
439            for j in 0..m {
440                predictor[(i, j)] =
441                    (2.0 * std::f64::consts::PI * (i as f64 / n as f64) + argvals[j]).sin();
442            }
443        }
444
445        // Determine max |x| for noise scaling
446        let mut x_max = 0.0_f64;
447        for i in 0..n {
448            for j in 0..m {
449                let v = predictor[(i, j)].abs();
450                if v > x_max {
451                    x_max = v;
452                }
453            }
454        }
455        let noise_scale = 0.05 * x_max;
456
457        // Build response with low noise
458        let noise = lcg_noise(42, n * m);
459        let mut response = FdMatrix::zeros(n, m);
460        for i in 0..n {
461            for j in 0..m {
462                response[(i, j)] =
463                    true_beta0 + true_beta[j] * predictor[(i, j)] + noise_scale * noise[i * m + j];
464            }
465        }
466
467        let result =
468            concurrent_regression(&response, &[predictor], Some(&argvals), 0.15, "gaussian")
469                .expect("recovery test should succeed");
470
471        // Check interior indices only (boundary edge effects excluded).
472        // For bandwidth=0.15 on a [0,1] grid with 50 points (spacing ≈0.020),
473        // the effective boundary zone is ~8 grid points on each side.
474        for j in 5..45 {
475            let diff = (result.beta_curve[(0, j)] - true_beta[j]).abs();
476            assert!(
477                diff < 0.15,
478                "recovery failed at j={j}: got {}, expected {}, diff={diff}",
479                result.beta_curve[(0, j)],
480                true_beta[j]
481            );
482        }
483    }
484
485    // -----------------------------------------------------------------------
486    // Task 3: Monotone roughness test
487    // -----------------------------------------------------------------------
488
489    fn roughness(row: &[f64]) -> f64 {
490        let m = row.len();
491        if m < 3 {
492            return 0.0;
493        }
494        (1..m - 1)
495            .map(|j| {
496                let d = row[j + 1] - 2.0 * row[j] + row[j - 1];
497                d * d
498            })
499            .sum()
500    }
501
502    #[test]
503    fn test_monotone_roughness() {
504        let n = 50usize;
505        let m = 50usize;
506        let argvals = uniform_grid(m);
507
508        let true_beta0 = 0.5_f64;
509        let true_beta: Vec<f64> = argvals
510            .iter()
511            .map(|&t| (2.0 * std::f64::consts::PI * t).sin())
512            .collect();
513
514        let mut predictor = FdMatrix::zeros(n, m);
515        for i in 0..n {
516            for j in 0..m {
517                predictor[(i, j)] =
518                    (2.0 * std::f64::consts::PI * (i as f64 / n as f64) + argvals[j]).sin();
519            }
520        }
521
522        let mut x_max = 0.0_f64;
523        for i in 0..n {
524            for j in 0..m {
525                let v = predictor[(i, j)].abs();
526                if v > x_max {
527                    x_max = v;
528                }
529            }
530        }
531        let noise_scale = 0.05 * x_max;
532        let noise = lcg_noise(42, n * m);
533        let mut response = FdMatrix::zeros(n, m);
534        for i in 0..n {
535            for j in 0..m {
536                response[(i, j)] =
537                    true_beta0 + true_beta[j] * predictor[(i, j)] + noise_scale * noise[i * m + j];
538            }
539        }
540
541        let bandwidths = [0.05, 0.15, 0.35];
542        let roughnesses: Vec<f64> = bandwidths
543            .iter()
544            .map(|&bw| {
545                let r = concurrent_regression(
546                    &response,
547                    &[predictor.clone()],
548                    Some(&argvals),
549                    bw,
550                    "gaussian",
551                )
552                .expect("monotone roughness call should succeed");
553                let beta_row: Vec<f64> = (0..m).map(|j| r.beta_curve[(0, j)]).collect();
554                roughness(&beta_row)
555            })
556            .collect();
557
558        assert!(
559            roughnesses[0] > roughnesses[1],
560            "roughness(bw=0.05)={} should be > roughness(bw=0.15)={}",
561            roughnesses[0],
562            roughnesses[1]
563        );
564        assert!(
565            roughnesses[1] > roughnesses[2],
566            "roughness(bw=0.15)={} should be > roughness(bw=0.35)={}",
567            roughnesses[1],
568            roughnesses[2]
569        );
570    }
571
572    // -----------------------------------------------------------------------
573    // Task 4: Residuals consistency
574    // -----------------------------------------------------------------------
575
576    #[test]
577    fn test_residuals_consistency() {
578        let n = 6;
579        let m = 8;
580        let argvals = uniform_grid(m);
581
582        let mut response = FdMatrix::zeros(n, m);
583        let mut predictor = FdMatrix::zeros(n, m);
584        for i in 0..n {
585            for j in 0..m {
586                response[(i, j)] = (i as f64 + 1.0) * argvals[j] + 0.1;
587                predictor[(i, j)] = argvals[j] + i as f64 * 0.1;
588            }
589        }
590
591        let result = concurrent_regression(&response, &[predictor], None, 0.2, "gaussian")
592            .expect("residuals consistency call should succeed");
593
594        for i in 0..n {
595            for j in 0..m {
596                let expected_resid = response[(i, j)] - result.fitted[(i, j)];
597                let diff = (result.residuals[(i, j)] - expected_resid).abs();
598                assert!(
599                    diff < 1e-10,
600                    "residual inconsistency at ({i},{j}): stored={}, computed={expected_resid}, diff={diff}",
601                    result.residuals[(i, j)]
602                );
603            }
604        }
605    }
606
607    // -----------------------------------------------------------------------
608    // Task 4: Invalid input guards
609    // -----------------------------------------------------------------------
610
611    #[test]
612    fn test_invalid_inputs() {
613        let n = 6;
614        let m = 8;
615        let argvals = uniform_grid(m);
616
617        let mut response = FdMatrix::zeros(n, m);
618        let mut predictor = FdMatrix::zeros(n, m);
619        for i in 0..n {
620            for j in 0..m {
621                response[(i, j)] = (i as f64 + 1.0) * argvals[j];
622                predictor[(i, j)] = argvals[j];
623            }
624        }
625
626        // 1. Empty predictors slice
627        let err = concurrent_regression(&response, &[], None, 0.2, "gaussian")
628            .expect_err("empty predictors should return Err");
629        assert!(
630            matches!(
631                err,
632                FdarError::InvalidDimension {
633                    parameter: "predictors",
634                    ..
635                }
636            ),
637            "expected InvalidDimension for empty predictors, got {err:?}"
638        );
639
640        // 2. Response with n < 2
641        let tiny_response = FdMatrix::zeros(1, m);
642        let tiny_pred = FdMatrix::zeros(1, m);
643        let err = concurrent_regression(&tiny_response, &[tiny_pred], None, 0.2, "gaussian")
644            .expect_err("n<2 response should return Err");
645        assert!(
646            matches!(
647                err,
648                FdarError::InvalidDimension {
649                    parameter: "response",
650                    ..
651                }
652            ),
653            "expected InvalidDimension for response n<2, got {err:?}"
654        );
655
656        // 3. predictor[0].nrows() != n
657        let wrong_n_pred = FdMatrix::zeros(n + 1, m);
658        let err = concurrent_regression(&response, &[wrong_n_pred], None, 0.2, "gaussian")
659            .expect_err("wrong nrows predictor should return Err");
660        assert!(
661            matches!(
662                err,
663                FdarError::InvalidDimension {
664                    parameter: "predictors[k]",
665                    ..
666                }
667            ),
668            "expected InvalidDimension for predictor nrows mismatch, got {err:?}"
669        );
670
671        // 4. predictor[0].ncols() != m
672        let wrong_m_pred = FdMatrix::zeros(n, m + 1);
673        let err = concurrent_regression(&response, &[wrong_m_pred], None, 0.2, "gaussian")
674            .expect_err("wrong ncols predictor should return Err");
675        assert!(
676            matches!(
677                err,
678                FdarError::InvalidDimension {
679                    parameter: "predictors[k]",
680                    ..
681                }
682            ),
683            "expected InvalidDimension for predictor ncols mismatch, got {err:?}"
684        );
685
686        // 5. bandwidth = 0.0
687        let err = concurrent_regression(&response, &[predictor.clone()], None, 0.0, "gaussian")
688            .expect_err("bandwidth=0.0 should return Err");
689        assert!(
690            matches!(
691                err,
692                FdarError::InvalidParameter {
693                    parameter: "bandwidth",
694                    ..
695                }
696            ),
697            "expected InvalidParameter for bandwidth=0.0, got {err:?}"
698        );
699
700        // 6. bandwidth < 0
701        let err = concurrent_regression(&response, &[predictor.clone()], None, -0.1, "gaussian")
702            .expect_err("negative bandwidth should return Err");
703        assert!(
704            matches!(
705                err,
706                FdarError::InvalidParameter {
707                    parameter: "bandwidth",
708                    ..
709                }
710            ),
711            "expected InvalidParameter for negative bandwidth, got {err:?}"
712        );
713
714        // 7. argvals Some with wrong length
715        let wrong_argvals: Vec<f64> = uniform_grid(m + 3);
716        let err = concurrent_regression(
717            &response,
718            &[predictor],
719            Some(&wrong_argvals),
720            0.2,
721            "gaussian",
722        )
723        .expect_err("wrong argvals length should return Err");
724        assert!(
725            matches!(
726                err,
727                FdarError::InvalidDimension {
728                    parameter: "argvals",
729                    ..
730                }
731            ),
732            "expected InvalidDimension for argvals length mismatch, got {err:?}"
733        );
734    }
735
736    // -----------------------------------------------------------------------
737    // Regression test: CR-01 — NaN/Inf bandwidth must return an error
738    //
739    // Before the fix, `NaN <= 0.0` evaluated to `false` under IEEE 754, so
740    // NaN bandwidth bypassed the guard and silently returned all-zero
741    // coefficients. This test pins the corrected behaviour.
742    // -----------------------------------------------------------------------
743
744    #[test]
745    fn test_nan_inf_bandwidth_returns_error() {
746        let n = 4;
747        let m = 6;
748
749        let mut response = FdMatrix::zeros(n, m);
750        let mut predictor = FdMatrix::zeros(n, m);
751        for i in 0..n {
752            for j in 0..m {
753                response[(i, j)] = (i as f64 + 1.0) * (j as f64 + 1.0);
754                predictor[(i, j)] = (i as f64 + 1.0) + (j as f64 * 0.1);
755            }
756        }
757
758        // NaN bandwidth: previously bypassed the guard → silent wrong output.
759        let err =
760            concurrent_regression(&response, &[predictor.clone()], None, f64::NAN, "gaussian")
761                .expect_err("NaN bandwidth must return Err");
762        assert!(
763            matches!(
764                err,
765                FdarError::InvalidParameter {
766                    parameter: "bandwidth",
767                    ..
768                }
769            ),
770            "expected InvalidParameter for NaN bandwidth, got {err:?}"
771        );
772
773        // +Infinity bandwidth: also previously bypassed the guard.
774        let err = concurrent_regression(
775            &response,
776            &[predictor.clone()],
777            None,
778            f64::INFINITY,
779            "gaussian",
780        )
781        .expect_err("+Inf bandwidth must return Err");
782        assert!(
783            matches!(
784                err,
785                FdarError::InvalidParameter {
786                    parameter: "bandwidth",
787                    ..
788                }
789            ),
790            "expected InvalidParameter for +Inf bandwidth, got {err:?}"
791        );
792
793        // Sanity-check: a normal positive bandwidth still succeeds.
794        concurrent_regression(&response, &[predictor], None, 0.2, "gaussian")
795            .expect("positive finite bandwidth must succeed");
796    }
797
798    // -----------------------------------------------------------------------
799    // Regression test: WR-01 — underdetermined system (n <= p) must return
800    // InvalidDimension rather than silently returning ridge-dominated output.
801    // -----------------------------------------------------------------------
802
803    #[test]
804    fn test_underdetermined_system_returns_error() {
805        let m = 8;
806
807        // Case 1: n == p (square, still underdetermined for the p+1-column design).
808        // 3 predictors, 3 observations → design matrix is 3×4, rank-deficient.
809        let n = 3usize;
810        let p = 3usize;
811        let response = FdMatrix::zeros(n, m);
812        let predictors: Vec<FdMatrix> = (0..p).map(|_| FdMatrix::zeros(n, m)).collect();
813
814        let err = concurrent_regression(&response, &predictors, None, 0.2, "gaussian")
815            .expect_err("n == p should return Err (underdetermined)");
816        assert!(
817            matches!(
818                err,
819                FdarError::InvalidDimension {
820                    parameter: "response",
821                    ..
822                }
823            ),
824            "expected InvalidDimension for n==p, got {err:?}"
825        );
826
827        // Case 2: n < p (severely underdetermined).
828        let n2 = 2usize;
829        let p2 = 4usize;
830        let response2 = FdMatrix::zeros(n2, m);
831        let predictors2: Vec<FdMatrix> = (0..p2).map(|_| FdMatrix::zeros(n2, m)).collect();
832
833        let err2 = concurrent_regression(&response2, &predictors2, None, 0.2, "gaussian")
834            .expect_err("n < p should return Err (underdetermined)");
835        assert!(
836            matches!(
837                err2,
838                FdarError::InvalidDimension {
839                    parameter: "response",
840                    ..
841                }
842            ),
843            "expected InvalidDimension for n<p, got {err2:?}"
844        );
845
846        // Sanity-check: n > p should still succeed.
847        let n3 = 6usize;
848        let p3 = 2usize;
849        let mut resp3 = FdMatrix::zeros(n3, m);
850        let argvals = uniform_grid(m);
851        for i in 0..n3 {
852            for j in 0..m {
853                resp3[(i, j)] = (i as f64 + 1.0) * argvals[j];
854            }
855        }
856        let preds3: Vec<FdMatrix> = (0..p3)
857            .map(|k| {
858                let mut pred = FdMatrix::zeros(n3, m);
859                for i in 0..n3 {
860                    for j in 0..m {
861                        pred[(i, j)] = (i as f64 + k as f64 + 1.0) * argvals[j] + 0.1;
862                    }
863                }
864                pred
865            })
866            .collect();
867        concurrent_regression(&resp3, &preds3, None, 0.2, "gaussian")
868            .expect("n > p should succeed");
869    }
870}