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}