Skip to main content

fdars_core/basis/
monomial.rs

1//! Monomial basis: `B_j(t) = t^j` for `j = 0, 1, …, nbasis-1`.
2//!
3//! ## Evaluation matrix
4//!
5//! The evaluation matrix is column-major of shape `(n × nbasis)`:
6//!
7//! ```text
8//! eval_matrix[i + j * n] = argvals[i].powi(j as i32)
9//! ```
10//!
11//! ## Roughness penalty
12//!
13//! The default roughness order is `lfd_order = 2` (curvature).  The penalty
14//! matrix is computed analytically using the exact Gram integral of the
15//! `lfd_order`-th derivative of each basis function.
16//!
17//! For integer exponents `e_i`, `e_j` and domain `[a, b]`:
18//!
19//! ```text
20//! c_i = e_i * (e_i-1) * … * (e_i-d+1)   (falling factorial)
21//! c_j = e_j * (e_j-1) * … * (e_j-d+1)
22//!
23//! R[i,j] = 0                                  if c_i ≈ 0 or c_j ≈ 0
24//!         = c_i * c_j * ln(b/a)               if |e_i + e_j - 2d + 1| < 1e-15
25//!         = c_i * c_j * (b^p - a^p) / p       otherwise, p = e_i + e_j - 2d + 1
26//! ```
27//!
28//! Reference: standard polynomial calculus, same semantics as R's `fda` package
29//! `create.monomial.basis`.
30
31use crate::basis::basis_system::BasisSystem;
32use crate::error::FdarError;
33
34// ─── Public factory ──────────────────────────────────────────────────────────
35
36/// Construct a monomial (polynomial power) basis over `argvals` with `nbasis` functions.
37///
38/// The j-th basis function is `B_j(t) = t^j` for `j = 0, …, nbasis-1`.
39///
40/// Returns a [`BasisSystem`] containing:
41/// - a column-major evaluation matrix of shape `(n × nbasis)`,
42/// - an analytic 2nd-derivative Gram penalty matrix of shape `(nbasis × nbasis)`.
43///
44/// # Errors
45///
46/// - [`FdarError::InvalidDimension`] if `argvals.len() < 2`.
47/// - [`FdarError::InvalidParameter`] if `nbasis < 1`.
48///
49/// # Examples
50///
51/// ```
52/// use fdars_core::monomial_basis;
53///
54/// let t = vec![0.0, 1.0, 2.0];
55/// let bs = monomial_basis(&t, 3).unwrap();
56/// // Column 0: B₀ = 1 → [1, 1, 1]
57/// assert!((bs.eval_matrix[0] - 1.0).abs() < 1e-12);
58/// assert!((bs.eval_matrix[1] - 1.0).abs() < 1e-12);
59/// assert!((bs.eval_matrix[2] - 1.0).abs() < 1e-12);
60/// // Column 1: B₁ = t → [0, 1, 2]
61/// assert!((bs.eval_matrix[3] - 0.0).abs() < 1e-12);
62/// assert!((bs.eval_matrix[4] - 1.0).abs() < 1e-12);
63/// assert!((bs.eval_matrix[5] - 2.0).abs() < 1e-12);
64/// // Column 2: B₂ = t² → [0, 1, 4]
65/// assert!((bs.eval_matrix[6] - 0.0).abs() < 1e-12);
66/// assert!((bs.eval_matrix[7] - 1.0).abs() < 1e-12);
67/// assert!((bs.eval_matrix[8] - 4.0).abs() < 1e-12);
68/// // P[2,2] for exponents [0,1,2], lfd_order=2, domain [0,2]:
69/// // c₂=2, c₂=2, power=2+2-4+1=1, P=2*2*(2^1-0^1)/1 = 8.0
70/// let p22 = bs.penalty_matrix[2 + 2 * 3];
71/// assert!((p22 - 8.0).abs() < 1e-9, "P[2,2]={p22}");
72/// ```
73pub fn monomial_basis(argvals: &[f64], nbasis: usize) -> Result<BasisSystem, FdarError> {
74    let n = argvals.len();
75    if n < 2 {
76        return Err(FdarError::InvalidDimension {
77            parameter: "argvals",
78            expected: ">= 2".to_string(),
79            actual: n.to_string(),
80        });
81    }
82    if nbasis < 1 {
83        return Err(FdarError::InvalidParameter {
84            parameter: "nbasis",
85            message: "must be >= 1".to_string(),
86        });
87    }
88
89    // Build column-major evaluation matrix: eval_matrix[i + j*n] = t^j
90    let mut eval_matrix = vec![0.0_f64; n * nbasis];
91    for (ti, &t) in argvals.iter().enumerate() {
92        for j in 0..nbasis {
93            eval_matrix[ti + j * n] = t.powi(j as i32);
94        }
95    }
96
97    // Compute analytic penalty matrix with lfd_order = 2
98    let lfd_order = 2_usize;
99    let a = argvals[0];
100    let b = argvals[n - 1];
101    let penalty_matrix = monomial_penalty_analytic(nbasis, lfd_order, a, b);
102
103    Ok(BasisSystem {
104        eval_matrix,
105        penalty_matrix,
106        nbasis,
107        n_eval: n,
108        lfd_order,
109    })
110}
111
112// ─── Private helpers ─────────────────────────────────────────────────────────
113
114/// Falling factorial: `e * (e-1) * … * (e-d+1)`.
115/// Returns 1.0 if `d == 0`, and 0.0 if any factor is zero (integer exponent < d).
116fn falling_factorial(e: f64, d: usize) -> f64 {
117    if d == 0 {
118        return 1.0;
119    }
120    let mut acc = 1.0_f64;
121    for k in 0..d {
122        acc *= e - k as f64;
123    }
124    acc
125}
126
127/// Analytic Gram entry for the `d`-th derivative penalty of `t^{e_i}` and `t^{e_j}`
128/// on domain `[a, b]`.
129///
130/// Formula:
131/// ```text
132/// p = e_i + e_j - 2*d + 1
133/// if |p| < 1e-15: c_i * c_j * ln(b/a)
134/// else:           c_i * c_j * (b^p - a^p) / p
135/// ```
136fn gram_entry(ei: f64, ej: f64, d: usize, a: f64, b: f64) -> f64 {
137    let ci = falling_factorial(ei, d);
138    let cj = falling_factorial(ej, d);
139    if ci.abs() < 1e-15 || cj.abs() < 1e-15 {
140        return 0.0;
141    }
142    let p = ei + ej - 2.0 * d as f64 + 1.0;
143    if p.abs() < 1e-15 {
144        // Integral of t^{-1} over [a,b] — requires a > 0
145        if a <= 0.0 {
146            // Integral ∫₀ᵇ t⁻¹ dt is improper — this path is unreachable for the
147            // current lfd_order=2 with non-negative integer exponents (all falling
148            // factorials yield ei,ej >= 2, so p = ei+ej-3 >= 1 > 0).
149            // If lfd_order is ever made user-configurable, this branch WILL be reached
150            // and must return Err, not 0 — the correct value is +∞.
151            debug_assert!(
152                false,
153                "gram_entry: improper integral t^(-1) encountered (a={a}, b={b}); \
154                 penalty result would be wrong if lfd_order < 2"
155            );
156            return 0.0;
157        }
158        ci * cj * (b.ln() - a.ln())
159    } else {
160        ci * cj * (b.powf(p) - a.powf(p)) / p
161    }
162}
163
164/// Build the `nbasis × nbasis` analytic penalty matrix (column-major).
165fn monomial_penalty_analytic(nbasis: usize, lfd_order: usize, a: f64, b: f64) -> Vec<f64> {
166    let mut penalty = vec![0.0_f64; nbasis * nbasis];
167    for j in 0..nbasis {
168        for k in j..nbasis {
169            let val = gram_entry(j as f64, k as f64, lfd_order, a, b);
170            penalty[j + k * nbasis] = val;
171            penalty[k + j * nbasis] = val; // symmetry
172        }
173    }
174    penalty
175}
176
177// ─── Tests ───────────────────────────────────────────────────────────────────
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    /// monomial_basis returns FdarError for argvals.len() < 2.
184    #[test]
185    fn monomial_invalid_argvals_too_short() {
186        let result = monomial_basis(&[0.5], 2);
187        assert!(matches!(result, Err(FdarError::InvalidDimension { .. })));
188    }
189
190    /// monomial_basis returns FdarError for nbasis == 0.
191    #[test]
192    fn monomial_invalid_nbasis_zero() {
193        let result = monomial_basis(&[0.0, 1.0], 0);
194        assert!(matches!(result, Err(FdarError::InvalidParameter { .. })));
195    }
196
197    /// Closed-form eval: t=[0,1,2], nbasis=3 → cols [1,1,1], [0,1,2], [0,1,4].
198    ///
199    /// Column-major layout: element (ti, j) is at index ti + j * n_eval.
200    #[test]
201    fn monomial_eval_matrix_closed_form() {
202        let t = vec![0.0, 1.0, 2.0];
203        let bs = monomial_basis(&t, 3).unwrap();
204        let n = bs.n_eval; // == 3
205                           // col 0: B₀(t) = 1 — indices 0, 1, 2
206        assert!((bs.eval_matrix[0] - 1.0).abs() < 1e-12, "B₀(0)");
207        assert!((bs.eval_matrix[1] - 1.0).abs() < 1e-12, "B₀(1)");
208        assert!((bs.eval_matrix[2] - 1.0).abs() < 1e-12, "B₀(2)");
209        // col 1: B₁(t) = t — indices n, n+1, n+2
210        assert!((bs.eval_matrix[n] - 0.0).abs() < 1e-12, "B₁(0)");
211        assert!((bs.eval_matrix[n + 1] - 1.0).abs() < 1e-12, "B₁(1)");
212        assert!((bs.eval_matrix[n + 2] - 2.0).abs() < 1e-12, "B₁(2)");
213        // col 2: B₂(t) = t² — indices 2*n, 2*n+1, 2*n+2
214        assert!((bs.eval_matrix[2 * n] - 0.0).abs() < 1e-12, "B₂(0)");
215        assert!((bs.eval_matrix[2 * n + 1] - 1.0).abs() < 1e-12, "B₂(1)");
216        assert!((bs.eval_matrix[2 * n + 2] - 4.0).abs() < 1e-12, "B₂(2)");
217    }
218
219    /// eval_matrix shape invariant.
220    #[test]
221    fn monomial_eval_matrix_shape() {
222        let t = vec![0.0, 0.5, 1.0];
223        let bs = monomial_basis(&t, 4).unwrap();
224        assert_eq!(bs.eval_matrix.len(), 3 * 4);
225        assert_eq!(bs.penalty_matrix.len(), 4 * 4);
226        assert_eq!(bs.nbasis, 4);
227        assert_eq!(bs.n_eval, 3);
228        assert_eq!(bs.lfd_order, 2);
229    }
230
231    /// Penalty P[2,2] == 4.0 for exponents [0,1,2], lfd_order=2, domain [0,1].
232    #[test]
233    fn monomial_penalty_p22_standard_domain() {
234        let t = vec![0.0, 0.5, 1.0];
235        let bs = monomial_basis(&t, 3).unwrap();
236        let k = 3;
237        let p22 = bs.penalty_matrix[2 + 2 * k];
238        // c₂ = 2, c₂ = 2; power = 2+2-4+1 = 1; P = 2*2*(1^1-0^1)/1 = 4.0
239        assert!((p22 - 4.0).abs() < 1e-9, "P[2,2] = {p22}, expected 4.0");
240    }
241
242    /// Penalty is symmetric.
243    #[test]
244    fn monomial_penalty_symmetry() {
245        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
246        let bs = monomial_basis(&t, 5).unwrap();
247        let k = 5;
248        for j in 0..k {
249            for l in 0..k {
250                let pjl = bs.penalty_matrix[j + l * k];
251                let plj = bs.penalty_matrix[l + j * k];
252                assert!(
253                    (pjl - plj).abs() < 1e-12,
254                    "P[{j},{l}]={pjl} != P[{l},{j}]={plj}"
255                );
256            }
257        }
258    }
259
260    /// Penalty diagonal entries are non-negative (PSD check).
261    #[test]
262    fn monomial_penalty_diagonal_psd() {
263        let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
264        let bs = monomial_basis(&t, 6).unwrap();
265        let k = 6;
266        for j in 0..k {
267            let diag = bs.penalty_matrix[j + j * k];
268            assert!(diag >= -1e-10, "P[{j},{j}]={diag} is negative");
269        }
270    }
271
272    /// P[0,0] == 0 and P[1,1] == 0 for lfd_order=2 (D²(1)=0, D²(t)=0).
273    ///
274    /// Column-major layout: P[j,k] at index j + k * nbasis.
275    #[test]
276    fn monomial_penalty_low_exponents_zero() {
277        let t = vec![0.0, 0.5, 1.0];
278        let bs = monomial_basis(&t, 3).unwrap();
279        let k = bs.nbasis; // == 3
280                           // P[0,0] at index 0 + 0*k = 0
281        assert!(bs.penalty_matrix[0].abs() < 1e-12, "P[0,0] should be 0");
282        // P[1,1] at index 1 + 1*k = 1 + k
283        assert!(bs.penalty_matrix[1 + k].abs() < 1e-12, "P[1,1] should be 0");
284    }
285
286    /// BasisSystem derives Debug, Clone, PartialEq.
287    #[test]
288    fn basis_system_derives() {
289        let t = vec![0.0, 1.0];
290        let bs = monomial_basis(&t, 2).unwrap();
291        let bs2 = bs.clone();
292        assert_eq!(bs, bs2);
293        let _ = format!("{bs:?}"); // Debug
294    }
295}