use phyz::diff::{
AdjointRollout, CollisionMesh, ContactSetup, FinalStateObjective, GroundContact,
adjoint_rollout_gradient, rollout_objective,
};
use phyz::math::{DVec, Mat3, SpatialInertia, SpatialTransform, Vec3};
use phyz::model::{Joint, JointType, Model, ModelBuilder};
fn si_from(p: [f64; 10]) -> SpatialInertia {
SpatialInertia::new(
p[0],
Vec3::new(p[1], p[2], p[3]),
Mat3::new(p[4], p[7], p[8], p[7], p[5], p[9], p[8], p[9], p[6]),
)
}
fn q0_objective<'a>() -> FinalStateObjective<'a> {
FinalStateObjective {
value: &|q: &[f64], _v: &[f64]| q[0],
gradient: &|q: &[f64], v: &[f64]| {
let mut gq = vec![0.0; q.len()];
gq[0] = 1.0;
(gq, vec![0.0; v.len()])
},
}
}
fn v0_objective<'a>() -> FinalStateObjective<'a> {
FinalStateObjective {
value: &|_q: &[f64], v: &[f64]| v[0],
gradient: &|q: &[f64], v: &[f64]| {
let mut gv = vec![0.0; v.len()];
gv[0] = 1.0;
(vec![0.0; q.len()], gv)
},
}
}
#[test]
fn flywheel_inertia_gradient_matches_closed_form() {
const TAU: f64 = 0.02;
const IZZ: f64 = 0.05;
const DT: f64 = 1.0 / 480.0;
const STEPS: usize = 96;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_revolute_body(
"disc",
-1,
SpatialTransform::identity(),
si_from([1.4, 0.0, 0.0, 0.0, 0.03, 0.03, IZZ, 0.0, 0.0, 0.0]),
)
.build();
let ctrl = |_t: usize| DVec::from_slice(&[TAU]);
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: vec![0.0],
v0: vec![0.0],
steps: STEPS,
ctrl: &ctrl,
};
let obj = v0_objective();
let g = adjoint_rollout_gradient(&rollout, &obj);
let t_total = STEPS as f64 * DT;
let j_expected = TAU * t_total / IZZ;
assert!(
(g.objective - j_expected).abs() / j_expected <= 1e-12,
"J = {} vs closed form {}",
g.objective,
j_expected
);
let d_izz_expected = -TAU * t_total / (IZZ * IZZ);
let rel = (g.d_inertia[0][6] - d_izz_expected).abs() / d_izz_expected.abs();
assert!(
rel <= 1e-12,
"dJ/dIzz = {} vs closed form {} (rel {rel:.3e})",
g.d_inertia[0][6],
d_izz_expected
);
for (k, &gk) in g.d_inertia[0].iter().enumerate() {
if k == 6 {
continue;
}
assert!(
gk.abs() <= 1e-10 * d_izz_expected.abs(),
"π[{k}] should not influence the flywheel, got {gk}"
);
}
}
#[test]
fn pendulum_inertia_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 300;
const GATE: f64 = 1e-6;
let pi0: [f64; 10] = [1.3, 0.4, 0.1, 0.05, 0.02, 0.03, 0.04, 0.005, 0.002, 0.001];
let build = |p: [f64; 10]| -> Model {
ModelBuilder::new()
.gravity(Vec3::new(-9.81, 0.0, 0.0))
.dt(DT)
.add_revolute_body("bob", -1, SpatialTransform::identity(), si_from(p))
.build()
};
let ctrl = |_t: usize| DVec::from_slice(&[0.01]);
let obj = q0_objective();
let j_at = |p: [f64; 10]| -> f64 {
let m = build(p);
let ro = AdjointRollout {
model: &m,
contact: None,
q0: vec![0.3],
v0: vec![0.2],
steps: STEPS,
ctrl: &ctrl,
};
rollout_objective(&ro, &obj)
};
let model = build(pi0);
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: vec![0.3],
v0: vec![0.2],
steps: STEPS,
ctrl: &ctrl,
};
let g = adjoint_rollout_gradient(&rollout, &obj);
let h_for = |k: usize| match k {
0 => 1e-5, 1..=3 => 1e-6, _ => 1e-7, };
let mut live = 0;
for k in 0..10 {
let h = h_for(k);
let mut pp = pi0;
pp[k] += h;
let mut pm = pi0;
pm[k] -= h;
let fd = (j_at(pp) - j_at(pm)) / (2.0 * h);
let adj = g.d_inertia[0][k];
if fd.abs() > 1e-8 {
live += 1;
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"dJ/dπ[{k}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-8,
"dJ/dπ[{k}]: fd is dead ({fd}) but adjoint says {adj}"
);
}
}
assert!(
live >= 4,
"only {live} live channels — gate under-constrained"
);
}
#[test]
fn two_link_chain_inertia_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 250;
const GATE: f64 = 1e-6;
let p1: [f64; 10] = [
1.1, 0.25, 0.05, -0.03, 0.02, 0.025, 0.03, 0.004, 0.001, 0.002,
];
let p2: [f64; 10] = [
0.7, 0.15, -0.04, 0.06, 0.01, 0.012, 0.014, 0.002, 0.001, 0.0015,
];
let build = |pa: [f64; 10], pb: [f64; 10]| -> Model {
let joint2 = Joint {
joint_type: JointType::Revolute,
parent_to_joint: SpatialTransform::from_translation(Vec3::new(0.5, 0.0, 0.0)),
axis: Vec3::new(1.0, 0.0, 0.0),
damping: 0.02,
limits: None,
..Default::default()
};
ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_revolute_body("link1", -1, SpatialTransform::identity(), si_from(pa))
.add_body("link2", 0, joint2, si_from(pb))
.build()
};
let ctrl = |_t: usize| DVec::from_slice(&[0.02, -0.01]);
let obj = q0_objective();
let q0 = vec![0.2, -0.3];
let v0 = vec![0.1, 0.25];
let j_at = |pa: [f64; 10], pb: [f64; 10]| -> f64 {
let m = build(pa, pb);
let ro = AdjointRollout {
model: &m,
contact: None,
q0: q0.clone(),
v0: v0.clone(),
steps: STEPS,
ctrl: &ctrl,
};
rollout_objective(&ro, &obj)
};
let model = build(p1, p2);
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: q0.clone(),
v0: v0.clone(),
steps: STEPS,
ctrl: &ctrl,
};
let g = adjoint_rollout_gradient(&rollout, &obj);
let h_for = |k: usize| match k {
0 => 1e-5,
1..=3 => 1e-6,
_ => 1e-6,
};
let mut live = 0;
for b in 0..2 {
for k in 0..10 {
let h = h_for(k);
let perturb = |sign: f64| -> f64 {
let mut pa = p1;
let mut pb = p2;
if b == 0 {
pa[k] += sign * h;
} else {
pb[k] += sign * h;
}
j_at(pa, pb)
};
let fd = (perturb(1.0) - perturb(-1.0)) / (2.0 * h);
let adj = g.d_inertia[b][k];
if fd.abs() > 1e-8 {
live += 1;
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"dJ/dπ[body {b}][{k}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-8,
"dJ/dπ[body {b}][{k}]: fd dead ({fd}) but adjoint {adj}"
);
}
}
}
assert!(
live >= 14,
"only {live}/20 live channels — chain not exercising the packing"
);
}
#[test]
fn adjoint_is_deterministic() {
const DT: f64 = 1e-3;
let model = ModelBuilder::new()
.gravity(Vec3::new(-9.81, 0.0, 0.0))
.dt(DT)
.add_revolute_body(
"bob",
-1,
SpatialTransform::identity(),
si_from([1.3, 0.4, 0.1, 0.05, 0.02, 0.03, 0.04, 0.005, 0.002, 0.001]),
)
.build();
let ctrl = |_t: usize| DVec::from_slice(&[0.01]);
let obj = q0_objective();
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: vec![0.3],
v0: vec![0.2],
steps: 200,
ctrl: &ctrl,
};
let a = adjoint_rollout_gradient(&rollout, &obj);
let b = adjoint_rollout_gradient(&rollout, &obj);
assert_eq!(a.objective, b.objective, "objective must be bit-identical");
for k in 0..10 {
assert_eq!(
a.d_inertia[0][k], b.d_inertia[0][k],
"dJ/dπ[{k}] must be bit-identical"
);
}
}
#[test]
fn box_on_plane_vertex_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 800;
const GATE: f64 = 1e-4;
const HALF: f64 = 0.05;
const MASS: f64 = 0.5;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_prismatic_body(
"box",
-1,
SpatialTransform::identity(),
Vec3::new(0.0, 0.0, 1.0),
si_from([MASS, 0.0, 0.0, 0.0, 1e-3, 1e-3, 1e-3, 0.0, 0.0, 0.0]),
)
.build();
let ground = GroundContact {
height: -HALF, stiffness: 2e3,
damping: 20.0,
};
let box_vertices = |half: f64| -> Vec<Vec3> {
let mut v = Vec::new();
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
v.push(Vec3::new(sx * half, sy * half, sz * half));
}
}
}
v
};
let ctrl = |_t: usize| DVec::from_slice(&[0.0]);
let obj = q0_objective();
let j_with_mesh = |vertices: Vec<Vec3>| -> f64 {
let meshes = [CollisionMesh { body: 0, vertices }];
let ro = AdjointRollout {
model: &model,
contact: Some(ContactSetup {
ground,
meshes: &meshes,
}),
q0: vec![0.0],
v0: vec![0.0],
steps: STEPS,
ctrl: &ctrl,
};
rollout_objective(&ro, &obj)
};
let meshes = [CollisionMesh {
body: 0,
vertices: box_vertices(HALF),
}];
let rollout = AdjointRollout {
model: &model,
contact: Some(ContactSetup {
ground,
meshes: &meshes,
}),
q0: vec![0.0],
v0: vec![0.0],
steps: STEPS,
ctrl: &ctrl,
};
let g = adjoint_rollout_gradient(&rollout, &obj);
let d_eq = MASS * 9.81 / (4.0 * ground.stiffness);
assert!(
(g.objective + d_eq).abs() < 1e-6,
"box should settle at q = −mg/4k = {}, got {}",
-d_eq,
g.objective
);
const H: f64 = 1e-7;
let samples: [(usize, usize); 4] = [(0, 2), (6, 2), (0, 0), (1, 2)];
for (vi, axis) in samples {
let mut vp = box_vertices(HALF);
let mut vm = box_vertices(HALF);
match axis {
0 => {
vp[vi].x += H;
vm[vi].x -= H;
}
1 => {
vp[vi].y += H;
vm[vi].y -= H;
}
_ => {
vp[vi].z += H;
vm[vi].z -= H;
}
}
let fd = (j_with_mesh(vp) - j_with_mesh(vm)) / (2.0 * H);
let adj = match axis {
0 => g.d_vertices[0][vi].x,
1 => g.d_vertices[0][vi].y,
_ => g.d_vertices[0][vi].z,
};
if fd.abs() > 1e-6 {
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"∂J/∂x[v{vi}][{axis}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-6,
"∂J/∂x[v{vi}][{axis}]: fd dead ({fd}) but adjoint {adj}"
);
}
}
for vi in 0..8 {
let gz = g.d_vertices[0][vi].z;
if vi % 2 == 0 {
let rel = (gz + 0.25).abs() / 0.25;
assert!(
rel <= 1e-3,
"bottom vertex {vi}: ∂J/∂z = {gz}, expected −0.25 (rel {rel:.3e})"
);
} else {
assert!(gz.abs() <= 1e-9, "top vertex {vi} should be dead, got {gz}");
}
assert!(
g.d_vertices[0][vi].x.abs() <= 1e-9 && g.d_vertices[0][vi].y.abs() <= 1e-9,
"tangential channels must be dead without rotation/friction (v{vi})"
);
}
}
#[test]
fn tilting_paddle_vertex_gradient_matches_fd() {
const DT: f64 = 5e-4;
const STEPS: usize = 1200;
const GATE: f64 = 1e-4;
let joint = Joint {
joint_type: JointType::Revolute,
parent_to_joint: SpatialTransform::identity(),
axis: Vec3::new(0.0, 1.0, 0.0),
damping: 0.05,
limits: None,
..Default::default()
};
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_body(
"paddle",
-1,
joint,
si_from([0.5, 0.3, 0.0, 0.0, 5e-4, 4e-3, 4e-3, 0.0, 0.0, 0.0]),
)
.build();
let ground = GroundContact {
height: -0.035,
stiffness: 2e3,
damping: 20.0,
};
let paddle_vertices = || -> Vec<Vec3> {
let mut v = Vec::new();
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
v.push(Vec3::new(0.3 + sx * 0.05, sy * 0.04, sz * 0.03));
}
}
}
v
};
let ctrl = |_t: usize| DVec::from_slice(&[0.0]);
let obj = q0_objective();
let j_with_mesh = |vertices: Vec<Vec3>, q0: f64| -> f64 {
let meshes = [CollisionMesh { body: 0, vertices }];
let ro = AdjointRollout {
model: &model,
contact: Some(ContactSetup {
ground,
meshes: &meshes,
}),
q0: vec![q0],
v0: vec![0.0],
steps: STEPS,
ctrl: &ctrl,
};
rollout_objective(&ro, &obj)
};
let q_eq = j_with_mesh(paddle_vertices(), 0.012);
let meshes = [CollisionMesh {
body: 0,
vertices: paddle_vertices(),
}];
let rollout = AdjointRollout {
model: &model,
contact: Some(ContactSetup {
ground,
meshes: &meshes,
}),
q0: vec![q_eq],
v0: vec![0.0],
steps: STEPS,
ctrl: &ctrl,
};
let g = adjoint_rollout_gradient(&rollout, &obj);
const H: f64 = 1e-7;
let samples: [(usize, usize); 5] = [(4, 2), (6, 2), (4, 0), (6, 1), (1, 2)];
let mut live = 0;
for (vi, axis) in samples {
let mut vp = paddle_vertices();
let mut vm = paddle_vertices();
match axis {
0 => {
vp[vi].x += H;
vm[vi].x -= H;
}
1 => {
vp[vi].y += H;
vm[vi].y -= H;
}
_ => {
vp[vi].z += H;
vm[vi].z -= H;
}
}
let fd = (j_with_mesh(vp, q_eq) - j_with_mesh(vm, q_eq)) / (2.0 * H);
let adj = match axis {
0 => g.d_vertices[0][vi].x,
1 => g.d_vertices[0][vi].y,
_ => g.d_vertices[0][vi].z,
};
if fd.abs() > 1e-6 {
live += 1;
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"∂J/∂x[v{vi}][{axis}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-6,
"∂J/∂x[v{vi}][{axis}]: fd dead ({fd}) but adjoint {adj}"
);
}
}
assert!(
live >= 3,
"only {live} live samples — rotation chain not exercised"
);
}
#[test]
fn mixed_joint_chain_inertia_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 200;
const GATE: f64 = 1e-5;
let p: [[f64; 10]; 3] = [
[
1.1, 0.25, 0.05, -0.03, 0.02, 0.025, 0.03, 0.004, 0.001, 0.002,
],
[
0.7, 0.15, -0.04, 0.06, 0.01, 0.012, 0.014, 0.002, 0.001, 0.0015,
],
[
0.4, -0.1, 0.08, 0.02, 0.008, 0.006, 0.009, 0.001, 0.0005, 0.0008,
],
];
let build = |p: [[f64; 10]; 3]| -> Model {
let slide = Joint {
joint_type: JointType::Prismatic,
parent_to_joint: SpatialTransform::from_translation(Vec3::new(0.4, 0.0, 0.0)),
axis: Vec3::new(0.0, 1.0, 0.0),
damping: 0.01,
limits: None,
..Default::default()
};
let welded = Joint {
joint_type: JointType::Fixed,
parent_to_joint: SpatialTransform::from_translation(Vec3::new(0.0, 0.3, 0.1)),
..Default::default()
};
ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_revolute_body("link1", -1, SpatialTransform::identity(), si_from(p[0]))
.add_body("link2", 0, slide, si_from(p[1]))
.add_body("link3", 1, welded, si_from(p[2]))
.build()
};
let ctrl = |_t: usize| DVec::from_slice(&[0.02, -0.05]);
let obj = q0_objective();
let (q0, v0) = (vec![0.2, 0.1], vec![0.15, -0.05]);
let j_at = |pp: [[f64; 10]; 3]| -> f64 {
let m = build(pp);
rollout_objective(
&AdjointRollout {
model: &m,
contact: None,
q0: q0.clone(),
v0: v0.clone(),
steps: STEPS,
ctrl: &ctrl,
},
&obj,
)
};
let model = build(p);
let g = adjoint_rollout_gradient(
&AdjointRollout {
model: &model,
contact: None,
q0: q0.clone(),
v0: v0.clone(),
steps: STEPS,
ctrl: &ctrl,
},
&obj,
);
let mut live = 0;
for b in 0..3 {
for k in 0..10 {
let h = match k {
0 => 1e-5,
1..=3 => 1e-6,
_ => 1e-7,
};
let (mut pp, mut pm) = (p, p);
pp[b][k] += h;
pm[b][k] -= h;
let fd = (j_at(pp) - j_at(pm)) / (2.0 * h);
let adj = g.d_inertia[b][k];
if fd.abs() > 1e-7 {
live += 1;
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"dJ/dπ[{b}][{k}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-6,
"dJ/dπ[{b}][{k}]: fd is dead ({fd}) but adjoint says {adj}"
);
}
}
}
assert!(
live >= 8,
"only {live} live channels — gate under-constrained"
);
}