Skip to main content

fdars_core/wavelet/
regression.rs

1//! Wavelet-domain scalar-on-function regression (`wcr`, WAV-03).
2//!
3//! `wcr` transforms each functional predictor curve into its multi-level DWT
4//! coefficient pyramid (via the Phase 69 primitive), concatenates the bands into a
5//! single per-curve coefficient vector, and fits a scalar-on-function regression
6//! **in coefficient space** — either PCR (reusing [`crate::regression::fdata_to_pc_1d`])
7//! or PLS (reusing [`crate::regression::fdata_to_pls_1d`]). The fitted
8//! coefficient-space weights are then mapped back to the time-domain functional
9//! coefficient β(t) by the inverse DWT ([`crate::wavelet::reconstruct`]).
10//!
11//! ## Why this recovers β(t) exactly
12//!
13//! The multi-level orthogonal DWT is a linear, orthonormal map `W`: the design row
14//! for curve `i` is `c_i = W x_i` (wavelet coefficients). If the true relationship
15//! is `y = α + C β_c` in coefficient space (with `C` the coefficient design), then
16//! in the time domain `y = α + X (Wᵀ β_c)`, so the time-domain coefficient is
17//! `β(t) = Wᵀ β_c` — exactly the inverse DWT of the coefficient-space weights.
18//! [`coeff_weights_to_beta_t`] performs that inverse DWT.
19//!
20//! ## Shared seams (reused by the `wnet` regressor, Plan 70-02)
21//!
22//! - [`curves_to_coeff_design`] — the curves → concatenated-coefficient-design seam.
23//! - [`coeff_weights_to_beta_t`] — the coefficient-weights → β(t) seam.
24//!
25//! ## End-to-end example (via the prelude, WAV-06)
26//!
27//! ```
28//! use fdars_core::prelude::*;
29//!
30//! fn main() -> Result<(), fdars_core::FdarError> {
31//!     // 6 curves of length 32 (a db4-decomposable grid), built deterministically.
32//!     let (n, m) = (6usize, 32usize);
33//!     let mut flat = vec![0.0_f64; n * m];
34//!     for i in 0..n {
35//!         for j in 0..m {
36//!             // A smooth, per-curve-varying fill (no RNG → deterministic doctest).
37//!             let t = j as f64 / m as f64;
38//!             flat[i + j * n] = ((i as f64 + 1.0) * t).sin() + 0.5 * (i as f64) * t;
39//!         }
40//!     }
41//!     let data = FdMatrix::from_column_major(flat, n, m)?;
42//!     let y: Vec<f64> = (0..n).map(|i| 1.0 + 0.3 * i as f64).collect();
43//!
44//!     // Fit the wavelet-domain PCR regressor, then predict + read the coefficients.
45//!     let fit = wcr(&data, &y, &WcrConfig::default())?;
46//!     let preds = fit.predict(&data)?;
47//!     let beta = fit.beta_t();
48//!     let fitted = fit.fitted_values();
49//!
50//!     assert_eq!(preds.len(), fitted.len());
51//!     assert_eq!(beta.len(), m);
52//!
53//!     // Self-consistency: predicting on the TRAINING curves reproduces the stored
54//!     // fitted values exactly (the affine intercept folds in the centering offset).
55//!     for (p, f) in preds.iter().zip(fitted) {
56//!         assert!((p - f).abs() < 1e-7, "predict diverges from fitted: {p} vs {f}");
57//!     }
58//!     Ok(())
59//! }
60//! ```
61//!
62//! The full wavelet surface (DWT primitives + `wcr`/`wnet` + config/result types)
63//! is re-exported at the crate root and via [`crate::prelude`] (Phase 71, WAV-06).
64
65use crate::error::FdarError;
66use crate::matrix::FdMatrix;
67use crate::regression::{fdata_to_pc_1d, fdata_to_pls_1d};
68use crate::wavelet::{decompose_matrix, reconstruct, BoundaryMode, WaveletCoeffs, WaveletFamily};
69
70/// Which coefficient-space fit `wcr` uses.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73#[non_exhaustive]
74pub enum WcrMethod {
75    /// Principal-component regression on the wavelet-coefficient design
76    /// (reuses [`crate::regression::fdata_to_pc_1d`]).
77    #[default]
78    Pcr,
79    /// Partial-least-squares regression on the wavelet-coefficient design
80    /// (reuses [`crate::regression::fdata_to_pls_1d`]).
81    Pls,
82}
83
84/// Configuration for [`wcr`].
85///
86/// The DWT parameters (`family`, `mode`, `level`) select the wavelet basis the
87/// curves are transformed into; `ncomp` and `method` select the coefficient-space
88/// fit. [`Default`] is db4 / periodic / auto-depth / PCR with `ncomp == 5`.
89#[derive(Debug, Clone, PartialEq)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91#[non_exhaustive]
92pub struct WcrConfig {
93    /// Wavelet family for the DWT of each curve (default [`WaveletFamily::Daubechies(4)`]).
94    pub family: WaveletFamily,
95    /// Boundary handling for the DWT (default [`BoundaryMode::Periodic`]).
96    pub mode: BoundaryMode,
97    /// Explicit decomposition depth; `None` (default) uses the maximum useful level.
98    pub level: Option<usize>,
99    /// Number of coefficient-space components (FPC or PLS) to fit.
100    pub ncomp: usize,
101    /// Which coefficient-space regressor to use (default [`WcrMethod::Pcr`]).
102    pub method: WcrMethod,
103}
104
105impl Default for WcrConfig {
106    fn default() -> Self {
107        Self {
108            family: WaveletFamily::Daubechies(4),
109            mode: BoundaryMode::Periodic,
110            level: None,
111            ncomp: 5,
112            method: WcrMethod::Pcr,
113        }
114    }
115}
116
117/// Result of a [`wcr`] fit.
118///
119/// Carries the time-domain functional coefficient β(t), the coefficient-space
120/// weights it was reconstructed from, fitted values / residuals, and the DWT
121/// configuration a future `predict` (Phase 71) needs to reproduce the transform.
122#[derive(Debug, Clone, PartialEq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124#[non_exhaustive]
125pub struct WcrResult {
126    /// Affine intercept α such that
127    /// `ŷ_i = intercept + Σ_j design[i,j] · coeff_weights[j]` reproduces the fitted
128    /// values directly (matching the [`predict`](WcrResult::predict) formula and
129    /// `wnet`'s intercept convention).
130    ///
131    /// This is NOT the raw OLS intercept from the score regression: the centering
132    /// offset `Σ_j col_mean_j · coeff_weights[j]` has been folded in. Because of
133    /// this, manual reconstruction from the public fields uses the stored intercept
134    /// as-is (no re-centering needed).
135    pub intercept: f64,
136    /// Time-domain functional coefficient β(t) (length `m` = curve length).
137    pub beta_t: Vec<f64>,
138    /// Fitted response values (length `n`).
139    pub fitted_values: Vec<f64>,
140    /// Residuals `y - ŷ` (length `n`).
141    pub residuals: Vec<f64>,
142    /// Effective number of coefficient-space components used.
143    pub ncomp: usize,
144    /// The fitting method used.
145    pub method: WcrMethod,
146    /// Coefficient-space functional coefficient (length `P` = total wavelet coefficients).
147    pub coeff_weights: Vec<f64>,
148    /// Wavelet family used for the DWT (for reproducing the transform in prediction).
149    pub family: WaveletFamily,
150    /// Boundary mode used for the DWT.
151    pub mode: BoundaryMode,
152    /// Effective decomposition depth used.
153    pub level: usize,
154}
155
156/// Band layout of a per-curve wavelet-coefficient vector — everything
157/// [`reconstruct`] needs to rebuild a [`WaveletCoeffs`] shell from a flat vector.
158///
159/// Coefficients are concatenated **finest-first**: `[approx ++ details[0] ++
160/// details[1] ++ ...]`, matching [`WaveletCoeffs`] band order.
161#[derive(Debug, Clone, PartialEq)]
162pub(crate) struct CoeffLayout {
163    /// Length of the coarse approximation band (the first `approx_len` coefficients).
164    pub(crate) approx_len: usize,
165    /// Detail-band lengths, finest-first (matching [`WaveletCoeffs::details`] order).
166    pub(crate) detail_lens: Vec<usize>,
167    /// Original signal length (curve length `m`).
168    pub(crate) signal_len: usize,
169    /// Wavelet family used for the transform.
170    pub(crate) family: WaveletFamily,
171    /// Boundary mode used for the transform.
172    pub(crate) mode: BoundaryMode,
173    /// Per-level analysis-input lengths, finest-first (the [`WaveletCoeffs::level_lens`]).
174    pub(crate) level_lens: Vec<usize>,
175}
176
177impl CoeffLayout {
178    /// Total number of wavelet coefficients per curve (`P` = approx + all details).
179    pub(crate) fn total_len(&self) -> usize {
180        self.approx_len + self.detail_lens.iter().sum::<usize>()
181    }
182
183    /// Number of decomposition levels.
184    pub(crate) fn levels(&self) -> usize {
185        self.detail_lens.len()
186    }
187}
188
189/// Flatten one curve's [`WaveletCoeffs`] into a finest-first coefficient vector.
190fn coeffs_to_row(coeffs: &WaveletCoeffs) -> Vec<f64> {
191    let mut row = Vec::with_capacity(
192        coeffs.approx.len() + coeffs.details.iter().map(Vec::len).sum::<usize>(),
193    );
194    row.extend_from_slice(&coeffs.approx);
195    for band in &coeffs.details {
196        row.extend_from_slice(band);
197    }
198    row
199}
200
201/// SHARED SEAM. Transform every curve (row) of `data` into its concatenated
202/// wavelet-coefficient vector, assembling an `n × P` design matrix.
203///
204/// Each row of the returned [`FdMatrix`] is one curve's `[approx ++ details...]`
205/// (finest-first). All curves must share the same length (guaranteed by a common
206/// evaluation grid), so every row has the same layout — the returned [`CoeffLayout`]
207/// records that shared band structure so coefficient-space weights can be scattered
208/// back to a [`WaveletCoeffs`] for the inverse DWT.
209///
210/// # Errors
211/// - [`FdarError::InvalidDimension`] if `data` is empty (surfaced from
212///   [`decompose_matrix`]), or if the per-curve coefficient layouts disagree
213///   (should not happen for a common grid).
214/// - [`FdarError::InvalidParameter`] if the family is unsupported or the level is
215///   out of range (surfaced from [`decompose_matrix`]).
216pub(crate) fn curves_to_coeff_design(
217    data: &FdMatrix,
218    family: WaveletFamily,
219    mode: BoundaryMode,
220    level: Option<usize>,
221) -> Result<(FdMatrix, CoeffLayout), FdarError> {
222    let per_curve = decompose_matrix(data, family.clone(), mode, level)?;
223    // per_curve is non-empty: decompose_matrix rejects zero-row matrices.
224    let first = &per_curve[0];
225    let layout = CoeffLayout {
226        approx_len: first.approx.len(),
227        detail_lens: first.details.iter().map(Vec::len).collect(),
228        signal_len: first.signal_len,
229        family,
230        mode,
231        level_lens: first.level_lens.clone(),
232    };
233    let p = layout.total_len();
234    let n = per_curve.len();
235
236    // Assemble the n × P design in column-major order, validating that every curve
237    // produced the same band structure as curve 0.
238    let mut flat = vec![0.0_f64; n * p];
239    for (i, coeffs) in per_curve.iter().enumerate() {
240        if coeffs.approx.len() != layout.approx_len
241            || coeffs.details.len() != layout.detail_lens.len()
242            || coeffs
243                .details
244                .iter()
245                .zip(&layout.detail_lens)
246                .any(|(band, &len)| band.len() != len)
247            || coeffs.signal_len != layout.signal_len
248        {
249            return Err(FdarError::InvalidDimension {
250                parameter: "data",
251                expected: format!("all curves share curve-0 coefficient layout (P = {p})"),
252                actual: format!("curve {i} produced a different band structure"),
253            });
254        }
255        let row = coeffs_to_row(coeffs);
256        for (j, &v) in row.iter().enumerate() {
257            flat[i + j * n] = v;
258        }
259    }
260
261    let design = FdMatrix::from_column_major(flat, n, p)?;
262    Ok((design, layout))
263}
264
265/// SHARED SEAM. Map a `P`-length coefficient-space weight vector back to the time
266/// domain via the inverse DWT.
267///
268/// Splits `weights` into the approximation band and the finest-first detail bands per
269/// `layout`, packs them into a [`WaveletCoeffs`] shell, and calls [`reconstruct`],
270/// yielding β(t) of length `layout.signal_len`.
271///
272/// # Errors
273/// - [`FdarError::InvalidDimension`] if `weights.len()` does not equal the total
274///   coefficient count implied by `layout`.
275/// - [`FdarError::InvalidParameter`] if the family is unsupported (surfaced from
276///   [`reconstruct`]).
277pub(crate) fn coeff_weights_to_beta_t(
278    weights: &[f64],
279    layout: &CoeffLayout,
280) -> Result<Vec<f64>, FdarError> {
281    let expected = layout.total_len();
282    if weights.len() != expected {
283        return Err(FdarError::InvalidDimension {
284            parameter: "weights",
285            expected: format!("{expected} coefficients (approx + all detail bands)"),
286            actual: format!("{} coefficients", weights.len()),
287        });
288    }
289
290    let approx = weights[..layout.approx_len].to_vec();
291    let mut details: Vec<Vec<f64>> = Vec::with_capacity(layout.detail_lens.len());
292    let mut offset = layout.approx_len;
293    for &len in &layout.detail_lens {
294        details.push(weights[offset..offset + len].to_vec());
295        offset += len;
296    }
297
298    let coeffs = WaveletCoeffs {
299        approx,
300        details,
301        levels: layout.levels(),
302        signal_len: layout.signal_len,
303        family: layout.family.clone(),
304        mode: layout.mode,
305        level_lens: layout.level_lens.clone(),
306    };
307    reconstruct(&coeffs)
308}
309
310// ---------------------------------------------------------------------------
311// Local OLS helpers (mirrors scalar_on_function's private OLS path; those helpers
312// are module-private there, so wcr carries a small self-contained normal-equations
313// solver rather than widening their visibility).
314// ---------------------------------------------------------------------------
315
316/// Build the OLS design `[1, scores]` (n × (1 + ncomp)).
317fn design_with_intercept(scores: &FdMatrix, ncomp: usize) -> FdMatrix {
318    let n = scores.nrows();
319    let mut design = FdMatrix::zeros(n, 1 + ncomp);
320    for i in 0..n {
321        design[(i, 0)] = 1.0;
322        for k in 0..ncomp {
323            design[(i, 1 + k)] = scores[(i, k)];
324        }
325    }
326    design
327}
328
329/// Solve OLS `min ||Xb - y||²` via normal equations with Cholesky.
330fn ols_solve(x: &FdMatrix, y: &[f64]) -> Result<Vec<f64>, FdarError> {
331    let (n, p) = x.shape();
332    if n < p || p == 0 {
333        return Err(FdarError::InvalidDimension {
334            parameter: "design matrix",
335            expected: format!("n >= p and p > 0 (p={p})"),
336            actual: format!("n={n}, p={p}"),
337        });
338    }
339    // X'X (p × p) and X'y (p).
340    let mut xtx = vec![0.0_f64; p * p];
341    let mut xty = vec![0.0_f64; p];
342    for a in 0..p {
343        for b in 0..p {
344            let mut s = 0.0;
345            for i in 0..n {
346                s += x[(i, a)] * x[(i, b)];
347            }
348            xtx[a + b * p] = s;
349        }
350        let mut sy = 0.0;
351        for i in 0..n {
352            sy += x[(i, a)] * y[i];
353        }
354        xty[a] = sy;
355    }
356    let l = cholesky_factor(&xtx, p)?;
357    Ok(cholesky_solve(&l, &xty, p))
358}
359
360/// Cholesky factor `A = L Lᵀ` (column-major `p × p`, lower-triangular `L`).
361fn cholesky_factor(a: &[f64], p: usize) -> Result<Vec<f64>, FdarError> {
362    let mut l = vec![0.0_f64; p * p];
363    for j in 0..p {
364        let mut diag = a[j + j * p];
365        for k in 0..j {
366            diag -= l[j + k * p] * l[j + k * p];
367        }
368        if diag <= 0.0 {
369            return Err(FdarError::ComputationFailed {
370                operation: "Cholesky factorization (wcr OLS)",
371                detail: "design matrix X'X is not positive definite; try reducing ncomp"
372                    .to_string(),
373            });
374        }
375        let ljj = diag.sqrt();
376        l[j + j * p] = ljj;
377        for i in (j + 1)..p {
378            let mut s = a[i + j * p];
379            for k in 0..j {
380                s -= l[i + k * p] * l[j + k * p];
381            }
382            l[i + j * p] = s / ljj;
383        }
384    }
385    Ok(l)
386}
387
388/// Solve `L Lᵀ b = rhs` by forward then back substitution.
389fn cholesky_solve(l: &[f64], rhs: &[f64], p: usize) -> Vec<f64> {
390    // Forward: L z = rhs.
391    let mut z = vec![0.0_f64; p];
392    for i in 0..p {
393        let mut s = rhs[i];
394        for k in 0..i {
395            s -= l[i + k * p] * z[k];
396        }
397        z[i] = s / l[i + i * p];
398    }
399    // Back: Lᵀ b = z.
400    let mut b = vec![0.0_f64; p];
401    for i in (0..p).rev() {
402        let mut s = z[i];
403        for k in (i + 1)..p {
404            s -= l[k + i * p] * b[k];
405        }
406        b[i] = s / l[i + i * p];
407    }
408    b
409}
410
411/// Recover the plain-dot coefficient-space functional coefficient β_coeff.
412///
413/// The fit's predictions satisfy `fitted_i = intercept + ⟨centered_row_i, β_coeff⟩`
414/// (plain dot) for a unique `β_coeff` in the span of the (full-column-rank, `P ≤ n`)
415/// coefficient design. This regresses the centered fitted contribution
416/// `fitted_i - intercept` onto the column-centered design via the normal equations,
417/// recovering that exact `β_coeff` independently of which reduced-rank method (PCR or
418/// PLS) produced the fit or which internal integration weighting it used.
419fn recover_coeff_weights(
420    design: &FdMatrix,
421    fitted: &[f64],
422    intercept: f64,
423) -> Result<Vec<f64>, FdarError> {
424    let (n, p) = design.shape();
425    // Column means (centering absorbs the intercept).
426    let col_means: Vec<f64> = (0..p)
427        .map(|j| design.column(j).iter().sum::<f64>() / n as f64)
428        .collect();
429    // Centered design X_c and centered target r = fitted - intercept.
430    let mut xc = FdMatrix::zeros(n, p);
431    for j in 0..p {
432        for i in 0..n {
433            xc[(i, j)] = design[(i, j)] - col_means[j];
434        }
435    }
436    let r: Vec<f64> = fitted.iter().map(|&f| f - intercept).collect();
437    // Normal equations X_c' X_c b = X_c' r.
438    let mut xtx = vec![0.0_f64; p * p];
439    let mut xtr = vec![0.0_f64; p];
440    for a in 0..p {
441        for b in 0..p {
442            let mut s = 0.0;
443            for i in 0..n {
444                s += xc[(i, a)] * xc[(i, b)];
445            }
446            xtx[a + b * p] = s;
447        }
448        let mut sr = 0.0;
449        for i in 0..n {
450            sr += xc[(i, a)] * r[i];
451        }
452        xtr[a] = sr;
453    }
454    // Ridge-nudge the diagonal for numerical stability against rank-deficient bands
455    // (near-zero-variance coefficient columns from short signals); tiny relative to
456    // the trace, so it does not perturb a well-posed recovery.
457    let trace: f64 = (0..p).map(|j| xtx[j + j * p]).sum();
458    let eps = 1e-10 * (trace / p as f64).max(1e-12);
459    for j in 0..p {
460        xtx[j + j * p] += eps;
461    }
462    let l = cholesky_factor(&xtx, p)?;
463    Ok(cholesky_solve(&l, &xtr, p))
464}
465
466/// Compute fitted values `ŷ = X b`.
467fn compute_fitted(design: &FdMatrix, coeffs: &[f64]) -> Vec<f64> {
468    let (n, p) = design.shape();
469    (0..n)
470        .map(|i| {
471            let mut yhat = 0.0;
472            for j in 0..p {
473                yhat += design[(i, j)] * coeffs[j];
474            }
475            yhat
476        })
477        .collect()
478}
479
480// ---------------------------------------------------------------------------
481// wcr entry point
482// ---------------------------------------------------------------------------
483
484/// Fit the wavelet-domain scalar-on-function regressor `wcr` (WAV-03).
485///
486/// Transforms every curve into its wavelet-coefficient vector, fits PCR or PLS in
487/// coefficient space (per `config.method`), and reconstructs the time-domain
488/// functional coefficient β(t) via the inverse DWT.
489///
490/// The coefficient index is treated as an abstract basis: the PCR/PLS calls receive
491/// a uniform grid `0..P` as their `argvals` (Simpson integration weights over that
492/// grid), since wavelet coefficients carry no intrinsic spacing.
493///
494/// # Arguments
495/// * `data` — functional predictor matrix (n × m), one curve per row.
496/// * `y` — scalar response (length n).
497/// * `config` — DWT + coefficient-space fit configuration.
498///
499/// # Errors
500/// - [`FdarError::InvalidDimension`] if `data` has fewer than 3 rows, zero columns,
501///   or `y.len() != n`.
502/// - [`FdarError::InvalidParameter`] if `config.ncomp == 0`, or if the DWT rejects
503///   the family/level (surfaced from [`decompose_matrix`]).
504/// - [`FdarError::ComputationFailed`] if the underlying PCA/PLS or OLS fails.
505#[must_use = "expensive computation whose result should not be discarded"]
506pub fn wcr(data: &FdMatrix, y: &[f64], config: &WcrConfig) -> Result<WcrResult, FdarError> {
507    let (n, m) = data.shape();
508    if n < 3 {
509        return Err(FdarError::InvalidDimension {
510            parameter: "data",
511            expected: "at least 3 rows (observations)".to_string(),
512            actual: format!("{n} rows"),
513        });
514    }
515    if m == 0 {
516        return Err(FdarError::InvalidDimension {
517            parameter: "data",
518            expected: "at least 1 column (evaluation point)".to_string(),
519            actual: format!("{m} columns"),
520        });
521    }
522    if y.len() != n {
523        return Err(FdarError::InvalidDimension {
524            parameter: "y",
525            expected: format!("{n} elements (== data rows)"),
526            actual: format!("{} elements", y.len()),
527        });
528    }
529    if config.ncomp == 0 {
530        return Err(FdarError::InvalidParameter {
531            parameter: "ncomp",
532            message: "ncomp must be >= 1".to_string(),
533        });
534    }
535
536    // Curves -> coefficient design (shared seam). Surfaces DWT errors unchanged.
537    let (design, layout) =
538        curves_to_coeff_design(data, config.family.clone(), config.mode, config.level)?;
539    let p = design.ncols();
540
541    // Coefficients form an abstract basis: use a uniform 0..P grid for integration.
542    let argvals: Vec<f64> = (0..p).map(|j| j as f64).collect();
543
544    // Clamp effective ncomp to the fittable rank. The OLS design is `[1, scores]`
545    // (n × (ncomp + 1)), so `ols_solve` needs ncomp + 1 <= n, i.e. ncomp <= n - 1;
546    // otherwise a valid small-n call (e.g. default ncomp = 5 with n <= 5) would be
547    // rejected by ols_solve's `n < p` guard. `n >= 3` is enforced above, so
548    // `n.saturating_sub(1) >= 2`.
549    let ncomp = config.ncomp.min(n.saturating_sub(1)).min(p);
550
551    // Fit in coefficient space: PCR or PLS yields reduced-rank scores, then OLS on
552    // [1, scores] gives the intercept and fitted values.
553    let (scores, ncomp) = match config.method {
554        WcrMethod::Pcr => {
555            let fpca = fdata_to_pc_1d(&design, ncomp, &argvals)?;
556            let k = fpca.scores.ncols();
557            (fpca.scores, k)
558        }
559        WcrMethod::Pls => {
560            let pls = fdata_to_pls_1d(&design, y, ncomp, &argvals)?;
561            let k = pls.scores.ncols();
562            (pls.scores, k)
563        }
564    };
565    let ols_design = design_with_intercept(&scores, ncomp);
566    let coeffs = ols_solve(&ols_design, y)?;
567    let intercept = coeffs[0];
568    let fitted_values = compute_fitted(&ols_design, &coeffs);
569
570    // Recover the coefficient-space functional coefficient β_coeff directly in the raw
571    // wavelet-coefficient basis, so β(t) acts on a curve by the plain functional inner
572    // product ⟨coeffs(curve), β_coeff⟩ (matching decompose → concatenate → dot).
573    //
574    // The score-projection recovery (Σ_k γ_k · rotation/weight_k) instead yields β_coeff
575    // in each method's *internal* integration-weighted inner product (sqrt-weighted for
576    // PCR's SVD, int-weighted for PLS's NIPALS), which does not match a plain dot. Since
577    // the reduced-rank fitted values lie exactly in the span of the coefficient design
578    // (full column rank here, P ≤ n), regressing the centered fitted contribution back
579    // onto the centered design recovers the exact, method-agnostic plain-dot β_coeff.
580    let coeff_weights = recover_coeff_weights(&design, &fitted_values, intercept)?;
581
582    // Re-express the intercept in the affine coefficient-space convention so that
583    // `fitted_i == intercept + Σ_j design[i,j]·coeff_weights[j]` holds directly
584    // (matching `compute_fitted_affine`, and mirroring `wnet`'s intercept). The
585    // centered recovery above satisfies `fitted_i = intercept +
586    // Σ_j (design[i,j] − col_mean_j)·w_j`, so the affine intercept folds in the
587    // constant centering offset `Σ_j col_mean_j·w_j`. This makes `predict` (WAV-05)
588    // reproduce the stored `fitted_values` exactly. β(t) (the slope) is unchanged.
589    let (n_rows, p_cols) = design.shape();
590    let intercept = {
591        let offset: f64 = (0..p_cols)
592            .map(|j| {
593                let col_mean = design.column(j).iter().sum::<f64>() / n_rows as f64;
594                col_mean * coeff_weights[j]
595            })
596            .sum();
597        intercept - offset
598    };
599
600    // β(t) via inverse DWT of the coefficient-space weights (shared seam).
601    let beta_t = coeff_weights_to_beta_t(&coeff_weights, &layout)?;
602
603    let residuals: Vec<f64> = y
604        .iter()
605        .zip(&fitted_values)
606        .map(|(&yi, &yh)| yi - yh)
607        .collect();
608
609    Ok(WcrResult {
610        intercept,
611        beta_t,
612        fitted_values,
613        residuals,
614        ncomp,
615        method: config.method,
616        coeff_weights,
617        family: config.family.clone(),
618        mode: config.mode,
619        level: layout.levels(),
620    })
621}
622
623impl WcrResult {
624    /// Predict the scalar response for new functional curves (WAV-05).
625    ///
626    /// Re-transforms each new curve into the wavelet-coefficient design using the
627    /// STORED fitted DWT configuration (`family` / `mode` / effective `level`), then
628    /// applies the affine coefficient-space map `ŷ = intercept + Σ_j design[i,j] ·
629    /// coeff_weights[j]`. Re-passing the training curves reproduces the stored
630    /// [`fitted_values`](WcrResult::fitted_values) exactly (up to float rounding).
631    ///
632    /// # Arguments
633    /// * `new` — functional predictor matrix (rows = curves) on the SAME evaluation
634    ///   grid as the training data (`new.ncols()` must equal the training grid length).
635    ///
636    /// # Errors
637    /// - [`FdarError::InvalidDimension`] with `parameter: "new"` if `new` has zero
638    ///   rows (no curves to predict), if `new.ncols()` differs from the training
639    ///   grid length, or (defensively) if the re-transformed design width disagrees
640    ///   with the stored coefficient-space width.
641    /// - [`FdarError::InvalidParameter`] if the DWT rejects the stored family/level
642    ///   (surfaced from [`decompose_matrix`]).
643    pub fn predict(&self, new: &FdMatrix) -> Result<Vec<f64>, FdarError> {
644        let train_m = self.beta_t.len();
645        if new.nrows() == 0 {
646            return Err(FdarError::InvalidDimension {
647                parameter: "new",
648                expected: "at least 1 row (curve)".to_string(),
649                actual: "0 rows".to_string(),
650            });
651        }
652        if new.ncols() != train_m {
653            return Err(FdarError::InvalidDimension {
654                parameter: "new",
655                expected: format!("{train_m} columns (== training grid length)"),
656                actual: format!("{} columns", new.ncols()),
657            });
658        }
659        // Re-transform with the STORED fitted DWT config so the new-curve design
660        // matches the fit-time design exactly.
661        let (design, _layout) =
662            curves_to_coeff_design(new, self.family.clone(), self.mode, Some(self.level))?;
663        if design.ncols() != self.coeff_weights.len() {
664            return Err(FdarError::InvalidDimension {
665                parameter: "new",
666                expected: format!(
667                    "coefficient-space width {} (== stored coeff_weights)",
668                    self.coeff_weights.len()
669                ),
670                actual: format!("{} coefficients", design.ncols()),
671            });
672        }
673        Ok(compute_fitted_affine(
674            &design,
675            &self.coeff_weights,
676            self.intercept,
677        ))
678    }
679
680    /// The time-domain functional coefficient β(t) (length `m` = curve length).
681    #[must_use]
682    pub fn beta_t(&self) -> &[f64] {
683        &self.beta_t
684    }
685
686    /// The functional coefficient β(t) (crate-convention alias of [`beta_t`](WcrResult::beta_t)).
687    #[must_use]
688    pub fn coefficient_function(&self) -> &[f64] {
689        &self.beta_t
690    }
691
692    /// The fitted response values (length `n`).
693    #[must_use]
694    pub fn fitted_values(&self) -> &[f64] {
695        &self.fitted_values
696    }
697}
698
699// ===========================================================================
700// wnet — wavelet-domain elastic-net scalar-on-function regressor (WAV-04)
701// ===========================================================================
702//
703// `wnet` is the sparse/elastic-net half of the wavelet-domain regressor pair.
704// It reuses the shared `curves_to_coeff_design` / `coeff_weights_to_beta_t`
705// seams above, but fits an **elastic-net** (L1 lasso + L2 ridge) directly on
706// the wavelet-coefficient design via a NEW thin per-coefficient coordinate-
707// descent adapter ([`elastic_net_cd`]), with a deterministic cross-validated λ
708// ([`wnet_cv_lambda`]). A sparse wavelet basis is exactly where L1 shrinkage
709// shines: localized signal concentrates in a few coefficients, and the L1
710// penalty drives the rest to exactly zero.
711//
712// The per-coefficient CD is modeled on the group-lasso soft-threshold PATTERN
713// in `scalar_on_function::additive` (partial-residual → coordinate update →
714// shrink) but is scalar-per-coefficient (elastic-net), not group-lasso.
715
716/// Configuration for [`wnet`].
717///
718/// The DWT parameters (`family`, `mode`, `level`) select the wavelet basis the
719/// curves are transformed into (same defaults as [`WcrConfig`]: db4 / periodic /
720/// auto-depth). `alpha` mixes L1 vs L2 (`alpha == 1` is pure lasso, `alpha == 0`
721/// is pure ridge), and the remaining fields drive the deterministic K-fold
722/// cross-validated λ search.
723///
724/// [`Default`] is db4 / periodic / auto-depth, `alpha == 0.5`, an auto geometric
725/// λ grid of 50 values, 5 folds, fixed seed 0, `max_iter == 1000`, `tol == 1e-6`.
726#[derive(Debug, Clone, PartialEq)]
727#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
728#[non_exhaustive]
729pub struct WnetConfig {
730    /// Wavelet family for the DWT of each curve (default [`WaveletFamily::Daubechies(4)`]).
731    pub family: WaveletFamily,
732    /// Boundary handling for the DWT (default [`BoundaryMode::Periodic`]).
733    pub mode: BoundaryMode,
734    /// Explicit decomposition depth; `None` (default) uses the maximum useful level.
735    pub level: Option<usize>,
736    /// Elastic-net mixing parameter ∈ [0, 1]: `1.0` is pure L1 (lasso),
737    /// `0.0` is pure L2 (ridge). Default `0.5`.
738    pub alpha: f64,
739    /// Explicit λ grid to search. `None` (default) auto-builds a geometric grid.
740    pub lambda_grid: Option<Vec<f64>>,
741    /// Number of λ values in the auto geometric grid (used when `lambda_grid` is
742    /// `None`). Default `50`.
743    pub n_lambda: usize,
744    /// Number of cross-validation folds. Default `5`.
745    pub n_folds: usize,
746    /// Fixed RNG seed for the (deterministic) fold partition. Default `0`.
747    pub seed: u64,
748    /// Maximum coordinate-descent sweeps. Default `1000`.
749    pub max_iter: usize,
750    /// Coordinate-descent convergence tolerance (max |Δβ| per sweep). Default `1e-6`.
751    pub tol: f64,
752}
753
754impl Default for WnetConfig {
755    fn default() -> Self {
756        Self {
757            family: WaveletFamily::Daubechies(4),
758            mode: BoundaryMode::Periodic,
759            level: None,
760            alpha: 0.5,
761            lambda_grid: None,
762            n_lambda: 50,
763            n_folds: 5,
764            seed: 0,
765            max_iter: 1000,
766            tol: 1e-6,
767        }
768    }
769}
770
771/// Result of a [`wnet`] fit.
772///
773/// Carries the time-domain functional coefficient β(t), the sparse coefficient-
774/// space weights it was reconstructed from, the indices of the nonzero
775/// (selected) coefficients, the CV-selected λ, the elastic-net mixing `alpha`,
776/// fitted values / residuals, and the DWT configuration a future `predict`
777/// (Phase 71) needs to reproduce the transform.
778#[derive(Debug, Clone, PartialEq)]
779#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
780#[non_exhaustive]
781pub struct WnetResult {
782    /// Affine intercept α such that
783    /// `ŷ_i = intercept + Σ_j design[i,j] · coeff_weights[j]` reproduces the fitted
784    /// values directly (the elastic-net affine intercept, matching the
785    /// [`predict`](WnetResult::predict) formula).
786    pub intercept: f64,
787    /// Time-domain functional coefficient β(t) (length `m` = curve length).
788    pub beta_t: Vec<f64>,
789    /// Fitted response values (length `n`).
790    pub fitted_values: Vec<f64>,
791    /// Residuals `y - ŷ` (length `n`).
792    pub residuals: Vec<f64>,
793    /// Coefficient-space functional coefficient (length `P` = total wavelet
794    /// coefficients) — sparse (many exact zeros).
795    pub coeff_weights: Vec<f64>,
796    /// Indices (into `coeff_weights`) of the nonzero/selected coefficients.
797    pub selected: Vec<usize>,
798    /// Cross-validation-selected λ.
799    pub lambda: f64,
800    /// Elastic-net mixing parameter used (`config.alpha`).
801    pub alpha: f64,
802    /// Wavelet family used for the DWT (for reproducing the transform in prediction).
803    pub family: WaveletFamily,
804    /// Boundary mode used for the DWT.
805    pub mode: BoundaryMode,
806    /// Effective decomposition depth used.
807    pub level: usize,
808}
809
810/// Soft-threshold operator `sign(z)·max(|z| - γ, 0)` (the L1 proximal step).
811#[inline]
812fn soft_threshold(z: f64, gamma: f64) -> f64 {
813    if z > gamma {
814        z - gamma
815    } else if z < -gamma {
816        z + gamma
817    } else {
818        0.0
819    }
820}
821
822/// SHARED CD ENGINE. Per-coefficient elastic-net coordinate descent on the raw
823/// wavelet-coefficient design.
824///
825/// Fits `min_β (1/2n)‖y - α - Xβ‖² + λ[α_mix‖β‖₁ + ½(1-α_mix)‖β‖²]` by cyclic
826/// coordinate descent. For coordinate `j`, the update uses the partial residual
827/// `r = y_centered - Σ_{k≠j} βₖ X_c,ₖ` (maintained via a running fitted vector for
828/// O(nP)/sweep), the coordinate gradient `z_j = (X_c,ⱼ · r)/n`, then applies the
829/// L1 soft-threshold with the L2-ridge denominator:
830/// `βⱼ = soft(z_j, λ·α_mix) / (‖X_c,ⱼ‖²/n + λ(1-α_mix))`.
831///
832/// Columns are centered internally (so the penalty is scale-consistent across
833/// coefficients only up to their own norm — we do NOT rescale to unit variance,
834/// keeping the coefficient-space geometry faithful to the DWT). The intercept is
835/// recovered as `mean(y) - Σ βⱼ·mean(Xⱼ)` on the un-centered column means.
836///
837/// Returns `(intercept, coeff_weights)` where `coeff_weights` has length `P`.
838///
839/// # Errors
840/// - [`FdarError::InvalidDimension`] if `y.len()` does not equal `design.nrows()`.
841/// - [`FdarError::InvalidParameter`] if `alpha` is outside `[0, 1]`, `lambda` is
842///   negative or non-finite, `tol` is negative or non-finite, or `max_iter == 0`.
843pub(crate) fn elastic_net_cd(
844    design: &FdMatrix,
845    y: &[f64],
846    lambda: f64,
847    alpha: f64,
848    max_iter: usize,
849    tol: f64,
850) -> Result<(f64, Vec<f64>), FdarError> {
851    let (n, p) = design.shape();
852    if y.len() != n {
853        return Err(FdarError::InvalidDimension {
854            parameter: "y",
855            expected: format!("{n} elements (== design rows)"),
856            actual: format!("{} elements", y.len()),
857        });
858    }
859    if !(0.0..=1.0).contains(&alpha) {
860        return Err(FdarError::InvalidParameter {
861            parameter: "alpha",
862            message: format!("alpha must be in [0, 1], got {alpha}"),
863        });
864    }
865    if lambda < 0.0 || !lambda.is_finite() {
866        return Err(FdarError::InvalidParameter {
867            parameter: "lambda",
868            message: format!("lambda must be finite and >= 0, got {lambda}"),
869        });
870    }
871    if !tol.is_finite() || tol < 0.0 {
872        return Err(FdarError::InvalidParameter {
873            parameter: "tol",
874            message: format!("tol must be finite and >= 0, got {tol}"),
875        });
876    }
877    if max_iter == 0 {
878        return Err(FdarError::InvalidParameter {
879            parameter: "max_iter",
880            message: "max_iter must be >= 1".to_string(),
881        });
882    }
883
884    let n_f = n as f64;
885    let mu_y = y.iter().sum::<f64>() / n_f;
886    let y_centered: Vec<f64> = y.iter().map(|&v| v - mu_y).collect();
887
888    // Per-column means and centered columns; precompute ‖X_c,ⱼ‖²/n.
889    let col_means: Vec<f64> = (0..p)
890        .map(|j| design.column(j).iter().sum::<f64>() / n_f)
891        .collect();
892    let mut xc = vec![0.0_f64; n * p]; // column-major, n × p
893    let mut col_norm_sq_over_n = vec![0.0_f64; p];
894    for j in 0..p {
895        let mu = col_means[j];
896        let mut norm_sq = 0.0;
897        let col = design.column(j);
898        for i in 0..n {
899            let v = col[i] - mu;
900            xc[i + j * n] = v;
901            norm_sq += v * v;
902        }
903        col_norm_sq_over_n[j] = norm_sq / n_f;
904    }
905
906    // Coefficients start at zero; the running fit tracks Σⱼ βⱼ X_c,ⱼ so a
907    // coordinate's partial residual is (y_centered - fit + βⱼ X_c,ⱼ) in O(n).
908    let mut beta = vec![0.0_f64; p];
909    let mut fit = vec![0.0_f64; n]; // Σⱼ βⱼ X_c,ⱼ
910    let l1 = lambda * alpha;
911    let l2 = lambda * (1.0 - alpha);
912
913    for _sweep in 0..max_iter {
914        let mut max_delta = 0.0_f64;
915        for j in 0..p {
916            let denom = col_norm_sq_over_n[j] + l2;
917            if denom <= 0.0 {
918                // Dead column (zero-variance) with no ridge: leave at zero.
919                if beta[j] != 0.0 {
920                    let old = beta[j];
921                    for i in 0..n {
922                        fit[i] -= old * xc[i + j * n];
923                    }
924                    max_delta = max_delta.max(old.abs());
925                    beta[j] = 0.0;
926                }
927                continue;
928            }
929            // z_j = (X_c,ⱼ · partial_residual)/n where
930            // partial_residual = y_centered - (fit - βⱼ X_c,ⱼ).
931            let old = beta[j];
932            let mut dot = 0.0;
933            for i in 0..n {
934                let r = y_centered[i] - fit[i] + old * xc[i + j * n];
935                dot += xc[i + j * n] * r;
936            }
937            let z = dot / n_f;
938            let new = soft_threshold(z, l1) / denom;
939            if new != old {
940                let diff = new - old;
941                for i in 0..n {
942                    fit[i] += diff * xc[i + j * n];
943                }
944                max_delta = max_delta.max(diff.abs());
945                beta[j] = new;
946            }
947        }
948        if max_delta < tol {
949            break;
950        }
951    }
952
953    // Intercept on un-centered column means: mu_y - Σ βⱼ·mean(Xⱼ).
954    let intercept = mu_y - (0..p).map(|j| beta[j] * col_means[j]).sum::<f64>();
955    Ok((intercept, beta))
956}
957
958/// Build the geometric λ grid used by [`wnet_cv_lambda`].
959///
960/// If `config.lambda_grid` is `Some`, that grid is returned verbatim (validated
961/// non-empty by the caller). Otherwise a log-spaced grid of `config.n_lambda`
962/// values from `λ_max` down to `λ_max · ε` (ε = 1e-3) is built, where `λ_max` is
963/// the smallest λ that zeroes every coefficient:
964/// `λ_max = max_j |X_c,ⱼ · y_centered| / (n · max(α, tiny))`.
965///
966/// The grid is returned in descending order (largest/sparsest λ first) so ties in
967/// CV-MSE naturally resolve toward the larger λ when scanned.
968fn build_lambda_grid(design: &FdMatrix, y: &[f64], config: &WnetConfig) -> Vec<f64> {
969    if let Some(grid) = &config.lambda_grid {
970        return grid.clone();
971    }
972    let (n, p) = design.shape();
973    let n_f = n as f64;
974    let mu_y = y.iter().sum::<f64>() / n_f;
975    let y_centered: Vec<f64> = y.iter().map(|&v| v - mu_y).collect();
976
977    // λ_max = max_j |X_c,ⱼ · y_centered| / (n·α_eff).
978    let alpha_eff = config.alpha.max(1e-3);
979    let mut max_corr = 0.0_f64;
980    for j in 0..p {
981        let mu = design.column(j).iter().sum::<f64>() / n_f;
982        let col = design.column(j);
983        let dot: f64 = (0..n).map(|i| (col[i] - mu) * y_centered[i]).sum();
984        max_corr = max_corr.max(dot.abs());
985    }
986    let lambda_max = (max_corr / (n_f * alpha_eff)).max(1e-8);
987
988    let n_lambda = config.n_lambda.max(1);
989    if n_lambda == 1 {
990        return vec![lambda_max];
991    }
992    let eps = 1e-3_f64;
993    let log_max = lambda_max.ln();
994    let log_min = (lambda_max * eps).ln();
995    let step = (log_max - log_min) / (n_lambda as f64 - 1.0);
996    (0..n_lambda)
997        .map(|k| (log_max - step * k as f64).exp())
998        .collect()
999}
1000
1001/// SHARED CV HELPER. Deterministic K-fold cross-validated λ selection for `wnet`.
1002///
1003/// Builds the geometric λ grid (or uses `config.lambda_grid`), partitions the `n`
1004/// observations into `config.n_folds` folds via [`crate::cv::create_folds`] with
1005/// the FIXED `config.seed` (so the partition — and therefore the selected λ — is
1006/// identical across runs), computes CV-MSE per λ (fit [`elastic_net_cd`] on each
1007/// training set, score on the held-out fold), and returns the λ minimizing
1008/// CV-MSE. Ties (within a small epsilon) resolve toward the LARGER λ (sparser).
1009///
1010/// # Errors
1011/// - [`FdarError::InvalidParameter`] if `config.n_folds < 2`,
1012///   `config.n_folds > n`, `config.alpha` is outside `[0, 1]`, or an explicit
1013///   `lambda_grid` is empty.
1014/// - [`FdarError::InvalidDimension`] if `y.len()` does not equal `design.nrows()`.
1015pub(crate) fn wnet_cv_lambda(
1016    design: &FdMatrix,
1017    y: &[f64],
1018    config: &WnetConfig,
1019) -> Result<f64, FdarError> {
1020    let (n, _p) = design.shape();
1021    if y.len() != n {
1022        return Err(FdarError::InvalidDimension {
1023            parameter: "y",
1024            expected: format!("{n} elements (== design rows)"),
1025            actual: format!("{} elements", y.len()),
1026        });
1027    }
1028    if config.n_folds < 2 {
1029        return Err(FdarError::InvalidParameter {
1030            parameter: "n_folds",
1031            message: format!("n_folds must be >= 2, got {}", config.n_folds),
1032        });
1033    }
1034    if config.n_folds > n {
1035        return Err(FdarError::InvalidParameter {
1036            parameter: "n_folds",
1037            message: format!(
1038                "n_folds ({}) must not exceed the number of observations ({n})",
1039                config.n_folds
1040            ),
1041        });
1042    }
1043    if !(0.0..=1.0).contains(&config.alpha) {
1044        return Err(FdarError::InvalidParameter {
1045            parameter: "alpha",
1046            message: format!("alpha must be in [0, 1], got {}", config.alpha),
1047        });
1048    }
1049    if let Some(grid) = &config.lambda_grid {
1050        if grid.is_empty() {
1051            return Err(FdarError::InvalidParameter {
1052                parameter: "lambda_grid",
1053                message: "explicit lambda_grid must be non-empty".to_string(),
1054            });
1055        }
1056    }
1057
1058    let grid = build_lambda_grid(design, y, config);
1059    let folds = crate::cv::create_folds(n, config.n_folds, config.seed);
1060
1061    // Precompute per-fold train/test index sets (shared across all λ).
1062    let fold_sets: Vec<(Vec<usize>, Vec<usize>)> = (0..config.n_folds)
1063        .map(|f| crate::cv::fold_indices(&folds, f))
1064        .collect();
1065
1066    let mut best_lambda = grid[0];
1067    let mut best_mse = f64::INFINITY;
1068    let tie_eps = 1e-12;
1069
1070    for &lam in &grid {
1071        let mut total_sse = 0.0_f64;
1072        let mut scored = 0usize;
1073        for (train_idx, test_idx) in &fold_sets {
1074            if train_idx.is_empty() || test_idx.is_empty() {
1075                continue;
1076            }
1077            let train_data = crate::cv::subset_rows(design, train_idx);
1078            let train_y = crate::cv::subset_vec(y, train_idx);
1079            let (intercept, beta) = elastic_net_cd(
1080                &train_data,
1081                &train_y,
1082                lam,
1083                config.alpha,
1084                config.max_iter,
1085                config.tol,
1086            )?;
1087            for &oi in test_idx {
1088                let mut yhat = intercept;
1089                for j in 0..design.ncols() {
1090                    yhat += design[(oi, j)] * beta[j];
1091                }
1092                let e = y[oi] - yhat;
1093                total_sse += e * e;
1094                scored += 1;
1095            }
1096        }
1097        if scored == 0 {
1098            continue;
1099        }
1100        let mse = total_sse / scored as f64;
1101        // Grid is descending (largest λ first). Strictly-less keeps the FIRST
1102        // (larger) λ on a tie; the epsilon guards float noise so a marginally
1103        // smaller MSE at a smaller λ does not override a near-equal larger λ.
1104        if mse < best_mse - tie_eps {
1105            best_mse = mse;
1106            best_lambda = lam;
1107        }
1108    }
1109
1110    Ok(best_lambda)
1111}
1112
1113// ---------------------------------------------------------------------------
1114// wnet entry point
1115// ---------------------------------------------------------------------------
1116
1117/// Fit the wavelet-domain elastic-net scalar-on-function regressor `wnet` (WAV-04).
1118///
1119/// Transforms every curve into its wavelet-coefficient vector (shared seam
1120/// [`curves_to_coeff_design`]), selects a deterministic cross-validated λ
1121/// ([`wnet_cv_lambda`]), refits the per-coefficient elastic-net
1122/// ([`elastic_net_cd`]) at that λ on the full data, and reconstructs the
1123/// time-domain functional coefficient β(t) via the inverse DWT (shared seam
1124/// [`coeff_weights_to_beta_t`]).
1125///
1126/// Because a sparse wavelet basis concentrates localized signal in a few
1127/// coefficients, the L1 penalty drives the rest to exactly zero — the nonzero
1128/// indices are reported in [`WnetResult::selected`].
1129///
1130/// # Arguments
1131/// * `data` — functional predictor matrix (n × m), one curve per row.
1132/// * `y` — scalar response (length n).
1133/// * `config` — DWT + elastic-net + CV configuration.
1134///
1135/// # Errors
1136/// - [`FdarError::InvalidDimension`] if `data` has fewer than 3 rows, zero
1137///   columns, or `y.len() != n`.
1138/// - [`FdarError::InvalidParameter`] if `config.alpha ∉ [0, 1]`,
1139///   `config.n_folds < 2`, `config.n_folds > n`, `config.max_iter == 0`,
1140///   `config.tol` is negative or non-finite, an explicit `config.lambda_grid`
1141///   is empty, or the DWT rejects the family/level (surfaced from
1142///   [`decompose_matrix`]).
1143/// - [`FdarError::ComputationFailed`] if the underlying transform fails.
1144#[must_use = "expensive computation whose result should not be discarded"]
1145pub fn wnet(data: &FdMatrix, y: &[f64], config: &WnetConfig) -> Result<WnetResult, FdarError> {
1146    let (n, m) = data.shape();
1147    if n < 3 {
1148        return Err(FdarError::InvalidDimension {
1149            parameter: "data",
1150            expected: "at least 3 rows (observations)".to_string(),
1151            actual: format!("{n} rows"),
1152        });
1153    }
1154    if m == 0 {
1155        return Err(FdarError::InvalidDimension {
1156            parameter: "data",
1157            expected: "at least 1 column (evaluation point)".to_string(),
1158            actual: format!("{m} columns"),
1159        });
1160    }
1161    if y.len() != n {
1162        return Err(FdarError::InvalidDimension {
1163            parameter: "y",
1164            expected: format!("{n} elements (== data rows)"),
1165            actual: format!("{} elements", y.len()),
1166        });
1167    }
1168    if !(0.0..=1.0).contains(&config.alpha) {
1169        return Err(FdarError::InvalidParameter {
1170            parameter: "alpha",
1171            message: format!("alpha must be in [0, 1], got {}", config.alpha),
1172        });
1173    }
1174    if config.n_folds < 2 {
1175        return Err(FdarError::InvalidParameter {
1176            parameter: "n_folds",
1177            message: format!("n_folds must be >= 2, got {}", config.n_folds),
1178        });
1179    }
1180    if config.n_folds > n {
1181        return Err(FdarError::InvalidParameter {
1182            parameter: "n_folds",
1183            message: format!(
1184                "n_folds ({}) must not exceed the number of observations ({n})",
1185                config.n_folds
1186            ),
1187        });
1188    }
1189    if config.max_iter == 0 {
1190        return Err(FdarError::InvalidParameter {
1191            parameter: "max_iter",
1192            message: "max_iter must be >= 1".to_string(),
1193        });
1194    }
1195    if !config.tol.is_finite() || config.tol < 0.0 {
1196        return Err(FdarError::InvalidParameter {
1197            parameter: "tol",
1198            message: format!("tol must be finite and >= 0, got {}", config.tol),
1199        });
1200    }
1201    if let Some(grid) = &config.lambda_grid {
1202        if grid.is_empty() {
1203            return Err(FdarError::InvalidParameter {
1204                parameter: "lambda_grid",
1205                message: "explicit lambda_grid must be non-empty".to_string(),
1206            });
1207        }
1208    }
1209
1210    // Curves -> coefficient design (shared seam). Surfaces DWT errors unchanged.
1211    let (design, layout) =
1212        curves_to_coeff_design(data, config.family.clone(), config.mode, config.level)?;
1213
1214    // Deterministic CV-selected λ, then refit on the full data at that λ.
1215    let lambda = wnet_cv_lambda(&design, y, config)?;
1216    let (intercept, coeff_weights) = elastic_net_cd(
1217        &design,
1218        y,
1219        lambda,
1220        config.alpha,
1221        config.max_iter,
1222        config.tol,
1223    )?;
1224
1225    // Selected (nonzero) coefficients.
1226    let selected: Vec<usize> = coeff_weights
1227        .iter()
1228        .enumerate()
1229        .filter(|(_, &b)| b != 0.0)
1230        .map(|(j, _)| j)
1231        .collect();
1232
1233    // Fitted values via the plain coefficient-space dot: ŷ = intercept + X·β.
1234    let fitted_values = compute_fitted_affine(&design, &coeff_weights, intercept);
1235    let residuals: Vec<f64> = y
1236        .iter()
1237        .zip(&fitted_values)
1238        .map(|(&yi, &yh)| yi - yh)
1239        .collect();
1240
1241    // β(t) via inverse DWT of the coefficient-space weights (shared seam).
1242    let beta_t = coeff_weights_to_beta_t(&coeff_weights, &layout)?;
1243
1244    Ok(WnetResult {
1245        intercept,
1246        beta_t,
1247        fitted_values,
1248        residuals,
1249        coeff_weights,
1250        selected,
1251        lambda,
1252        alpha: config.alpha,
1253        family: config.family.clone(),
1254        mode: config.mode,
1255        level: layout.levels(),
1256    })
1257}
1258
1259impl WnetResult {
1260    /// Predict the scalar response for new functional curves (WAV-05).
1261    ///
1262    /// Re-transforms each new curve into the wavelet-coefficient design using the
1263    /// STORED fitted DWT configuration (`family` / `mode` / effective `level`), then
1264    /// applies the affine coefficient-space map `ŷ = intercept + Σ_j design[i,j] ·
1265    /// coeff_weights[j]`. Re-passing the training curves reproduces the stored
1266    /// [`fitted_values`](WnetResult::fitted_values) exactly (up to float rounding).
1267    ///
1268    /// # Arguments
1269    /// * `new` — functional predictor matrix (rows = curves) on the SAME evaluation
1270    ///   grid as the training data (`new.ncols()` must equal the training grid length).
1271    ///
1272    /// # Errors
1273    /// - [`FdarError::InvalidDimension`] with `parameter: "new"` if `new` has zero
1274    ///   rows (no curves to predict), if `new.ncols()` differs from the training
1275    ///   grid length, or (defensively) if the re-transformed design width disagrees
1276    ///   with the stored coefficient-space width.
1277    /// - [`FdarError::InvalidParameter`] if the DWT rejects the stored family/level
1278    ///   (surfaced from [`decompose_matrix`]).
1279    pub fn predict(&self, new: &FdMatrix) -> Result<Vec<f64>, FdarError> {
1280        let train_m = self.beta_t.len();
1281        if new.nrows() == 0 {
1282            return Err(FdarError::InvalidDimension {
1283                parameter: "new",
1284                expected: "at least 1 row (curve)".to_string(),
1285                actual: "0 rows".to_string(),
1286            });
1287        }
1288        if new.ncols() != train_m {
1289            return Err(FdarError::InvalidDimension {
1290                parameter: "new",
1291                expected: format!("{train_m} columns (== training grid length)"),
1292                actual: format!("{} columns", new.ncols()),
1293            });
1294        }
1295        // Re-transform with the STORED fitted DWT config so the new-curve design
1296        // matches the fit-time design exactly.
1297        let (design, _layout) =
1298            curves_to_coeff_design(new, self.family.clone(), self.mode, Some(self.level))?;
1299        if design.ncols() != self.coeff_weights.len() {
1300            return Err(FdarError::InvalidDimension {
1301                parameter: "new",
1302                expected: format!(
1303                    "coefficient-space width {} (== stored coeff_weights)",
1304                    self.coeff_weights.len()
1305                ),
1306                actual: format!("{} coefficients", design.ncols()),
1307            });
1308        }
1309        Ok(compute_fitted_affine(
1310            &design,
1311            &self.coeff_weights,
1312            self.intercept,
1313        ))
1314    }
1315
1316    /// The time-domain functional coefficient β(t) (length `m` = curve length).
1317    #[must_use]
1318    pub fn beta_t(&self) -> &[f64] {
1319        &self.beta_t
1320    }
1321
1322    /// The functional coefficient β(t) (crate-convention alias of [`beta_t`](WnetResult::beta_t)).
1323    #[must_use]
1324    pub fn coefficient_function(&self) -> &[f64] {
1325        &self.beta_t
1326    }
1327
1328    /// The fitted response values (length `n`).
1329    #[must_use]
1330    pub fn fitted_values(&self) -> &[f64] {
1331        &self.fitted_values
1332    }
1333}
1334
1335/// Compute fitted values `ŷ = intercept + X β` (affine coefficient-space dot).
1336fn compute_fitted_affine(design: &FdMatrix, coeffs: &[f64], intercept: f64) -> Vec<f64> {
1337    let (n, p) = design.shape();
1338    (0..n)
1339        .map(|i| {
1340            let mut yhat = intercept;
1341            for j in 0..p {
1342                yhat += design[(i, j)] * coeffs[j];
1343            }
1344            yhat
1345        })
1346        .collect()
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352    use crate::matrix::FdMatrix;
1353
1354    /// Deterministic pseudo-random value stream (LCG) — spans full rank, no dep.
1355    /// Mirrors the DWT module's own test helper so the design is truly full-rank.
1356    fn pseudo_random(n: usize, seed: u64) -> Vec<f64> {
1357        let mut state = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
1358        (0..n)
1359            .map(|_| {
1360                state = state
1361                    .wrapping_mul(6_364_136_223_846_793_005)
1362                    .wrapping_add(1_442_695_040_888_963_407);
1363                let u = (state >> 11) as f64 / (1u64 << 53) as f64;
1364                2.0 * u - 1.0
1365            })
1366            .collect()
1367    }
1368
1369    /// Build a spanning, full-rank n×m predicate design from independent
1370    /// pseudo-random rows (n ≫ m). Returns the FdMatrix.
1371    fn spanning_design(n: usize, m: usize, seed0: u64) -> FdMatrix {
1372        let mut flat = vec![0.0_f64; n * m];
1373        for i in 0..n {
1374            let row = pseudo_random(m, seed0 + i as u64);
1375            for j in 0..m {
1376                flat[i + j * n] = row[j];
1377            }
1378        }
1379        FdMatrix::from_column_major(flat, n, m).unwrap()
1380    }
1381
1382    fn rel_l2(recovered: &[f64], truth: &[f64]) -> f64 {
1383        let num: f64 = recovered
1384            .iter()
1385            .zip(truth)
1386            .map(|(a, b)| (a - b) * (a - b))
1387            .sum::<f64>()
1388            .sqrt();
1389        let den: f64 = truth.iter().map(|b| b * b).sum::<f64>().sqrt().max(1e-300);
1390        num / den
1391    }
1392
1393    /// Fit `wcr` with a given method on a spanning design where y is generated
1394    /// from a known coefficient-space β, and assert β(t) recovers the inverse-DWT
1395    /// of that β within a tight relative L2 tolerance.
1396    fn recovery_for_method(method: WcrMethod) {
1397        let (n, m) = (120usize, 32usize);
1398        let data = spanning_design(n, m, 1000);
1399        let family = WaveletFamily::Daubechies(4);
1400        let mode = BoundaryMode::Periodic;
1401
1402        // Coefficient design + layout (the exact seam wcr uses internally).
1403        let (design, layout) = curves_to_coeff_design(&data, family.clone(), mode, None).unwrap();
1404        let p = design.ncols();
1405
1406        // Known coefficient-space β and intercept; y is exact (no noise) so a
1407        // full-rank fit must recover β_coeff exactly.
1408        let beta_coeff = pseudo_random(p, 77);
1409        let true_intercept = 0.37_f64;
1410        let y: Vec<f64> = (0..n)
1411            .map(|i| {
1412                let mut acc = true_intercept;
1413                for j in 0..p {
1414                    acc += design[(i, j)] * beta_coeff[j];
1415                }
1416                acc
1417            })
1418            .collect();
1419
1420        // The true time-domain coefficient is the inverse DWT of β_coeff.
1421        let beta_t_true = coeff_weights_to_beta_t(&beta_coeff, &layout).unwrap();
1422
1423        // Fit with enough components to span the coefficient design (min(n, P)).
1424        let config = WcrConfig {
1425            family,
1426            mode,
1427            level: None,
1428            ncomp: p.min(n),
1429            method,
1430            ..Default::default()
1431        };
1432        let fit = wcr(&data, &y, &config).unwrap();
1433
1434        assert_eq!(fit.method, method);
1435        assert_eq!(fit.beta_t.len(), m);
1436        assert_eq!(fit.coeff_weights.len(), p);
1437
1438        let e = rel_l2(&fit.beta_t, &beta_t_true);
1439        assert!(
1440            e < 1e-6,
1441            "{method:?}: beta_t recovery rel L2 err {e} exceeds tolerance on spanning full-rank design"
1442        );
1443
1444        // Finite outputs.
1445        assert!(fit.beta_t.iter().all(|x| x.is_finite()));
1446        assert!(fit.fitted_values.iter().all(|x| x.is_finite()));
1447        assert!(fit.residuals.iter().all(|x| x.is_finite()));
1448        // With an exact (noiseless) full-rank fit, residuals ≈ 0.
1449        let max_resid = fit
1450            .residuals
1451            .iter()
1452            .fold(0.0_f64, |acc, &r| acc.max(r.abs()));
1453        assert!(
1454            max_resid < 1e-6,
1455            "{method:?}: residuals not ~0 ({max_resid})"
1456        );
1457    }
1458
1459    #[test]
1460    fn wcr_pcr_recovers_known_beta_t_on_spanning_design() {
1461        recovery_for_method(WcrMethod::Pcr);
1462    }
1463
1464    #[test]
1465    fn wcr_pls_recovers_known_beta_t_on_spanning_design() {
1466        recovery_for_method(WcrMethod::Pls);
1467    }
1468
1469    #[test]
1470    fn wcr_default_config_is_db4_periodic_auto_pcr() {
1471        let c = WcrConfig::default();
1472        assert_eq!(c.family, WaveletFamily::Daubechies(4));
1473        assert_eq!(c.mode, BoundaryMode::Periodic);
1474        assert_eq!(c.level, None);
1475        assert_eq!(c.method, WcrMethod::Pcr);
1476        assert_eq!(WcrMethod::default(), WcrMethod::Pcr);
1477    }
1478
1479    #[test]
1480    fn curves_to_coeff_design_layout_and_shape() {
1481        let (n, m) = (10usize, 48usize);
1482        let data = spanning_design(n, m, 500);
1483        let (design, layout) = curves_to_coeff_design(
1484            &data,
1485            WaveletFamily::Daubechies(4),
1486            BoundaryMode::Periodic,
1487            None,
1488        )
1489        .unwrap();
1490        assert_eq!(design.nrows(), n);
1491        assert_eq!(design.ncols(), layout.total_len());
1492        assert_eq!(layout.signal_len, m);
1493        assert_eq!(layout.levels(), layout.detail_lens.len());
1494    }
1495
1496    #[test]
1497    fn coeff_weights_to_beta_t_inverts_decompose() {
1498        // A round-trip sanity: reconstruct of a curve's own coefficients == curve.
1499        let (n, m) = (4usize, 48usize);
1500        let data = spanning_design(n, m, 900);
1501        let (design, layout) = curves_to_coeff_design(
1502            &data,
1503            WaveletFamily::Daubechies(6),
1504            BoundaryMode::Periodic,
1505            None,
1506        )
1507        .unwrap();
1508        let row0: Vec<f64> = (0..design.ncols()).map(|j| design[(0, j)]).collect();
1509        let recon = coeff_weights_to_beta_t(&row0, &layout).unwrap();
1510        let orig = data.row(0);
1511        assert!(rel_l2(&recon, &orig) < 1e-10);
1512    }
1513
1514    #[test]
1515    fn coeff_weights_to_beta_t_rejects_wrong_length() {
1516        let (n, m) = (4usize, 48usize);
1517        let data = spanning_design(n, m, 901);
1518        let (_design, layout) =
1519            curves_to_coeff_design(&data, WaveletFamily::Haar, BoundaryMode::Periodic, None)
1520                .unwrap();
1521        let wrong = vec![0.0; layout.total_len() + 1];
1522        assert!(matches!(
1523            coeff_weights_to_beta_t(&wrong, &layout),
1524            Err(FdarError::InvalidDimension { .. })
1525        ));
1526    }
1527
1528    // --- Validation gate (SC4) ---
1529
1530    fn base_config() -> WcrConfig {
1531        WcrConfig {
1532            ncomp: 3,
1533            ..Default::default()
1534        }
1535    }
1536
1537    #[test]
1538    fn wcr_rejects_too_few_rows() {
1539        let data = spanning_design(2, 48, 1);
1540        let y = vec![0.0, 1.0];
1541        assert!(matches!(
1542            wcr(&data, &y, &base_config()),
1543            Err(FdarError::InvalidDimension { .. })
1544        ));
1545    }
1546
1547    #[test]
1548    fn wcr_rejects_mismatched_y_len() {
1549        let data = spanning_design(10, 48, 2);
1550        let y = vec![0.0; 9];
1551        assert!(matches!(
1552            wcr(&data, &y, &base_config()),
1553            Err(FdarError::InvalidDimension { .. })
1554        ));
1555    }
1556
1557    #[test]
1558    fn wcr_rejects_zero_ncomp() {
1559        let data = spanning_design(10, 48, 3);
1560        let y = vec![0.0; 10];
1561        let config = WcrConfig {
1562            ncomp: 0,
1563            ..Default::default()
1564        };
1565        assert!(matches!(
1566            wcr(&data, &y, &config),
1567            Err(FdarError::InvalidParameter { .. })
1568        ));
1569    }
1570
1571    #[test]
1572    fn wcr_surfaces_unsupported_family() {
1573        let data = spanning_design(10, 48, 4);
1574        let y = vec![0.0; 10];
1575        let config = WcrConfig {
1576            family: WaveletFamily::Daubechies(11),
1577            ..base_config()
1578        };
1579        assert!(matches!(
1580            wcr(&data, &y, &config),
1581            Err(FdarError::InvalidParameter { .. })
1582        ));
1583    }
1584
1585    #[test]
1586    fn wcr_surfaces_level_out_of_range() {
1587        let data = spanning_design(10, 48, 5);
1588        let y = vec![0.0; 10];
1589        let config = WcrConfig {
1590            level: Some(999),
1591            ..base_config()
1592        };
1593        assert!(matches!(
1594            wcr(&data, &y, &config),
1595            Err(FdarError::InvalidParameter { .. })
1596        ));
1597    }
1598
1599    #[test]
1600    fn wcr_finite_outputs_both_methods() {
1601        let (n, m) = (100usize, 40usize);
1602        let data = spanning_design(n, m, 4242);
1603        let y = pseudo_random(n, 8080);
1604        for method in [WcrMethod::Pcr, WcrMethod::Pls] {
1605            let config = WcrConfig {
1606                ncomp: 8,
1607                method,
1608                ..Default::default()
1609            };
1610            let fit = wcr(&data, &y, &config).unwrap();
1611            assert!(fit.intercept.is_finite());
1612            assert!(fit.beta_t.iter().all(|x| x.is_finite()));
1613            assert!(fit.fitted_values.iter().all(|x| x.is_finite()));
1614            assert!(fit.residuals.iter().all(|x| x.is_finite()));
1615        }
1616    }
1617
1618    #[test]
1619    fn wcr_small_n_default_config_succeeds() {
1620        // CR-01 regression: default WcrConfig has ncomp = 5. With a small sample
1621        // (n = 4), the old clamp `ncomp.min(n).min(p)` gave ncomp = 4, making the
1622        // OLS design n × (n + 1) = 4 × 5 which ols_solve rejected (n < p). The fix
1623        // clamps to `n - 1`, so the design stays overdetermined and the fit succeeds.
1624        let (n, m) = (4usize, 32usize);
1625        let data = spanning_design(n, m, 2468);
1626        let y = pseudo_random(n, 1357);
1627        let config = WcrConfig::default(); // ncomp = 5 > n
1628        let fit = wcr(&data, &y, &config).unwrap();
1629        // Effective ncomp clamped to n - 1 (= 3), never n.
1630        assert!(fit.ncomp < n, "ncomp {} exceeds n - 1", fit.ncomp);
1631        assert_eq!(fit.beta_t.len(), m);
1632        assert!(fit.intercept.is_finite());
1633        assert!(fit.beta_t.iter().all(|x| x.is_finite()));
1634        assert!(fit.fitted_values.iter().all(|x| x.is_finite()));
1635        assert!(fit.residuals.iter().all(|x| x.is_finite()));
1636
1637        // Also confirm the documented minimum n = 3 works under the default config.
1638        let data3 = spanning_design(3, m, 2469);
1639        let y3 = pseudo_random(3, 1358);
1640        let fit3 = wcr(&data3, &y3, &WcrConfig::default()).unwrap();
1641        assert!(fit3.ncomp <= 2);
1642        assert!(fit3.beta_t.iter().all(|x| x.is_finite()));
1643    }
1644
1645    // --- wcr::predict + accessors (WAV-05) ---
1646
1647    #[test]
1648    fn wcr_predict_reproduces_training_fitted() {
1649        let (n, m) = (120usize, 32usize);
1650        let data = spanning_design(n, m, 6100);
1651        let y = pseudo_random(n, 6101);
1652        let config = WcrConfig {
1653            ncomp: 8,
1654            ..Default::default()
1655        };
1656        let fit = wcr(&data, &y, &config).unwrap();
1657        let preds = fit.predict(&data).unwrap();
1658        assert_eq!(preds.len(), fit.fitted_values.len());
1659        for (i, (&p, &f)) in preds.iter().zip(&fit.fitted_values).enumerate() {
1660            assert!(
1661                (p - f).abs() <= 1e-8,
1662                "wcr predict[{i}] {p} != fitted {f} (|Δ| {})",
1663                (p - f).abs()
1664            );
1665        }
1666    }
1667
1668    #[test]
1669    fn wcr_predict_on_new_curves_is_finite_and_rejects_grid_mismatch() {
1670        let (n, m) = (100usize, 32usize);
1671        let data = spanning_design(n, m, 6200);
1672        let y = pseudo_random(n, 6201);
1673        let fit = wcr(
1674            &data,
1675            &y,
1676            &WcrConfig {
1677                ncomp: 6,
1678                ..Default::default()
1679            },
1680        )
1681        .unwrap();
1682
1683        // Fresh same-m curves → finite predictions.
1684        let fresh = spanning_design(40, m, 6202);
1685        let preds = fit.predict(&fresh).unwrap();
1686        assert_eq!(preds.len(), 40);
1687        assert!(preds.iter().all(|x| x.is_finite()));
1688
1689        // Different ncols → InvalidDimension, never a panic.
1690        let wrong = spanning_design(10, m + 8, 6203);
1691        assert!(matches!(
1692            fit.predict(&wrong),
1693            Err(FdarError::InvalidDimension { .. })
1694        ));
1695    }
1696
1697    #[test]
1698    fn wcr_predict_on_zero_row_input_errors_naming_new_no_panic() {
1699        let (n, m) = (100usize, 32usize);
1700        let data = spanning_design(n, m, 6400);
1701        let y = pseudo_random(n, 6401);
1702        let fit = wcr(
1703            &data,
1704            &y,
1705            &WcrConfig {
1706                ncomp: 6,
1707                ..Default::default()
1708            },
1709        )
1710        .unwrap();
1711
1712        // Zero-row input (correct ncols) → InvalidDimension naming "new", never a panic
1713        // and never the internal "data" parameter surfaced from decompose_matrix.
1714        let empty = FdMatrix::zeros(0, m);
1715        match fit.predict(&empty) {
1716            Err(FdarError::InvalidDimension { parameter, .. }) => {
1717                assert_eq!(parameter, "new");
1718            }
1719            other => panic!("expected InvalidDimension naming \"new\", got {other:?}"),
1720        }
1721    }
1722
1723    // ===================================================================
1724    // wnet — wavelet-domain elastic-net regressor (WAV-04)
1725    // ===================================================================
1726
1727    /// Build a synthetic sparse coefficient-space β (a handful of nonzero
1728    /// coefficients, the rest exactly zero) plus the spanning full-rank design
1729    /// and layout. Returns `(data, design, layout, beta_coeff, support)`.
1730    fn sparse_wnet_problem(
1731        n: usize,
1732        m: usize,
1733        seed0: u64,
1734    ) -> (FdMatrix, FdMatrix, CoeffLayout, Vec<f64>, Vec<usize>) {
1735        let data = spanning_design(n, m, seed0);
1736        let family = WaveletFamily::Daubechies(4);
1737        let mode = BoundaryMode::Periodic;
1738        let (design, layout) = curves_to_coeff_design(&data, family, mode, None).unwrap();
1739        let p = design.ncols();
1740
1741        // Localize β in a few coefficients spread across the bands.
1742        let support: Vec<usize> = vec![0, 2, p / 2, p - 3]
1743            .into_iter()
1744            .filter(|&j| j < p)
1745            .collect();
1746        let mut beta_coeff = vec![0.0_f64; p];
1747        // Give the true-support coefficients large, well-separated magnitudes so
1748        // they clearly dominate the elastic-net solution.
1749        let mags = [4.0, -3.5, 5.0, -4.5];
1750        for (k, &j) in support.iter().enumerate() {
1751            beta_coeff[j] = mags[k % mags.len()];
1752        }
1753        (data, design, layout, beta_coeff, support)
1754    }
1755
1756    #[test]
1757    fn wnet_elastic_net_cd_recovers_sparse_support() {
1758        // Fixed-λ path (Task 1): a moderate λ produces a sparse solution whose
1759        // nonzero coefficients concentrate on the true support.
1760        let (n, m) = (256usize, 32usize);
1761        let (_data, design, _layout, beta_coeff, support) = sparse_wnet_problem(n, m, 3000);
1762        let p = design.ncols();
1763
1764        // y = intercept + X β (noiseless) — the localized signal.
1765        let intercept_true = 0.5_f64;
1766        let y: Vec<f64> = (0..n)
1767            .map(|i| {
1768                let mut acc = intercept_true;
1769                for j in 0..p {
1770                    acc += design[(i, j)] * beta_coeff[j];
1771                }
1772                acc
1773            })
1774            .collect();
1775
1776        // Moderate λ, alpha=0.9 (strongly L1) → sparse.
1777        let (intercept, beta) = elastic_net_cd(&design, &y, 0.05, 0.9, 2000, 1e-8).unwrap();
1778
1779        assert!(intercept.is_finite());
1780        assert!(beta.iter().all(|b| b.is_finite()));
1781
1782        let selected: Vec<usize> = beta
1783            .iter()
1784            .enumerate()
1785            .filter(|(_, &b)| b.abs() > 1e-8)
1786            .map(|(j, _)| j)
1787            .collect();
1788
1789        // True support is among the selected set.
1790        for &j in &support {
1791            assert!(
1792                selected.contains(&j),
1793                "true-support coeff {j} not selected (selected={selected:?})"
1794            );
1795        }
1796        // The selected set is meaningfully sparse relative to P.
1797        assert!(
1798            selected.len() < p / 2,
1799            "selection not sparse: |selected|={} of P={p}",
1800            selected.len()
1801        );
1802    }
1803
1804    #[test]
1805    fn wnet_fixed_lambda_end_to_end_finite() {
1806        // Task 1 tracer: full wnet path (with CV under the hood) yields finite
1807        // β(t)/fitted/coeff outputs on the localized-signal problem.
1808        let (n, m) = (200usize, 32usize);
1809        let (data, design, _layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 3100);
1810        let p = design.ncols();
1811        let y: Vec<f64> = (0..n)
1812            .map(|i| {
1813                let mut acc = 0.25;
1814                for j in 0..p {
1815                    acc += design[(i, j)] * beta_coeff[j];
1816                }
1817                acc
1818            })
1819            .collect();
1820
1821        let config = WnetConfig {
1822            n_lambda: 15,
1823            n_folds: 4,
1824            ..Default::default()
1825        };
1826        let fit = wnet(&data, &y, &config).unwrap();
1827        assert_eq!(fit.beta_t.len(), m);
1828        assert_eq!(fit.coeff_weights.len(), p);
1829        assert!(fit.intercept.is_finite());
1830        assert!(fit.beta_t.iter().all(|x| x.is_finite()));
1831        assert!(fit.fitted_values.iter().all(|x| x.is_finite()));
1832        assert!(fit.residuals.iter().all(|x| x.is_finite()));
1833        assert!(fit.coeff_weights.iter().all(|x| x.is_finite()));
1834        // selected indices match the nonzero coeff_weights.
1835        for &j in &fit.selected {
1836            assert!(fit.coeff_weights[j] != 0.0);
1837        }
1838    }
1839
1840    #[test]
1841    fn wnet_default_config_is_db4_periodic_auto() {
1842        let c = WnetConfig::default();
1843        assert_eq!(c.family, WaveletFamily::Daubechies(4));
1844        assert_eq!(c.mode, BoundaryMode::Periodic);
1845        assert_eq!(c.level, None);
1846        assert!((c.alpha - 0.5).abs() < 1e-15);
1847        assert_eq!(c.lambda_grid, None);
1848        assert_eq!(c.n_lambda, 50);
1849        assert_eq!(c.n_folds, 5);
1850        assert_eq!(c.seed, 0);
1851    }
1852
1853    // --- Deterministic CV-λ (SC3) ---
1854
1855    #[test]
1856    fn wnet_cv_lambda_is_deterministic_across_runs() {
1857        let (n, m) = (200usize, 32usize);
1858        let (data, design, _layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 3200);
1859        let p = design.ncols();
1860        // Add mild noise so CV-MSE is non-degenerate but λ still well-defined.
1861        let noise = pseudo_random(n, 9999);
1862        let y: Vec<f64> = (0..n)
1863            .map(|i| {
1864                let mut acc = 0.1;
1865                for j in 0..p {
1866                    acc += design[(i, j)] * beta_coeff[j];
1867                }
1868                acc + 0.05 * noise[i]
1869            })
1870            .collect();
1871
1872        let config = WnetConfig {
1873            alpha: 0.8,
1874            n_lambda: 20,
1875            n_folds: 5,
1876            seed: 0,
1877            ..Default::default()
1878        };
1879        let fit1 = wnet(&data, &y, &config).unwrap();
1880        let fit2 = wnet(&data, &y, &config).unwrap();
1881        assert_eq!(
1882            fit1.lambda, fit2.lambda,
1883            "CV-selected lambda differs across runs: {} vs {}",
1884            fit1.lambda, fit2.lambda
1885        );
1886        // Also exercise the helper directly.
1887        let l1 = wnet_cv_lambda(&design, &y, &config).unwrap();
1888        let l2 = wnet_cv_lambda(&design, &y, &config).unwrap();
1889        assert_eq!(l1, l2);
1890    }
1891
1892    // --- β(t) recovery on SNR data (SC3) ---
1893
1894    #[test]
1895    fn wnet_recovers_beta_t_on_snr_data() {
1896        // Spanning full-rank design, moderate SNR: the fit at the CV λ must be
1897        // non-degenerate and β(t) must track the injected β(t).
1898        let (n, m) = (300usize, 32usize);
1899        let (data, design, layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 3300);
1900        let p = design.ncols();
1901        let beta_t_true = coeff_weights_to_beta_t(&beta_coeff, &layout).unwrap();
1902
1903        // Signal variance vs noise: pick noise small relative to signal spread.
1904        let signal: Vec<f64> = (0..n)
1905            .map(|i| {
1906                let mut acc = 0.0;
1907                for j in 0..p {
1908                    acc += design[(i, j)] * beta_coeff[j];
1909                }
1910                acc
1911            })
1912            .collect();
1913        let sig_sd = {
1914            let mean = signal.iter().sum::<f64>() / n as f64;
1915            (signal.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / n as f64).sqrt()
1916        };
1917        let noise = pseudo_random(n, 4141);
1918        let noise_scale = 0.05 * sig_sd; // ~20:1 SNR
1919        let y: Vec<f64> = (0..n)
1920            .map(|i| 0.3 + signal[i] + noise_scale * noise[i])
1921            .collect();
1922
1923        let config = WnetConfig {
1924            alpha: 0.7,
1925            n_lambda: 30,
1926            n_folds: 5,
1927            ..Default::default()
1928        };
1929        let fit = wnet(&data, &y, &config).unwrap();
1930
1931        // Non-degenerate: not all-zero.
1932        let nonzero = fit.coeff_weights.iter().filter(|&&b| b != 0.0).count();
1933        assert!(nonzero > 0, "degenerate all-zero fit at CV lambda");
1934
1935        // β(t) tracks the injected β(t) within tolerance.
1936        let e = rel_l2(&fit.beta_t, &beta_t_true);
1937        assert!(
1938            e < 0.35,
1939            "wnet beta_t recovery rel L2 err {e} exceeds tolerance on SNR data"
1940        );
1941        assert!(fit.beta_t.iter().all(|x| x.is_finite()));
1942        assert!(fit.fitted_values.iter().all(|x| x.is_finite()));
1943    }
1944
1945    // --- Validation gate (SC4) ---
1946
1947    fn base_wnet_config() -> WnetConfig {
1948        WnetConfig {
1949            n_lambda: 10,
1950            n_folds: 3,
1951            ..Default::default()
1952        }
1953    }
1954
1955    #[test]
1956    fn wnet_rejects_too_few_rows() {
1957        let data = spanning_design(2, 32, 10);
1958        let y = vec![0.0, 1.0];
1959        assert!(matches!(
1960            wnet(&data, &y, &base_wnet_config()),
1961            Err(FdarError::InvalidDimension { .. })
1962        ));
1963    }
1964
1965    #[test]
1966    fn wnet_rejects_zero_cols() {
1967        // An empty-column matrix is rejected before any DWT.
1968        let data = FdMatrix::zeros(5, 0);
1969        let y = vec![0.0; 5];
1970        assert!(matches!(
1971            wnet(&data, &y, &base_wnet_config()),
1972            Err(FdarError::InvalidDimension { .. })
1973        ));
1974    }
1975
1976    #[test]
1977    fn wnet_rejects_mismatched_y_len() {
1978        let data = spanning_design(10, 32, 11);
1979        let y = vec![0.0; 9];
1980        assert!(matches!(
1981            wnet(&data, &y, &base_wnet_config()),
1982            Err(FdarError::InvalidDimension { .. })
1983        ));
1984    }
1985
1986    #[test]
1987    fn wnet_rejects_alpha_out_of_range() {
1988        let data = spanning_design(10, 32, 12);
1989        let y = vec![0.0; 10];
1990        let config = WnetConfig {
1991            alpha: 1.5,
1992            ..base_wnet_config()
1993        };
1994        assert!(matches!(
1995            wnet(&data, &y, &config),
1996            Err(FdarError::InvalidParameter { .. })
1997        ));
1998        let config = WnetConfig {
1999            alpha: -0.1,
2000            ..base_wnet_config()
2001        };
2002        assert!(matches!(
2003            wnet(&data, &y, &config),
2004            Err(FdarError::InvalidParameter { .. })
2005        ));
2006    }
2007
2008    #[test]
2009    fn wnet_rejects_too_few_folds() {
2010        let data = spanning_design(10, 32, 13);
2011        let y = vec![0.0; 10];
2012        let config = WnetConfig {
2013            n_folds: 1,
2014            ..base_wnet_config()
2015        };
2016        assert!(matches!(
2017            wnet(&data, &y, &config),
2018            Err(FdarError::InvalidParameter { .. })
2019        ));
2020    }
2021
2022    #[test]
2023    fn wnet_rejects_too_many_folds() {
2024        // WR-01: n_folds > n must be rejected rather than silently running fewer folds.
2025        let data = spanning_design(10, 32, 130);
2026        let y = vec![0.0; 10];
2027        let config = WnetConfig {
2028            n_folds: 11,
2029            ..base_wnet_config()
2030        };
2031        assert!(matches!(
2032            wnet(&data, &y, &config),
2033            Err(FdarError::InvalidParameter { .. })
2034        ));
2035        // The shared CV helper also rejects it directly.
2036        let (design, _layout) = curves_to_coeff_design(
2037            &data,
2038            WaveletFamily::Daubechies(4),
2039            BoundaryMode::Periodic,
2040            None,
2041        )
2042        .unwrap();
2043        assert!(matches!(
2044            wnet_cv_lambda(&design, &y, &config),
2045            Err(FdarError::InvalidParameter { .. })
2046        ));
2047    }
2048
2049    #[test]
2050    fn wnet_rejects_negative_or_nan_tol() {
2051        // WR-02: negative or NaN tol is rejected (both via the entry and the CD engine).
2052        let data = spanning_design(10, 32, 131);
2053        let y = pseudo_random(10, 5);
2054        for bad in [-1e-6_f64, f64::NAN] {
2055            let config = WnetConfig {
2056                tol: bad,
2057                ..base_wnet_config()
2058            };
2059            assert!(matches!(
2060                wnet(&data, &y, &config),
2061                Err(FdarError::InvalidParameter { .. })
2062            ));
2063        }
2064        // elastic_net_cd rejects it directly too.
2065        let (design, _layout) = curves_to_coeff_design(
2066            &data,
2067            WaveletFamily::Daubechies(4),
2068            BoundaryMode::Periodic,
2069            None,
2070        )
2071        .unwrap();
2072        assert!(matches!(
2073            elastic_net_cd(&design, &y, 0.1, 0.5, 100, -1.0),
2074            Err(FdarError::InvalidParameter { .. })
2075        ));
2076        assert!(matches!(
2077            elastic_net_cd(&design, &y, 0.1, 0.5, 100, f64::NAN),
2078            Err(FdarError::InvalidParameter { .. })
2079        ));
2080    }
2081
2082    #[test]
2083    fn wnet_rejects_zero_max_iter() {
2084        // WR-03: max_iter == 0 would silently return an all-zero-coefficient model.
2085        let data = spanning_design(10, 32, 132);
2086        let y = pseudo_random(10, 6);
2087        let config = WnetConfig {
2088            max_iter: 0,
2089            ..base_wnet_config()
2090        };
2091        assert!(matches!(
2092            wnet(&data, &y, &config),
2093            Err(FdarError::InvalidParameter { .. })
2094        ));
2095        // elastic_net_cd rejects it directly too.
2096        let (design, _layout) = curves_to_coeff_design(
2097            &data,
2098            WaveletFamily::Daubechies(4),
2099            BoundaryMode::Periodic,
2100            None,
2101        )
2102        .unwrap();
2103        assert!(matches!(
2104            elastic_net_cd(&design, &y, 0.1, 0.5, 0, 1e-6),
2105            Err(FdarError::InvalidParameter { .. })
2106        ));
2107    }
2108
2109    #[test]
2110    fn wnet_rejects_empty_lambda_grid() {
2111        let data = spanning_design(10, 32, 14);
2112        let y = vec![0.0; 10];
2113        let config = WnetConfig {
2114            lambda_grid: Some(vec![]),
2115            ..base_wnet_config()
2116        };
2117        assert!(matches!(
2118            wnet(&data, &y, &config),
2119            Err(FdarError::InvalidParameter { .. })
2120        ));
2121    }
2122
2123    #[test]
2124    fn wnet_surfaces_unsupported_family() {
2125        let data = spanning_design(10, 32, 15);
2126        let y = vec![0.0; 10];
2127        let config = WnetConfig {
2128            family: WaveletFamily::Daubechies(11),
2129            ..base_wnet_config()
2130        };
2131        assert!(matches!(
2132            wnet(&data, &y, &config),
2133            Err(FdarError::InvalidParameter { .. })
2134        ));
2135    }
2136
2137    #[test]
2138    fn wnet_surfaces_level_out_of_range() {
2139        let data = spanning_design(10, 32, 16);
2140        let y = vec![0.0; 10];
2141        let config = WnetConfig {
2142            level: Some(999),
2143            ..base_wnet_config()
2144        };
2145        assert!(matches!(
2146            wnet(&data, &y, &config),
2147            Err(FdarError::InvalidParameter { .. })
2148        ));
2149    }
2150
2151    #[test]
2152    fn wnet_finite_outputs_on_larger_snr_design() {
2153        let (n, m) = (256usize, 48usize);
2154        let (data, design, _layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 3400);
2155        let p = design.ncols();
2156        let noise = pseudo_random(n, 2727);
2157        let y: Vec<f64> = (0..n)
2158            .map(|i| {
2159                let mut acc = 0.2;
2160                for j in 0..p {
2161                    acc += design[(i, j)] * beta_coeff[j];
2162                }
2163                acc + 0.1 * noise[i]
2164            })
2165            .collect();
2166
2167        let config = WnetConfig {
2168            alpha: 0.6,
2169            n_lambda: 25,
2170            n_folds: 5,
2171            ..Default::default()
2172        };
2173        let fit = wnet(&data, &y, &config).unwrap();
2174        assert!(fit.intercept.is_finite());
2175        assert!(fit.lambda.is_finite());
2176        assert!(fit.beta_t.iter().all(|x| x.is_finite()));
2177        assert!(fit.fitted_values.iter().all(|x| x.is_finite()));
2178        assert!(fit.residuals.iter().all(|x| x.is_finite()));
2179        assert!(fit.coeff_weights.iter().all(|x| x.is_finite()));
2180    }
2181
2182    #[test]
2183    fn wnet_explicit_lambda_grid_is_used() {
2184        // With a single-λ explicit grid, the CV selection must return that λ.
2185        let (n, m) = (120usize, 32usize);
2186        let (data, design, _layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 3500);
2187        let p = design.ncols();
2188        let y: Vec<f64> = (0..n)
2189            .map(|i| {
2190                let mut acc = 0.0;
2191                for j in 0..p {
2192                    acc += design[(i, j)] * beta_coeff[j];
2193                }
2194                acc
2195            })
2196            .collect();
2197        let config = WnetConfig {
2198            lambda_grid: Some(vec![0.123]),
2199            ..base_wnet_config()
2200        };
2201        let fit = wnet(&data, &y, &config).unwrap();
2202        assert!((fit.lambda - 0.123).abs() < 1e-15);
2203    }
2204
2205    // --- wnet::predict + accessors (WAV-05) ---
2206
2207    #[test]
2208    fn wnet_predict_reproduces_training_fitted() {
2209        let (n, m) = (200usize, 32usize);
2210        let (data, design, _layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 6300);
2211        let p = design.ncols();
2212        let noise = pseudo_random(n, 6301);
2213        let y: Vec<f64> = (0..n)
2214            .map(|i| {
2215                let mut acc = 0.4;
2216                for j in 0..p {
2217                    acc += design[(i, j)] * beta_coeff[j];
2218                }
2219                acc + 0.05 * noise[i]
2220            })
2221            .collect();
2222        let config = WnetConfig {
2223            alpha: 0.7,
2224            n_lambda: 20,
2225            n_folds: 5,
2226            ..Default::default()
2227        };
2228        let fit = wnet(&data, &y, &config).unwrap();
2229        let preds = fit.predict(&data).unwrap();
2230        assert_eq!(preds.len(), fit.fitted_values.len());
2231        for (i, (&pv, &f)) in preds.iter().zip(&fit.fitted_values).enumerate() {
2232            assert!(
2233                (pv - f).abs() <= 1e-8,
2234                "wnet predict[{i}] {pv} != fitted {f} (|Δ| {})",
2235                (pv - f).abs()
2236            );
2237        }
2238    }
2239
2240    #[test]
2241    fn wnet_predict_on_new_curves_is_finite_and_rejects_grid_mismatch() {
2242        let (n, m) = (150usize, 32usize);
2243        let (data, design, _layout, beta_coeff, _support) = sparse_wnet_problem(n, m, 6400);
2244        let p = design.ncols();
2245        let y: Vec<f64> = (0..n)
2246            .map(|i| {
2247                let mut acc = 0.2;
2248                for j in 0..p {
2249                    acc += design[(i, j)] * beta_coeff[j];
2250                }
2251                acc
2252            })
2253            .collect();
2254        let fit = wnet(
2255            &data,
2256            &y,
2257            &WnetConfig {
2258                n_lambda: 12,
2259                n_folds: 4,
2260                ..Default::default()
2261            },
2262        )
2263        .unwrap();
2264
2265        // Fresh same-m curves → finite predictions.
2266        let fresh = spanning_design(30, m, 6401);
2267        let preds = fit.predict(&fresh).unwrap();
2268        assert_eq!(preds.len(), 30);
2269        assert!(preds.iter().all(|x| x.is_finite()));
2270
2271        // Different ncols → InvalidDimension, never a panic.
2272        let wrong = spanning_design(10, m + 16, 6402);
2273        assert!(matches!(
2274            fit.predict(&wrong),
2275            Err(FdarError::InvalidDimension { .. })
2276        ));
2277
2278        // Zero-row input (correct ncols) → InvalidDimension naming "new", no panic.
2279        let empty = FdMatrix::zeros(0, m);
2280        match fit.predict(&empty) {
2281            Err(FdarError::InvalidDimension { parameter, .. }) => {
2282                assert_eq!(parameter, "new");
2283            }
2284            other => panic!("expected InvalidDimension naming \"new\", got {other:?}"),
2285        }
2286    }
2287
2288    #[test]
2289    fn accessors_return_stored_slices() {
2290        let (n, m) = (100usize, 32usize);
2291        let data = spanning_design(n, m, 6500);
2292        let y = pseudo_random(n, 6501);
2293
2294        let wcr_fit = wcr(
2295            &data,
2296            &y,
2297            &WcrConfig {
2298                ncomp: 5,
2299                ..Default::default()
2300            },
2301        )
2302        .unwrap();
2303        assert_eq!(wcr_fit.beta_t(), wcr_fit.beta_t.as_slice());
2304        assert_eq!(wcr_fit.coefficient_function(), wcr_fit.beta_t.as_slice());
2305        assert_eq!(wcr_fit.beta_t().len(), m);
2306        assert_eq!(wcr_fit.fitted_values(), wcr_fit.fitted_values.as_slice());
2307        assert_eq!(wcr_fit.fitted_values().len(), n);
2308
2309        let wnet_fit = wnet(
2310            &data,
2311            &y,
2312            &WnetConfig {
2313                n_lambda: 10,
2314                n_folds: 4,
2315                ..Default::default()
2316            },
2317        )
2318        .unwrap();
2319        assert_eq!(wnet_fit.beta_t(), wnet_fit.beta_t.as_slice());
2320        assert_eq!(wnet_fit.coefficient_function(), wnet_fit.beta_t.as_slice());
2321        assert_eq!(wnet_fit.beta_t().len(), m);
2322        assert_eq!(wnet_fit.fitted_values(), wnet_fit.fitted_values.as_slice());
2323        assert_eq!(wnet_fit.fitted_values().len(), n);
2324    }
2325}