Skip to main content

phyz_diff/
symbolic.rs

1//! Symbolic Jacobian computation via tang-expr.
2//!
3//! Traces the Articulated Body Algorithm (ABA) and semi-implicit Euler step
4//! symbolically using `ExprId` as the scalar type, then differentiates the
5//! output expressions w.r.t. all input variables. The resulting derivatives
6//! are simplified and compiled to fast closures.
7//!
8//! # How it works
9//!
10//! 1. Model topology (parent indices, joint types, axes, inertias) is read
11//!    from the concrete `Model` at compile time.
12//! 2. State variables (q, v, ctrl) become `ExprId::var(i)` in the expression
13//!    graph. Gravity and timestep become `ExprId::from_f64(...)` literals.
14//! 3. We re-implement the single-DOF ABA path using tang's generic spatial
15//!    algebra types (`SpatialVec<ExprId>`, `SpatialMat<ExprId>`, etc.).
16//! 4. The output accelerations are combined with semi-implicit Euler to
17//!    produce (q', v') expressions. We differentiate each output w.r.t.
18//!    each input, simplify, and compile to closures.
19//!
20//! # Limitations
21//!
22//! - Only single-DOF joints (revolute, prismatic) are supported. Multi-DOF
23//!   joints (spherical, free) require symbolic matrix inversion which is not
24//!   yet implemented.
25//! - The compiled closures are specialized to a specific model topology.
26//!   If the model changes, recompile.
27
28use phyz_model::{JointType, Model};
29use tang::Scalar;
30use tang::{Mat3, SpatialInertia, SpatialMat, SpatialTransform, SpatialVec, Vec3};
31use tang_expr::{ExprId, trace};
32use tang_la::DMat;
33
34use crate::StepJacobians;
35
36/// Compiled multi-output closure: maps input params to output values.
37type CompiledFn = Box<dyn Fn(&[f64], &mut [f64])>;
38
39/// Compiled symbolic step Jacobians.
40///
41/// Caches compiled closures for evaluating all 5 Jacobian matrices.
42/// Reusable across different states for the same model topology.
43pub struct CompiledStepJacobians {
44    /// Number of position DOFs.
45    nq: usize,
46    /// Number of velocity DOFs.
47    nv: usize,
48    /// Total number of input variables: nq + nv + nv (q, v, ctrl).
49    num_inputs: usize,
50    /// Compiled closure for all Jacobian entries.
51    /// Layout: [dqnext_dq (nq*nq), dqnext_dv (nq*nv), dvnext_dq (nv*nq), dvnext_dv (nv*nv), dvnext_dctrl (nv*nv)]
52    jac_fn: CompiledFn,
53    /// Total number of Jacobian entries.
54    num_outputs: usize,
55}
56
57impl CompiledStepJacobians {
58    /// Build compiled Jacobians for the given model.
59    ///
60    /// This traces ABA + semi-implicit Euler symbolically, differentiates
61    /// all outputs w.r.t. all inputs, simplifies, and compiles.
62    ///
63    /// Only supports models with single-DOF joints (revolute/prismatic).
64    /// Panics if any joint is multi-DOF.
65    pub fn build(model: &Model) -> Self {
66        let nq = model.nq;
67        let nv = model.nv;
68        let num_inputs = nq + nv + nv; // q, v, ctrl
69
70        // Trace the symbolic computation
71        let (mut graph, (qnext_exprs, vnext_exprs)) = trace(|| symbolic_step(model));
72
73        // Differentiate each output w.r.t. each input
74        let num_jac_entries = nq * nq + nq * nv + nv * nq + nv * nv + nv * nv;
75        let mut jac_exprs = Vec::with_capacity(num_jac_entries);
76
77        // dqnext_dq: nq x nq
78        for &qn in &qnext_exprs {
79            for j in 0..nq {
80                let d = graph.diff(qn, j as u16);
81                let d = graph.simplify(d);
82                jac_exprs.push(d);
83            }
84        }
85
86        // dqnext_dv: nq x nv
87        for &qn in &qnext_exprs {
88            for j in 0..nv {
89                let var_idx = (nq + j) as u16;
90                let d = graph.diff(qn, var_idx);
91                let d = graph.simplify(d);
92                jac_exprs.push(d);
93            }
94        }
95
96        // dvnext_dq: nv x nq
97        for &vn in &vnext_exprs {
98            for j in 0..nq {
99                let d = graph.diff(vn, j as u16);
100                let d = graph.simplify(d);
101                jac_exprs.push(d);
102            }
103        }
104
105        // dvnext_dv: nv x nv
106        for &vn in &vnext_exprs {
107            for j in 0..nv {
108                let var_idx = (nq + j) as u16;
109                let d = graph.diff(vn, var_idx);
110                let d = graph.simplify(d);
111                jac_exprs.push(d);
112            }
113        }
114
115        // dvnext_dctrl: nv x nv
116        for &vn in &vnext_exprs {
117            for j in 0..nv {
118                let var_idx = (nq + nv + j) as u16;
119                let d = graph.diff(vn, var_idx);
120                let d = graph.simplify(d);
121                jac_exprs.push(d);
122            }
123        }
124
125        let jac_fn = graph.compile_many(&jac_exprs);
126
127        Self {
128            nq,
129            nv,
130            num_inputs,
131            jac_fn,
132            num_outputs: num_jac_entries,
133        }
134    }
135
136    /// Evaluate the Jacobians at a given state.
137    ///
138    /// `inputs` layout: [q_0..q_nq, v_0..v_nv, ctrl_0..ctrl_nv]
139    pub fn eval(&self, inputs: &[f64]) -> StepJacobians {
140        assert_eq!(inputs.len(), self.num_inputs);
141
142        let mut flat = vec![0.0; self.num_outputs];
143        (self.jac_fn)(inputs, &mut flat);
144
145        let nq = self.nq;
146        let nv = self.nv;
147        let mut offset = 0;
148
149        // dqnext_dq: nq x nq
150        let dqnext_dq = DMat::from_fn(nq, nq, |i, j| flat[offset + i * nq + j]);
151        offset += nq * nq;
152
153        // dqnext_dv: nq x nv
154        let dqnext_dv = DMat::from_fn(nq, nv, |i, j| flat[offset + i * nv + j]);
155        offset += nq * nv;
156
157        // dvnext_dq: nv x nq
158        let dvnext_dq = DMat::from_fn(nv, nq, |i, j| flat[offset + i * nq + j]);
159        offset += nv * nq;
160
161        // dvnext_dv: nv x nv
162        let dvnext_dv = DMat::from_fn(nv, nv, |i, j| flat[offset + i * nv + j]);
163        offset += nv * nv;
164
165        // dvnext_dctrl: nv x nv
166        let dvnext_dctrl = DMat::from_fn(nv, nv, |i, j| flat[offset + i * nv + j]);
167
168        StepJacobians {
169            dqnext_dq,
170            dqnext_dv,
171            dvnext_dq,
172            dvnext_dv,
173            dvnext_dctrl,
174        }
175    }
176}
177
178/// Convenience: compute symbolic step Jacobians for a given model and state.
179///
180/// Builds the compiled system, evaluates at the given state, and returns.
181/// For repeated evaluations on the same model, prefer building once with
182/// `CompiledStepJacobians::build()` and calling `eval()` multiple times.
183pub fn symbolic_step_jacobians(model: &Model, state: &phyz_model::State) -> StepJacobians {
184    let compiled = CompiledStepJacobians::build(model);
185    let mut inputs = Vec::with_capacity(model.nq + model.nv + model.nv);
186    inputs.extend_from_slice(state.q.as_slice());
187    inputs.extend_from_slice(state.v.as_slice());
188    inputs.extend_from_slice(state.ctrl.as_slice());
189    compiled.eval(&inputs)
190}
191
192// ---------------------------------------------------------------------------
193// Symbolic ABA + semi-implicit Euler step
194// ---------------------------------------------------------------------------
195
196/// Symbolically trace ABA + semi-implicit Euler.
197///
198/// Variable layout:
199///   vars [0..nq)           = q (joint positions)
200///   vars [nq..nq+nv)       = v (joint velocities)
201///   vars [nq+nv..nq+2*nv)  = ctrl (control inputs)
202///
203/// Returns (q_next, v_next) as vectors of ExprId.
204fn symbolic_step(model: &Model) -> (Vec<ExprId>, Vec<ExprId>) {
205    let nq = model.nq;
206    let nv = model.nv;
207
208    // Create symbolic variables
209    let q: Vec<ExprId> = (0..nq).map(|i| ExprId::var(i as u16)).collect();
210    let v: Vec<ExprId> = (0..nv).map(|i| ExprId::var((nq + i) as u16)).collect();
211    let ctrl: Vec<ExprId> = (0..nv).map(|i| ExprId::var((nq + nv + i) as u16)).collect();
212
213    // Run symbolic ABA to get accelerations
214    let qdd = symbolic_aba(model, &q, &v, &ctrl);
215
216    // Semi-implicit Euler:
217    //   v' = v + dt * qdd
218    //   q' = q + dt * v'
219    let dt = ExprId::from_f64(model.dt);
220
221    let v_next: Vec<ExprId> = (0..nv).map(|i| v[i] + dt * qdd[i]).collect();
222
223    let q_next: Vec<ExprId> = (0..nq).map(|i| q[i] + dt * v_next[i]).collect();
224
225    (q_next, v_next)
226}
227
228/// Symbolic ABA implementation for single-DOF joints.
229///
230/// This mirrors the concrete ABA in phyz-rigid but uses ExprId as scalar.
231fn symbolic_aba(model: &Model, q: &[ExprId], v: &[ExprId], ctrl: &[ExprId]) -> Vec<ExprId> {
232    let nb = model.nbodies();
233    let nv = model.nv;
234    let zero_sv: SpatialVec<ExprId> = SpatialVec::zero();
235
236    // Per-body storage
237    let mut x_tree: Vec<SpatialTransform<ExprId>> = vec![SpatialTransform::identity(); nb];
238    let mut vel: Vec<SpatialVec<ExprId>> = vec![zero_sv; nb];
239    let mut c_bias: Vec<SpatialVec<ExprId>> = vec![zero_sv; nb];
240    let mut p_a: Vec<SpatialVec<ExprId>> = vec![zero_sv; nb];
241    let mut i_a: Vec<SpatialMat<ExprId>> = vec![SpatialMat::zero(); nb];
242
243    // Gravity as spatial acceleration (base acceleration trick)
244    let grav = lift_vec3(model.gravity);
245    let a0: SpatialVec<ExprId> = SpatialVec::new(Vec3::zero(), -grav);
246
247    // -- Pass 1: Forward -- velocities and bias --
248    for i in 0..nb {
249        let body = &model.bodies[i];
250        let joint = &model.joints[body.joint_idx];
251        let q_idx = model.q_offsets[body.joint_idx];
252        let v_idx = model.v_offsets[body.joint_idx];
253        let ndof = joint.ndof();
254
255        assert!(
256            ndof <= 1,
257            "symbolic ABA only supports single-DOF joints (revolute/prismatic/fixed), \
258             found joint with ndof={ndof}"
259        );
260
261        // Compute joint transform symbolically
262        let x_joint = if ndof == 0 {
263            SpatialTransform::identity()
264        } else {
265            sym_joint_transform(joint.joint_type, &lift_vec3(joint.axis), q[q_idx])
266        };
267
268        let parent_xf = lift_spatial_transform(&joint.parent_to_joint);
269        x_tree[i] = x_joint.compose(&parent_xf);
270
271        let v_joint = if ndof == 0 {
272            zero_sv
273        } else {
274            sym_joint_velocity(joint.joint_type, &lift_vec3(joint.axis), v[v_idx])
275        };
276
277        if body.parent < 0 {
278            vel[i] = v_joint;
279            c_bias[i] = zero_sv;
280        } else {
281            let pi = body.parent as usize;
282            let v_parent = x_tree[i].apply_motion(&vel[pi]);
283            vel[i] = v_parent + v_joint;
284            c_bias[i] = vel[i].cross_motion(&v_joint);
285        }
286
287        // Initialize articulated inertia with rigid body inertia
288        let inertia = lift_spatial_inertia(&body.inertia);
289        i_a[i] = inertia.to_matrix();
290
291        // Bias force: v x* (I*v) (gyroscopic)
292        p_a[i] = vel[i].cross_force(&i_a[i].mul_vec(&vel[i]));
293    }
294
295    // -- Pass 2: Backward -- articulated inertias and forces --
296    for i in (0..nb).rev() {
297        let body = &model.bodies[i];
298        let joint = &model.joints[body.joint_idx];
299        let v_idx = model.v_offsets[body.joint_idx];
300        let ndof = joint.ndof();
301
302        if ndof == 0 {
303            // Fixed joint: propagate to parent
304            if body.parent >= 0 {
305                let pi = body.parent as usize;
306                let x_mot = x_tree[i].to_motion_matrix();
307                let ia_parent = x_mot.transpose().mul_mat(&i_a[i]).mul_mat(&x_mot);
308                let p_parent = x_tree[i].inv_apply_force(&p_a[i]);
309                i_a[pi] = i_a[pi] + ia_parent;
310                p_a[pi] = p_a[pi] + p_parent;
311            }
312            continue;
313        }
314
315        // Single-DOF path
316        let s_i = sym_motion_subspace(joint.joint_type, &lift_vec3(joint.axis));
317
318        let damping_lit = ExprId::from_f64(joint.damping);
319        let tau_i = ctrl[v_idx] - damping_lit * v[v_idx];
320
321        let ia = &i_a[i];
322        let u_i = tau_i - s_i.dot(&p_a[i]);
323        let d_i = s_i.dot(&ia.mul_vec(&s_i));
324
325        let d_inv = d_i.recip();
326        let u_inv_d = u_i * d_inv;
327        let ia_s = ia.mul_vec(&s_i);
328
329        if body.parent >= 0 {
330            let pi = body.parent as usize;
331            let outer = sym_outer_product_6(&ia_s, &ia.mul_vec(&s_i));
332            let ia_new = *ia - outer * d_inv;
333            let p_new = p_a[i] + ia_new.mul_vec(&c_bias[i]) + ia_s * u_inv_d;
334
335            let x_mot = x_tree[i].to_motion_matrix();
336            let ia_parent = x_mot.transpose().mul_mat(&ia_new).mul_mat(&x_mot);
337            let p_parent = x_tree[i].inv_apply_force(&p_new);
338
339            i_a[pi] = i_a[pi] + ia_parent;
340            p_a[pi] = p_a[pi] + p_parent;
341        }
342    }
343
344    // -- Pass 3: Forward -- accelerations --
345    let mut acc: Vec<SpatialVec<ExprId>> = vec![zero_sv; nb];
346    let mut qdd = vec![ExprId::ZERO; nv];
347
348    for i in 0..nb {
349        let body = &model.bodies[i];
350        let joint = &model.joints[body.joint_idx];
351        let v_idx = model.v_offsets[body.joint_idx];
352        let ndof = joint.ndof();
353
354        let a_parent = if body.parent < 0 {
355            x_tree[i].apply_motion(&a0)
356        } else {
357            let pi = body.parent as usize;
358            x_tree[i].apply_motion(&acc[pi])
359        };
360
361        if ndof == 0 {
362            acc[i] = a_parent + c_bias[i];
363            continue;
364        }
365
366        let s_i = sym_motion_subspace(joint.joint_type, &lift_vec3(joint.axis));
367        let ia = &i_a[i];
368        let d_i = s_i.dot(&ia.mul_vec(&s_i));
369        let d_inv = d_i.recip();
370
371        let damping_lit = ExprId::from_f64(joint.damping);
372        let tau_i = ctrl[v_idx] - damping_lit * v[v_idx];
373        let u_i = tau_i - s_i.dot(&p_a[i]);
374
375        let qdd_i = (u_i - ia.mul_vec(&(a_parent + c_bias[i])).dot(&s_i)) * d_inv;
376        qdd[v_idx] = qdd_i;
377        acc[i] = a_parent + c_bias[i] + s_i * qdd_i;
378    }
379
380    qdd
381}
382
383// ---------------------------------------------------------------------------
384// Helpers: lift f64 values to ExprId
385// ---------------------------------------------------------------------------
386
387/// Lift a concrete Vec3<f64> to Vec3<ExprId> (as literals).
388fn lift_vec3(v: Vec3<f64>) -> Vec3<ExprId> {
389    Vec3::new(
390        ExprId::from_f64(v.x),
391        ExprId::from_f64(v.y),
392        ExprId::from_f64(v.z),
393    )
394}
395
396/// Lift a concrete Mat3<f64> to Mat3<ExprId> (as literals).
397fn lift_mat3(m: Mat3<f64>) -> Mat3<ExprId> {
398    Mat3::new(
399        ExprId::from_f64(m.c0.x),
400        ExprId::from_f64(m.c1.x),
401        ExprId::from_f64(m.c2.x),
402        ExprId::from_f64(m.c0.y),
403        ExprId::from_f64(m.c1.y),
404        ExprId::from_f64(m.c2.y),
405        ExprId::from_f64(m.c0.z),
406        ExprId::from_f64(m.c1.z),
407        ExprId::from_f64(m.c2.z),
408    )
409}
410
411/// Lift a concrete SpatialTransform<f64> to SpatialTransform<ExprId>.
412fn lift_spatial_transform(xf: &tang::SpatialTransform<f64>) -> SpatialTransform<ExprId> {
413    SpatialTransform::new(lift_mat3(xf.rot), lift_vec3(xf.pos))
414}
415
416/// Lift a concrete SpatialInertia<f64> to SpatialInertia<ExprId>.
417fn lift_spatial_inertia(si: &tang::SpatialInertia<f64>) -> SpatialInertia<ExprId> {
418    SpatialInertia::new(
419        ExprId::from_f64(si.mass),
420        lift_vec3(si.com),
421        lift_mat3(si.inertia),
422    )
423}
424
425// ---------------------------------------------------------------------------
426// Helpers: symbolic joint operations
427// ---------------------------------------------------------------------------
428
429/// Compute the symbolic joint transform for a single-DOF joint.
430fn sym_joint_transform(
431    joint_type: JointType,
432    axis: &Vec3<ExprId>,
433    q: ExprId,
434) -> SpatialTransform<ExprId> {
435    match joint_type {
436        JointType::Revolute | JointType::Hinge => {
437            // Rodrigues formula: negate angle for coordinate transform
438            let (s, c) = (-q).sin_cos();
439            let ax = tang::skew(axis);
440            let rot = Mat3::identity() + ax * s + ax.mul_mat(&ax) * (ExprId::ONE - c);
441            SpatialTransform::new(rot, Vec3::zero())
442        }
443        JointType::Prismatic | JointType::Slide => {
444            let pos = *axis * q;
445            SpatialTransform::new(Mat3::identity(), pos)
446        }
447        JointType::Fixed => SpatialTransform::identity(),
448        _ => panic!(
449            "symbolic joint transform not supported for {:?}",
450            joint_type
451        ),
452    }
453}
454
455/// Compute the symbolic joint velocity contribution S * qd.
456fn sym_joint_velocity(
457    joint_type: JointType,
458    axis: &Vec3<ExprId>,
459    qd: ExprId,
460) -> SpatialVec<ExprId> {
461    match joint_type {
462        JointType::Revolute | JointType::Hinge => SpatialVec::new(*axis * qd, Vec3::zero()),
463        JointType::Prismatic | JointType::Slide => SpatialVec::new(Vec3::zero(), *axis * qd),
464        JointType::Fixed => SpatialVec::zero(),
465        _ => panic!("symbolic joint velocity not supported for {:?}", joint_type),
466    }
467}
468
469/// Motion subspace vector for a single-DOF joint.
470fn sym_motion_subspace(joint_type: JointType, axis: &Vec3<ExprId>) -> SpatialVec<ExprId> {
471    match joint_type {
472        JointType::Revolute | JointType::Hinge => SpatialVec::new(*axis, Vec3::zero()),
473        JointType::Prismatic | JointType::Slide => SpatialVec::new(Vec3::zero(), *axis),
474        _ => panic!("motion_subspace not supported for {:?}", joint_type),
475    }
476}
477
478/// Outer product of two spatial vectors, returning a SpatialMat.
479fn sym_outer_product_6(a: &SpatialVec<ExprId>, b: &SpatialVec<ExprId>) -> SpatialMat<ExprId> {
480    fn vec3_outer(a: Vec3<ExprId>, b: Vec3<ExprId>) -> Mat3<ExprId> {
481        Mat3::new(
482            a.x * b.x,
483            a.x * b.y,
484            a.x * b.z,
485            a.y * b.x,
486            a.y * b.y,
487            a.y * b.z,
488            a.z * b.x,
489            a.z * b.y,
490            a.z * b.z,
491        )
492    }
493
494    SpatialMat::new(
495        vec3_outer(a.angular, b.angular),
496        vec3_outer(a.angular, b.linear),
497        vec3_outer(a.linear, b.angular),
498        vec3_outer(a.linear, b.linear),
499    )
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use phyz_math::{GRAVITY, SpatialInertia as ConcreteSI, SpatialTransform as ConcreteXF};
506    use phyz_model::ModelBuilder;
507
508    fn make_pendulum() -> Model {
509        ModelBuilder::new()
510            .gravity(phyz_math::Vec3::new(0.0, -GRAVITY, 0.0))
511            .dt(0.001)
512            .add_revolute_body(
513                "link1",
514                -1,
515                ConcreteXF::identity(),
516                ConcreteSI::new(
517                    1.0,
518                    phyz_math::Vec3::new(0.0, -0.5, 0.0),
519                    phyz_math::Mat3::from_diagonal(&phyz_math::Vec3::new(
520                        1.0 / 12.0,
521                        0.0,
522                        1.0 / 12.0,
523                    )),
524                ),
525            )
526            .build()
527    }
528
529    fn make_double_pendulum() -> Model {
530        let length = 1.0;
531        let mass = 1.0;
532        ModelBuilder::new()
533            .gravity(phyz_math::Vec3::new(0.0, -GRAVITY, 0.0))
534            .dt(0.001)
535            .add_revolute_body(
536                "link1",
537                -1,
538                ConcreteXF::identity(),
539                ConcreteSI::new(
540                    mass,
541                    phyz_math::Vec3::new(0.0, -length / 2.0, 0.0),
542                    phyz_math::Mat3::from_diagonal(&phyz_math::Vec3::new(
543                        mass * length * length / 12.0,
544                        0.0,
545                        mass * length * length / 12.0,
546                    )),
547                ),
548            )
549            .add_revolute_body(
550                "link2",
551                0,
552                ConcreteXF::from_translation(phyz_math::Vec3::new(0.0, -length, 0.0)),
553                ConcreteSI::new(
554                    mass,
555                    phyz_math::Vec3::new(0.0, -length / 2.0, 0.0),
556                    phyz_math::Mat3::from_diagonal(&phyz_math::Vec3::new(
557                        mass * length * length / 12.0,
558                        0.0,
559                        mass * length * length / 12.0,
560                    )),
561                ),
562            )
563            .build()
564    }
565
566    #[test]
567    fn test_symbolic_vs_fd_pendulum() {
568        let model = make_pendulum();
569        let mut state = model.default_state();
570        state.q[0] = 0.3;
571        state.v[0] = 0.1;
572
573        let sym = symbolic_step_jacobians(&model, &state);
574        let fd = crate::finite_diff_jacobians(&model, &state, 1e-6);
575
576        let eps = 1e-4;
577        assert!(
578            (&fd.dqnext_dq - &sym.dqnext_dq).norm() < eps,
579            "dqnext_dq mismatch: fd={:?}, sym={:?}",
580            fd.dqnext_dq,
581            sym.dqnext_dq
582        );
583        assert!(
584            (&fd.dqnext_dv - &sym.dqnext_dv).norm() < eps,
585            "dqnext_dv mismatch: fd={:?}, sym={:?}",
586            fd.dqnext_dv,
587            sym.dqnext_dv
588        );
589        assert!(
590            (&fd.dvnext_dq - &sym.dvnext_dq).norm() < eps,
591            "dvnext_dq mismatch: fd={:?}, sym={:?}",
592            fd.dvnext_dq,
593            sym.dvnext_dq
594        );
595        assert!(
596            (&fd.dvnext_dv - &sym.dvnext_dv).norm() < eps,
597            "dvnext_dv mismatch: fd={:?}, sym={:?}",
598            fd.dvnext_dv,
599            sym.dvnext_dv
600        );
601        assert!(
602            (&fd.dvnext_dctrl - &sym.dvnext_dctrl).norm() < eps,
603            "dvnext_dctrl mismatch: fd={:?}, sym={:?}",
604            fd.dvnext_dctrl,
605            sym.dvnext_dctrl
606        );
607    }
608
609    #[test]
610    fn test_symbolic_vs_fd_double_pendulum() {
611        let model = make_double_pendulum();
612        let mut state = model.default_state();
613        state.q[0] = 0.3;
614        state.q[1] = -0.2;
615        state.v[0] = 0.1;
616        state.v[1] = -0.05;
617
618        let sym = symbolic_step_jacobians(&model, &state);
619        let fd = crate::finite_diff_jacobians(&model, &state, 1e-6);
620
621        let eps = 1e-4;
622        assert!(
623            (&fd.dqnext_dq - &sym.dqnext_dq).norm() < eps,
624            "dqnext_dq mismatch: norm={}",
625            (&fd.dqnext_dq - &sym.dqnext_dq).norm()
626        );
627        assert!(
628            (&fd.dqnext_dv - &sym.dqnext_dv).norm() < eps,
629            "dqnext_dv mismatch: norm={}",
630            (&fd.dqnext_dv - &sym.dqnext_dv).norm()
631        );
632        assert!(
633            (&fd.dvnext_dq - &sym.dvnext_dq).norm() < eps,
634            "dvnext_dq mismatch: norm={}",
635            (&fd.dvnext_dq - &sym.dvnext_dq).norm()
636        );
637        assert!(
638            (&fd.dvnext_dv - &sym.dvnext_dv).norm() < eps,
639            "dvnext_dv mismatch: norm={}",
640            (&fd.dvnext_dv - &sym.dvnext_dv).norm()
641        );
642        assert!(
643            (&fd.dvnext_dctrl - &sym.dvnext_dctrl).norm() < eps,
644            "dvnext_dctrl mismatch: norm={}",
645            (&fd.dvnext_dctrl - &sym.dvnext_dctrl).norm()
646        );
647    }
648
649    #[test]
650    fn test_compiled_reuse() {
651        let model = make_pendulum();
652
653        // Build once, evaluate at different states
654        let compiled = CompiledStepJacobians::build(&model);
655
656        // State 1
657        let inputs1 = vec![0.3, 0.1, 0.0]; // q, v, ctrl
658        let jac1 = compiled.eval(&inputs1);
659
660        // State 2
661        let inputs2 = vec![0.5, -0.2, 1.0];
662        let jac2 = compiled.eval(&inputs2);
663
664        // Jacobians should be different at different states
665        assert!(
666            (&jac1.dvnext_dq - &jac2.dvnext_dq).norm() > 1e-6,
667            "Jacobians should differ at different states"
668        );
669    }
670
671    #[test]
672    fn test_symbolic_vs_analytical() {
673        // Symbolic should match the existing semi_implicit_step_jacobians
674        let model = make_pendulum();
675        let mut state = model.default_state();
676        state.q[0] = 0.3;
677        state.v[0] = 0.1;
678
679        let sym = symbolic_step_jacobians(&model, &state);
680        let anal = crate::semi_implicit_step_jacobians(&model, &state);
681
682        let eps = 1e-4;
683        assert!(
684            (&anal.dqnext_dq - &sym.dqnext_dq).norm() < eps,
685            "dqnext_dq: analytical vs symbolic mismatch"
686        );
687        assert!(
688            (&anal.dvnext_dq - &sym.dvnext_dq).norm() < eps,
689            "dvnext_dq: analytical vs symbolic mismatch"
690        );
691        assert!(
692            (&anal.dvnext_dv - &sym.dvnext_dv).norm() < eps,
693            "dvnext_dv: analytical vs symbolic mismatch"
694        );
695        assert!(
696            (&anal.dvnext_dctrl - &sym.dvnext_dctrl).norm() < eps,
697            "dvnext_dctrl: analytical vs symbolic mismatch"
698        );
699    }
700}