Skip to main content

ferromotion_learn/
dual.rs

1//! **Forward-mode automatic differentiation** — dual and hyper-dual numbers for *exact* first and second
2//! derivatives of a function w.r.t. its **inputs**. Reverse mode ([`crate::autodiff`]) is the right tool for
3//! `∂loss/∂parameters` (one scalar out, many params in); forward mode is the right tool for the *other*
4//! direction a physics-informed model needs: the derivatives of the model's output w.r.t. a small number of
5//! inputs — `∂u/∂t`, `∂u/∂x`, `∂²u/∂x²` for a PINN residual; `∂L/∂q̇`, `∂²L/∂q̇²` (the learned mass matrix)
6//! for a Lagrangian net.
7//!
8//! A [`Dual`] carries `(value, derivative)` and propagates the chain rule automatically through arithmetic:
9//! evaluate any function on `Dual::var(x)` and read `.eps` for `f'(x)`. A [`HyperDual`] carries four
10//! components `(re, e1, e2, e12)` with two independent infinitesimals `ε₁, ε₂` (`ε₁²=ε₂²=0`); seed `ε₁` on
11//! one input and `ε₂` on another and the `e12` slot holds the exact mixed partial `∂²f/∂x∂y` — with **no
12//! step-size error**, unlike finite differences (seed both on the *same* input to get `f''`). Pure `f64`,
13//! wasm-clean. Verified against central differences.
14
15use core::ops::{Add, Div, Mul, Neg, Sub};
16
17/// A first-order dual number `re + eps·ε` (`ε² = 0`). Evaluating `f` on `Dual::var(x)` yields `f(x)` in `re`
18/// and `f'(x)` in `eps`.
19#[derive(Clone, Copy, Debug)]
20pub struct Dual {
21    pub re: f64,
22    pub eps: f64,
23}
24
25impl Dual {
26    /// The active variable `x` (seed derivative 1).
27    pub fn var(x: f64) -> Self {
28        Dual { re: x, eps: 1.0 }
29    }
30    /// A constant (derivative 0).
31    pub fn constant(c: f64) -> Self {
32        Dual { re: c, eps: 0.0 }
33    }
34    fn map(self, v: f64, d: f64) -> Self {
35        Dual { re: v, eps: d * self.eps }
36    }
37    /// Sine.
38    pub fn sin(self) -> Self {
39        self.map(self.re.sin(), self.re.cos())
40    }
41    /// Cosine.
42    pub fn cos(self) -> Self {
43        self.map(self.re.cos(), -self.re.sin())
44    }
45    /// Exponential.
46    pub fn exp(self) -> Self {
47        let e = self.re.exp();
48        self.map(e, e)
49    }
50    /// Natural log.
51    pub fn ln(self) -> Self {
52        self.map(self.re.ln(), 1.0 / self.re)
53    }
54    /// Hyperbolic tangent.
55    pub fn tanh(self) -> Self {
56        let t = self.re.tanh();
57        self.map(t, 1.0 - t * t)
58    }
59    /// Real power.
60    pub fn powf(self, n: f64) -> Self {
61        self.map(self.re.powf(n), n * self.re.powf(n - 1.0))
62    }
63}
64
65impl Add for Dual {
66    type Output = Dual;
67    fn add(self, o: Dual) -> Dual {
68        Dual { re: self.re + o.re, eps: self.eps + o.eps }
69    }
70}
71impl Sub for Dual {
72    type Output = Dual;
73    fn sub(self, o: Dual) -> Dual {
74        Dual { re: self.re - o.re, eps: self.eps - o.eps }
75    }
76}
77impl Mul for Dual {
78    type Output = Dual;
79    fn mul(self, o: Dual) -> Dual {
80        Dual { re: self.re * o.re, eps: self.re * o.eps + self.eps * o.re }
81    }
82}
83impl Div for Dual {
84    type Output = Dual;
85    fn div(self, o: Dual) -> Dual {
86        Dual { re: self.re / o.re, eps: (self.eps * o.re - self.re * o.eps) / (o.re * o.re) }
87    }
88}
89impl Neg for Dual {
90    type Output = Dual;
91    fn neg(self) -> Dual {
92        Dual { re: -self.re, eps: -self.eps }
93    }
94}
95
96/// A second-order (hyper-)dual number `re + e1·ε₁ + e2·ε₂ + e12·ε₁ε₂`, with `ε₁² = ε₂² = 0`. Seeding `ε₁`
97/// and `ε₂` on two inputs makes `e12` the exact mixed partial `∂²f/∂x∂y`; seeding both on the same input
98/// makes `e12 = f''`.
99#[derive(Clone, Copy, Debug)]
100pub struct HyperDual {
101    pub re: f64,
102    pub e1: f64,
103    pub e2: f64,
104    pub e12: f64,
105}
106
107impl HyperDual {
108    /// A constant.
109    pub fn constant(c: f64) -> Self {
110        HyperDual { re: c, e1: 0.0, e2: 0.0, e12: 0.0 }
111    }
112    /// A value seeded active in **both** infinitesimal directions — used to read the pure second derivative
113    /// `f''(x)` from `e12`.
114    pub fn var2(x: f64) -> Self {
115        HyperDual { re: x, e1: 1.0, e2: 1.0, e12: 0.0 }
116    }
117    /// A value seeded active in the `ε₁` direction only (for mixed partials).
118    pub fn var_e1(x: f64) -> Self {
119        HyperDual { re: x, e1: 1.0, e2: 0.0, e12: 0.0 }
120    }
121    /// A value seeded active in the `ε₂` direction only (for mixed partials).
122    pub fn var_e2(x: f64) -> Self {
123        HyperDual { re: x, e1: 0.0, e2: 1.0, e12: 0.0 }
124    }
125    /// Apply a scalar function given `h(x)`, `h'(x)`, `h''(x)`.
126    fn chain(self, h: f64, dh: f64, ddh: f64) -> Self {
127        HyperDual {
128            re: h,
129            e1: dh * self.e1,
130            e2: dh * self.e2,
131            e12: dh * self.e12 + ddh * self.e1 * self.e2,
132        }
133    }
134    /// Sine.
135    pub fn sin(self) -> Self {
136        self.chain(self.re.sin(), self.re.cos(), -self.re.sin())
137    }
138    /// Cosine.
139    pub fn cos(self) -> Self {
140        self.chain(self.re.cos(), -self.re.sin(), -self.re.cos())
141    }
142    /// Exponential.
143    pub fn exp(self) -> Self {
144        let e = self.re.exp();
145        self.chain(e, e, e)
146    }
147    /// Hyperbolic tangent (`tanh' = 1−tanh²`, `tanh'' = −2·tanh·(1−tanh²)`).
148    pub fn tanh(self) -> Self {
149        let t = self.re.tanh();
150        let d = 1.0 - t * t;
151        self.chain(t, d, -2.0 * t * d)
152    }
153    /// Real power.
154    pub fn powf(self, n: f64) -> Self {
155        self.chain(self.re.powf(n), n * self.re.powf(n - 1.0), n * (n - 1.0) * self.re.powf(n - 2.0))
156    }
157}
158
159impl Add for HyperDual {
160    type Output = HyperDual;
161    fn add(self, o: HyperDual) -> HyperDual {
162        HyperDual { re: self.re + o.re, e1: self.e1 + o.e1, e2: self.e2 + o.e2, e12: self.e12 + o.e12 }
163    }
164}
165impl Sub for HyperDual {
166    type Output = HyperDual;
167    fn sub(self, o: HyperDual) -> HyperDual {
168        HyperDual { re: self.re - o.re, e1: self.e1 - o.e1, e2: self.e2 - o.e2, e12: self.e12 - o.e12 }
169    }
170}
171impl Mul for HyperDual {
172    type Output = HyperDual;
173    fn mul(self, o: HyperDual) -> HyperDual {
174        HyperDual {
175            re: self.re * o.re,
176            e1: self.re * o.e1 + self.e1 * o.re,
177            e2: self.re * o.e2 + self.e2 * o.re,
178            e12: self.re * o.e12 + self.e1 * o.e2 + self.e2 * o.e1 + self.e12 * o.re,
179        }
180    }
181}
182impl Neg for HyperDual {
183    type Output = HyperDual;
184    fn neg(self) -> HyperDual {
185        HyperDual { re: -self.re, e1: -self.e1, e2: -self.e2, e12: -self.e12 }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn dual_first_derivative_matches_finite_difference() {
195        // THE ORACLE. f(x) = sin(x)·exp(x); f'(x) via dual vs central difference.
196        let f = |x: f64| x.sin() * x.exp();
197        let x0 = 0.8;
198        let d = (Dual::var(x0).sin()) * (Dual::var(x0).exp());
199        let fd = (f(x0 + 1e-6) - f(x0 - 1e-6)) / 2e-6;
200        assert!((d.re - f(x0)).abs() < 1e-12, "value");
201        assert!((d.eps - fd).abs() < 1e-6, "dual f'={} vs fd={fd}", d.eps);
202    }
203
204    #[test]
205    fn hyperdual_second_derivative_matches_finite_difference() {
206        // THE HEADLINE. f(x) = tanh(x²); f''(x) read from e12, vs a central second difference.
207        let f = |x: f64| (x * x).tanh();
208        let x0 = 0.9;
209        let x = HyperDual::var2(x0);
210        let y = (x * x).tanh();
211        let h = 1e-4;
212        let fdd = (f(x0 + h) - 2.0 * f(x0) + f(x0 - h)) / (h * h);
213        assert!((y.re - f(x0)).abs() < 1e-12, "value");
214        assert!((y.e1 - 2.0 * x0 * (1.0 - f(x0) * f(x0))).abs() < 1e-9, "first derivative in e1");
215        assert!((y.e12 - fdd).abs() < 1e-4, "hyperdual f''={} vs fd={fdd}", y.e12);
216    }
217
218    #[test]
219    fn hyperdual_mixed_partial_is_exact() {
220        // THE DISCRIMINATOR. f(x,y) = sin(x)·y²; ∂²f/∂x∂y = 2y·cos(x), read from e12 with x on ε₁, y on ε₂.
221        let (x0, y0) = (0.5, 1.7);
222        let x = HyperDual::var_e1(x0);
223        let y = HyperDual::var_e2(y0);
224        let out = x.sin() * (y * y);
225        let expect = 2.0 * y0 * x0.cos();
226        assert!((out.e12 - expect).abs() < 1e-12, "mixed partial {} vs {expect}", out.e12);
227        // and the pure first partials live in e1 (∂/∂x = cos x · y²) and e2 (∂/∂y = sin x · 2y)
228        assert!((out.e1 - x0.cos() * y0 * y0).abs() < 1e-12, "∂/∂x");
229        assert!((out.e2 - x0.sin() * 2.0 * y0).abs() < 1e-12, "∂/∂y");
230    }
231}
232
233
234// ---- Dual numbers THROUGH the rigid-body dynamics ----
235
236/// The bridge that makes derivatives of dynamics free: `Dual` satisfies the generic-scalar contract
237/// of [`ferromotion_core::gendyn`], so seeding a dual in any state (`q`, `q̇`, `q̈`) or any inertial
238/// parameter (mass, COM, inertia entry) and running RNEA/forward dynamics yields the exact partial
239/// derivative in `eps` — no hand-derived derivative code, no finite-difference truncation error.
240/// This is the differentiation engine of gradient-based system identification and calibration.
241impl ferromotion_core::gendyn::Real for Dual {
242    fn from_f64(v: f64) -> Self {
243        Dual::constant(v)
244    }
245    fn sin(self) -> Self {
246        Dual::sin(self)
247    }
248    fn cos(self) -> Self {
249        Dual::cos(self)
250    }
251    fn sqrt(self) -> Self {
252        self.map(self.re.sqrt(), 0.5 / self.re.sqrt())
253    }
254    fn tanh(self) -> Self {
255        Dual::tanh(self)
256    }
257}
258
259#[cfg(test)]
260mod gendyn_tests {
261    use super::*;
262    use ferromotion_core::gendyn::GenModel;
263    use ferromotion_core::{Iso, Joint, LinkInertia, Robot};
264    use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
265
266    fn test_robot() -> (Robot, Vec<LinkInertia>) {
267        let mk = |xyz: [f64; 3], rpy: [f64; 3]| {
268            Iso::from_parts(
269                Translation3::new(xyz[0], xyz[1], xyz[2]),
270                UnitQuaternion::from_euler_angles(rpy[0], rpy[1], rpy[2]),
271            )
272        };
273        let joints = vec![
274            Joint::revolute(mk([0.0, 0.0, 0.3], [0.0, 0.0, 0.4]), Vector3::z()),
275            Joint::revolute(mk([0.1, 0.0, 0.2], [0.3, 0.0, 0.0]), Vector3::y()),
276            Joint::prismatic(mk([0.0, 0.05, 0.25], [0.0, 0.2, 0.0]), Vector3::x()),
277            Joint::revolute(mk([0.2, 0.0, 0.1], [0.0, 0.0, -0.3]), Vector3::y()),
278        ];
279        let inertia: Vec<LinkInertia> = (0..4)
280            .map(|i| {
281                let f = i as f64;
282                LinkInertia {
283                    mass: 1.5 + 0.3 * f,
284                    com: Vector3::new(0.02 * f, -0.01, 0.05 + 0.01 * f),
285                    inertia: Matrix3::new(
286                        0.02 + 0.005 * f, 0.001, 0.002,
287                        0.001, 0.03 + 0.002 * f, 0.0015,
288                        0.002, 0.0015, 0.025,
289                    ),
290                }
291            })
292            .collect();
293        (Robot { joints, ee_offset: Iso::identity() }, inertia)
294    }
295
296    /// ∂τ/∂q and ∂τ/∂q̇ through dual-RNEA must match the hand-derived analytical derivatives
297    /// (`dyn_derivatives::id_derivatives`) — the strongest available oracle.
298    #[test]
299    fn dual_rnea_matches_analytical_id_derivatives() {
300        let (robot, inertia) = test_robot();
301        let g = Vector3::new(0.0, 0.0, -9.81);
302        let q = [0.3, -0.7, 0.12, 1.1];
303        let qd = [0.5, -0.2, 0.3, -0.8];
304        let qdd = [1.2, 0.4, -0.9, 0.3];
305        let (dq_ref, dqd_ref) = ferromotion_core::id_derivatives(&robot, &inertia, &q, &qd, &qdd, g);
306        let m = GenModel::<Dual>::from_robot(&robot, &inertia, [0.0, 0.0, -9.81]);
307        let n = 4;
308        for j in 0..n {
309            let mk = |v: &[f64], active: Option<usize>| -> Vec<Dual> {
310                v.iter()
311                    .enumerate()
312                    .map(|(i, &x)| if Some(i) == active { Dual::var(x) } else { Dual::constant(x) })
313                    .collect()
314            };
315            // ∂τ/∂q_j
316            let tau = m.rnea(&mk(&q, Some(j)), &mk(&qd, None), &mk(&qdd, None));
317            for i in 0..n {
318                let (got, want) = (tau[i].eps, dq_ref[(i, j)]);
319                assert!((got - want).abs() < 1e-8 * want.abs().max(1.0), "dtau/dq ({i},{j}): {got} vs {want}");
320            }
321            // ∂τ/∂q̇_j
322            let tau = m.rnea(&mk(&q, None), &mk(&qd, Some(j)), &mk(&qdd, None));
323            for i in 0..n {
324                let (got, want) = (tau[i].eps, dqd_ref[(i, j)]);
325                assert!((got - want).abs() < 1e-8 * want.abs().max(1.0), "dtau/dqd ({i},{j}): {got} vs {want}");
326            }
327        }
328    }
329
330    /// ∂τ/∂(inertial parameter) — the calibration gradient — seeded through the MODEL, verified
331    /// against central finite differences of the f64 reference dynamics.
332    #[test]
333    fn dual_parameter_gradients_match_finite_differences() {
334        let (robot, inertia) = test_robot();
335        let g = Vector3::new(0.0, 0.0, -9.81);
336        let q = [0.3, -0.7, 0.12, 1.1];
337        let qd = [0.5, -0.2, 0.3, -0.8];
338        let qdd = [1.2, 0.4, -0.9, 0.3];
339        let eps = 1e-6;
340        // parameters to test: (link, kind) where kind: 0 = mass, 1 = com.y, 2 = inertia[0][0]
341        for &(link, kind) in &[(0usize, 0usize), (2, 0), (1, 1), (3, 2)] {
342            // dual: seed the parameter
343            let mut m = GenModel::<Dual>::from_robot(&robot, &inertia, [0.0, 0.0, -9.81]);
344            match kind {
345                0 => m.links[link].mass.eps = 1.0,
346                1 => m.links[link].com.0[1].eps = 1.0,
347                _ => m.links[link].inertia.0[0][0].eps = 1.0,
348            }
349            let mkc = |v: &[f64]| -> Vec<Dual> { v.iter().map(|&x| Dual::constant(x)).collect() };
350            let tau = m.rnea(&mkc(&q), &mkc(&qd), &mkc(&qdd));
351            // finite differences on the f64 reference
352            let perturb = |s: f64| -> Vec<f64> {
353                let mut li = inertia.clone();
354                match kind {
355                    0 => li[link].mass += s,
356                    1 => li[link].com[1] += s,
357                    _ => li[link].inertia[(0, 0)] += s,
358                }
359                ferromotion_core::inverse_dynamics(&robot, &li, &q, &qd, &qdd, g)
360            };
361            let (tp, tm) = (perturb(eps), perturb(-eps));
362            for i in 0..4 {
363                let want = (tp[i] - tm[i]) / (2.0 * eps);
364                let got = tau[i].eps;
365                assert!(
366                    (got - want).abs() < 1e-5 * want.abs().max(1.0),
367                    "dtau/dparam link={link} kind={kind} row {i}: {got} vs {want}"
368                );
369            }
370        }
371    }
372}