Skip to main content

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}