Skip to main content

fdars_core/wavelet/
mod.rs

1//! Discrete Wavelet Transform (DWT) primitive: single-level orthonormal filter bank.
2//!
3//! This module provides the numerical core of the wavelet milestone: the
4//! orthonormal Daubechies filter tables (Haar/db1 through db10, in [`filters`]) and
5//! one single-level DWT step that perfectly reconstructs its input.
6//!
7//! - **Analysis** ([`single_level_analysis`]): a signal is convolved with the
8//!   analysis low-pass filter (`dec_lo`) and high-pass filter (`dec_hi`), then
9//!   downsampled by 2, yielding an *approximation* and a *detail* coefficient
10//!   vector. Under [`BoundaryMode::Periodic`] each has length `ceil(n/2)`; under
11//!   [`BoundaryMode::Symmetric`] each has length `n` (the signal is mirror-extended
12//!   to `2n` internally).
13//! - **Synthesis** ([`single_level_synthesis`]): the exact inverse of analysis —
14//!   the approximation and detail are scattered back through the transpose of the
15//!   orthogonal even-length core and summed to reconstruct the original signal.
16//!
17//! Two boundary handling modes ([`BoundaryMode`]) are supported, and analysis /
18//! synthesis form an exact adjoint pair under **each** independently:
19//!
20//! - [`BoundaryMode::Periodic`] — circular (wrap-around) convolution; the signal is
21//!   used as-is when even, or extended by one sample when odd.
22//! - [`BoundaryMode::Symmetric`] — half-point boundary reflection; the signal is
23//!   mirror-extended to length `2n` internally.
24//!
25//! Both modes route through one orthogonal even-length periodic core whose transpose
26//! is its exact inverse, so single-level analysis followed by synthesis reconstructs
27//! any signal of any length to ≤1e-10 relative error for every in-scope family and
28//! both boundary modes — the property multi-level (Mallat pyramid) construction
29//! builds on. The `rec_lo`/`rec_hi` (time-reversed) synthesis filters are exposed on
30//! [`filters::FilterBank`] for downstream use and verified by the filter-invariant
31//! tests, though this single-level engine reconstructs via the core transpose.
32//!
33//! No crate-root or prelude re-exports are added in this phase (deferred to a later
34//! phase); the module is reachable only as `crate::wavelet::...`.
35
36pub mod filters;
37pub mod regression;
38
39use crate::error::FdarError;
40use filters::FilterBank;
41
42/// A wavelet family: Haar (db1) or Daubechies of a given vanishing-moment order.
43///
44/// `Daubechies(N)` carries the vanishing-moment order `N ∈ 2..=10` (db2..db10);
45/// db1 is spelled [`WaveletFamily::Haar`]. Construct from a plain `dbN` order via
46/// [`WaveletFamily::from_db_order`].
47#[derive(Debug, Clone, PartialEq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[non_exhaustive]
50pub enum WaveletFamily {
51    /// The Haar wavelet (equivalently db1): filter length 2.
52    Haar,
53    /// Daubechies wavelet with `N` vanishing moments (`N ∈ 2..=10`, filter length `2N`).
54    Daubechies(usize),
55}
56
57impl WaveletFamily {
58    /// Map a plain Daubechies order to a [`WaveletFamily`].
59    ///
60    /// `1` maps to [`WaveletFamily::Haar`] (db1 == Haar); `2..=10` map to
61    /// [`WaveletFamily::Daubechies`].
62    ///
63    /// # Errors
64    /// Returns [`FdarError::InvalidParameter`] for an order of `0` or `> 10`.
65    pub fn from_db_order(order: usize) -> Result<Self, FdarError> {
66        match order {
67            1 => Ok(WaveletFamily::Haar),
68            2..=10 => Ok(WaveletFamily::Daubechies(order)),
69            _ => Err(FdarError::InvalidParameter {
70                parameter: "order",
71                message: format!(
72                    "Daubechies order {order} out of range: supported orders are 1..=10 (1 == Haar)"
73                ),
74            }),
75        }
76    }
77}
78
79/// Boundary handling for the single-level convolution/downsampling step.
80///
81/// Analysis and synthesis are an exact adjoint pair under each mode independently,
82/// so the single-level round-trip reconstructs perfectly regardless of the mode.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85#[non_exhaustive]
86pub enum BoundaryMode {
87    /// Circular (periodic) extension: indices wrap around modulo `n`.
88    #[default]
89    Periodic,
90    /// Half-point symmetric extension: indices reflect at the boundaries.
91    Symmetric,
92}
93
94/// Build the even-length internal signal a boundary `mode` transforms.
95///
96/// Both modes route through one orthogonal even-length periodic core (below); they
97/// differ only in how the raw signal is extended to an even length `m`:
98///
99/// - [`BoundaryMode::Periodic`]: even `n` is used as-is (`m = n`); odd `n` is
100///   extended by one sample (`m = n + 1`) that duplicates the last sample — the
101///   half-point periodization convention. Coefficient count is `ceil(n/2)`.
102/// - [`BoundaryMode::Symmetric`]: the signal is half-point mirror-reflected to
103///   length `m = 2n` (`[s₀..s_{n-1}, s_{n-1}..s₀]`), always even. Coefficient count
104///   is `n`.
105///
106/// Synthesis reconstructs the length-`m` extension exactly, then truncates to `n`.
107fn extended_len(n: usize, mode: BoundaryMode) -> usize {
108    match mode {
109        BoundaryMode::Periodic => {
110            if n % 2 == 0 {
111                n
112            } else {
113                n + 1
114            }
115        }
116        BoundaryMode::Symmetric => 2 * n,
117    }
118}
119
120/// Materialize the even-length extension of `signal` for the given boundary `mode`.
121fn extend_signal(signal: &[f64], mode: BoundaryMode) -> Vec<f64> {
122    let n = signal.len();
123    let m = extended_len(n, mode);
124    let mut ext = vec![0.0_f64; m];
125    match mode {
126        BoundaryMode::Periodic => {
127            ext[..n].copy_from_slice(signal);
128            if m > n {
129                // Odd n: duplicate the last sample (half-point right boundary).
130                ext[n] = signal[n - 1];
131            }
132        }
133        BoundaryMode::Symmetric => {
134            for i in 0..n {
135                ext[i] = signal[i];
136                ext[m - 1 - i] = signal[i];
137            }
138        }
139    }
140    ext
141}
142
143/// Coefficient-vector length (approx == detail) produced by `mode` for signal length `n`.
144#[inline]
145fn coeff_len(n: usize, mode: BoundaryMode) -> usize {
146    extended_len(n, mode) / 2
147}
148
149/// Orthogonal even-length periodic analysis core: `out[t] = Σ_k h[k]·s[(2t+k) mod m]`.
150///
151/// `sig.len()` must be even. Returns two vectors of length `m/2`. This is the exact
152/// textbook orthonormal DWT step; its transpose (below) is its exact inverse.
153fn core_analysis(sig: &[f64], fb: &FilterBank) -> (Vec<f64>, Vec<f64>) {
154    let m = sig.len();
155    debug_assert!(m % 2 == 0 && m > 0);
156    let l = fb.filter_len();
157    let out = m / 2;
158    let mut approx = vec![0.0_f64; out];
159    let mut detail = vec![0.0_f64; out];
160    for t in 0..out {
161        let mut a = 0.0_f64;
162        let mut d = 0.0_f64;
163        for k in 0..l {
164            let idx = (2 * t + k) % m;
165            let s = sig[idx];
166            a += fb.dec_lo[k] * s;
167            d += fb.dec_hi[k] * s;
168        }
169        approx[t] = a;
170        detail[t] = d;
171    }
172    (approx, detail)
173}
174
175/// Transpose (exact inverse) of [`core_analysis`]: scatter coefficients back to length `m`.
176///
177/// `s[(2t+k) mod m] += dec_lo[k]·approx[t] + dec_hi[k]·detail[t]`. Because the
178/// even-length periodic analysis operator is orthogonal, this transpose reconstructs
179/// the length-`m` signal exactly.
180fn core_synthesis(approx: &[f64], detail: &[f64], fb: &FilterBank, m: usize) -> Vec<f64> {
181    let l = fb.filter_len();
182    let out = approx.len();
183    let mut signal = vec![0.0_f64; m];
184    for t in 0..out {
185        let a = approx[t];
186        let d = detail[t];
187        for k in 0..l {
188            let idx = (2 * t + k) % m;
189            signal[idx] += fb.dec_lo[k] * a + fb.dec_hi[k] * d;
190        }
191    }
192    signal
193}
194
195/// Single-level DWT analysis: split a signal into approximation + detail coefficients.
196///
197/// The signal is extended to an even length per the boundary `mode` (see
198/// [`extend_signal`]) and convolved with the analysis low-pass (`dec_lo`) and
199/// high-pass (`dec_hi`) filters, downsampled by 2. Under [`BoundaryMode::Periodic`]
200/// each output vector has length `ceil(n/2)`; under [`BoundaryMode::Symmetric`] it
201/// has length `n` (`n = signal.len()`). Analysis and synthesis form an exact
202/// adjoint pair under each mode, so the round-trip reconstructs perfectly.
203///
204/// # Errors
205/// Returns [`FdarError::InvalidParameter`] if `signal` is empty.
206#[must_use = "the approximation/detail coefficients are the result of the transform"]
207pub(crate) fn single_level_analysis(
208    signal: &[f64],
209    fb: &FilterBank,
210    mode: BoundaryMode,
211) -> Result<(Vec<f64>, Vec<f64>), FdarError> {
212    if signal.is_empty() {
213        return Err(FdarError::InvalidParameter {
214            parameter: "signal",
215            message: "signal must be non-empty".to_string(),
216        });
217    }
218    let ext = extend_signal(signal, mode);
219    Ok(core_analysis(&ext, fb))
220}
221
222/// Single-level DWT synthesis: reconstruct a signal from approximation + detail.
223///
224/// This is the exact inverse of [`single_level_analysis`] under the same boundary
225/// `mode`: the coefficients are scattered back through the transpose of the
226/// orthogonal even-length core, reconstructing the internal even-length extension,
227/// which is then truncated to `output_len` samples. Never emits NaN/inf when inputs
228/// are finite.
229///
230/// # Odd-length ambiguity (callers beware)
231/// Under [`BoundaryMode::Periodic`] an odd length `n` and the even length `n+1`
232/// both produce `ceil(n/2)` coefficients (e.g. `n=7` and `n=8` both give 4). The
233/// coefficient-count validation below therefore *cannot* distinguish them: passing
234/// `output_len = n+1` for a signal that was analyzed at odd `n` validates spuriously
235/// and returns a signal of the wrong length. Callers **must** pass the exact original
236/// signal length. A `debug_assert!` guards this in debug builds; `reconstruct`
237/// recovers the exact per-level length from [`WaveletCoeffs::level_lens`].
238///
239/// # Errors
240/// - [`FdarError::InvalidParameter`] if `output_len == 0`.
241/// - [`FdarError::InvalidDimension`] if `approx` and `detail` differ in length, or
242///   if their length does not match the mode's expected coefficient count for
243///   `output_len` (`ceil(output_len/2)` periodic, `output_len` symmetric).
244#[must_use = "the reconstructed signal is the result of the inverse transform"]
245pub(crate) fn single_level_synthesis(
246    approx: &[f64],
247    detail: &[f64],
248    fb: &FilterBank,
249    mode: BoundaryMode,
250    output_len: usize,
251) -> Result<Vec<f64>, FdarError> {
252    if output_len == 0 {
253        return Err(FdarError::InvalidParameter {
254            parameter: "output_len",
255            message: "output_len must be non-zero".to_string(),
256        });
257    }
258    if approx.len() != detail.len() {
259        return Err(FdarError::InvalidDimension {
260            parameter: "detail",
261            expected: format!("{} (== approx length)", approx.len()),
262            actual: detail.len().to_string(),
263        });
264    }
265    let expected_coeff_len = coeff_len(output_len, mode);
266    if approx.len() != expected_coeff_len {
267        return Err(FdarError::InvalidDimension {
268            parameter: "approx",
269            expected: format!("{expected_coeff_len} (mode-dependent coefficient length)"),
270            actual: approx.len().to_string(),
271        });
272    }
273    let m = extended_len(output_len, mode);
274    // Odd-length guard: for Periodic mode with odd original length, m == output_len+1
275    // and truncation drops the padding sample. `output_len <= m` always holds for the
276    // true original length; a caller passing n+1 for an odd n (which validates
277    // spuriously above) would still satisfy this, so this is a best-effort internal
278    // guard against grosser off-by-ones in the extended length.
279    debug_assert!(
280        output_len <= m,
281        "output_len {output_len} > extended length {m}; likely off-by-one in odd-signal path"
282    );
283    let mut signal = core_synthesis(approx, detail, fb, m);
284    signal.truncate(output_len);
285    Ok(signal)
286}
287
288// ---------------------------------------------------------------------------
289// Multi-level Mallat pyramid (Plan 69-02)
290// ---------------------------------------------------------------------------
291
292/// Maximum useful decomposition depth for a signal of length `signal_len`.
293///
294/// Returns `floor(log2(signal_len / (filter_len - 1)))`, the deepest level at
295/// which the coarse approximation band still stays at least as long as the filter
296/// support — deeper decompositions would collapse a band below the filter length.
297/// For Haar (`filter_len == 2`) this is simply `floor(log2(signal_len))`.
298///
299/// # Errors
300/// Returns [`FdarError::InvalidParameter`] if `signal_len == 0`, if `family` is an
301/// unsupported Daubechies order (surfaced via [`filters::filter_bank`]), or if the
302/// signal is too short to admit even a single useful level.
303pub fn max_level(signal_len: usize, family: &WaveletFamily) -> Result<usize, FdarError> {
304    if signal_len == 0 {
305        return Err(FdarError::InvalidParameter {
306            parameter: "signal_len",
307            message: "signal length must be non-zero".to_string(),
308        });
309    }
310    let fb = filters::filter_bank(family)?;
311    let filter_len = fb.filter_len();
312    // filter_len is always >= 2 for in-scope families; guard defensively.
313    if filter_len <= 1 {
314        return Err(FdarError::InvalidParameter {
315            parameter: "family",
316            message: "filter length must exceed 1".to_string(),
317        });
318    }
319    let ratio = signal_len as f64 / (filter_len - 1) as f64;
320    // floor(log2(ratio)); ratio >= 1 required for at least one useful level.
321    let level = if ratio < 1.0 {
322        0
323    } else {
324        ratio.log2().floor() as usize
325    };
326    if level < 1 {
327        return Err(FdarError::InvalidParameter {
328            parameter: "signal_len",
329            message: format!(
330                "signal length {signal_len} is too short for even one useful decomposition level \
331                 with filter length {filter_len}"
332            ),
333        });
334    }
335    Ok(level)
336}
337
338/// Multi-level orthogonal DWT coefficients (the Mallat pyramid) for one signal.
339///
340/// Produced by [`decompose`] and inverted by [`reconstruct`]. Holds the final coarse
341/// approximation, the per-level detail bands, and the metadata reconstruction needs
342/// to return exactly the original number of samples.
343///
344/// The detail bands are stored **finest-first**: `details[0]` is the level-1 detail
345/// (highest frequency, longest band) and `details[levels - 1]` is the coarsest detail
346/// produced alongside the final approximation.
347#[derive(Debug, Clone, PartialEq)]
348#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
349#[non_exhaustive]
350pub struct WaveletCoeffs {
351    /// The final coarse approximation band (output of the last analysis level).
352    pub approx: Vec<f64>,
353    /// Per-level detail bands, **finest-first**: `details[0]` is the level-1 detail.
354    pub details: Vec<Vec<f64>>,
355    /// Number of decomposition levels (`== details.len()`).
356    pub levels: usize,
357    /// Original signal length, so [`reconstruct`] returns exactly this many samples.
358    pub signal_len: usize,
359    /// The wavelet family used for the transform (needed to rebuild the filter bank).
360    pub family: WaveletFamily,
361    /// The boundary mode used for the transform.
362    pub mode: BoundaryMode,
363    /// Input length at each analysis level, finest-first: `level_lens[0] == signal_len`
364    /// and `level_lens[i]` is the length of the approximation fed into level `i`.
365    ///
366    /// This per-level bookkeeping lets [`reconstruct`] recover each level's exact
367    /// synthesis target length; an off-by-one here mis-aligns the pyramid.
368    pub(crate) level_lens: Vec<usize>,
369}
370
371impl WaveletCoeffs {
372    /// Number of decomposition levels.
373    #[must_use]
374    pub fn levels(&self) -> usize {
375        self.levels
376    }
377
378    /// Original signal length (the number of samples [`reconstruct`] returns).
379    #[must_use]
380    pub fn signal_len(&self) -> usize {
381        self.signal_len
382    }
383
384    /// The final coarse approximation band.
385    #[must_use]
386    pub fn approx(&self) -> &[f64] {
387        &self.approx
388    }
389
390    /// The detail band at `level` (finest-first: `0` is the level-1 detail).
391    ///
392    /// Returns `None` if `level >= levels`.
393    #[must_use]
394    pub fn detail(&self, level: usize) -> Option<&[f64]> {
395        self.details.get(level).map(Vec::as_slice)
396    }
397
398    /// The wavelet family used for the transform.
399    #[must_use]
400    pub fn family(&self) -> &WaveletFamily {
401        &self.family
402    }
403
404    /// The boundary mode used for the transform.
405    #[must_use]
406    pub fn mode(&self) -> BoundaryMode {
407        self.mode
408    }
409}
410
411/// Multi-level orthogonal DWT: decompose a signal into a Mallat coefficient pyramid.
412///
413/// Applies [`single_level_analysis`] repeatedly to the running approximation band,
414/// collecting one detail band per level (finest-first). When `level` is `None` the
415/// depth defaults to [`max_level`]; an explicit `level` is validated to lie in
416/// `1..=max_level`.
417///
418/// # Errors
419/// - [`FdarError::InvalidParameter`] if `signal` is empty, the family is unsupported,
420///   or an explicit `level` is `0` or exceeds [`max_level`].
421#[must_use = "the coefficient pyramid is the result of the transform"]
422pub fn decompose(
423    signal: &[f64],
424    family: WaveletFamily,
425    mode: BoundaryMode,
426    level: Option<usize>,
427) -> Result<WaveletCoeffs, FdarError> {
428    if signal.is_empty() {
429        return Err(FdarError::InvalidParameter {
430            parameter: "signal",
431            message: "signal must be non-empty".to_string(),
432        });
433    }
434    let max_lvl = max_level(signal.len(), &family)?;
435    let effective_level = match level {
436        None => max_lvl,
437        Some(0) => {
438            return Err(FdarError::InvalidParameter {
439                parameter: "level",
440                message: "decomposition level must be at least 1".to_string(),
441            });
442        }
443        Some(l) if l > max_lvl => {
444            return Err(FdarError::InvalidParameter {
445                parameter: "level",
446                message: format!(
447                    "decomposition level {l} exceeds the maximum useful level {max_lvl} \
448                     for signal length {} with this family",
449                    signal.len()
450                ),
451            });
452        }
453        Some(l) => l,
454    };
455
456    let fb = filters::filter_bank(&family)?;
457    let mut details: Vec<Vec<f64>> = Vec::with_capacity(effective_level);
458    let mut level_lens: Vec<usize> = Vec::with_capacity(effective_level);
459    let mut current = signal.to_vec();
460    for _ in 0..effective_level {
461        level_lens.push(current.len());
462        let (approx, detail) = single_level_analysis(&current, &fb, mode)?;
463        details.push(detail);
464        current = approx;
465    }
466
467    Ok(WaveletCoeffs {
468        approx: current,
469        details,
470        levels: effective_level,
471        signal_len: signal.len(),
472        family,
473        mode,
474        level_lens,
475    })
476}
477
478/// Invert [`decompose`]: reconstruct the original signal from a coefficient pyramid.
479///
480/// Rebuilds the filter bank from `coeffs.family`, then folds the detail bands back in
481/// reverse level order (coarsest-first), calling [`single_level_synthesis`] with each
482/// level's exact analysis-input length recovered from `coeffs.level_lens`. Returns
483/// exactly `coeffs.signal_len` samples. Never emits NaN/inf when inputs are finite.
484///
485/// # Errors
486/// - [`FdarError::InvalidDimension`] if the coefficient pyramid is internally
487///   inconsistent (band count / metadata mismatch).
488/// - [`FdarError::InvalidParameter`] if the family is unsupported.
489#[must_use = "the reconstructed signal is the result of the inverse transform"]
490pub fn reconstruct(coeffs: &WaveletCoeffs) -> Result<Vec<f64>, FdarError> {
491    if coeffs.details.len() != coeffs.levels || coeffs.level_lens.len() != coeffs.levels {
492        return Err(FdarError::InvalidDimension {
493            parameter: "coeffs",
494            expected: format!("{} detail bands and level lengths", coeffs.levels),
495            actual: format!(
496                "{} detail bands, {} level lengths",
497                coeffs.details.len(),
498                coeffs.level_lens.len()
499            ),
500        });
501    }
502    if coeffs.levels == 0 {
503        return Err(FdarError::InvalidDimension {
504            parameter: "levels",
505            expected: "at least 1".to_string(),
506            actual: "0".to_string(),
507        });
508    }
509    let fb = filters::filter_bank(&coeffs.family)?;
510    let mut approx = coeffs.approx.clone();
511    // Fold coarsest-first: level index counts down from levels-1 to 0.
512    for lvl in (0..coeffs.levels).rev() {
513        let detail = &coeffs.details[lvl];
514        let target_len = coeffs.level_lens[lvl];
515        approx = single_level_synthesis(&approx, detail, &fb, coeffs.mode, target_len)?;
516    }
517    Ok(approx)
518}
519
520/// Multi-level orthogonal DWT over every row (curve) of an [`FdMatrix`].
521///
522/// Each row is a signal of length `data.ncols()`; the result holds one
523/// [`WaveletCoeffs`] per row, in row order. The batch path is exactly the per-row
524/// [`decompose`] applied to each row (`data.row(i)`), so its output is identical to
525/// calling `decompose` on each row individually.
526///
527/// # Errors
528/// - [`FdarError::InvalidDimension`] if the matrix has zero rows or zero columns.
529/// - [`FdarError::InvalidParameter`] if the family is unsupported or an explicit
530///   `level` is invalid for the row length (surfaced from [`decompose`]).
531#[must_use = "the per-row coefficient pyramids are the result of the transform"]
532pub fn decompose_matrix(
533    data: &crate::matrix::FdMatrix,
534    family: WaveletFamily,
535    mode: BoundaryMode,
536    level: Option<usize>,
537) -> Result<Vec<WaveletCoeffs>, FdarError> {
538    if data.nrows() == 0 || data.ncols() == 0 {
539        return Err(FdarError::InvalidDimension {
540            parameter: "data",
541            expected: "non-empty matrix (nrows > 0 && ncols > 0)".to_string(),
542            actual: format!("{}x{}", data.nrows(), data.ncols()),
543        });
544    }
545    let mut out = Vec::with_capacity(data.nrows());
546    for i in 0..data.nrows() {
547        let row = data.row(i);
548        out.push(decompose(&row, family.clone(), mode, level)?);
549    }
550    Ok(out)
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::wavelet::filters::filter_bank;
557
558    /// Deterministic pseudo-random signal (LCG) — spans full rank, no external dep.
559    fn pseudo_random(n: usize, seed: u64) -> Vec<f64> {
560        let mut state = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
561        (0..n)
562            .map(|_| {
563                state = state
564                    .wrapping_mul(6_364_136_223_846_793_005)
565                    .wrapping_add(1_442_695_040_888_963_407);
566                // Map top bits to roughly [-1, 1).
567                let u = (state >> 11) as f64 / (1u64 << 53) as f64;
568                2.0 * u - 1.0
569            })
570            .collect()
571    }
572
573    fn rel_err(recon: &[f64], orig: &[f64]) -> f64 {
574        let num: f64 = recon
575            .iter()
576            .zip(orig)
577            .map(|(r, o)| (r - o) * (r - o))
578            .sum::<f64>()
579            .sqrt();
580        let den: f64 = orig.iter().map(|o| o * o).sum::<f64>().sqrt().max(1e-300);
581        num / den
582    }
583
584    fn round_trip_families() -> Vec<WaveletFamily> {
585        vec![
586            WaveletFamily::Haar,
587            WaveletFamily::Daubechies(2),
588            WaveletFamily::Daubechies(4),
589            WaveletFamily::Daubechies(6),
590            WaveletFamily::Daubechies(8),
591            WaveletFamily::Daubechies(10),
592        ]
593    }
594
595    // --- Task 1 tracer: Haar known-answer + even-length periodic round-trip ---
596
597    #[test]
598    fn haar_known_answer_coefficients() {
599        // input [a, b, c, d]
600        let a = 1.5_f64;
601        let b = -0.5;
602        let c = 3.0;
603        let d = 2.0;
604        let signal = [a, b, c, d];
605        let fb = filter_bank(&WaveletFamily::Haar).unwrap();
606        let (approx, detail) = single_level_analysis(&signal, &fb, BoundaryMode::Periodic).unwrap();
607        let s2 = std::f64::consts::SQRT_2;
608        assert!((approx[0] - (a + b) / s2).abs() < 1e-12);
609        assert!((approx[1] - (c + d) / s2).abs() < 1e-12);
610        assert!((detail[0] - (a - b) / s2).abs() < 1e-12);
611        assert!((detail[1] - (c - d) / s2).abs() < 1e-12);
612    }
613
614    #[test]
615    fn haar_even_length_round_trip() {
616        let signal = pseudo_random(8, 42);
617        let fb = filter_bank(&WaveletFamily::Haar).unwrap();
618        let (approx, detail) = single_level_analysis(&signal, &fb, BoundaryMode::Periodic).unwrap();
619        let recon =
620            single_level_synthesis(&approx, &detail, &fb, BoundaryMode::Periodic, signal.len())
621                .unwrap();
622        assert!(rel_err(&recon, &signal) < 1e-10);
623    }
624
625    #[test]
626    fn empty_signal_is_invalid_parameter() {
627        let fb = filter_bank(&WaveletFamily::Haar).unwrap();
628        assert!(matches!(
629            single_level_analysis(&[], &fb, BoundaryMode::Periodic),
630            Err(FdarError::InvalidParameter { .. })
631        ));
632    }
633
634    // --- Task 3: all families, both modes, arbitrary length ---
635
636    #[test]
637    fn round_trip_periodic_non_power_of_two() {
638        let signal = pseudo_random(37, 7);
639        for fam in round_trip_families() {
640            let fb = filter_bank(&fam).unwrap();
641            let (approx, detail) =
642                single_level_analysis(&signal, &fb, BoundaryMode::Periodic).unwrap();
643            let recon =
644                single_level_synthesis(&approx, &detail, &fb, BoundaryMode::Periodic, signal.len())
645                    .unwrap();
646            let e = rel_err(&recon, &signal);
647            assert!(e < 1e-10, "{fam:?} periodic n=37 rel err {e}");
648        }
649    }
650
651    #[test]
652    fn round_trip_symmetric_non_power_of_two() {
653        let signal = pseudo_random(37, 11);
654        for fam in round_trip_families() {
655            let fb = filter_bank(&fam).unwrap();
656            let (approx, detail) =
657                single_level_analysis(&signal, &fb, BoundaryMode::Symmetric).unwrap();
658            let recon = single_level_synthesis(
659                &approx,
660                &detail,
661                &fb,
662                BoundaryMode::Symmetric,
663                signal.len(),
664            )
665            .unwrap();
666            let e = rel_err(&recon, &signal);
667            assert!(e < 1e-10, "{fam:?} symmetric n=37 rel err {e}");
668        }
669    }
670
671    #[test]
672    fn coefficient_lengths_are_ceil_half() {
673        let fb = filter_bank(&WaveletFamily::Daubechies(4)).unwrap();
674        for n in [36_usize, 37] {
675            let signal = pseudo_random(n, 3);
676            let (approx, detail) =
677                single_level_analysis(&signal, &fb, BoundaryMode::Periodic).unwrap();
678            assert_eq!(approx.len(), n.div_ceil(2));
679            assert_eq!(detail.len(), n.div_ceil(2));
680            let recon =
681                single_level_synthesis(&approx, &detail, &fb, BoundaryMode::Periodic, n).unwrap();
682            assert_eq!(recon.len(), n);
683        }
684    }
685
686    #[test]
687    fn reconstruction_has_no_nan_or_inf() {
688        for &n in &[36_usize, 37] {
689            let signal = pseudo_random(n, 99);
690            for fam in round_trip_families() {
691                let fb = filter_bank(&fam).unwrap();
692                for mode in [BoundaryMode::Periodic, BoundaryMode::Symmetric] {
693                    let (approx, detail) = single_level_analysis(&signal, &fb, mode).unwrap();
694                    let recon = single_level_synthesis(&approx, &detail, &fb, mode, n).unwrap();
695                    assert!(
696                        recon.iter().all(|x| x.is_finite()),
697                        "{fam:?} {mode:?} n={n} produced non-finite"
698                    );
699                }
700            }
701        }
702    }
703
704    #[test]
705    fn db4_even_and_odd_round_trip_both_modes() {
706        let fb = filter_bank(&WaveletFamily::Daubechies(4)).unwrap();
707        for n in [36_usize, 37] {
708            let signal = pseudo_random(n, 5);
709            for mode in [BoundaryMode::Periodic, BoundaryMode::Symmetric] {
710                let (approx, detail) = single_level_analysis(&signal, &fb, mode).unwrap();
711                let recon = single_level_synthesis(&approx, &detail, &fb, mode, n).unwrap();
712                let e = rel_err(&recon, &signal);
713                assert!(e < 1e-10, "db4 n={n} {mode:?} rel err {e}");
714            }
715        }
716    }
717
718    #[test]
719    fn odd_order_daubechies_round_trip_both_modes() {
720        // IN-02: odd-order families (db3, db5, db7, db9) were previously only
721        // exercised by filter-invariant tests. Assert explicit end-to-end
722        // reconstruction to <=1e-10 relative on a non-power-of-2 length under both
723        // boundary modes. Tolerance must never be loosened; a failure here is a real
724        // filter-table / engine bug.
725        let n = 37_usize; // non-power-of-2, odd
726        let signal = pseudo_random(n, 202);
727        for order in [3_usize, 5, 7, 9] {
728            let fam = WaveletFamily::from_db_order(order).unwrap();
729            let fb = filter_bank(&fam).unwrap();
730            for mode in [BoundaryMode::Periodic, BoundaryMode::Symmetric] {
731                let (approx, detail) = single_level_analysis(&signal, &fb, mode).unwrap();
732                let recon = single_level_synthesis(&approx, &detail, &fb, mode, n).unwrap();
733                let e = rel_err(&recon, &signal);
734                assert!(e < 1e-10, "db{order} n={n} {mode:?} rel err {e}");
735                assert!(recon.iter().all(|x| x.is_finite()));
736            }
737        }
738    }
739
740    #[test]
741    fn odd_order_daubechies_multi_level_round_trip_both_modes() {
742        // IN-02 (multi-level): non-power-of-2 length, auto depth, odd-order families.
743        let n = 201_usize;
744        let signal = pseudo_random(n, 303);
745        for order in [3_usize, 5, 7, 9] {
746            let fam = WaveletFamily::from_db_order(order).unwrap();
747            for mode in [BoundaryMode::Periodic, BoundaryMode::Symmetric] {
748                let coeffs = decompose(&signal, fam.clone(), mode, None).unwrap();
749                let recon = reconstruct(&coeffs).unwrap();
750                assert_eq!(recon.len(), n);
751                let e = rel_err(&recon, &signal);
752                assert!(
753                    e < 1e-10,
754                    "db{order} n={n} {mode:?} multi-level rel err {e}"
755                );
756                assert!(recon.iter().all(|x| x.is_finite()));
757            }
758        }
759    }
760
761    #[test]
762    fn synthesis_rejects_mismatched_coefficient_lengths() {
763        let fb = filter_bank(&WaveletFamily::Haar).unwrap();
764        let approx = vec![1.0, 2.0];
765        let detail = vec![1.0];
766        assert!(matches!(
767            single_level_synthesis(&approx, &detail, &fb, BoundaryMode::Periodic, 4),
768            Err(FdarError::InvalidDimension { .. })
769        ));
770    }
771
772    #[test]
773    fn synthesis_rejects_zero_output_len() {
774        let fb = filter_bank(&WaveletFamily::Haar).unwrap();
775        assert!(matches!(
776            single_level_synthesis(&[], &[], &fb, BoundaryMode::Periodic, 0),
777            Err(FdarError::InvalidParameter { .. })
778        ));
779    }
780
781    #[test]
782    fn from_db_order_maps_correctly() {
783        assert_eq!(
784            WaveletFamily::from_db_order(1).unwrap(),
785            WaveletFamily::Haar
786        );
787        assert_eq!(
788            WaveletFamily::from_db_order(2).unwrap(),
789            WaveletFamily::Daubechies(2)
790        );
791        assert_eq!(
792            WaveletFamily::from_db_order(10).unwrap(),
793            WaveletFamily::Daubechies(10)
794        );
795        assert!(matches!(
796            WaveletFamily::from_db_order(0),
797            Err(FdarError::InvalidParameter { .. })
798        ));
799        assert!(matches!(
800            WaveletFamily::from_db_order(11),
801            Err(FdarError::InvalidParameter { .. })
802        ));
803    }
804
805    #[test]
806    fn default_boundary_mode_is_periodic() {
807        assert_eq!(BoundaryMode::default(), BoundaryMode::Periodic);
808    }
809
810    // --- Task 1: max_level + WaveletCoeffs ---
811
812    #[test]
813    fn max_level_haar_is_log2_of_n() {
814        // Haar filter_len == 2 -> filter_len - 1 == 1 -> floor(log2(1024/1)) == 10.
815        assert_eq!(max_level(1024, &WaveletFamily::Haar).unwrap(), 10);
816    }
817
818    #[test]
819    fn max_level_db4_uses_filter_len_minus_one() {
820        // db4 filter_len == 8 -> floor(log2(1024/7)) == floor(log2(146.28)) == 7.
821        let expected = (1024.0_f64 / 7.0).log2().floor() as usize;
822        assert_eq!(
823            max_level(1024, &WaveletFamily::Daubechies(4)).unwrap(),
824            expected
825        );
826        assert_eq!(expected, 7);
827    }
828
829    #[test]
830    fn max_level_rejects_zero_length() {
831        assert!(matches!(
832            max_level(0, &WaveletFamily::Haar),
833            Err(FdarError::InvalidParameter { .. })
834        ));
835    }
836
837    #[test]
838    fn max_level_rejects_too_short_signal() {
839        // n=1 with Haar: ratio = 1/1 = 1, log2 = 0 -> no useful level.
840        assert!(matches!(
841            max_level(1, &WaveletFamily::Haar),
842            Err(FdarError::InvalidParameter { .. })
843        ));
844        // db4 needs at least filter_len-1 = 7 samples for one level.
845        assert!(matches!(
846            max_level(6, &WaveletFamily::Daubechies(4)),
847            Err(FdarError::InvalidParameter { .. })
848        ));
849    }
850
851    #[test]
852    fn max_level_rejects_unsupported_family() {
853        assert!(matches!(
854            max_level(1024, &WaveletFamily::Daubechies(11)),
855            Err(FdarError::InvalidParameter { .. })
856        ));
857    }
858
859    #[test]
860    fn wavelet_coeffs_partial_eq_on_identical_inputs() {
861        let signal = pseudo_random(64, 21);
862        let a = decompose(
863            &signal,
864            WaveletFamily::Daubechies(4),
865            BoundaryMode::Periodic,
866            Some(3),
867        )
868        .unwrap();
869        let b = decompose(
870            &signal,
871            WaveletFamily::Daubechies(4),
872            BoundaryMode::Periodic,
873            Some(3),
874        )
875        .unwrap();
876        assert_eq!(a, b);
877        // Accessors expose the expected metadata.
878        assert_eq!(a.levels(), 3);
879        assert_eq!(a.signal_len(), 64);
880        assert_eq!(a.detail(0).unwrap().len(), a.details[0].len());
881        assert!(a.detail(3).is_none());
882        assert_eq!(a.family(), &WaveletFamily::Daubechies(4));
883        assert_eq!(a.mode(), BoundaryMode::Periodic);
884    }
885
886    // --- Task 2: multi-level decompose/reconstruct ---
887
888    #[test]
889    fn multi_level_round_trip_periodic_all_families() {
890        // n=256 is long enough that every family (incl. db10, filter_len 20) admits
891        // >=2 useful levels: max_level(256, db10) = floor(log2(256/19)) = 3.
892        let signal = pseudo_random(256, 123);
893        for fam in round_trip_families() {
894            let coeffs = decompose(&signal, fam.clone(), BoundaryMode::Periodic, Some(2)).unwrap();
895            assert_eq!(coeffs.levels, 2);
896            let recon = reconstruct(&coeffs).unwrap();
897            assert_eq!(recon.len(), signal.len());
898            let e = rel_err(&recon, &signal);
899            assert!(e < 1e-10, "{fam:?} periodic L=2 rel err {e}");
900            assert!(recon.iter().all(|x| x.is_finite()));
901        }
902    }
903
904    #[test]
905    fn multi_level_round_trip_symmetric_non_power_of_two() {
906        // n=201 is non-power-of-2 and long enough for >=2 auto levels across all
907        // families: max_level(201, db10) = floor(log2(201/19)) = 3.
908        let signal = pseudo_random(201, 456);
909        for fam in round_trip_families() {
910            let coeffs = decompose(&signal, fam.clone(), BoundaryMode::Symmetric, None).unwrap();
911            assert!(
912                coeffs.levels >= 2,
913                "{fam:?} expected >=2 auto levels for n=201"
914            );
915            let recon = reconstruct(&coeffs).unwrap();
916            assert_eq!(recon.len(), 201);
917            let e = rel_err(&recon, &signal);
918            assert!(e < 1e-10, "{fam:?} symmetric n=201 auto rel err {e}");
919            assert!(recon.iter().all(|x| x.is_finite()));
920        }
921    }
922
923    #[test]
924    fn multi_level_round_trip_periodic_non_power_of_two() {
925        // Non-power-of-2 length under periodic mode too (SC2 covers both modes).
926        let signal = pseudo_random(201, 789);
927        for fam in round_trip_families() {
928            let coeffs = decompose(&signal, fam.clone(), BoundaryMode::Periodic, None).unwrap();
929            assert!(coeffs.levels >= 2, "{fam:?} expected >=2 auto levels");
930            let recon = reconstruct(&coeffs).unwrap();
931            assert_eq!(recon.len(), 201);
932            let e = rel_err(&recon, &signal);
933            assert!(e < 1e-10, "{fam:?} periodic n=201 rel err {e}");
934        }
935    }
936
937    #[test]
938    fn auto_level_equals_max_level() {
939        let signal = pseudo_random(200, 8);
940        let coeffs = decompose(
941            &signal,
942            WaveletFamily::Daubechies(4),
943            BoundaryMode::Periodic,
944            None,
945        )
946        .unwrap();
947        assert_eq!(
948            coeffs.levels,
949            max_level(200, &WaveletFamily::Daubechies(4)).unwrap()
950        );
951    }
952
953    #[test]
954    fn explicit_level_out_of_range_is_invalid() {
955        let signal = pseudo_random(64, 8);
956        let maxl = max_level(64, &WaveletFamily::Daubechies(4)).unwrap();
957        assert!(matches!(
958            decompose(
959                &signal,
960                WaveletFamily::Daubechies(4),
961                BoundaryMode::Periodic,
962                Some(maxl + 1)
963            ),
964            Err(FdarError::InvalidParameter { .. })
965        ));
966        assert!(matches!(
967            decompose(
968                &signal,
969                WaveletFamily::Daubechies(4),
970                BoundaryMode::Periodic,
971                Some(0)
972            ),
973            Err(FdarError::InvalidParameter { .. })
974        ));
975    }
976
977    #[test]
978    fn decompose_rejects_empty_signal() {
979        assert!(matches!(
980            decompose(&[], WaveletFamily::Haar, BoundaryMode::Periodic, None),
981            Err(FdarError::InvalidParameter { .. })
982        ));
983    }
984
985    #[test]
986    fn signal_len_preserved_odd_and_even() {
987        for &n in &[37_usize, 64] {
988            let signal = pseudo_random(n, n as u64);
989            let coeffs = decompose(
990                &signal,
991                WaveletFamily::Daubechies(2),
992                BoundaryMode::Periodic,
993                Some(2),
994            )
995            .unwrap();
996            let recon = reconstruct(&coeffs).unwrap();
997            assert_eq!(recon.len(), n, "n={n}");
998        }
999    }
1000
1001    #[test]
1002    fn reconstruct_rejects_inconsistent_coeffs() {
1003        let signal = pseudo_random(64, 3);
1004        let mut coeffs = decompose(
1005            &signal,
1006            WaveletFamily::Haar,
1007            BoundaryMode::Periodic,
1008            Some(3),
1009        )
1010        .unwrap();
1011        // Corrupt band count without touching `levels`.
1012        coeffs.details.pop();
1013        assert!(matches!(
1014            reconstruct(&coeffs),
1015            Err(FdarError::InvalidDimension { .. })
1016        ));
1017    }
1018
1019    // --- Task 3: FdMatrix batch path + invalid-input gate ---
1020
1021    #[test]
1022    fn decompose_matrix_matches_per_row_slice_path() {
1023        use crate::matrix::FdMatrix;
1024        let nrows = 5;
1025        let ncols = 48;
1026        // Column-major flat buffer of distinct pseudo-random rows.
1027        let mut flat = vec![0.0_f64; nrows * ncols];
1028        for i in 0..nrows {
1029            let row = pseudo_random(ncols, 1000 + i as u64);
1030            for j in 0..ncols {
1031                flat[i + j * nrows] = row[j];
1032            }
1033        }
1034        let m = FdMatrix::from_column_major(flat, nrows, ncols).unwrap();
1035        let batch = decompose_matrix(
1036            &m,
1037            WaveletFamily::Daubechies(4),
1038            BoundaryMode::Periodic,
1039            None,
1040        )
1041        .unwrap();
1042        assert_eq!(batch.len(), nrows);
1043        for i in 0..nrows {
1044            let per_row = decompose(
1045                &m.row(i),
1046                WaveletFamily::Daubechies(4),
1047                BoundaryMode::Periodic,
1048                None,
1049            )
1050            .unwrap();
1051            assert_eq!(batch[i], per_row, "row {i} batch != per-row");
1052        }
1053    }
1054
1055    #[test]
1056    fn decompose_matrix_round_trip_both_modes() {
1057        use crate::matrix::FdMatrix;
1058        let nrows = 4;
1059        let ncols = 48;
1060        let mut rows = Vec::new();
1061        let mut flat = vec![0.0_f64; nrows * ncols];
1062        for i in 0..nrows {
1063            let row = pseudo_random(ncols, 2000 + i as u64);
1064            for j in 0..ncols {
1065                flat[i + j * nrows] = row[j];
1066            }
1067            rows.push(row);
1068        }
1069        let m = FdMatrix::from_column_major(flat, nrows, ncols).unwrap();
1070        for mode in [BoundaryMode::Periodic, BoundaryMode::Symmetric] {
1071            let batch = decompose_matrix(&m, WaveletFamily::Daubechies(6), mode, None).unwrap();
1072            for (i, coeffs) in batch.iter().enumerate() {
1073                let recon = reconstruct(coeffs).unwrap();
1074                assert_eq!(recon.len(), ncols);
1075                let e = rel_err(&recon, &rows[i]);
1076                assert!(e < 1e-10, "row {i} {mode:?} rel err {e}");
1077                assert!(recon.iter().all(|x| x.is_finite()));
1078            }
1079        }
1080    }
1081
1082    #[test]
1083    fn decompose_matrix_rejects_empty_matrix() {
1084        use crate::matrix::FdMatrix;
1085        let zero_rows = FdMatrix::from_column_major(vec![], 0, 5).unwrap();
1086        assert!(matches!(
1087            decompose_matrix(
1088                &zero_rows,
1089                WaveletFamily::Haar,
1090                BoundaryMode::Periodic,
1091                None
1092            ),
1093            Err(FdarError::InvalidDimension { .. })
1094        ));
1095        let zero_cols = FdMatrix::from_column_major(vec![], 5, 0).unwrap();
1096        assert!(matches!(
1097            decompose_matrix(
1098                &zero_cols,
1099                WaveletFamily::Haar,
1100                BoundaryMode::Periodic,
1101                None
1102            ),
1103            Err(FdarError::InvalidDimension { .. })
1104        ));
1105    }
1106
1107    #[test]
1108    fn unsupported_order_surfaces_invalid_parameter() {
1109        // from_db_order(11) errors directly.
1110        assert!(matches!(
1111            WaveletFamily::from_db_order(11),
1112            Err(FdarError::InvalidParameter { .. })
1113        ));
1114        // An unsupported family reaching decompose returns InvalidParameter, no panic.
1115        let signal = pseudo_random(64, 1);
1116        assert!(matches!(
1117            decompose(
1118                &signal,
1119                WaveletFamily::Daubechies(11),
1120                BoundaryMode::Periodic,
1121                None
1122            ),
1123            Err(FdarError::InvalidParameter { .. })
1124        ));
1125    }
1126}