Skip to main content

gam_math/
nested_dual.rs

1//! Nested second-order forward-AD dual for FD-free high-order cross-checks (#932).
2//!
3//! [`Dual2<S>`] is a single-direction second-order jet (`value`, first and second
4//! derivative in ONE direction) over a scalar field `S`. Because the field is
5//! generic, it composes with itself: `Dual2<Dual2<f64>>` carries every mixed
6//! partial `∂^i_a ∂^j_b` for `i, j ∈ {0, 1, 2}`, i.e. the full `2 + 2 = 4`th-order
7//! bidirectional derivative in two INDEPENDENT directions `a`, `b`.
8//!
9//! Why this exists: the flexible survival marginal-slope Jet4 tower (the
10//! moving-boundary implicit-intercept path, gam#932) is the last derivative
11//! surface whose fourth-order channel is verified only by a finite-difference
12//! stencil — the hand reference is provably incomplete there, and a 4th-order
13//! FD probes a 6th derivative, so the truncation floor sits far above machine
14//! precision (the same pathology as gam#979). A nested dual evaluates the SAME
15//! single-source program by a DIFFERENT composition ordering (two nested
16//! second-order sweeps instead of one fourth-order sweep), so its
17//! `∂²_a ∂²_b` channel is a truncation-free, hand-oracle-free cross-check of the
18//! Jet4 bidirectional block.
19//!
20//! The construction is standard forward-over-forward automatic differentiation;
21//! its correctness is pinned channel-for-channel against the engine
22//! [`crate::jet_tower::Tower4`] on smooth programs (see the module tests).
23
24/// Minimal scalar field a [`Dual2`] can be built over. Implemented by `f64` (the
25/// base case) and by [`Dual2`] itself (the nesting case). Every operation mirrors
26/// the [`crate::jet_tower::Tower4`] / [`crate::jet_scalar::JetScalar`] Faà di
27/// Bruno convention exactly, so a program written against `JetField` evaluates
28/// identically on the engine tower and on a nested dual.
29///
30/// This is the SHARED scalar-field algebra base of the #932 tower: the
31/// const-`K` packed [`crate::jet_scalar::JetScalar`] and the runtime-`p`
32/// Vec-backed flex jets (`survival::marginal_slope::timepoint_exact::FlexJet`)
33/// both EXTEND it, so the field ops and the single Faà di Bruno composition are
34/// declared exactly ONCE here. It is deliberately NOT `Copy` (the Vec-backed
35/// flex jets are `Clone`, not `Copy`) and carries no constructor (a Vec-backed
36/// constant needs a primary count) — the `Copy`, `from_f64`-carrying nested-dual
37/// oracle path adds those through [`JetFieldConst`].
38pub trait JetField {
39    /// The real value channel (recurses through any nesting to the `f64` leaf).
40    fn value(&self) -> f64;
41    fn add(&self, o: &Self) -> Self;
42    fn sub(&self, o: &Self) -> Self;
43    fn mul(&self, o: &Self) -> Self;
44    fn neg(&self) -> Self;
45    /// Multiply every channel by a plain `f64`.
46    fn scale(&self, s: f64) -> Self;
47    /// Faà di Bruno composition `f ∘ self` given the OUTER real function's
48    /// derivative stack `d = [f(u), f′(u), f″(u), f‴(u), f⁗(u)]` evaluated at
49    /// `u = self.value()` — the identical `[f64; 5]` stack shape
50    /// [`crate::jet_tower::Tower4::compose_unary`] consumes.
51    fn compose_unary(&self, d: [f64; 5]) -> Self;
52}
53
54/// A [`JetField`] that is `Copy` and can be built from a real constant with
55/// every derivative channel zero. The nested-dual oracle (`Dual2` over a `Copy`
56/// leaf) needs a dimensionless constructor; the runtime-`p` Vec-backed flex jets
57/// deliberately do NOT satisfy this (their constant needs a primary count), so
58/// it lives on this subtrait rather than the shared algebra base.
59pub trait JetFieldConst: JetField + Copy {
60    /// A constant field element with value `x` and every derivative channel zero.
61    fn from_f64(x: f64) -> Self;
62}
63
64impl JetField for f64 {
65    #[inline]
66    fn value(&self) -> f64 {
67        *self
68    }
69    #[inline]
70    fn add(&self, o: &Self) -> Self {
71        *self + *o
72    }
73    #[inline]
74    fn sub(&self, o: &Self) -> Self {
75        *self - *o
76    }
77    #[inline]
78    fn mul(&self, o: &Self) -> Self {
79        *self * *o
80    }
81    #[inline]
82    fn neg(&self) -> Self {
83        -*self
84    }
85    #[inline]
86    fn scale(&self, s: f64) -> Self {
87        *self * s
88    }
89    #[inline]
90    fn compose_unary(&self, d: [f64; 5]) -> Self {
91        // The stack is already evaluated at `u = *self`; `f(u)` is `d[0]`.
92        d[0]
93    }
94}
95
96impl JetFieldConst for f64 {
97    #[inline]
98    fn from_f64(x: f64) -> Self {
99        x
100    }
101}
102
103/// A single-direction second-order jet over the field `S`: value `v`, first
104/// derivative `g`, second derivative `h`, all with respect to ONE seeded
105/// direction. Nest it (`Dual2<Dual2<f64>>`) for a second, independent direction.
106#[derive(Clone, Copy, Debug)]
107pub struct Dual2<S: JetField> {
108    /// Value channel.
109    pub v: S,
110    /// First derivative in this dual's direction.
111    pub g: S,
112    /// Second derivative in this dual's direction.
113    pub h: S,
114}
115
116impl<S: JetFieldConst> Dual2<S> {
117    /// A constant (value `v`, zero derivatives) — carries no dependence on this
118    /// dual's direction (but `v` may still depend on an inner nested direction).
119    #[inline]
120    pub fn constant(v: S) -> Self {
121        Self {
122            v,
123            g: S::from_f64(0.0),
124            h: S::from_f64(0.0),
125        }
126    }
127
128    /// The seeded variable at `v`: unit first derivative in this dual's
129    /// direction, zero second derivative.
130    #[inline]
131    pub fn variable(v: S) -> Self {
132        Self {
133            v,
134            g: S::from_f64(1.0),
135            h: S::from_f64(0.0),
136        }
137    }
138}
139
140impl<S: JetField> JetField for Dual2<S> {
141    #[inline]
142    fn value(&self) -> f64 {
143        self.v.value()
144    }
145    #[inline]
146    fn add(&self, o: &Self) -> Self {
147        Self {
148            v: self.v.add(&o.v),
149            g: self.g.add(&o.g),
150            h: self.h.add(&o.h),
151        }
152    }
153    #[inline]
154    fn sub(&self, o: &Self) -> Self {
155        Self {
156            v: self.v.sub(&o.v),
157            g: self.g.sub(&o.g),
158            h: self.h.sub(&o.h),
159        }
160    }
161    #[inline]
162    fn mul(&self, o: &Self) -> Self {
163        // Leibniz in one direction: (uv)′ = u′v + uv′,
164        // (uv)″ = u″v + 2u′v′ + uv″.
165        Self {
166            v: self.v.mul(&o.v),
167            g: self.v.mul(&o.g).add(&self.g.mul(&o.v)),
168            h: self
169                .v
170                .mul(&o.h)
171                .add(&self.g.mul(&o.g).scale(2.0))
172                .add(&self.h.mul(&o.v)),
173        }
174    }
175    #[inline]
176    fn neg(&self) -> Self {
177        Self {
178            v: self.v.neg(),
179            g: self.g.neg(),
180            h: self.h.neg(),
181        }
182    }
183    #[inline]
184    fn scale(&self, s: f64) -> Self {
185        Self {
186            v: self.v.scale(s),
187            g: self.g.scale(s),
188            h: self.h.scale(s),
189        }
190    }
191    #[inline]
192    fn compose_unary(&self, d: [f64; 5]) -> Self {
193        // f∘self in one direction: with u = self, φ = f,
194        //   value  = φ(u)
195        //   first  = φ′(u)·u′
196        //   second = φ′(u)·u″ + φ″(u)·(u′)²
197        // φ(u), φ′(u), φ″(u) are field-valued: compose the SHIFTED real stacks
198        // with the inner value `self.v`, which propagates any nested direction.
199        let f0 = self.v.compose_unary([d[0], d[1], d[2], d[3], d[4]]);
200        let f1 = self.v.compose_unary([d[1], d[2], d[3], d[4], 0.0]);
201        let f2 = self.v.compose_unary([d[2], d[3], d[4], 0.0, 0.0]);
202        Self {
203            v: f0,
204            g: f1.mul(&self.g),
205            h: f1.mul(&self.h).add(&f2.mul(&self.g).mul(&self.g)),
206        }
207    }
208}
209
210impl<S: JetFieldConst> JetFieldConst for Dual2<S> {
211    #[inline]
212    fn from_f64(x: f64) -> Self {
213        Self::constant(S::from_f64(x))
214    }
215}
216
217/// A `Dual2<Dual2<f64>>` seeded with independent directions `a` (outer) and `b`
218/// (inner): value `x`, unit first derivative along both requested directions.
219/// `p0` should be seeded `(a=1, b=0)` and `p1` `(a=0, b=1)` for a two-primary
220/// program (mirrors `Tower4::variable(x, 0)` / `Tower4::variable(x, 1)`).
221pub type Dual22 = Dual2<Dual2<f64>>;
222
223impl Dual22 {
224    /// Seed a primary that varies only along the OUTER direction `a`
225    /// (`∂/∂a = 1`, `∂/∂b = 0`) — the `Tower4::variable(x, 0)` analogue.
226    #[inline]
227    pub fn seed_outer(x: f64) -> Self {
228        Dual2::variable(Dual2::<f64>::constant(x))
229    }
230    /// Seed a primary that varies only along the INNER direction `b`
231    /// (`∂/∂a = 0`, `∂/∂b = 1`) — the `Tower4::variable(x, 1)` analogue.
232    #[inline]
233    pub fn seed_inner(x: f64) -> Self {
234        Dual2::constant(Dual2::<f64>::variable(x))
235    }
236
237    /// Seed a primary at value `base` that moves as `base + s·d1 + t·d2` under the
238    /// two independent scalar directions `s` (outer `a`) and `t` (inner `b`):
239    /// `∂/∂a = d1`, `∂/∂b = d2`, all second-and-higher self-derivatives zero
240    /// (the primary is affine in `s, t`). This is what a directional
241    /// bidirectional contraction along arbitrary weight vectors `d1, d2` needs —
242    /// seed every primary `i` with `seed_directional(base_i, d1_i, d2_i)`, run
243    /// the program, and read `channels()[8]` (`∂²_a ∂²_b`) for
244    /// `Σ_{a,b,c,d} ℓ_{abcd}·d1_a d1_b d2_c d2_d`.
245    #[inline]
246    pub fn seed_directional(base: f64, d1: f64, d2: f64) -> Self {
247        Dual2 {
248            // value carries the inner (`t`) direction on its `g` channel.
249            v: Dual2::<f64> {
250                v: base,
251                g: d2,
252                h: 0.0,
253            },
254            // outer (`s`) first derivative is `d1`, itself constant in `t`.
255            g: Dual2::<f64>::constant(d1),
256            h: Dual2::<f64>::constant(0.0),
257        }
258    }
259
260    /// Build a nested dual directly from its nine `(s-order, t-order)` channels,
261    /// ordered as [`Self::channels`]: `[v, ∂a, ∂b, ∂aa, ∂ab, ∂bb, ∂aab, ∂abb,
262    /// ∂aabb]`. The inverse of [`Self::channels`]. Used to assemble the result of
263    /// a channel-space operation (e.g. a moment-recurrence residual term) back
264    /// into a `Dual22`.
265    #[inline]
266    pub fn from_channels(c: [f64; 9]) -> Self {
267        Dual2 {
268            v: Dual2::<f64> {
269                v: c[0],
270                g: c[2],
271                h: c[5],
272            },
273            g: Dual2::<f64> {
274                v: c[1],
275                g: c[4],
276                h: c[7],
277            },
278            h: Dual2::<f64> {
279                v: c[3],
280                g: c[6],
281                h: c[8],
282            },
283        }
284    }
285
286    /// The nine channels this nested dual represents, keyed to the two-primary
287    /// [`crate::jet_tower::Tower4`] indices `0` (outer `a`) and `1` (inner `b`):
288    /// `(value, ∂a, ∂b, ∂aa, ∂ab, ∂bb, ∂aab, ∂abb, ∂aabb)`.
289    #[inline]
290    pub fn channels(&self) -> [f64; 9] {
291        [
292            self.v.v, self.g.v, self.v.g, self.h.v, self.g.g, self.v.h, self.h.g, self.g.h,
293            self.h.h,
294        ]
295    }
296}
297
298#[cfg(test)]
299mod nested_dual_tower4_oracle_tests {
300    use super::*;
301    use crate::jet_tower::Tower4;
302
303    // `Tower4` is a production `JetField` (its `JetScalar` impl in `jet_scalar.rs`
304    // now rides the shared base), so the SAME `program` runs on both `Tower4<2>`
305    // and `Dual2<Dual2<f64>>` with no test-only bridge. The oracle path adds only
306    // the `Copy` constructor through `JetFieldConst`.
307    impl<const K: usize> JetFieldConst for Tower4<K> {
308        fn from_f64(x: f64) -> Self {
309            Tower4::constant(x)
310        }
311    }
312
313    /// exp stack `[e,e,e,e,e]` at `u`.
314    fn exp_stack(u: f64) -> [f64; 5] {
315        let e = u.exp();
316        [e, e, e, e, e]
317    }
318    /// ln stack `[ln u, 1/u, -1/u², 2/u³, -6/u⁴]` at `u > 0`.
319    fn ln_stack(u: f64) -> [f64; 5] {
320        let r = 1.0 / u;
321        [u.ln(), r, -r * r, 2.0 * r * r * r, -6.0 * r * r * r * r]
322    }
323
324    /// A smooth two-primary program with genuinely nonzero mixed fourth
325    /// derivatives, written once over `JetField` so it evaluates identically on
326    /// the engine `Tower4<2>` and on the nested `Dual2<Dual2<f64>>`.
327    ///
328    ///   f(p0, p1) = exp(p0·p1 + 0.3·p0)
329    ///             + ln(1 + p0² + 0.5·p1² + 0.2·p0·p1)
330    ///             − 0.7·(p0 − p1)²
331    fn program<J: JetFieldConst>(p0: &J, p1: &J) -> J {
332        let one = J::from_f64(1.0);
333        // exp(p0·p1 + 0.3·p0)
334        let arg_e = p0.mul(p1).add(&p0.scale(0.3));
335        let term_exp = arg_e.compose_unary(exp_stack(arg_e.value()));
336        // ln(1 + p0² + 0.5·p1² + 0.2·p0·p1)
337        let arg_l = one
338            .add(&p0.mul(p0))
339            .add(&p1.mul(p1).scale(0.5))
340            .add(&p0.mul(p1).scale(0.2));
341        let term_ln = arg_l.compose_unary(ln_stack(arg_l.value()));
342        // −0.7·(p0 − p1)²
343        let diff = p0.sub(p1);
344        let term_quad = diff.mul(&diff).scale(-0.7);
345        term_exp.add(&term_ln).add(&term_quad)
346    }
347
348    /// The nested `Dual2<Dual2<f64>>` reproduces every channel it represents —
349    /// value, both gradients, the full Hessian, the two order-3 mixed channels,
350    /// and the order-4 bidirectional `∂²_a ∂²_b` — of the engine `Tower4<2>`, to
351    /// machine precision, over several smooth base points. This is the
352    /// truncation-free, hand-oracle-free proof the nested dual is a correct
353    /// fourth-order path before it is used to gate the flex Jet4 tower (#932).
354    #[test]
355    fn nested_dual2_reproduces_tower4_channels_932() {
356        let points = [
357            (0.31_f64, -0.42_f64),
358            (-0.85, 0.17),
359            (0.05, 0.93),
360            (1.2, -0.6),
361        ];
362        let mut max_rel = 0.0_f64;
363        for &(x0, x1) in &points {
364            // Engine tower.
365            let t0 = Tower4::<2>::variable(x0, 0);
366            let t1 = Tower4::<2>::variable(x1, 1);
367            let tower = program(&t0, &t1);
368
369            // Nested dual (outer = axis 0, inner = axis 1).
370            let d0 = Dual22::seed_outer(x0);
371            let d1 = Dual22::seed_inner(x1);
372            let nested = program(&d0, &d1);
373            let ch = nested.channels();
374
375            // (label, nested channel, tower channel).
376            let cmp = [
377                ("value", ch[0], tower.v),
378                ("d_a", ch[1], tower.g[0]),
379                ("d_b", ch[2], tower.g[1]),
380                ("d_aa", ch[3], tower.h[0][0]),
381                ("d_ab", ch[4], tower.h[0][1]),
382                ("d_bb", ch[5], tower.h[1][1]),
383                ("d_aab", ch[6], tower.t3[0][0][1]),
384                ("d_abb", ch[7], tower.t3[0][1][1]),
385                ("d_aabb", ch[8], tower.t4[0][0][1][1]),
386            ];
387            for (label, got, want) in cmp {
388                let rel = (got - want).abs() / want.abs().max(1.0);
389                max_rel = max_rel.max(rel);
390                assert!(
391                    rel <= 1e-12,
392                    "point ({x0},{x1}) channel {label}: nested {got:.16e} != tower {want:.16e} (rel {rel:.3e})"
393                );
394            }
395        }
396        eprintln!(
397            "[nested-dual #932] Dual2<Dual2> vs Tower4<2> max_rel over 4 points = {max_rel:.3e}"
398        );
399    }
400
401    /// The directional seeding (arbitrary weight vectors `d1`, `d2`) reproduces
402    /// the engine tower's fully-contracted derivatives:
403    ///   `∂_s f      = Σ_a g_a d1_a`,
404    ///   `∂_t f      = Σ_a g_a d2_a`,
405    ///   `∂_s∂_t f   = Σ_{ab} h_ab d1_a d2_b`,
406    ///   `∂²_s∂²_t f = Σ_{abcd} ℓ_abcd d1_a d1_b d2_c d2_d`,
407    /// the last being exactly the bidirectional 4th-order contraction a flex
408    /// Jet4 gate needs (`ℓ_{dir1,dir1,dir2,dir2}`), FD-free. This is the seeding
409    /// the flex-geometry consumer will use.
410    #[test]
411    fn nested_dual2_directional_matches_tower4_contraction_932() {
412        let d1 = [0.7_f64, -0.3_f64];
413        let d2 = [0.4_f64, 0.9_f64];
414        let points = [(0.31_f64, -0.42_f64), (-0.85, 0.17), (1.2, -0.6)];
415        let mut max_rel = 0.0_f64;
416        for &(x0, x1) in &points {
417            let t0 = Tower4::<2>::variable(x0, 0);
418            let t1 = Tower4::<2>::variable(x1, 1);
419            let tower = program(&t0, &t1);
420
421            // Full engine contractions.
422            let mut c_s = 0.0;
423            let mut c_t = 0.0;
424            let mut c_st = 0.0;
425            let mut c_sstt = 0.0;
426            for a in 0..2 {
427                c_s += tower.g[a] * d1[a];
428                c_t += tower.g[a] * d2[a];
429                for b in 0..2 {
430                    c_st += tower.h[a][b] * d1[a] * d2[b];
431                    for cc in 0..2 {
432                        for dd in 0..2 {
433                            c_sstt += tower.t4[a][b][cc][dd] * d1[a] * d1[b] * d2[cc] * d2[dd];
434                        }
435                    }
436                }
437            }
438
439            let p0 = Dual22::seed_directional(x0, d1[0], d2[0]);
440            let p1 = Dual22::seed_directional(x1, d1[1], d2[1]);
441            let ch = program(&p0, &p1).channels();
442
443            for (label, got, want) in [
444                ("d_s", ch[1], c_s),
445                ("d_t", ch[2], c_t),
446                ("d_st", ch[4], c_st),
447                ("d_sstt", ch[8], c_sstt),
448            ] {
449                let rel = (got - want).abs() / want.abs().max(1.0);
450                max_rel = max_rel.max(rel);
451                assert!(
452                    rel <= 1e-12,
453                    "point ({x0},{x1}) {label}: nested {got:.16e} != tower-contraction {want:.16e} (rel {rel:.3e})"
454                );
455            }
456        }
457        eprintln!(
458            "[nested-dual #932] directional Dual2<Dual2> vs Tower4<2> contraction max_rel = {max_rel:.3e}"
459        );
460    }
461
462    /// `from_channels` is the exact inverse of `channels` (round-trip identity),
463    /// so a channel-space operation can be assembled back into a `Dual22`
464    /// without silently transposing a slot.
465    #[test]
466    fn nested_dual2_channels_from_channels_roundtrip_932() {
467        let c = [1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
468        let round = Dual22::from_channels(c).channels();
469        assert_eq!(round, c, "channels∘from_channels must be identity");
470    }
471
472    /// Symmetry guard: the mixed channels are order-independent — `∂a∂b == ∂b∂a`
473    /// and `∂²a∂²b` computed with the seeds swapped agrees — so the nested read
474    /// is not silently reading a transposed channel.
475    #[test]
476    fn nested_dual2_seed_swap_symmetry_932() {
477        let (x0, x1) = (0.4_f64, -0.55_f64);
478        let d0 = Dual22::seed_outer(x0);
479        let d1 = Dual22::seed_inner(x1);
480        let ab = program(&d0, &d1).channels();
481
482        // Swap which variable carries the outer vs inner seed.
483        let e0 = Dual22::seed_inner(x0);
484        let e1 = Dual22::seed_outer(x1);
485        let ba = program(&e0, &e1).channels();
486
487        // value, and the fully-symmetric 4th channel, must be seed-order
488        // invariant; the gradients swap (a<->b), as do the order-3 channels.
489        let close = |a: f64, b: f64| (a - b).abs() <= 1e-12 * (1.0 + b.abs());
490        assert!(close(ab[0], ba[0]), "value not seed-order invariant");
491        assert!(close(ab[8], ba[8]), "d_aabb not seed-order invariant");
492        assert!(close(ab[1], ba[2]), "d_a (ab) != d_b (ba)");
493        assert!(close(ab[6], ba[7]), "d_aab (ab) != d_abb (ba)");
494    }
495}