Skip to main content

phyz_diff/rollout/
adjoint.rs

1//! Discrete trajectory adjoint for semi-implicit Euler rollouts.
2//!
3//! # What this computes
4//!
5//! For a rollout `x_{t+1} = f(x_t, u_t; π, V)` (states `x = (q, v)`, open-loop
6//! controls `u_t`, per-body inertia parameters `π`, collision-mesh vertices
7//! `V`) and a final-state objective `J = g(q_T, v_T)`, one backward pass
8//! yields **both** parameter sensitivities:
9//!
10//! - `dJ/dπ` — per body, in the canonical 10-vector packing
11//!   `[m, cx, cy, cz, Ixx, Iyy, Izz, Ixy, Ixz, Iyz]` (Task: the inertia
12//!   parameter adjoint that replaces vcad's finite-difference `∂J/∂p`).
13//! - `dJ/dV` — per collision-mesh vertex, `∂J/∂x` in the body frame (the
14//!   contact adjoint that plugs into vcad's `surface_gradient`).
15//!
16//! # How
17//!
18//! Reverse over the trajectory, tangent within the step. The adjoint state
19//! `λ_t = ∂J/∂x_t` is backpropagated through the step Jacobians; those
20//! Jacobians are read out **exactly** with [`tang::Dual`] lanes through the
21//! scalar-generic step of [`super::step`] — no finite differences anywhere.
22//!
23//! Semi-implicit Euler has the structure `v' = v + dt·a`, `q' = Φ(q, v')`
24//! with `a = ABA(q, v, u; π, V)`. Differentiating and collecting terms, with
25//! `w := Φ_v'ᵀ λ_q' + λ_v'` (an `nv`-covector):
26//!
27//! ```text
28//! λ_q = Φ_qᵀ λ_q' + dt·(∂a/∂q)ᵀ w     λ_v = w + dt·(∂a/∂v)ᵀ w
29//! dJ/dπ += dt·wᵀ·∂a/∂π               dJ/dV += dt·wᵀ·∂a/∂V
30//! ```
31//!
32//! For a model of single-DOF joints only, `Φ(q, v') = q + dt·v'`, so
33//! `Φ_q = I`, `Φ_v' = dt·I`, and this collapses to the familiar
34//! `w = dt·λ_q' + λ_v'`, `λ_q = λ_q' + dt·(∂a/∂q)ᵀw` — and the driver takes
35//! that closed form directly, so a single-DOF model pays nothing for this.
36//! With a spherical or free joint `Φ` is a Lie-group step on quaternion
37//! coordinates: `Φ_q` is not the identity and `Φ_v'` is `nq×nv`, not square
38//! (see [`super::step`]'s module docs for the layout). Both blocks are then
39//! read out with dual lanes through `Φ` alone — `nq + nv` lanes of a few dozen
40//! flops each, negligible beside the ABA lanes.
41//!
42//! The two **state** channels are dual lanes: `2·nv` seeded evaluations of
43//! the scalar-generic step per timestep.
44//!
45//! The **parameter** and **contact** channels are not — a lane per parameter
46//! would make the backward pass cost `O(steps · n_params)`, i.e. no better
47//! than finite differences. Both instead ride one linear solve. Write
48//! `y := M⁻¹w` (read out of a single ABA with zero velocity and zero gravity,
49//! where the bias and gravity terms vanish and `qdd = M⁻¹·ctrl`) and let
50//! `A^y := J y` be the body-frame motion field that joint rates `y` induce
51//! (one `O(nb)` velocity sweep). Then, since `M ∂a/∂π = −∂/∂π ID(q, v, a; π)`
52//! at fixed `(q, v, a)` and inverse dynamics is the sum over bodies of
53//! `⟨A^y_b, I_b A_b + V_b ×* I_b V_b⟩` (RNEA's backward pass is the adjoint of
54//! its acceleration pass):
55//!
56//! ```text
57//! dJ/dπ[b][k] −= dt·⟨A^y_b, ΔI·A_b + V_b ×* (ΔI·V_b)⟩,   ΔI := ∂I_b/∂π_k
58//! χ_b          = dt·A^y_b                                (= dt·wᵀ·∂a/∂wrench_b)
59//! ```
60//!
61//! `ΔI` is a constant of the model, priced once outside the time loop; `V_b`
62//! and `A_b` come from one `O(nb)` sweep at the nominal `(q, v, qdd)` recorded
63//! by the forward rollout. So **all** `10·nb` inertia directions and all
64//! `6·nb` wrench directions cost two `O(nb)` sweeps, not `16·nb` dual steps.
65//!
66//! The vertex channel then contracts `χ_b` against the **local** per-vertex
67//! wrench Jacobian (3 tiny dual evaluations of the penalty law per vertex),
68//! so vertices never cost a full step either. Cost per timestep is
69//! `O(nv)` dual steps + `O(1)` ABAs + `O(nb)` sweeps + `O(3·N)` local
70//! evaluations — **independent of the parameter count**.
71//!
72//! # Contract
73//!
74//! - Joint domain: all of them — revolute/prismatic/fixed plus spherical and
75//!   free. Multi-DOF joints carry quaternion configuration, so `q0` must be
76//!   laid out per [`super::step::DofLayout`] (use its `neutral_q`), and
77//!   `∂g/∂q` is an `nq`-vector while `∂g/∂v` is an `nv`-vector.
78//! - **Open-loop control**: `ctrl(t)` must not read the state. A
79//!   state-feedback law would add `∂u/∂x` terms this driver does not model.
80//! - Objective reads the **final state** only.
81//! - Contact: the per-vertex ground-penalty model of [`super::step`]
82//!   (see its module docs for the smoothness contract). The gradient is the
83//!   exact derivative of *that* forward model.
84//! - Determinism: pure `f64` arithmetic in a fixed order; two runs are
85//!   bit-identical.
86
87use super::step::{
88    CollisionMesh, GroundContact, N_INERTIA_PARAMS, aba_generic, config_update_generic, fk_generic,
89    inertia_from_params, joint_nq, lift_inertia, lift_vec3, nominal_motion, rollout_states,
90    step_generic, validate_and_params, vertex_wrench,
91};
92use phyz_math::{DVec, Vec3};
93use phyz_model::Model;
94use tang::{Dual, Mat3 as GMat3, SpatialInertia, SpatialMat, SpatialVec, Vec3 as GVec3};
95
96type D = Dual<f64>;
97
98/// `∂I_b/∂π_k` as a 6×6 spatial-inertia matrix — the inertia parameterisation
99/// is a fixed algebraic map, so these blocks are constants of the model and
100/// are priced once, outside the time loop.
101fn inertia_param_derivatives(
102    params: &[f64; N_INERTIA_PARAMS],
103) -> [SpatialMat<f64>; N_INERTIA_PARAMS] {
104    let dual_block = |m: &GMat3<D>| {
105        GMat3::new(
106            m.get(0, 0).dual,
107            m.get(0, 1).dual,
108            m.get(0, 2).dual,
109            m.get(1, 0).dual,
110            m.get(1, 1).dual,
111            m.get(1, 2).dual,
112            m.get(2, 0).dual,
113            m.get(2, 1).dual,
114            m.get(2, 2).dual,
115        )
116    };
117    core::array::from_fn(|k| {
118        let mut p: [D; N_INERTIA_PARAMS] = core::array::from_fn(|i| D::constant(params[i]));
119        p[k] = D::var(params[k]);
120        let m = inertia_from_params(&p).to_matrix();
121        SpatialMat::new(
122            dual_block(&m.upper_left),
123            dual_block(&m.upper_right),
124            dual_block(&m.lower_left),
125            dual_block(&m.lower_right),
126        )
127    })
128}
129
130/// Which block of state coordinates a lane sweep seeds.
131#[derive(Clone, Copy, PartialEq, Eq, Debug)]
132enum Seed {
133    /// Seed `q`, producing `(∂a/∂q_i)ᵀw` for `i` in `0..nq`.
134    Position,
135    /// Seed `v`, producing `(∂a/∂v_j)ᵀw` for `j` in `0..nv`.
136    Velocity,
137}
138
139/// A run of consecutive coordinates, evaluated at lane width `N`.
140///
141/// Everything except the seeded block is a constant, so a single pass through
142/// [`step_generic`] computes the primal once and pushes `N` tangent directions
143/// through it. That is the whole point: the kinematic tree walk, the joint
144/// transcendentals and the memory traffic are paid once per chunk instead of
145/// once per column.
146///
147/// A group owns *all* the chunks that share a width, so the dual-lifted
148/// inertias — `O(nb)` of them, and pure overhead against the lane work on a
149/// model with many bodies and few DOFs — are built once per group rather than
150/// once per chunk.
151struct LaneGroup<'a> {
152    model: &'a Model,
153    layout: &'a super::step::DofLayout,
154    contact: Option<(&'a GroundContact, &'a [CollisionMesh])>,
155    q: &'a [f64],
156    v: &'a [f64],
157    ctrl: &'a [f64],
158    /// The covector every lane's `∂qdd` is contracted against.
159    w: &'a [f64],
160    seed: Seed,
161    /// `(start, count)` per chunk. A tail chunk may not fill the width;
162    /// unseeded lanes stay exactly zero, so they cost arithmetic and nothing
163    /// else.
164    chunks: &'a [(usize, usize)],
165}
166
167impl crate::multidual::LaneOp for LaneGroup<'_> {
168    type Out = Vec<f64>;
169
170    fn call<const N: usize>(self) -> Vec<f64> {
171        type M<const N: usize> = crate::multidual::MultiDual<N>;
172
173        let lift = |xs: &[f64]| -> Vec<M<N>> { xs.iter().map(|&x| M::<N>::constant(x)).collect() };
174        let q_c = lift(self.q);
175        let v_c = lift(self.v);
176        let ctrl_d = lift(self.ctrl);
177        let inertias: Vec<SpatialInertia<M<N>>> = self
178            .model
179            .bodies
180            .iter()
181            .map(|b| lift_inertia(&b.inertia))
182            .collect();
183
184        let mut out = Vec::new();
185        for &(start, count) in self.chunks {
186            let (mut q_d, mut v_d) = (q_c.clone(), v_c.clone());
187            for lane in 0..count {
188                let idx = start + lane;
189                match self.seed {
190                    Seed::Position => q_d[idx] = M::<N>::var(self.q[idx], lane),
191                    Seed::Velocity => v_d[idx] = M::<N>::var(self.v[idx], lane),
192                }
193            }
194
195            let (_, _, qdd) = step_generic(
196                self.model,
197                self.layout,
198                &inertias,
199                self.contact,
200                None,
201                &q_d,
202                &v_d,
203                &ctrl_d,
204            );
205
206            // wᵀ·∂qdd/∂(coordinate) for each lane. Summed in coordinate order,
207            // the same order the scalar path used, so the result is
208            // bit-identical.
209            out.extend((0..count).map(|lane| -> f64 {
210                qdd.iter()
211                    .zip(self.w)
212                    .map(|(a, &wi)| a.dual[lane] * wi)
213                    .sum()
214            }));
215        }
216        out
217    }
218}
219
220/// How `n` coordinates are cut into chunks: `(width, start, count)`, widest
221/// first, each chunk as wide as the remainder justifies and no wider.
222///
223/// Lanes past the coordinate count are wasted arithmetic, so the tail narrows
224/// rather than padding a full-width chunk: 20 coordinates run as 16 + 4, not
225/// 16 + 16.
226fn chunk_plan(n: usize) -> Vec<(usize, usize, usize)> {
227    let mut plan = Vec::new();
228    let mut start = 0;
229    while start < n {
230        let remaining = n - start;
231        let width = crate::multidual::width_for(remaining);
232        let count = remaining.min(width);
233        plan.push((width, start, count));
234        start += count;
235    }
236    plan
237}
238
239/// `(∂a/∂x_k)ᵀw` for every coordinate `k` of the seeded block, in vector mode.
240///
241/// Chunks that share a width are evaluated together in one monomorphisation,
242/// so the per-width setup (dual-lifting every body's inertia) is paid once for
243/// the group instead of once per chunk.
244///
245/// A **single-coordinate block takes the scalar path instead**, using the
246/// caller's `inertias_dual`, which is lifted once for the whole rollout. Vector
247/// mode at width 1 does the same arithmetic but cannot reuse that lift across
248/// timesteps, and on a model with many bodies and one DOF — a welded assembly
249/// on one hinge — the lift is comparable to the lane work it accompanies,
250/// costing ~10%. Since `MultiDual<1>` is bit-identical to `Dual` this is purely
251/// a choice of which code is faster, never which answer comes out.
252#[allow(clippy::too_many_arguments)]
253fn state_lanes(
254    model: &Model,
255    layout: &super::step::DofLayout,
256    contact: Option<(&GroundContact, &[CollisionMesh])>,
257    inertias_dual: &[SpatialInertia<D>],
258    q: &[f64],
259    v: &[f64],
260    ctrl: &[f64],
261    w: &[f64],
262    seed: Seed,
263) -> Vec<f64> {
264    let n = match seed {
265        Seed::Position => layout.nq,
266        Seed::Velocity => layout.nv,
267    };
268
269    if n == 1 {
270        let lift = |xs: &[f64]| -> Vec<D> { xs.iter().map(|&x| D::constant(x)).collect() };
271        let (mut q_d, mut v_d, u_d) = (lift(q), lift(v), lift(ctrl));
272        match seed {
273            Seed::Position => q_d[0] = D::var(q[0]),
274            Seed::Velocity => v_d[0] = D::var(v[0]),
275        }
276        let (_, _, qdd) = step_generic(
277            model,
278            layout,
279            inertias_dual,
280            contact,
281            None,
282            &q_d,
283            &v_d,
284            &u_d,
285        );
286        return vec![qdd.iter().zip(w).map(|(a, &wi)| a.dual * wi).sum()];
287    }
288
289    let plan = chunk_plan(n);
290    let mut out = Vec::with_capacity(n);
291
292    // Widths appear in descending order and each at most once in practice, but
293    // group explicitly rather than relying on that.
294    let mut done = vec![false; plan.len()];
295    for i in 0..plan.len() {
296        if done[i] {
297            continue;
298        }
299        let width = plan[i].0;
300        let mut chunks: Vec<(usize, usize)> = Vec::new();
301        for (j, c) in plan.iter().enumerate() {
302            if c.0 == width && !done[j] {
303                done[j] = true;
304                chunks.push((c.1, c.2));
305            }
306        }
307        out.extend(crate::multidual::for_lanes(
308            width,
309            LaneGroup {
310                model,
311                layout,
312                contact,
313                q,
314                v,
315                ctrl,
316                w,
317                seed,
318                chunks: &chunks,
319            },
320        ));
321    }
322    out
323}
324
325/// Ground contact configuration for an adjoint rollout: the plane and the
326/// collision skins that feel it.
327pub struct ContactSetup<'a> {
328    /// The ground plane and penalty law.
329    pub ground: GroundContact,
330    /// Collision skins (body-frame vertices), one entry per skinned body.
331    pub meshes: &'a [CollisionMesh],
332}
333
334/// A differentiable rollout: model, optional contact, initial state, and an
335/// open-loop control schedule.
336pub struct AdjointRollout<'a> {
337    /// Topology, gravity, timestep, joint damping, and **nominal** body
338    /// inertias (the π the gradient is taken at).
339    pub model: &'a Model,
340    /// Optional ground contact.
341    pub contact: Option<ContactSetup<'a>>,
342    /// Initial joint positions, in [`super::step::DofLayout`] packing (length
343    /// `DofLayout::of(model).nq`, which exceeds `Model::nq` when the model has
344    /// spherical or free joints).
345    pub q0: Vec<f64>,
346    /// Initial joint velocities (length `nv`).
347    pub v0: Vec<f64>,
348    /// Number of semi-implicit Euler steps.
349    pub steps: usize,
350    /// Open-loop control at step `t` (length `nv`; must not read the state).
351    pub ctrl: &'a dyn Fn(usize) -> DVec,
352}
353
354/// The analytic gradient of a final-state objective: `(∂g/∂q_T, ∂g/∂v_T)`.
355pub type ObjectiveGradientFn<'a> = &'a dyn Fn(&[f64], &[f64]) -> (Vec<f64>, Vec<f64>);
356
357/// A final-state objective `J = g(q_T, v_T)` with its analytic gradient.
358pub struct FinalStateObjective<'a> {
359    /// `g(q_T, v_T)`.
360    pub value: &'a dyn Fn(&[f64], &[f64]) -> f64,
361    /// `(∂g/∂q_T, ∂g/∂v_T)`.
362    pub gradient: ObjectiveGradientFn<'a>,
363}
364
365/// Everything one backward pass produces.
366pub struct AdjointGradients {
367    /// The objective at the nominal rollout.
368    pub objective: f64,
369    /// `dJ/dπ` per body, canonical packing `[m, cx, cy, cz, Ixx, Iyy, Izz,
370    /// Ixy, Ixz, Iyz]` (COM-frame inertia, body-frame COM).
371    pub d_inertia: Vec<[f64; N_INERTIA_PARAMS]>,
372    /// `∂J/∂x` per collision-mesh vertex (body frame), parallel to
373    /// `contact.meshes`; empty when the rollout has no contact.
374    pub d_vertices: Vec<Vec<Vec3>>,
375}
376
377/// Run the nominal rollout and return the objective value only (the FD
378/// oracle for gates, and a cheap primal probe for callers).
379pub fn rollout_objective(rollout: &AdjointRollout, objective: &FinalStateObjective) -> f64 {
380    let (layout, _) = validate_and_params(rollout.model);
381    let contact = rollout.contact.as_ref().map(|c| (&c.ground, c.meshes));
382    let (states, _) = rollout_states(
383        rollout.model,
384        &layout,
385        contact,
386        &rollout.q0,
387        &rollout.v0,
388        rollout.ctrl,
389        rollout.steps,
390    );
391    let (q_t, v_t) = states.last().expect("rollout produced no states");
392    (objective.value)(q_t, v_t)
393}
394
395/// One nominal rollout forward, one adjoint pass backward: `J`, `dJ/dπ` per
396/// body, and (when contact is configured) `∂J/∂x` per collision-mesh vertex.
397pub fn adjoint_rollout_gradient(
398    rollout: &AdjointRollout,
399    objective: &FinalStateObjective,
400) -> AdjointGradients {
401    let model = rollout.model;
402    let (layout, params) = validate_and_params(model);
403    let nb = model.nbodies();
404    let nq = layout.nq;
405    let nv = layout.nv;
406    let dt = model.dt;
407    let contact = rollout.contact.as_ref().map(|c| (&c.ground, c.meshes));
408    assert_eq!(rollout.q0.len(), nq, "q0 length (DofLayout::nq)");
409    assert_eq!(rollout.v0.len(), nv, "v0 length (nv)");
410
411    // Forward: store the whole trajectory and its accelerations.
412    let (states, accels) = rollout_states(
413        model,
414        &layout,
415        contact,
416        &rollout.q0,
417        &rollout.v0,
418        rollout.ctrl,
419        rollout.steps,
420    );
421    let (q_final, v_final) = states.last().expect("rollout produced no states");
422    let j0 = (objective.value)(q_final, v_final);
423    let (mut lam_q, mut lam_v) = (objective.gradient)(q_final, v_final);
424    assert_eq!(lam_q.len(), nq, "objective ∂g/∂q length");
425    assert_eq!(lam_v.len(), nv, "objective ∂g/∂v length");
426
427    let mut d_inertia = vec![[0.0f64; N_INERTIA_PARAMS]; nb];
428    let mut d_vertices: Vec<Vec<Vec3>> = rollout
429        .contact
430        .as_ref()
431        .map(|c| {
432            c.meshes
433                .iter()
434                .map(|m| vec![Vec3::zeros(); m.vertices.len()])
435                .collect()
436        })
437        .unwrap_or_default();
438
439    let inertias_f64: Vec<SpatialInertia<f64>> = model.bodies.iter().map(|b| b.inertia).collect();
440    // Dual-lifted inertias for the single-coordinate lane path, hoisted out of
441    // the time loop the way the all-scalar implementation had them.
442    let inertias_dual: Vec<SpatialInertia<D>> = model
443        .bodies
444        .iter()
445        .map(|b| lift_inertia(&b.inertia))
446        .collect();
447
448    // Constants of the model, hoisted out of the time loop.
449    let d_i_mats: Vec<[SpatialMat<f64>; N_INERTIA_PARAMS]> =
450        params.iter().map(inertia_param_derivatives).collect();
451    // `y = M⁻¹w` is read out of ABA with zero velocity and zero gravity, so
452    // the bias and gravity terms vanish and `qdd = M⁻¹·ctrl`.
453    let model_free = {
454        let mut m = model.clone();
455        m.gravity = Vec3::zeros();
456        m
457    };
458    let zeros_v = vec![0.0f64; nv];
459    // With no multi-DOF joint, `Φ(q, v') = q + dt·v'` exactly, so `Φ_q = I` and
460    // `Φ_v' = dt·I` and the two Φ lane sweeps below are a known answer. Skip
461    // them: this is the common case and it keeps the single-DOF cost at the
462    // `2·nv` state lanes.
463    let euclidean_q = model
464        .joints
465        .iter()
466        .all(|j| joint_nq(j.joint_type) == j.ndof());
467
468    // Backward over steps.
469    for t in (0..rollout.steps).rev() {
470        let (q_t, v_t) = &states[t];
471        let v_next = &states[t + 1].1;
472        let u_t = (rollout.ctrl)(t);
473        let u_t = u_t.as_slice();
474
475        let lift = |xs: &[f64]| -> Vec<D> { xs.iter().map(|&x| D::constant(x)).collect() };
476        let q_c = lift(q_t);
477        let vn_c = lift(v_next);
478        let dt_d = D::constant(dt);
479
480        // Configuration-update lanes: Φ contracted against λ_q' along each of
481        // its two argument blocks. `phi_q = Φ_qᵀ λ_q'` (nq), `psi = Φ_v'ᵀ λ_q'`
482        // (nv). Both are dual lanes through Φ alone — no ABA.
483        let phi_dot = |q_d: &[D], vn_d: &[D]| -> f64 {
484            let qn = config_update_generic(model, &layout, q_d, vn_d, dt_d);
485            qn.iter().zip(&lam_q).map(|(x, &l)| x.dual * l).sum()
486        };
487        let (phi_q, psi) = if euclidean_q {
488            (lam_q.clone(), lam_q.iter().map(|&l| dt * l).collect())
489        } else {
490            let mut phi_q = vec![0.0f64; nq];
491            for i in 0..nq {
492                let mut q_d = q_c.clone();
493                q_d[i] = D::var(q_t[i]);
494                phi_q[i] = phi_dot(&q_d, &vn_c);
495            }
496            let mut psi = vec![0.0f64; nv];
497            for j in 0..nv {
498                let mut vn_d = vn_c.clone();
499                vn_d[j] = D::var(v_next[j]);
500                psi[j] = phi_dot(&q_c, &vn_d);
501            }
502            (phi_q, psi)
503        };
504
505        // w = Φ_v'ᵀλ_q' + λ_v' — the covector every channel contracts against.
506        let w: Vec<f64> = psi.iter().zip(&lam_v).map(|(&p, &lv)| p + lv).collect();
507
508        // State lanes: aq[i] = (∂a/∂q_i)ᵀw (nq of them), av[j] = (∂a/∂v_j)ᵀw.
509        //
510        // These are the backward pass's dominant cost — one seeded evaluation
511        // of the step per state coordinate — so they run in vector mode: each
512        // pass carries up to 16 tangent directions through a single primal.
513        // Lane-for-lane the arithmetic is bit-identical to the scalar `Dual`
514        // path (see [`crate::multidual`]), so widening changes the time and
515        // not the answer.
516        let aq = state_lanes(
517            model,
518            &layout,
519            contact,
520            &inertias_dual,
521            q_t,
522            v_t,
523            u_t,
524            &w,
525            Seed::Position,
526        );
527        let av = state_lanes(
528            model,
529            &layout,
530            contact,
531            &inertias_dual,
532            q_t,
533            v_t,
534            u_t,
535            &w,
536            Seed::Velocity,
537        );
538
539        // Inertia and wrench channels, analytically (see module docs). Two
540        // O(nb) sweeps price *all* 10·nb parameter directions and all 6·nb
541        // wrench directions at once — no dual lane per parameter.
542        //
543        //   y   = M⁻¹w                     (one ABA)
544        //   A^y = J y                      (one velocity sweep)
545        //   dJ/dπ[b][k] -= dt·⟨A^y_b, ΔI·A_b + V_b ×* (ΔI·V_b)⟩
546        //   χ_b          = dt·A^y_b
547        let (vel_b, acc_b) = nominal_motion(model, &layout, q_t, v_t, &accels[t]);
548        let y = aba_generic::<f64>(&model_free, &layout, &inertias_f64, q_t, &zeros_v, &w, None);
549        let (_, a_y) = fk_generic::<f64>(model, &layout, q_t, &y);
550
551        for b in 0..nb {
552            for k in 0..N_INERTIA_PARAMS {
553                let di = &d_i_mats[b][k];
554                let f = di.mul_vec(&acc_b[b]) + vel_b[b].cross_force(&di.mul_vec(&vel_b[b]));
555                d_inertia[b][k] -= dt * a_y[b].dot(&f);
556            }
557        }
558
559        // Vertex channel: price the wrench cotangent χ once per contacting
560        // body, then contract each vertex's local wrench Jacobian against it.
561        if let Some(setup) = rollout.contact.as_ref() {
562            // χ[b][c] = dt·wᵀ·∂a/∂(wrench_b component c). A body-frame wrench
563            // on body b enters the dynamics as Jᵀ_b, so wᵀM⁻¹Jᵀ_b = (J_b y)ᵀ
564            // — the same A^y field the inertia channel already computed.
565            let chi: Vec<[f64; 6]> = (0..nb)
566                .map(|b| {
567                    let m = a_y[b].as_array();
568                    core::array::from_fn(|c| dt * m[c])
569                })
570                .collect();
571
572            // Local per-vertex wrench Jacobian at the nominal kinematics:
573            // 3 dual evaluations of the penalty law, holding the state fixed.
574            let (xforms, vels) = fk_generic::<f64>(model, &layout, q_t, v_t);
575            for (mi, mesh) in setup.meshes.iter().enumerate() {
576                let b = mesh.body;
577                let xf_d = tang::SpatialTransform::<D>::new(
578                    tang::Mat3::from_cols(
579                        lift_vec3(xforms[b].rot.col(0)),
580                        lift_vec3(xforms[b].rot.col(1)),
581                        lift_vec3(xforms[b].rot.col(2)),
582                    ),
583                    lift_vec3(xforms[b].pos),
584                );
585                let twist_d =
586                    SpatialVec::new(lift_vec3(vels[b].angular), lift_vec3(vels[b].linear));
587                for (vi, vtx) in mesh.vertices.iter().enumerate() {
588                    for k in 0..3 {
589                        let mut x_b: GVec3<D> = lift_vec3(*vtx);
590                        match k {
591                            0 => x_b.x = D::var(vtx.x),
592                            1 => x_b.y = D::var(vtx.y),
593                            _ => x_b.z = D::var(vtx.z),
594                        }
595                        let wr = vertex_wrench(&setup.ground, x_b, &xf_d, &twist_d);
596                        let wr = wr.as_array();
597                        let mut acc = 0.0;
598                        for (c, w_c) in wr.iter().enumerate() {
599                            acc += chi[b][c] * w_c.dual;
600                        }
601                        match k {
602                            0 => d_vertices[mi][vi].x += acc,
603                            1 => d_vertices[mi][vi].y += acc,
604                            _ => d_vertices[mi][vi].z += acc,
605                        }
606                    }
607                }
608            }
609        }
610
611        // λ update (see module docs for the derivation).
612        for i in 0..nq {
613            lam_q[i] = phi_q[i] + dt * aq[i];
614        }
615        for j in 0..nv {
616            lam_v[j] = w[j] + dt * av[j];
617        }
618    }
619
620    AdjointGradients {
621        objective: j0,
622        d_inertia,
623        d_vertices,
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use phyz_math::{Mat3, SpatialInertia as SI64, SpatialTransform};
631    use phyz_model::ModelBuilder;
632
633    /// The scalar-`Dual` lane extraction this module used before vector mode:
634    /// one seeded pass through the step per coordinate. Kept as the reference
635    /// the fast path is checked against.
636    fn state_lanes_scalar(
637        model: &Model,
638        layout: &super::super::step::DofLayout,
639        q: &[f64],
640        v: &[f64],
641        ctrl: &[f64],
642        w: &[f64],
643        seed: Seed,
644    ) -> Vec<f64> {
645        let lift = |xs: &[f64]| -> Vec<D> { xs.iter().map(|&x| D::constant(x)).collect() };
646        let (q_c, v_c, u_c) = (lift(q), lift(v), lift(ctrl));
647        let inertias: Vec<SpatialInertia<D>> = model
648            .bodies
649            .iter()
650            .map(|b| lift_inertia(&b.inertia))
651            .collect();
652
653        let n = match seed {
654            Seed::Position => layout.nq,
655            Seed::Velocity => layout.nv,
656        };
657        (0..n)
658            .map(|k| {
659                let (mut q_d, mut v_d) = (q_c.clone(), v_c.clone());
660                match seed {
661                    Seed::Position => q_d[k] = D::var(q[k]),
662                    Seed::Velocity => v_d[k] = D::var(v[k]),
663                }
664                let (_, _, qdd) =
665                    step_generic(model, layout, &inertias, None, None, &q_d, &v_d, &u_c);
666                qdd.iter().zip(w).map(|(a, &wi)| a.dual * wi).sum()
667            })
668            .collect()
669    }
670
671    fn link(m: f64) -> SI64 {
672        SI64::new(
673            m,
674            Vec3::new(0.0, -0.5, 0.0),
675            Mat3::from_diagonal(&Vec3::new(0.1, 0.13, 0.11)),
676        )
677    }
678
679    fn chain(n: usize) -> Model {
680        let off = SpatialTransform::new(Mat3::identity(), Vec3::new(0.0, -1.0, 0.0));
681        let mut b = ModelBuilder::new()
682            .gravity(Vec3::new(0.0, -9.81, 0.0))
683            .dt(2.0e-3);
684        for i in 0..n {
685            let xf = if i == 0 {
686                SpatialTransform::identity()
687            } else {
688                off
689            };
690            b = b.add_revolute_body(
691                &format!("l{i}"),
692                i as i32 - 1,
693                xf,
694                link(1.0 + 0.1 * i as f64),
695            );
696        }
697        b.build()
698    }
699
700    /// Vector mode must agree with the scalar path **bit for bit**, at every
701    /// DOF count — including the ones where the chunk does not fill its width
702    /// (3, 5, 17) and the ones that need more than one chunk (17, 20).
703    ///
704    /// Approximate agreement would not do: `phyz`'s rollouts are bitwise
705    /// reproducible, and a gradient that shifted in the last ulp because the
706    /// model gained a joint would break that promise silently.
707    #[test]
708    fn vector_mode_matches_scalar_dual_bitwise() {
709        for n in [1usize, 2, 3, 5, 8, 17, 20] {
710            let model = chain(n);
711            let layout = super::super::step::DofLayout::of(&model);
712            let q: Vec<f64> = (0..layout.nq).map(|i| 0.21 + 0.07 * i as f64).collect();
713            let v: Vec<f64> = (0..layout.nv).map(|i| -0.13 + 0.05 * i as f64).collect();
714            let ctrl: Vec<f64> = (0..layout.nv).map(|i| 0.02 * i as f64).collect();
715            let w: Vec<f64> = (0..layout.nv).map(|i| 1.0 - 0.03 * i as f64).collect();
716
717            for seed in [Seed::Position, Seed::Velocity] {
718                let inert: Vec<SpatialInertia<D>> = model
719                    .bodies
720                    .iter()
721                    .map(|b| lift_inertia(&b.inertia))
722                    .collect();
723                let fast = state_lanes(&model, &layout, None, &inert, &q, &v, &ctrl, &w, seed);
724                let slow = state_lanes_scalar(&model, &layout, &q, &v, &ctrl, &w, seed);
725                assert_eq!(fast.len(), slow.len(), "n = {n}, {seed:?}");
726                for (k, (a, b)) in fast.iter().zip(&slow).enumerate() {
727                    assert_eq!(
728                        a.to_bits(),
729                        b.to_bits(),
730                        "n = {n}, {seed:?}, lane {k}: {a} vs {b}"
731                    );
732                }
733            }
734        }
735    }
736
737    /// The same, with contact wrenches in the loop — a separate code path
738    /// inside the step, and one where the penalty law's `max` could in
739    /// principle take a different branch per lane if the comparison ever
740    /// consulted a tangent.
741    #[test]
742    fn vector_mode_matches_scalar_dual_with_contact() {
743        let model = chain(3);
744        let layout = super::super::step::DofLayout::of(&model);
745        let meshes = [CollisionMesh {
746            body: 2,
747            vertices: vec![
748                Vec3::new(0.02, -1.0, 0.0),
749                Vec3::new(-0.02, -1.0, 0.01),
750                Vec3::new(0.0, -0.98, -0.02),
751            ],
752        }];
753        let ground = GroundContact {
754            height: -1.5,
755            stiffness: 4.0e3,
756            damping: 40.0,
757        };
758        let contact = Some((&ground, &meshes[..]));
759
760        let q: Vec<f64> = (0..layout.nq).map(|i| 0.4 + 0.05 * i as f64).collect();
761        let v = vec![0.1, -0.2, 0.05];
762        let ctrl = vec![0.0; layout.nv];
763        let w = vec![0.9, 1.1, -0.7];
764
765        for seed in [Seed::Position, Seed::Velocity] {
766            let inert: Vec<SpatialInertia<D>> = model
767                .bodies
768                .iter()
769                .map(|b| lift_inertia(&b.inertia))
770                .collect();
771            let fast = state_lanes(&model, &layout, contact, &inert, &q, &v, &ctrl, &w, seed);
772            // The scalar reference takes no contact, so run it against a
773            // hand-rolled seeded pass that does.
774            let lift = |xs: &[f64]| -> Vec<D> { xs.iter().map(|&x| D::constant(x)).collect() };
775            let (q_c, v_c, u_c) = (lift(&q), lift(&v), lift(&ctrl));
776            let inertias: Vec<SpatialInertia<D>> = model
777                .bodies
778                .iter()
779                .map(|b| lift_inertia(&b.inertia))
780                .collect();
781            let n = match seed {
782                Seed::Position => layout.nq,
783                Seed::Velocity => layout.nv,
784            };
785            for k in 0..n {
786                let (mut q_d, mut v_d) = (q_c.clone(), v_c.clone());
787                match seed {
788                    Seed::Position => q_d[k] = D::var(q[k]),
789                    Seed::Velocity => v_d[k] = D::var(v[k]),
790                }
791                let (_, _, qdd) =
792                    step_generic(&model, &layout, &inertias, contact, None, &q_d, &v_d, &u_c);
793                let slow: f64 = qdd.iter().zip(&w).map(|(a, &wi)| a.dual * wi).sum();
794                assert_eq!(
795                    fast[k].to_bits(),
796                    slow.to_bits(),
797                    "{seed:?} lane {k}: {} vs {slow}",
798                    fast[k]
799                );
800            }
801        }
802    }
803
804    /// A non-empty check on the reference itself: a pendulum's `∂a/∂q` is not
805    /// zero, so the bitwise comparisons above are comparing real numbers
806    /// rather than two identically empty answers.
807    #[test]
808    fn lanes_are_not_trivially_zero() {
809        let model = chain(2);
810        let layout = super::super::step::DofLayout::of(&model);
811        let q = vec![0.3, 0.5];
812        let v = vec![0.2, -0.1];
813        let ctrl = vec![0.0, 0.0];
814        let w = vec![1.0, 1.0];
815        let inert: Vec<SpatialInertia<D>> = model
816            .bodies
817            .iter()
818            .map(|b| lift_inertia(&b.inertia))
819            .collect();
820        let aq = state_lanes(
821            &model,
822            &layout,
823            None,
824            &inert,
825            &q,
826            &v,
827            &ctrl,
828            &w,
829            Seed::Position,
830        );
831        assert!(
832            aq.iter().any(|x| x.abs() > 1e-9),
833            "every position lane vanished: {aq:?}"
834        );
835    }
836}