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}