fdars_core/autodiff.rs
1//! In-crate forward-mode automatic differentiation (AD) substrate.
2//!
3//! This module provides the numeric substrate for forward-mode automatic
4//! differentiation used by the differentiable FDA subset. It defines a
5//! [`Scalar`] trait bounding the arithmetic and transcendental operations a
6//! differentiable computation needs, a forward-mode [`Dual`] number carrying a
7//! value (primal) and a tangent (directional derivative), and a
8//! zero-cost [`Scalar`] implementation for `f64`.
9//!
10//! # Design invariants
11//!
12//! - **No external dependency.** The [`Scalar`] trait is defined entirely
13//! in-crate. The `num-traits` crate is a *transitive-only* dependency of
14//! `fdars-core` and cannot be `use`d without a `Cargo.toml` change, which
15//! would violate the no-new-dependency constraint. This module therefore
16//! never imports it.
17//! - **Additive / non-breaking.** `Scalar` is implemented for `f64` as a
18//! zero-cost passthrough to the inherent `f64` methods, so generic code
19//! instantiated at `f64` is numerically identical to plain `f64` code.
20//! - **Value-only ordering for `Dual`.** [`Dual`]'s [`PartialOrd`] compares the
21//! `value` (primal) field ONLY. This is the correct forward-mode branching
22//! semantics: control-flow decisions are made on the primal while tangents
23//! propagate through the selected branch. Deriving `PartialOrd` would use the
24//! tangent as a lexicographic tiebreaker and corrupt those semantics.
25//!
26//! # Forward-mode in one line
27//!
28//! Seed an input's tangent to `1.0` (via [`Dual::seed`]), run a computation
29//! written against [`Scalar`], and [`extract`](Dual::extract) the
30//! `(value, derivative)` pair. The [`diff`] helper wraps this pattern.
31//!
32//! ```
33//! use fdars_core::autodiff::{diff, Dual, Scalar};
34//!
35//! // f(x) = x^2, f'(x) = 2x. At x = 3: f = 9, f' = 6.
36//! let (value, deriv) = diff(|x| x * x, 3.0);
37//! assert!((value - 9.0).abs() < 1e-12);
38//! assert!((deriv - 6.0).abs() < 1e-12);
39//! ```
40//!
41//! # Composing differentiable ops
42//!
43//! [`grad`] flows a gradient through a composition of the crate's
44//! `Scalar`-generic differentiable ops. Here one scalar objective composes a
45//! soft-DTW distance and an FPCA-score projection, then `grad` returns the
46//! objective value and its full gradient w.r.t. the input curve's samples.
47//!
48//! ```
49//! use fdars_core::prelude::*;
50//! use fdars_core::regression::fdata_to_pc_1d;
51//!
52//! // Small trained FPCA model (mirrors regression::fdata_to_pc_1d usage).
53//! let m = 10usize;
54//! let n = 12usize;
55//! let argvals: Vec<f64> = (0..m).map(|j| 0.1 + 0.8 * j as f64 / (m - 1) as f64).collect();
56//! let mut raw = vec![0.0f64; n * m];
57//! for i in 0..n {
58//! for (j, &t) in argvals.iter().enumerate() {
59//! let phase = i as f64 * 0.3;
60//! raw[i + j * n] = (std::f64::consts::PI * t + phase).sin()
61//! + 0.5 * (2.0 * std::f64::consts::PI * t).cos();
62//! }
63//! }
64//! let data = FdMatrix::from_column_major(raw, n, m).unwrap();
65//! let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
66//!
67//! // Objective: soft-DTW(curve, reference) + sum of squared FPCA scores.
68//! let reference: Vec<Dual> = argvals
69//! .iter()
70//! .map(|&t| Dual::constant((std::f64::consts::PI * t).sin()))
71//! .collect();
72//! let curve: Vec<f64> = argvals
73//! .iter()
74//! .map(|&t| (std::f64::consts::PI * t).cos())
75//! .collect();
76//!
77//! let objective = |c: &[Dual]| -> Dual {
78//! let sdtw = soft_dtw_distance_generic(c, &reference, 0.1);
79//! let scores = project_scores_generic(c, &fpca.mean, &fpca.rotation, &fpca.weights, 2);
80//! let mut acc = Dual::constant(0.0);
81//! for s in &scores {
82//! acc += *s * *s;
83//! }
84//! sdtw + acc
85//! };
86//!
87//! let (value, gradient) = grad(objective, &curve);
88//! assert_eq!(gradient.len(), m);
89//! assert!(value.is_finite());
90//! ```
91
92use std::fmt::Debug;
93use std::ops::{Add, AddAssign, Div, Mul, MulAssign, Neg, Sub, SubAssign};
94
95/// Numeric substrate for forward-mode automatic differentiation.
96///
97/// A `Scalar` provides the arithmetic and transcendental operations a
98/// differentiable computation is written against. It is implemented for `f64`
99/// (a zero-cost passthrough) and for [`Dual`] (which propagates tangents via
100/// the chain rule).
101///
102/// # Domain restrictions
103///
104/// The transcendental methods inherit the domain restrictions of the
105/// underlying `f64` operations. In particular [`sqrt`](Scalar::sqrt) and
106/// [`ln`](Scalar::ln) require an in-domain (non-negative / positive) value, and
107/// [`powf`](Scalar::powf) can produce `NaN`/`Inf` tangents at `value == 0.0`
108/// with `p < 1.0`. Out-of-domain inputs propagate `NaN`/`Inf` exactly as they
109/// do for plain `f64`; callers own range checking.
110pub trait Scalar:
111 Copy
112 + Clone
113 + Debug
114 + PartialOrd
115 + Add<Output = Self>
116 + Sub<Output = Self>
117 + Mul<Output = Self>
118 + Div<Output = Self>
119 + Neg<Output = Self>
120 + AddAssign
121 + SubAssign
122 + MulAssign
123{
124 /// The additive identity (`0`).
125 fn zero() -> Self;
126 /// The multiplicative identity (`1`).
127 fn one() -> Self;
128 /// Construct a constant from an `f64` (tangent `0` for [`Dual`]).
129 fn from_f64(v: f64) -> Self;
130 /// Positive infinity sentinel (used e.g. for DP recurrence initialization).
131 fn infinity() -> Self;
132
133 /// Square root. Requires a non-negative value; the derivative diverges at 0.
134 fn sqrt(self) -> Self;
135 /// Natural exponential.
136 fn exp(self) -> Self;
137 /// Natural logarithm. Requires a strictly positive value.
138 fn ln(self) -> Self;
139 /// Sine.
140 fn sin(self) -> Self;
141 /// Cosine.
142 fn cos(self) -> Self;
143 /// Raise to a concrete `f64` power. The tangent uses `p * v^(p-1)`; at
144 /// `value == 0.0` with `p < 1.0` this may be `NaN`/`Inf`.
145 fn powf(self, p: f64) -> Self;
146 /// Absolute value. The tangent uses the subdifferential convention
147 /// `d/dx |v| = signum(v)`, with the honest at-zero selection
148 /// `signum(0) = 0`: at exactly `value == 0.0` the tangent is `0.0` (the
149 /// midpoint of the subdifferential `[-1, 1]`). The *value* is `v.abs()`
150 /// (bit-for-bit `f64` parity).
151 fn abs(self) -> Self;
152 /// Sign. Piecewise-constant, so the tangent is `0.0` everywhere. The
153 /// *value* is `f64::signum(v)` (`+1.0` at `+0.0`, `-1.0` at `-0.0`) to
154 /// preserve `f64` parity.
155 fn signum(self) -> Self;
156}
157
158impl Scalar for f64 {
159 #[inline]
160 fn zero() -> Self {
161 0.0
162 }
163 #[inline]
164 fn one() -> Self {
165 1.0
166 }
167 #[inline]
168 fn from_f64(v: f64) -> Self {
169 v
170 }
171 #[inline]
172 fn infinity() -> Self {
173 f64::INFINITY
174 }
175
176 #[inline]
177 fn sqrt(self) -> Self {
178 f64::sqrt(self)
179 }
180 #[inline]
181 fn exp(self) -> Self {
182 f64::exp(self)
183 }
184 #[inline]
185 fn ln(self) -> Self {
186 f64::ln(self)
187 }
188 #[inline]
189 fn sin(self) -> Self {
190 f64::sin(self)
191 }
192 #[inline]
193 fn cos(self) -> Self {
194 f64::cos(self)
195 }
196 #[inline]
197 fn powf(self, p: f64) -> Self {
198 f64::powf(self, p)
199 }
200 #[inline]
201 fn abs(self) -> Self {
202 f64::abs(self)
203 }
204 #[inline]
205 fn signum(self) -> Self {
206 f64::signum(self)
207 }
208}
209
210/// A forward-mode dual number: a value (primal) paired with a tangent
211/// (directional derivative).
212///
213/// Running a computation written against [`Scalar`] on `Dual` propagates the
214/// derivative through every operation via the chain rule. Seed an input's
215/// tangent to `1.0` with [`Dual::seed`], then [`extract`](Dual::extract) the
216/// `(value, derivative)` pair.
217///
218/// Both `PartialEq` and `PartialOrd` compare the `value` (primal) field only,
219/// so equality and ordering agree on the "primal decides control flow"
220/// semantics (see the module-level docs). Two `Dual`s with equal value but
221/// different tangents therefore compare *equal* and *unordered-as-Equal*; this
222/// keeps `a == b ⟺ a.partial_cmp(&b) == Some(Equal)` — the std contract that a
223/// derived (both-field) `PartialEq` would violate against the value-only
224/// `PartialOrd`.
225#[derive(Debug, Clone, Copy)]
226pub struct Dual {
227 /// The primal value of the computation.
228 pub value: f64,
229 /// The tangent (directional derivative) accumulated by the chain rule.
230 pub tangent: f64,
231}
232
233impl Dual {
234 /// Seed an independent variable: `value = x`, `tangent = 1.0`.
235 ///
236 /// Use this on the input you are differentiating with respect to.
237 #[inline]
238 #[must_use]
239 pub fn seed(value: f64) -> Self {
240 Dual {
241 value,
242 tangent: 1.0,
243 }
244 }
245
246 /// A constant: `value = x`, `tangent = 0.0` (no gradient flows through it).
247 #[inline]
248 #[must_use]
249 pub fn constant(value: f64) -> Self {
250 Dual {
251 value,
252 tangent: 0.0,
253 }
254 }
255
256 /// Extract the `(value, derivative)` pair after a computation.
257 #[inline]
258 #[must_use]
259 pub fn extract(self) -> (f64, f64) {
260 (self.value, self.tangent)
261 }
262}
263
264impl Add for Dual {
265 type Output = Self;
266 #[inline]
267 fn add(self, rhs: Self) -> Self {
268 Dual {
269 value: self.value + rhs.value,
270 tangent: self.tangent + rhs.tangent,
271 }
272 }
273}
274
275impl Sub for Dual {
276 type Output = Self;
277 #[inline]
278 fn sub(self, rhs: Self) -> Self {
279 Dual {
280 value: self.value - rhs.value,
281 tangent: self.tangent - rhs.tangent,
282 }
283 }
284}
285
286impl Mul for Dual {
287 type Output = Self;
288 #[inline]
289 fn mul(self, rhs: Self) -> Self {
290 // Product rule: d(u*v) = u'*v + u*v'
291 Dual {
292 value: self.value * rhs.value,
293 tangent: self.tangent * rhs.value + self.value * rhs.tangent,
294 }
295 }
296}
297
298impl Div for Dual {
299 type Output = Self;
300 #[inline]
301 fn div(self, rhs: Self) -> Self {
302 // Quotient rule: d(u/v) = (u'*v - u*v') / v^2
303 let v2 = rhs.value * rhs.value;
304 Dual {
305 value: self.value / rhs.value,
306 tangent: (self.tangent * rhs.value - self.value * rhs.tangent) / v2,
307 }
308 }
309}
310
311impl Neg for Dual {
312 type Output = Self;
313 #[inline]
314 fn neg(self) -> Self {
315 Dual {
316 value: -self.value,
317 tangent: -self.tangent,
318 }
319 }
320}
321
322impl AddAssign for Dual {
323 #[inline]
324 fn add_assign(&mut self, rhs: Self) {
325 *self = *self + rhs;
326 }
327}
328
329impl SubAssign for Dual {
330 #[inline]
331 fn sub_assign(&mut self, rhs: Self) {
332 *self = *self - rhs;
333 }
334}
335
336impl MulAssign for Dual {
337 #[inline]
338 fn mul_assign(&mut self, rhs: Self) {
339 *self = *self * rhs;
340 }
341}
342
343// Hand-written value-only equality, matching the value-only `PartialOrd` below.
344// Deriving `PartialEq` would compare the tangent too, breaking the std contract
345// `a == b ⟺ a.partial_cmp(&b) == Some(Equal)` for equal-value/different-tangent
346// Duals. Equality is primal-value-based (branch semantics).
347impl PartialEq for Dual {
348 #[inline]
349 fn eq(&self, other: &Self) -> bool {
350 self.value == other.value
351 }
352}
353
354// Hand-written value-only ordering. Do NOT `#[derive(PartialOrd)]`: derive would
355// use the tangent as a lexicographic tiebreaker, corrupting forward-mode branch
356// semantics (control flow must be decided by the primal value alone).
357impl PartialOrd for Dual {
358 #[inline]
359 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
360 self.value.partial_cmp(&other.value)
361 }
362}
363
364impl Scalar for Dual {
365 #[inline]
366 fn zero() -> Self {
367 Dual {
368 value: 0.0,
369 tangent: 0.0,
370 }
371 }
372 #[inline]
373 fn one() -> Self {
374 Dual {
375 value: 1.0,
376 tangent: 0.0,
377 }
378 }
379 #[inline]
380 fn from_f64(v: f64) -> Self {
381 Dual {
382 value: v,
383 tangent: 0.0,
384 }
385 }
386 #[inline]
387 fn infinity() -> Self {
388 Dual {
389 value: f64::INFINITY,
390 tangent: 0.0,
391 }
392 }
393
394 #[inline]
395 fn sqrt(self) -> Self {
396 // d/dx sqrt(v) = 1 / (2*sqrt(v))
397 let s = self.value.sqrt();
398 Dual {
399 value: s,
400 tangent: self.tangent / (2.0 * s),
401 }
402 }
403 #[inline]
404 fn exp(self) -> Self {
405 // d/dx exp(v) = exp(v)
406 let e = self.value.exp();
407 Dual {
408 value: e,
409 tangent: self.tangent * e,
410 }
411 }
412 #[inline]
413 fn ln(self) -> Self {
414 // d/dx ln(v) = 1/v
415 Dual {
416 value: self.value.ln(),
417 tangent: self.tangent / self.value,
418 }
419 }
420 #[inline]
421 fn sin(self) -> Self {
422 // d/dx sin(v) = cos(v)
423 Dual {
424 value: self.value.sin(),
425 tangent: self.tangent * self.value.cos(),
426 }
427 }
428 #[inline]
429 fn cos(self) -> Self {
430 // d/dx cos(v) = -sin(v)
431 Dual {
432 value: self.value.cos(),
433 tangent: -self.tangent * self.value.sin(),
434 }
435 }
436 #[inline]
437 fn powf(self, p: f64) -> Self {
438 // d/dx v^p = p * v^(p-1)
439 Dual {
440 value: self.value.powf(p),
441 tangent: self.tangent * p * self.value.powf(p - 1.0),
442 }
443 }
444 #[inline]
445 fn abs(self) -> Self {
446 // Subdifferential convention: d/dx |v| = signum(v), with the honest
447 // at-zero selection signum(0) = 0 (f64::signum returns ±1 at zero, so
448 // special-case exact zero). Value stays v.abs() for f64 parity.
449 let sub = if self.value == 0.0 {
450 0.0
451 } else {
452 self.value.signum()
453 };
454 Dual {
455 value: self.value.abs(),
456 tangent: self.tangent * sub,
457 }
458 }
459 #[inline]
460 fn signum(self) -> Self {
461 // Piecewise-constant: derivative is 0 everywhere. Value uses
462 // f64::signum for parity (returns ±1 at zero, only NaN yields NaN).
463 Dual {
464 value: self.value.signum(),
465 tangent: 0.0,
466 }
467 }
468}
469
470/// Compute `f(x)` and `f'(x)` in one forward-mode pass.
471///
472/// Seeds `x` (tangent `1.0`), runs `f`, and returns the extracted
473/// `(value, derivative)` pair.
474///
475/// ```
476/// use fdars_core::autodiff::{diff, Scalar};
477///
478/// // d/dx exp(x) at x = 0 is 1.
479/// let (v, d) = diff(|x| Scalar::exp(x), 0.0);
480/// assert!((v - 1.0).abs() < 1e-12);
481/// assert!((d - 1.0).abs() < 1e-12);
482/// ```
483#[must_use]
484pub fn diff<F: Fn(Dual) -> Dual>(f: F, x: f64) -> (f64, f64) {
485 f(Dual::seed(x)).extract()
486}
487
488/// Compute a scalar objective's value and its full gradient over an `m`-vector
489/// input, in `m` forward-mode passes (one per input).
490///
491/// This is the multi-input generalization of [`diff`]: for each input index
492/// `k`, it builds the argument vector where element `k` is
493/// [`Dual::seed`]ed (tangent `1.0`, the variable being differentiated) and every
494/// other element `j` is a [`Dual::constant`] (tangent `0.0`), runs `f`, and
495/// records the tangent of the result as `gradient[k]`. The primal `value` is
496/// identical across passes (only tangents differ), so it is captured once.
497///
498/// Returns `(value, gradient)` where `gradient.len() == x.len()`. An empty
499/// input yields `(f(&[]).value, Vec::new())`.
500///
501/// ```
502/// use fdars_core::autodiff::{grad, Dual, Scalar};
503///
504/// // f(x) = x0^2 + x1^2. Gradient = [2*x0, 2*x1]. At [3, 4]: value 25, grad [6, 8].
505/// let (value, gradient) = grad(|x| x[0] * x[0] + x[1] * x[1], &[3.0, 4.0]);
506/// assert!((value - 25.0).abs() < 1e-12);
507/// assert!((gradient[0] - 6.0).abs() < 1e-12);
508/// assert!((gradient[1] - 8.0).abs() < 1e-12);
509/// ```
510#[must_use]
511pub fn grad<F: Fn(&[Dual]) -> Dual>(f: F, x: &[f64]) -> (f64, Vec<f64>) {
512 let m = x.len();
513 if m == 0 {
514 return (f(&[]).value, Vec::new());
515 }
516 let mut gradient = vec![0.0; m];
517 let mut value = 0.0;
518 for k in 0..m {
519 let duals: Vec<Dual> = (0..m)
520 .map(|j| {
521 if j == k {
522 Dual::seed(x[j])
523 } else {
524 Dual::constant(x[j])
525 }
526 })
527 .collect();
528 let (v, t) = f(&duals).extract();
529 if k == 0 {
530 value = v;
531 }
532 gradient[k] = t;
533 }
534 (value, gradient)
535}
536
537/// Compute a vector-valued map's values and its full Jacobian over an `m`-vector
538/// input, in `m` forward-mode passes (one per input).
539///
540/// `f` returns a length-`n` `Vec<Dual>` (the outputs). Seeding input `k` in turn
541/// (as in [`grad`]) fills column `k` of the returned `n × m` Jacobian, where
542/// `jacobian[i][k] = d(output_i)/d(x[k])`. Output primal values are captured on
543/// the first pass.
544///
545/// Returns `(values, jacobian)` with `values.len() == n`, `jacobian.len() == n`,
546/// and each row of length `m`. An empty input yields `(values, empty rows)`.
547///
548/// ```
549/// use fdars_core::autodiff::{jacobian, Dual};
550///
551/// // f(x) = [x0*x1, x0 + x1]. J = [[x1, x0], [1, 1]]. At [2, 3]: [[3, 2], [1, 1]].
552/// let (values, j) = jacobian(|x| vec![x[0] * x[1], x[0] + x[1]], &[2.0, 3.0]);
553/// assert!((values[0] - 6.0).abs() < 1e-12);
554/// assert!((values[1] - 5.0).abs() < 1e-12);
555/// assert!((j[0][0] - 3.0).abs() < 1e-12 && (j[0][1] - 2.0).abs() < 1e-12);
556/// assert!((j[1][0] - 1.0).abs() < 1e-12 && (j[1][1] - 1.0).abs() < 1e-12);
557/// ```
558#[must_use]
559pub fn jacobian<F: Fn(&[Dual]) -> Vec<Dual>>(f: F, x: &[f64]) -> (Vec<f64>, Vec<Vec<f64>>) {
560 let m = x.len();
561 if m == 0 {
562 let outputs = f(&[]);
563 let values: Vec<f64> = outputs.iter().map(|d| d.value).collect();
564 let rows = values.len();
565 return (values, vec![Vec::new(); rows]);
566 }
567 let mut values: Vec<f64> = Vec::new();
568 let mut jac: Vec<Vec<f64>> = Vec::new();
569 for k in 0..m {
570 let duals: Vec<Dual> = (0..m)
571 .map(|j| {
572 if j == k {
573 Dual::seed(x[j])
574 } else {
575 Dual::constant(x[j])
576 }
577 })
578 .collect();
579 let outputs = f(&duals);
580 if k == 0 {
581 values = outputs.iter().map(|d| d.value).collect();
582 jac = vec![vec![0.0; m]; outputs.len()];
583 }
584 for (i, out) in outputs.iter().enumerate() {
585 jac[i][k] = out.tangent;
586 }
587 }
588 (values, jac)
589}
590
591/// Compute a scalar objective's value and its directional derivative along a
592/// supplied `direction`, in a SINGLE forward-mode pass.
593///
594/// Each input `j` is lifted to `Dual { value: x[j], tangent: direction[j] }`, so
595/// the returned tangent is `∇f(x) · direction`. Requires
596/// `direction.len() == x.len()` (asserted).
597///
598/// # Panics
599///
600/// Panics if `direction.len() != x.len()`.
601///
602/// ```
603/// use fdars_core::autodiff::{directional_derivative, Dual};
604///
605/// // f(x) = x0^2 + x1^2, ∇f = [2*x0, 2*x1]. At [1, 2] along [1, 0]: dir-deriv = 2.
606/// let (value, dd) = directional_derivative(|x| x[0] * x[0] + x[1] * x[1], &[1.0, 2.0], &[1.0, 0.0]);
607/// assert!((value - 5.0).abs() < 1e-12);
608/// assert!((dd - 2.0).abs() < 1e-12);
609/// ```
610#[must_use]
611pub fn directional_derivative<F: Fn(&[Dual]) -> Dual>(
612 f: F,
613 x: &[f64],
614 direction: &[f64],
615) -> (f64, f64) {
616 assert_eq!(
617 direction.len(),
618 x.len(),
619 "direction length must match input length"
620 );
621 let duals: Vec<Dual> = x
622 .iter()
623 .zip(direction.iter())
624 .map(|(&value, &tangent)| Dual { value, tangent })
625 .collect();
626 f(&duals).extract()
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use std::f64::consts::PI;
633
634 const TOL: f64 = 1e-10;
635
636 // ---------------------------------------------------------------------
637 // Tier 1: known-answer derivatives, tolerance 1e-10 (one per op).
638 // ---------------------------------------------------------------------
639
640 #[test]
641 fn dual_mul_known_answer() {
642 // f(x) = x^2, f'(x) = 2x. At x = 3: f = 9, f' = 6.
643 let d = Dual::seed(3.0);
644 let r = d * d;
645 assert!((r.value - 9.0).abs() < TOL, "primal {} != 9.0", r.value);
646 assert!(
647 (r.tangent - 6.0).abs() < TOL,
648 "tangent {} != 6.0",
649 r.tangent
650 );
651 }
652
653 #[test]
654 fn dual_sqrt_known_answer() {
655 // f(x) = sqrt(x), f'(x) = 1/(2 sqrt(x)). At x = 4: f = 2, f' = 0.25.
656 let r = Scalar::sqrt(Dual::seed(4.0));
657 assert!((r.value - 2.0).abs() < TOL);
658 assert!((r.tangent - 0.25).abs() < TOL);
659 }
660
661 #[test]
662 fn dual_exp_known_answer() {
663 // f(x) = exp(x), f'(x) = exp(x). At x = 1: both = e.
664 let e = std::f64::consts::E;
665 let r = Scalar::exp(Dual::seed(1.0));
666 assert!((r.value - e).abs() < TOL);
667 assert!((r.tangent - e).abs() < TOL);
668 }
669
670 #[test]
671 fn dual_ln_known_answer() {
672 // f(x) = ln(x), f'(x) = 1/x. At x = 2: f = ln 2, f' = 0.5.
673 let r = Scalar::ln(Dual::seed(2.0));
674 assert!((r.value - 2.0_f64.ln()).abs() < TOL);
675 assert!((r.tangent - 0.5).abs() < TOL);
676 }
677
678 #[test]
679 fn dual_sin_known_answer() {
680 // f(x) = sin(x), f'(x) = cos(x). At x = PI/4: both = sqrt(2)/2.
681 let expected = 2.0_f64.sqrt() / 2.0;
682 let r = Scalar::sin(Dual::seed(PI / 4.0));
683 assert!((r.value - expected).abs() < TOL);
684 assert!((r.tangent - expected).abs() < TOL);
685 }
686
687 #[test]
688 fn dual_cos_known_answer() {
689 // f(x) = cos(x), f'(x) = -sin(x). At x = PI/4: value sqrt(2)/2, deriv -sqrt(2)/2.
690 let expected = 2.0_f64.sqrt() / 2.0;
691 let r = Scalar::cos(Dual::seed(PI / 4.0));
692 assert!((r.value - expected).abs() < TOL);
693 assert!((r.tangent + expected).abs() < TOL);
694 }
695
696 #[test]
697 fn dual_powf_known_answer() {
698 // f(x) = x^1.5, f'(x) = 1.5 x^0.5. At x = 4: f = 8, f' = 1.5*2 = 3.
699 let r = Scalar::powf(Dual::seed(4.0), 1.5);
700 assert!((r.value - 8.0).abs() < TOL);
701 assert!((r.tangent - 3.0).abs() < TOL);
702 }
703
704 #[test]
705 fn dual_abs_known_answer() {
706 // f(x) = |x|, f'(x) = signum(x). At x = 2: f = 2, f' = 1. (Not probed at 0.)
707 let r = Scalar::abs(Dual::seed(2.0));
708 assert!((r.value - 2.0).abs() < TOL);
709 assert!((r.tangent - 1.0).abs() < TOL);
710 // And on the negative branch.
711 let rn = Scalar::abs(Dual::seed(-3.0));
712 assert!((rn.value - 3.0).abs() < TOL);
713 assert!((rn.tangent + 1.0).abs() < TOL);
714 }
715
716 #[test]
717 fn dual_sub_div_neg_known_answer() {
718 // f(x) = (x - 1) / 2, f'(x) = 0.5.
719 let d = Dual::seed(5.0);
720 let r = (d - Dual::constant(1.0)) / Dual::constant(2.0);
721 assert!((r.value - 2.0).abs() < TOL);
722 assert!((r.tangent - 0.5).abs() < TOL);
723 // f(x) = -x, f'(x) = -1.
724 let n = -Dual::seed(7.0);
725 assert!((n.value + 7.0).abs() < TOL);
726 assert!((n.tangent + 1.0).abs() < TOL);
727 }
728
729 #[test]
730 fn dual_assign_ops() {
731 // += , -=, *= must compose the same chain rules as their binary forms.
732 let mut acc = Dual::constant(0.0);
733 let x = Dual::seed(2.0);
734 acc += x; // acc = x -> value 2, tangent 1
735 acc *= x; // acc = x^2 -> value 4, tangent 4 (2*x*x')
736 acc -= Dual::constant(1.0); // acc = x^2 - 1 -> value 3, tangent 4
737 assert!((acc.value - 3.0).abs() < TOL);
738 assert!((acc.tangent - 4.0).abs() < TOL);
739 }
740
741 #[test]
742 fn dual_composed_chain_known_answer() {
743 // f(x) = sqrt(exp(x)*sin(x) + x^2). Make-or-break composed chain.
744 // f'(x) = (1/(2 f(x))) * (e^x*(sin x + cos x) + 2x).
745 let x0 = 1.0_f64;
746 let (value, deriv) = diff(
747 |x| {
748 let e = Scalar::exp(x);
749 let s = Scalar::sin(x);
750 let x2 = x * x;
751 Scalar::sqrt(e * s + x2)
752 },
753 x0,
754 );
755 let f = (x0.exp() * x0.sin() + x0 * x0).sqrt();
756 let expected_deriv = (1.0 / (2.0 * f)) * (x0.exp() * (x0.sin() + x0.cos()) + 2.0 * x0);
757 assert!((value - f).abs() < TOL, "value {value} != {f}");
758 assert!(
759 (deriv - expected_deriv).abs() < TOL,
760 "deriv {deriv} != {expected_deriv}"
761 );
762 }
763
764 #[test]
765 fn dual_partial_cmp_value_only() {
766 use std::cmp::Ordering;
767 // Equal value, different tangent -> compares Equal (value-only).
768 let a = Dual {
769 value: 1.0,
770 tangent: 0.5,
771 };
772 let b = Dual {
773 value: 1.0,
774 tangent: 0.3,
775 };
776 assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
777 // Smaller value is Less regardless of tangent.
778 let small = Dual {
779 value: 0.5,
780 tangent: 99.0,
781 };
782 let big = Dual {
783 value: 2.0,
784 tangent: -99.0,
785 };
786 assert_eq!(small.partial_cmp(&big), Some(Ordering::Less));
787 assert!(small < big);
788 }
789
790 #[test]
791 fn dual_eq_is_value_only_and_consistent_with_ord() {
792 use std::cmp::Ordering;
793 // Equal value, different tangent: PartialEq (value-only) says equal,
794 // and this must agree with PartialOrd == Some(Equal) (std contract).
795 let a = Dual {
796 value: 1.0,
797 tangent: 0.5,
798 };
799 let b = Dual {
800 value: 1.0,
801 tangent: -7.0,
802 };
803 assert_eq!(a, b);
804 assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
805 // Different value: not equal.
806 let c = Dual {
807 value: 2.0,
808 tangent: 0.5,
809 };
810 assert_ne!(a, c);
811 }
812
813 #[test]
814 fn dual_abs_at_zero_tangent_is_zero() {
815 // HI-01: honest subdifferential selection signum(0) = 0, so abs of a
816 // Dual seeded at exactly 0.0 yields tangent 0.0 (not ±1 from f64::signum).
817 let r = Scalar::abs(Dual::seed(0.0));
818 assert_eq!(r.value, 0.0);
819 assert_eq!(r.tangent, 0.0);
820 // Negative-zero primal also selects the 0 subgradient.
821 let rn = Scalar::abs(Dual::seed(-0.0));
822 assert_eq!(rn.value, 0.0);
823 assert_eq!(rn.tangent, 0.0);
824 }
825
826 #[test]
827 fn dual_signum_tangent_is_zero_value_is_f64_signum() {
828 // Tangent is 0 everywhere; value preserves f64::signum parity (±1 at 0).
829 let rp = Scalar::signum(Dual::seed(3.0));
830 assert_eq!(rp.value, 1.0);
831 assert_eq!(rp.tangent, 0.0);
832 let rn = Scalar::signum(Dual::seed(-3.0));
833 assert_eq!(rn.value, -1.0);
834 assert_eq!(rn.tangent, 0.0);
835 // f64::signum returns +1.0 at +0.0 (value parity), tangent still 0.
836 let rz = Scalar::signum(Dual::seed(0.0));
837 assert_eq!(rz.value, 1.0);
838 assert_eq!(rz.tangent, 0.0);
839 }
840
841 // ---------------------------------------------------------------------
842 // Guard tests: lock documented singular-point behavior (LO-02, LO-03).
843 // These pin the "callers own range checking" contract so a future refactor
844 // (e.g. clamping a denominator) cannot silently change the singular result.
845 // ---------------------------------------------------------------------
846
847 #[test]
848 fn dual_sqrt_at_zero_tangent_is_nonfinite() {
849 // LO-02: sqrt(0) tangent = 1/(2*0) diverges (non-finite).
850 let r = Scalar::sqrt(Dual::seed(0.0));
851 assert_eq!(r.value, 0.0);
852 assert!(
853 !r.tangent.is_finite(),
854 "tangent {} should be non-finite",
855 r.tangent
856 );
857 }
858
859 #[test]
860 fn dual_ln_at_zero_tangent_is_nonfinite() {
861 // LO-02: ln(0) value = -inf, tangent = 1/0 diverges (non-finite).
862 let r = Scalar::ln(Dual::seed(0.0));
863 assert!(r.value.is_infinite() && r.value < 0.0);
864 assert!(
865 !r.tangent.is_finite(),
866 "tangent {} should be non-finite",
867 r.tangent
868 );
869 }
870
871 #[test]
872 fn dual_powf_singular_and_linear_edges() {
873 // LO-03: powf(0, 0.5) tangent = 0.5 * 0^(-0.5) = Inf (documented divergence).
874 let r = Scalar::powf(Dual::seed(0.0), 0.5);
875 assert_eq!(r.value, 0.0);
876 assert!(
877 r.tangent.is_infinite(),
878 "tangent {} should be infinite",
879 r.tangent
880 );
881 // powf(0, 1.0) relies on 0^0 == 1.0, giving tangent 1*1*1 = 1 (d/dx x = 1).
882 let lin = Scalar::powf(Dual::seed(0.0), 1.0);
883 assert_eq!(lin.value, 0.0);
884 assert_eq!(lin.tangent, 1.0);
885 // Negative base with non-integer power: value and tangent both NaN.
886 let nan = Scalar::powf(Dual::seed(-2.0), 0.5);
887 assert!(nan.value.is_nan());
888 assert!(nan.tangent.is_nan());
889 }
890
891 // ---------------------------------------------------------------------
892 // Tier 2: central finite-difference cross-check, tolerance 1e-6.
893 // ---------------------------------------------------------------------
894
895 fn central_fd(f: impl Fn(f64) -> f64, x: f64) -> f64 {
896 let h = 1e-8_f64;
897 (f(x + h) - f(x - h)) / (2.0 * h)
898 }
899
900 #[test]
901 fn finite_diff_cross_check_composed() {
902 let x0 = 1.0_f64;
903 let (_, ad) = diff(
904 |x| {
905 let e = Scalar::exp(x);
906 let s = Scalar::sin(x);
907 Scalar::sqrt(e * s + x * x)
908 },
909 x0,
910 );
911 let fd = central_fd(|x| (x.exp() * x.sin() + x * x).sqrt(), x0);
912 assert!((ad - fd).abs() < 1e-6, "AD {ad} FD {fd}");
913 }
914
915 #[test]
916 fn finite_diff_cross_check_log_trig() {
917 // f(x) = ln(x) * cos(x) at x = 2.
918 let x0 = 2.0_f64;
919 let (_, ad) = diff(|x| Scalar::ln(x) * Scalar::cos(x), x0);
920 let fd = central_fd(|x| x.ln() * x.cos(), x0);
921 assert!((ad - fd).abs() < 1e-6, "AD {ad} FD {fd}");
922 }
923
924 #[test]
925 fn finite_diff_cross_check_powf_exp() {
926 // f(x) = x^1.5 / exp(x) at x = 1.5.
927 let x0 = 1.5_f64;
928 let (_, ad) = diff(|x| Scalar::powf(x, 1.5) / Scalar::exp(x), x0);
929 let fd = central_fd(|x| x.powf(1.5) / x.exp(), x0);
930 assert!((ad - fd).abs() < 1e-6, "AD {ad} FD {fd}");
931 }
932
933 // ---------------------------------------------------------------------
934 // Tier 3: f64 parity — bit-for-bit vs direct f64 methods.
935 // ---------------------------------------------------------------------
936
937 #[test]
938 fn f64_parity_transcendentals() {
939 let x = 2.5_f64;
940 assert_eq!(<f64 as Scalar>::sqrt(x), x.sqrt());
941 assert_eq!(<f64 as Scalar>::exp(x), x.exp());
942 assert_eq!(<f64 as Scalar>::ln(x), x.ln());
943 assert_eq!(<f64 as Scalar>::sin(x), x.sin());
944 assert_eq!(<f64 as Scalar>::cos(x), x.cos());
945 assert_eq!(<f64 as Scalar>::powf(x, 1.5), x.powf(1.5));
946 assert_eq!(<f64 as Scalar>::abs(-x), (-x).abs());
947 assert_eq!(<f64 as Scalar>::signum(-x), (-x).signum());
948 }
949
950 #[test]
951 fn f64_parity_constants() {
952 assert_eq!(<f64 as Scalar>::zero(), 0.0);
953 assert_eq!(<f64 as Scalar>::one(), 1.0);
954 assert_eq!(<f64 as Scalar>::from_f64(3.25), 3.25);
955 assert_eq!(<f64 as Scalar>::infinity(), f64::INFINITY);
956 }
957
958 // ---------------------------------------------------------------------
959 // grad / jacobian / directional_derivative (DIF-04 SC #1).
960 // ---------------------------------------------------------------------
961
962 #[test]
963 fn grad_sum_of_squares_closed_form() {
964 // f(x) = sum(x_i^2), grad = [2*x_i]. At [1,2,3]: value 14, grad [2,4,6].
965 let (value, gradient) = grad(
966 |x| {
967 let mut acc = Dual::constant(0.0);
968 for &xi in x {
969 acc += xi * xi;
970 }
971 acc
972 },
973 &[1.0, 2.0, 3.0],
974 );
975 assert_eq!(gradient.len(), 3);
976 assert!((value - 14.0).abs() <= 1e-12, "value {value} != 14.0");
977 for (g, expected) in gradient.iter().zip([2.0, 4.0, 6.0]) {
978 assert!((g - expected).abs() <= 1e-12, "grad {g} != {expected}");
979 }
980 }
981
982 #[test]
983 fn grad_single_input_agrees_with_diff() {
984 // Single-element input [3.0], f = x0^2 -> (9.0, [6.0]) matches diff.
985 let (value, gradient) = grad(|x| x[0] * x[0], &[3.0]);
986 assert_eq!(gradient.len(), 1);
987 assert!((value - 9.0).abs() <= 1e-12);
988 assert!((gradient[0] - 6.0).abs() <= 1e-12);
989 let (dv, dd) = diff(|x| x * x, 3.0);
990 assert!((value - dv).abs() <= 1e-12);
991 assert!((gradient[0] - dd).abs() <= 1e-12);
992 }
993
994 #[test]
995 fn grad_empty_input_returns_constant() {
996 // m == 0: evaluate f once, empty gradient, never index x.
997 let (value, gradient) = grad(|_x| Dual::constant(7.0), &[]);
998 assert!((value - 7.0).abs() <= 1e-12);
999 assert!(gradient.is_empty());
1000 }
1001
1002 #[test]
1003 fn jacobian_known_answer() {
1004 // f(x) = [x0*x1, x0 + x1]; J = [[x1, x0], [1, 1]]; at [2,3] -> [[3,2],[1,1]].
1005 let (values, j) = jacobian(|x| vec![x[0] * x[1], x[0] + x[1]], &[2.0, 3.0]);
1006 assert_eq!(values.len(), 2);
1007 assert!((values[0] - 6.0).abs() <= 1e-12);
1008 assert!((values[1] - 5.0).abs() <= 1e-12);
1009 assert_eq!(j.len(), 2);
1010 assert!((j[0][0] - 3.0).abs() <= 1e-12 && (j[0][1] - 2.0).abs() <= 1e-12);
1011 assert!((j[1][0] - 1.0).abs() <= 1e-12 && (j[1][1] - 1.0).abs() <= 1e-12);
1012 }
1013
1014 #[test]
1015 fn directional_derivative_projects_gradient() {
1016 // f(x) = x0^2 + x1^2, grad [2*x0, 2*x1]. At [1,2] along [1,0]: dd = 2.
1017 let (value, dd) =
1018 directional_derivative(|x| x[0] * x[0] + x[1] * x[1], &[1.0, 2.0], &[1.0, 0.0]);
1019 assert!((value - 5.0).abs() <= 1e-12);
1020 assert!((dd - 2.0).abs() <= 1e-12);
1021 }
1022
1023 // ---------------------------------------------------------------------
1024 // Composed-objective demo + central finite-difference cross-check
1025 // (DIF-04 SC #2): grad flows through soft_dtw + FPCA-score projection.
1026 // ---------------------------------------------------------------------
1027
1028 #[test]
1029 fn grad_composed_objective_matches_finite_diff() {
1030 use crate::matrix::FdMatrix;
1031 use crate::metric::soft_dtw_distance_generic;
1032 use crate::regression::{fdata_to_pc_1d, project_scores_generic};
1033 use rand::rngs::StdRng;
1034 use rand::{Rng, SeedableRng};
1035
1036 let m = 24usize;
1037 let n = 40usize;
1038 let ncomp = 3usize;
1039 let gamma = 0.1_f64;
1040 let lambda = 1.0_f64;
1041
1042 // Grid on [0.1, 0.9] avoids SRSF derivative zeros / degenerate points.
1043 let argvals: Vec<f64> = (0..m)
1044 .map(|j| 0.1 + 0.8 * j as f64 / (m - 1) as f64)
1045 .collect();
1046
1047 // Spanning full-rank training set: seeded random combos of three basis
1048 // functions with distinct coefficients (n >> m). Column-major FdMatrix.
1049 let mut rng = StdRng::seed_from_u64(20260906);
1050 let mut data = vec![0.0f64; n * m];
1051 for i in 0..n {
1052 let a: f64 = rng.gen_range(-1.0..1.0);
1053 let b: f64 = rng.gen_range(-1.0..1.0);
1054 let c: f64 = rng.gen_range(-1.0..1.0);
1055 for (j, &t) in argvals.iter().enumerate() {
1056 let v = a * (PI * t).sin() + b * (2.0 * PI * t).cos() + c * (3.0 * PI * t).sin();
1057 data[i + j * n] = v;
1058 }
1059 }
1060 let data = FdMatrix::from_column_major(data, n, m).unwrap();
1061 let fpca = fdata_to_pc_1d(&data, ncomp, &argvals).unwrap();
1062 let mean = fpca.mean.clone();
1063 let rotation = fpca.rotation.clone();
1064 let weights = fpca.weights.clone();
1065
1066 // Input curve + reference curve: further spanning combos, nonzero deriv.
1067 let curve: Vec<f64> = argvals
1068 .iter()
1069 .map(|&t| {
1070 0.7 * (PI * t).sin() - 0.4 * (2.0 * PI * t).cos() + 0.3 * (3.0 * PI * t).sin()
1071 })
1072 .collect();
1073 let reference: Vec<f64> = argvals
1074 .iter()
1075 .map(|&t| {
1076 0.2 * (PI * t).sin() + 0.5 * (2.0 * PI * t).cos() - 0.6 * (3.0 * PI * t).sin()
1077 })
1078 .collect();
1079 let reference_duals: Vec<Dual> = reference.iter().map(|&r| Dual::constant(r)).collect();
1080
1081 // Composed scalar objective: soft-DTW (op 1) + lambda * sum(scores^2) (op 2).
1082 let objective = |c: &[Dual]| -> Dual {
1083 let sdtw = soft_dtw_distance_generic(c, &reference_duals, gamma);
1084 let scores = project_scores_generic(c, &mean, &rotation, &weights, ncomp);
1085 let mut acc = Dual::constant(0.0);
1086 for s in &scores {
1087 acc += *s * *s;
1088 }
1089 sdtw + Dual::constant(lambda) * acc
1090 };
1091
1092 let (value, gradient) = grad(objective, &curve);
1093 assert_eq!(gradient.len(), m);
1094 assert!(value.is_finite(), "objective value not finite: {value}");
1095 assert!(value > 0.0, "objective value not positive: {value}");
1096
1097 // f64 reference for composition parity + central finite differences.
1098 let f64_obj = |c: &[f64]| -> f64 {
1099 let d = soft_dtw_distance_generic::<f64>(c, &reference, gamma);
1100 let sc = project_scores_generic::<f64>(c, &mean, &rotation, &weights, ncomp);
1101 d + lambda * sc.iter().map(|s| s * s).sum::<f64>()
1102 };
1103
1104 // Composition parity at f64.
1105 assert!(
1106 (value - f64_obj(&curve)).abs() < 1e-12,
1107 "composition parity broke: {value}"
1108 );
1109
1110 // Central FD (h = 1e-6) cross-check on every gradient component.
1111 let h = 1e-6_f64;
1112 for j in 0..m {
1113 let mut plus = curve.clone();
1114 let mut minus = curve.clone();
1115 plus[j] += h;
1116 minus[j] -= h;
1117 let fd = (f64_obj(&plus) - f64_obj(&minus)) / (2.0 * h);
1118 assert!(
1119 (gradient[j] - fd).abs() < 1e-6,
1120 "component {j}: AD {} vs FD {fd}",
1121 gradient[j]
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn dual_constants() {
1128 // `Dual`'s `PartialEq` is value-only, so assert both fields explicitly
1129 // to genuinely verify the tangent is 0.0 for these constants.
1130 let z = <Dual as Scalar>::zero();
1131 assert_eq!(z.value, 0.0);
1132 assert_eq!(z.tangent, 0.0);
1133 let o = <Dual as Scalar>::one();
1134 assert_eq!(o.value, 1.0);
1135 assert_eq!(o.tangent, 0.0);
1136 let c = <Dual as Scalar>::from_f64(4.5);
1137 assert_eq!(c.value, 4.5);
1138 assert_eq!(c.tangent, 0.0);
1139 let inf = <Dual as Scalar>::infinity();
1140 assert!(inf.value.is_infinite() && inf.value > 0.0);
1141 assert_eq!(inf.tangent, 0.0);
1142 }
1143}