Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Caliper

A modern, open robotics engine — one deterministic Rust core, three faces.

Caliper is a single Rust engine for serial-arm robotics: kinematics, inverse kinematics, singularity analysis, jerk-limited motion, dynamics and simulation, collision-aware planning, real-robot control and safety, kinematic calibration, and a Simulink-style dataflow graph. The same engine code is exposed through three faces:

  • CLIcaliper fk | ik | analyze | move | plan | sim | record | graph …
  • Pythonimport caliper, built with maturin / PyO3; scriptable like MATLAB/NumPy.
  • StudioCaliper Studio, a Tauri + React desktop app with a 3D scene and a node-graph editor.

One engine, three faces

There is exactly one implementation of every algorithm. The CLI parses arguments and calls the engine; the Python bindings marshal NumPy arrays and call the engine; Studio serializes a dataflow document and calls the engine. None of the faces re-implement math. A consequence worth stating up front: when the Python oracle validates FK against Pinocchio, it is validating the shipped Python face and the shared core at once, because the oracle runs through the PyO3 bindings.

The engine is deterministic and clock-free by design. Nothing consults the wall clock; simulation and control advance only when a step(dt) is called, and the one randomized component (RRT/RRT* sampling) uses a seeded splitmix64 PRNG rather than rand. A given input — including a given seed — produces the same output every time, which is what makes the whole stack unit-testable with no hardware.

Status (honest)

All nine phases of the build (0–8) exist and compile:

PhaseCapability
0–1URDF → frozen kinematic model, forward kinematics, geometric Jacobians, SE(3)/SO(3) screw math
2DLS/LM inverse kinematics + analytic 6R IK; singularity analysis
3Jerk-limited S-curve motion (MOVE_J/L/C) + waypoint retiming + time-optimal (TOPP) parameterization
4Inverse dynamics (RNEA), mass matrix (CRBA), forward dynamics, a semi-implicit-Euler Simulator
5Real-robot backend contract, computed-torque control loop, safety monitor, teleop, LeRobot dataset record/replay
6RRT-Connect / RRT* / PRM planning, shortcut smoothing, reachability, CHOMP-style trajectory optimization
7A pure-PyTorch behavior-cloning sidecar (learn/)
8A serde dataflow IR + deterministic graph executor + a node-editor face

What is trustworthy vs. what is not:

  • The headless stack (engine + CLI + PyO3 bindings) is machine-verified: cross-validated against Pinocchio and NumPy (residuals ≈ 1e-9…1e-15), covered by ~156 Rust tests and a Python oracle, and put through an independent first-principles re-derivation plus a large multi-agent correctness/safety audit.
  • The Studio GUI compiles, type-checks, builds, and was statically reviewed — but it has never been launched at runtime. Its rendering and interactions are not verified. This is deliberate (build now, human-review later), and it is called out honestly throughout this book and in the verification chapter.

This documentation describes only what is actually implemented. Where something is a stub, a by-design limitation, or unverified, it says so.

Zero to moving in 10 minutes

One guided path through the whole loop on a real robot description: load → inspect → diagnose → plan → simulate → record a dataset → diagnose that → train a tiny policy → judge the result. Every command below is copy-paste real; where output is shown, it is what the tools actually print (numbers are deterministic unless marked otherwise).

Along the way you meet the two things Caliper insists on that most stacks skip: doctors before you spend (asset doctor, dataset doctor, trajectory lint) and verdicts after you train (eval, profile, autopsy) — because "everybody just starts training and hopes for the best" is exactly the failure mode this engine exists to close.

0 · Install

Studio (macOS, Apple Silicon): grab the .dmg from Releases. The app is signed with a Development certificate but not yet notarized, so on first open: right-click the app → Open → Open (or allow it under System Settings → Privacy & Security). That's it — one file, no ROS, no GPU, no cloud.

CLI + Python face (from source — also where the sample robots live):

git clone https://github.com/msannikov03/caliper && cd caliper
cargo build --release -p caliper-cli
alias caliper="$PWD/target/release/caliper"      # for this shell session
# the pip route: build the Python bindings into a venv with maturin
python -m venv .venv && source .venv/bin/activate
pip install maturin
maturin develop --release -m crates/caliper-py/Cargo.toml

For steps 6–9 (learning) also install the sidecar and the sim renderer:

pip install -e learn          # caliper_learn: torch + numpy
pip install mujoco pillow     # camera collector + closed-loop eval

Requires a recent stable Rust (edition 2024; MSRV 1.89) and Python ≥ 3.11. Everything below runs from the repo root.

1 · Get a robot

The binary ships a small robot zoo — real-robot URDFs (Franka Panda, SO-100, SO-101, Kinova Gen3 lite) embedded in the executable, vendored verbatim with their licenses; no network involved. We'll use the SO-101 — the arm half the hobby-robotics world is building right now:

caliper fetch --list                    # table the zoo (name, dof, license, source)
URDF="$(caliper fetch so101_new_calib | head -1)"

fetch materializes the file (default: ~/.cache/caliper/zoo/), prints its absolute path on the first line — hence the head -1 — and then says exactly what you got: license, source, and the doctor findings this file is known to raise (the meshes are deliberately not embedded; more on that in step 3).

caliper load "$URDF"
robot: so101_new_calib
dof:   6
  [0] 1
  [1] 2
  ...
  [5] 6

Six revolute joints, loaded and frozen into a kinematic model. (Yes, the vendor named the joints 16.)

2 · Open it in Studio and jog

Launch Caliper StudioOpen URDF… (⌘O) → pick the fetched so101_new_calib.urdf (step 1 printed its path). You land in Jog mode: drag the joint sliders, or grab the tip gizmo to drive IK live, with the singularity HUD tracking manipulability as you go. A first-run tour walks you through the five modes and ⌘K (replay it any time: ⌘K → Show tour).

The zoo ships the URDF without its STL meshes (~58 MB that aren't ours to embed), so Studio shows the frame skeleton rather than the full body. For the full visual treatment pick a bundled sample (e.g. visual_arm) from the samples dropdown, or File → Open a complete SO-ARM100 checkout.

Studio remembers your session — robot, pose, mode — and restores it on the next launch.

3 · Run the doctor on it

Would this file actually survive physics, collision, MJCF export? Ask before finding out the hard way:

caliper doctor "$URDF"
asset doctor: 17 error(s), 17 warning(s), 0 info(s)

ERROR (17)
  [A003] collision mesh `assets/base_motor_holder_so101_v1.stl` on link `base` cannot be resolved (tried …)
  ...

Exactly the A003 findings fetch warned about: every missing mesh named, with the exact search paths tried. On your own CAD exports this is the class of defect that otherwise surfaces one crash at a time, or never. Findings are data, not errors: the exit code stays 0 (the report is the product). Mechanical defects get --repair, which writes a fixed copy and never touches your input:

caliper doctor my_export.urdf --repair        # → my_export.repaired.urdf

Studio runs this doctor automatically on every load. Full check catalog (A001A014): Doctors & trajectory lint.

4 · Plan a move — and get a verdict on it

Kinematics don't need meshes. Plan a collision-checked path and a jerk-limited trajectory to a joint goal:

caliper plan   "$URDF" --goal 0.3,-0.4,0.6,0.4,0.5,0.3
caliper report "$URDF" --goal 0.3,-0.4,0.6,0.4,0.5,0.3

plan prints the RRT-Connect waypoints (deterministic — seeded PRNG, same path every run). report is the pre-flight verdict on the motion itself:

  cycle time      : 0.3374 s  (100 samples)
  manipulability  : min …   mean …
  sigma_min       : min …  @ t=…s
  joint            limit-margin   vel-util   acc-util
  ...

  LINT: 0 error(s), 1 warning(s)
    [T007] WARN  singular corridor: σ_min falls to 6.0755e-8 (< 1.0000e-2) between t=0.000 s and t=0.102 s (worst at t=0.000 s)
           fix: re-pose the path away from the singular region (see `analyze` escape_direction) or accept DLS damping through it

And there's the point of the lint: the all-zeros home pose is a singularity, and the first tenth of a second of this move runs through its corridor — something you'd otherwise discover as a velocity spike on hardware. The full catalog (T001T009) covers limit violations, 360° detours, jerk spikes and collision near-misses; --strict turns Error findings into a non-zero exit for CI, and --json makes everything machine-readable.

5 · Simulate it

The SO-101 file carries inertial data, so dynamics work out of the box:

caliper sim "$URDF" --duration 1.5 --damping 0.5

You get a time-stepped table of q and total energy under gravity, ending with the honest number that says whether the integrator held together:

  energy drift: …

In Studio, switch to Simulate (⌘3) for the same engine interactively: gravity drop, computed-torque drive-to-goal, RRT plan, collision check. In builds with the MuJoCo feature, a Builtin | Contact toggle appears — drop free props on the robot and watch real contact dynamics on the same playback transport (contact simulation).

6 · Record a sim dataset (with a camera)

Time to make training data. The sidecar's camera collector plans collision-free reaches on a bundled 3-dof fixture (collide_arm), renders an over-the-shoulder MuJoCo camera per frame, and writes a native LeRobotDataset v3.0 — images as pre-encoded PNGs, no ffmpeg:

python -m caliper_learn.collect_sim demo_ds -n 4 --fps 30 --max-frames 80
demo_ds

Deterministic given --seed: reruns produce byte-identical image bytes. (It defaults to the vendored collide_arm fixture — the camera scene is built from the robot's own inertials and geometry, and the mesh-less SO-101 zoo file has nothing for a camera to see. Pass --urdf for a robot with resolvable geometry.)

No MuJoCo installed? The engine records a control-loop episode by itself:

caliper record oracle/fixtures/robots/collide_arm.urdf --out demo_ctl --goal 0.4,-0.3,0.5

7 · Run the dataset doctor

Before a single GPU-second is spent, ask whether this data can train anything:

caliper data doctor demo_ds
dataset doctor — demo_ds
...

Fifteen checks (D001D015) stream the dataset in two passes: per-dof variance collapse, stale stats.json (the silent normalization killer), saturated/echoed actions, contradictory demos, coverage holes, frozen tails, dead cameras, duplicate episodes. Same contract as every doctor: findings are data, stable codes, --json for machines. Studio's Data mode (⌘5) has the same doctor behind a button — findings click through to the offending episode.

8 · Train a tiny BC policy

Pure-PyTorch, CPU, about a minute — the point is the loop, not the score:

# train_tiny.py — run inside the venv: python train_tiny.py
from caliper_learn.data import DataConfig, make_datasets
from caliper_learn.policy import build_policy
from caliper_learn.train import TrainConfig, fit
from caliper_learn.checkpoint import save_checkpoint

train, val, stats, meta = make_datasets(DataConfig(root="demo_ds"))
policy = build_policy(
    "bc_mlp",
    {"obs_dim": meta["obs_dim"], "action_dim": meta["action_dim"]},
    stats=stats,
    seed=0,
)
hist = fit(policy, train, val, TrainConfig(steps=300, batch_size=32))
print(f"final train loss: {hist['final_train']:.4f}")
save_checkpoint(policy, "bc_tiny.pt")

Then close the loop in sim — deploy at the collection cadence (dt = 1/fps; deploying a lookahead policy at the wrong rate is a classic silent failure, documented in Learning sidecar):

import caliper
import numpy as np
from caliper_learn.deploy import rollout_policy

robot = caliper.Robot.from_urdf("oracle/fixtures/robots/collide_arm.urdf")
goal = [0.4, -0.3, 0.5]
res = rollout_policy(policy, robot, goal, ticks=120, dt=1 / 30, fps=30)
print("final |q - goal|:", np.abs(np.array(res.states[-1]) - goal).max())

Four episodes and 300 steps won't reach the goal — expect the residual to shrink, not vanish. That gap is precisely what the next step is for.

9 · Judge the result — eval and the autopsy

The loss went down. Did the policy actually work? Ask the eval harness — seeded closed-loop episodes with Wilson-95 confidence bounds, so 0/5 stays honest instead of hiding behind an average:

from caliper_learn.deploy import make_obs
from caliper_learn.eval import EvalConfig, evaluate, reach_eval_task, render_text

task = reach_eval_task(robot, "l3", [0.147, 0.0, 0.575], fps=30)
step = lambda s: policy.predict(make_obs(s[: robot.ndof], goal, policy.obs_dim))
print(render_text(evaluate(step, task, EvalConfig(n_episodes=5))))

And when a policy trained in the lerobot ecosystem "does nothing" on deploy, run the full post-mortem — dataset doctor + policy debugger + eval + latency profile, one report, one verdict paragraph:

caliper-learn autopsy <checkpoint_dir> demo_ds --urdf <robot.urdf> \
    --frame <tip> --target 0.147 0.0 0.575

It takes lerobot-Hub-convention checkpoints (safetensors only — no pickle is ever deserialized) and answers the question that burns the most hours: is it a data problem, a model problem, or a deploy-loop problem? Codes, thresholds and a full walkthrough: Verdicts — eval, profiling & the Policy Autopsy.

Where to next

Architecture

Caliper is a Cargo workspace of small, focused crates. The caliper umbrella crate re-exports the engine modules; the three faces build on top of it (and, in a few places, depend on individual sub-crates directly for types the facade does not re-export).

Crate map

CrateRole
caliper-spatialSE(3)/SO(3) screw math — twists, exp6/log6, adjoints, spatial inertia. Twist ordering is [v; ω], Pinocchio-compatible.
caliper-modelURDF parsing → a frozen struct-of-arrays kinematic Model.
caliper-kinematicsForward kinematics, geometric Jacobians (world/body), singularity analysis.
caliper-ikInverse kinematics — damped-least-squares / Levenberg–Marquardt CLIK, plus an analytic 6R solver.
caliper-dynamicsRNEA (inverse dynamics), CRBA (mass matrix), forward dynamics, and a semi-implicit-Euler Simulator.
caliper-motionJerk-limited S-curve trajectories (MOVE_J/L/C), waypoint retiming, and a time-optimal (TOPP) parameterization.
caliper-planningRRT-Connect / RRT* / PRM planners, shortcut smoothing, reachability analysis.
caliper-collisionSelf-contained, pure-nalgebra collision checker (OBB-SAT, GJK, EPA, half-space, capsule, mesh-as-hull).
caliper-trajoptCHOMP-style collision-aware trajectory optimization over an initial waypoint path.
caliper-halHardware/sim abstraction: the RobotBackend contract, computed-torque control loop, SafetyMonitor, teleop, LeRobot dataset record/replay, feature-gated CAN / Dynamixel skeletons.
caliper-calibKinematic (joint-offset / zero) calibration by damped Gauss–Newton.
caliper-graphPhase-8 dataflow IR (serde) + deterministic graph executor.
caliperUmbrella facade re-exporting the engine modules.
caliper-cliThe command-line face.
caliper-pyThe Python face (PyO3 / maturin, import caliper).
apps/studioCaliper Studio — the Tauri + React desktop face.
learn/The Phase-7 pure-PyTorch behavior-cloning sidecar (caliper_learn), a Python package outside the Cargo workspace.

The umbrella crate's re-exports (caliper::spatial, caliper::kinematics, caliper::ik, caliper::dynamics, caliper::motion, caliper::planning, caliper::collision, caliper::trajopt, caliper::hal, caliper::calib, caliper::graph, caliper::model) map one-to-one onto the crates above.

apps/studio is excluded from the default workspace build (it needs a built frontend); build it with npm run tauri dev from apps/studio.

Design principles

  • Lean dependencies. The engine is nalgebra + std in spirit. Collision is pure nalgebra on purpose — parry/rapier were rejected. Planning uses a hand-rolled seeded PRNG rather than pulling in rand. The graph executor adds only serde on top of the engine crates.
  • Determinism / clock-free. No Instant::now, no wall clock anywhere in the engine. Time is t == tick * dt; simulation and control advance only on an explicit step(dt). Randomized planners take a seed. This is what makes the whole stack bit-for-bit reproducible and testable without hardware.
  • Frozen model. caliper-model parses a URDF once into an immutable struct-of-arrays Model that the hot paths (FK, Jacobians, RNEA, CRBA) read without re-parsing or re-allocating.
  • No math in the faces. The CLI, Python bindings, and Studio backend are thin: they parse/marshal and dispatch to the engine. The dataflow graph's COMPUTE nodes each dispatch to an existing engine function — no new math lives in caliper-graph.
  • Consistent conventions. Twists and spatial quantities are [v; ω] and Pinocchio-compatible; the geometric Jacobian comes in a world (LWA-style) and a body (LOCAL) flavor, matching the oracle's Pinocchio reference frames.

Capabilities

This section is a per-capability guide to the engine. Each page describes what the algorithm does, the conventions it follows, and — importantly — how far it has actually been verified. Cross-validated numbers, self-consistent-only components, and by-design limitations are all called out explicitly; the whole trust map is collected in the verification chapter.

  • Kinematics & IK — FK, geometric Jacobians, DLS/LM and analytic 6R inverse kinematics, singularity analysis.
  • Motion — jerk-limited S-curve MOVE_J/L/C, waypoint retiming, time-optimal (TOPP) parameterization.
  • Dynamics & simulation — RNEA, CRBA, forward dynamics, the Simulator.
  • Planning — RRT-Connect / RRT* / PRM, shortcut smoothing, reachability, CHOMP-style trajectory optimization.
  • Collision — OBB-SAT, GJK, EPA penetration depth, half-space, capsule, mesh-as-convex-hull.
  • Control & safety — the backend contract, the computed-torque control loop, the safety monitor, teleop, dataset record.
  • Calibration — joint-offset (zero) calibration.
  • Doctors & trajectory lint — the asset doctor (A001A014, with mechanical repair), the dataset doctor (D001D015), and the trajectory lint (T001T009).
  • Studio dataflow graph — the Phase-8 serde IR and deterministic executor.
  • Learning sidecar — the pure-PyTorch behavior-cloning package.
  • Verdicts — eval, profiling & the Policy Autopsy — the seeded eval harness (E001E003), the deploy-loop latency profiler (L001L003), the policy debugger (P001P008), and the autopsy that merges them under one verdict.

For a single table of every capability against the face(s) that expose it — including honest gaps — see the capability matrix.

Kinematics & IK

Forward kinematics

caliper-kinematics computes forward kinematics from a frozen Model: given a joint configuration q, it places every frame in the world. It also produces the geometric Jacobian in two flavors:

  • world (LWA-style), and
  • body (LOCAL).

These two flavors match the two reference frames Pinocchio exposes, which is why the oracle can check them directly. FK and the world Jacobian are Pinocchio-validated to residuals on the order of 1e-9…1e-15.

Singularity analysis

The kinematics crate also computes singularity metrics from the Jacobian: the singular-value spectrum, Yoshikawa manipulability, and the condition number, plus the manipulability ellipsoid (eigendecomposition) and a redundant-arm nullspace. The scalar metrics (σ, manipulability, condition number) are cross-checked against a NumPy SVD in the oracle. The ellipsoid eigendecomposition and the nullspace are re-derived-correct but validated only against Caliper itself (no external reference); a "singular joint" classification is treated as advisory, not a hard guarantee.

Inverse kinematics

caliper-ik provides two IK paths:

  1. Iterative CLIK — damped-least-squares / Levenberg–Marquardt closed-loop inverse kinematics with:

    • manipulability-gated damping (more damping near singularities),
    • per-step clamping,
    • joint-limit handling,
    • multi-restart to escape poor local basins.
  2. Analytic 6R IK (caliper_ik::analytic) — a closed-form solver for the standard 6R wrist-partitioned geometry, returning the discrete set of branch solutions.

How IK is verified

The IK solver is validated by the FK∘IK round-trip: solve for q, run FK, and confirm the resulting pose matches the target to tolerance. That closure is strong evidence the solver converges to correct configurations, but note the honest caveat — it is self-consistent against Caliper's own FK, not checked against an independent task-space DLS reference. Because FK itself is externally validated against Pinocchio, a correct FK∘IK closure is meaningful, but a defect shared between FK and IK would not be caught by this test alone.

Motion

caliper-motion produces smooth, jerk-limited trajectories with O(1) closed-form sampling: a Trajectory answers sample(t) in constant time (position/velocity/acceleration), so it plays back cheaply and deterministically.

Jerk-limited S-curve profiles

The core is a 7-segment S-curve (jerk-limited trapezoidal) profile. It respects velocity, acceleration, and jerk limits (MotionLimits).

  • MOVE_J — joint-space, time-synchronized: all joints start and finish together, driven by the slowest joint's limits.
  • MOVE_L — Cartesian straight-line motion of the tool frame.
  • MOVE_C — Cartesian circular/arc motion.

The Cartesian entry points validate their caps, dt, and goal finiteness (non-finite goals are rejected), symmetric with the joint-space path — this was tightened in the audit.

MOVE_C fits the unique circle through the start / via / end tip positions and sweeps it the short way, so the parameterization passes through the via point on its way to the end (the naive arc frame could sweep the long way round — regression-tested). It is wired to every face: move_c in Rust and Python, and caliper move --target ... --via tx,ty,tz on the CLI, with oracle coverage (endpoint + via reached within joint velocity limits).

Waypoint retiming

retime_waypoints takes a joint-space waypoint path (for example, the output of the planner) and turns it into a playable, jerk-limited Trajectory. This is how a planned path becomes something a control loop or Studio can execute and record.

Time-optimal parameterization (TOPP)

caliper-motion also includes a time-optimal, acceleration-limited parameterization of a joint-space waypoint path (topp), with corner stops at every interior waypoint.

The reasoning is explicit in the code: a piecewise-linear path q(s) has a discontinuous tangent at every interior waypoint, so q''(s) is an unbounded Dirac there. Joint acceleration along the path is q̈ᵢ = q'ᵢ·s̈ + q''ᵢ·ṡ²; the q''·ṡ² term explodes at a corner unless the path velocity is zero there. Caliper therefore drives ṡ → 0 at each interior waypoint so the spike vanishes. Per segment the tangent q'(s) = Δq is constant, giving the two scalar bounds

|q̇ᵢ| = |Δqᵢ|·ṡ ≤ vmaxᵢ   ⟺   ṡ ≤ minᵢ vmaxᵢ/|Δqᵢ|
|q̈ᵢ| = |Δqᵢ|·s̈ ≤ amaxᵢ   ⟺   s̈ ≤ minᵢ amaxᵢ/|Δqᵢ|

and a rest-to-rest bang-bang (trapezoid/triangle) profile in s over [0,1] is time-optimal subject to those bounds. Segments are concatenated (rest between them) and resampled onto a uniform dt grid.

How motion is verified

All of caliper-motion is re-derived-correct but self-consistent-only: there is no third-party trajectory oracle (nothing Ruckig-class) wired in. The profiles are checked against Ruckig-class jerk-limited expectations and by property tests (endpoint exactness, monotonicity, limit adherence) rather than against an external reference implementation. This is one of the places where the trust comes from re-derivation plus invariants, not from an external cross-check.

Dynamics & simulation

caliper-dynamics implements the standard rigid-body dynamics algorithms on the frozen Model:

  • RNEA — the Recursive Newton–Euler Algorithm for inverse dynamics: given (q, q̇, q̈), compute the joint torques τ.
  • CRBA — the Composite Rigid Body Algorithm for the joint-space mass (inertia) matrix M(q).
  • Forward dynamics — given (q, q̇, τ), compute (using M and the RNEA bias term).
  • Simulator — a semi-implicit (symplectic-style) Euler integrator with gravity, advanced by explicit step(dt) calls.

Conventions

Spatial quantities follow the [v; ω] twist ordering and are Pinocchio-compatible, matching caliper-spatial. This alignment is what lets the oracle compare against Pinocchio directly.

How dynamics is verified

RNEA, CRBA, and forward dynamics are externally cross-validated against Pinocchio to residuals on the order of ~1e-9. (An earlier RNEA sign bug was in fact caught by exactly this external cross-validation, which is part of why the oracle exists.) The Simulator's integrators are checked for energy-bounded behavior rather than against an external reference.

Honest note. The Simulator is validated as energy-bounded, and its energy reporting assumes Earth gravity — a documented assumption, not a bug. The native simulation path has no collision built in; collision is a separate crate that plugs into the control/safety layer.

Contact simulation (MuJoCo)

caliper-sim-mujoco puts MuJoCo behind caliper's existing backend seam, so the same ControlLoop / SafetyMonitor / teleop / recording stack that drives PhysicsSimBackend (contact-free) can drive a full contact simulation unchanged. Faces: Studio's Simulate mode drives the live sim in mujoco builds (with the C001C003 stability lint run after every bake), and Python reaches the MJCF generator via model_to_mjcf (incl. material= / actuators=); the MujocoSim/MujocoBackend layer itself has no Python binding yet.

Two layers:

  • mjcf — generates a minimal MJCF document from a caliper Model: kinematic tree, hinge/slide joints, inertials (converted from caliper's link-origin spatial inertias to MuJoCo's about-COM convention), primitive collision geoms, an optional ground plane, and optional <position> actuators. Pure string work: always compiled and tested, no MuJoCo needed.
  • MujocoSim / MujocoBackend (cargo feature mujoco) — a thin safe layer over the pinned mujoco-rs 5.0.0 wrapper (tracks MuJoCo 3.9.0 exactly), plus a caliper_hal::RobotBackend implementation.

Actuation — chosen at construction

A MuJoCo <position> servo applies force on every step, so it cannot coexist with direct torque injection. The variant is therefore fixed when the model is built:

Variant (mjcf::Actuation)Torque modePosition mode
TorqueDirect (default)writes qfrc_applied directly — no actuators at allnon-physical teleport (mirrors PhysicsSimBackend)
PositionServo { kp, kv }UnsupportedMode — honest errorwrites servo targets to ctrl; MuJoCo computes the torque

estop() latches, zeroes qfrc_applied, and (on the servo variant) freezes ctrl at the current position — a zeroed servo target would actively drive to q = 0, the opposite of a stop.

Determinism

  • MuJoCo runs single-threaded per mjData; caliper never opts into mjThreadPool and generates no noisy sensors.
  • MujocoSim::reset() restores the full integration state (time, warmstart included), so two identical command sequences are bitwise identical — there is a test asserting exactly that.
  • Bitwise reproducibility holds per binary + per MuJoCo release only. That is why the wrapper is pinned exactly (mujoco-rs = "=5.0.0" ↔ MuJoCo 3.9.0).
  • step(dt) only accepts integer multiples of the model timestep — no silent remainder drift.

Live session (Studio)

Studio's Simulate mode can also run the sim live instead of baking a clip: a background thread owns a ControlLoop over the MujocoBackend (or, in MuJoCo-free builds, the builtin contact-free integrator) and steps it at a fixed 1 ms physics timestep while a PD servo holds a live-mutable joint target. Each emitted state carries the joint positions/velocities, world frames, tip position, prop poses, and the live contact count; it streams to the viewport at render rate — nominally 60 Hz, actually 1/(17 · 1 ms) ≈ 58.8 Hz after decimating to a whole number of physics steps.

Design points, stated plainly:

  • Fixed timestep, wall-clock paced. An accumulator converts elapsed wall time into whole physics steps; catch-up debt is capped at 0.25 s — beyond that, excess time is dropped rather than spiraling into ever-larger step batches.
  • Pause freezes; it does not de-energize. Pausing stops stepping and stops accumulating wall time, with the servo target untouched — the arm holds exactly where it is. This is deliberately not estop()/disable(): those de-energize, and a de-energized arm falls.
  • Reset rides the determinism anchor. Reset reseeds the backend — MujocoSim::reset(), the full mj_resetData (warmstart included, the same mechanism behind the bitwise-reproducibility test above) — and rebuilds the control loop at the reset pose, so the session clock and tick counter restart at zero. Reset works while paused and still emits one state, so the viewport always matches the sim.
  • Errors end the session loudly. A step error or a non-finite state ends the session with an error: … reason; there is no silent freeze.
  • Builtin fallback. MuJoCo-free builds run the identical session on PhysicsSimBackend — gravity only: no contacts, no ground reaction, and props are rejected with a clear error rather than silently dropped.
  • Driving it. Every input edits the PD hold target, never the streamed pose — the sim remains the single source of truth for where the arm is, so input and stream cannot fight. Joint sliders become live target editors (the measured pose is drawn as a ghost tick, making servo lag visible), the IK gizmo retargets the tip (solutions seeded from the target, not the lagging measurement, so consecutive drags compose), [/] select a joint and -/=/arrows jog it at a rate scaled to the joint type, and a gamepad drives the tip in cartesian world axes (0.15 stick deadband with a cubic response curve; A pauses, B resets). Space freezes and unfreezes. At most one target update is sent per rendered frame.

Grasping — a weld heuristic, stated plainly. A live session with a gripper channel can pick props up, and it does it the way sim teleop rigs actually do: not finger-friction physics, but an explicitly-labeled weld. The gripper joint is auto-detected by name (gripper/finger/jaw/… on the joint or its child link — SO-101's joints are named 16, the semantics live in the links; mimic joints are skipped so Panda resolves to the driving finger) or overridden explicitly; open/close is just a PD target move to the joint's limits (inset 2% so the hold target never slams a stop). When the gripper is commanded closed and a prop is in contact with the robot, the prop welds to the attach link — with its relative pose captured at that instant, so activation is snap-free (measured < 1 mm across the activation tick) — and opening releases it to fall naturally. One prop at a time; reset releases; a MuJoCo weld is a soft constraint, so a carried prop sags ~1–2 mm under a hard swing. Two honest limits: contact with any robot geom counts (a prop leaning on the forearm can be taken), and closed in the stream is the command, not a measurement — a gripper squeezing a prop reads closed while its joint never reaches the closed target.

Live vs. bake — both exist because they answer different questions. A bake is a fixed command sequence through the deterministic sim: reproducible clip-for-clip, and the C001C003 stability lint runs over the finished rollout. A live session is paced by the wall clock and driven by whatever the UI sends, so it is for watching and interacting, not for reproducible artifacts.

Recording teleop episodes. While driving a live session you can record straight into a native LeRobotDataset v3.0 — the same format the training side reads, no conversion step. Capture happens in the session thread at exact tick decimation (default 50 fps from the 1 kHz loop; any fps that divides the tick rate), writing observation.state (measured joints) and action (the PD hold target) per frame, so timestamps are exact k/fps — never wall-clock-sampled. A take is start/stop with a per-episode task label; stopping either saves the episode or discards it. Pausing mid-take freezes capture and resumes the same take with no timestamp gap. Resetting mid-take discards the take (a reset invalidates the demonstration) and says so. Ending the session finalizes the open dataset. A Studio-recorded dataset loads directly in real lerobot — verified against lerobot 0.6.0.

The live_* Tauri commands and live:// events behind this are Studio-internal IPC, not a public API — they fall in the same not-promised bucket as Studio UI layout in the stability contract. Script against the CLI/Python faces instead.

Verification

Feature-gated integration tests cover: MJCF round-trips through the real MuJoCo compiler; gravity sag with zero torque; a sphere-tipped pendulum settling on a ground plane (contact list non-empty, ±z normal, positive depth and normal force); bitwise-identical repeat runs; the existing ControlLoop converging through a MujocoBackend; and a cross-check of caliper's own gravity Simulator vs MuJoCo on the 2-link pendulum (|Δq| < 2·10⁻² rad over 0.3 s at h = 10⁻⁴ — a deliberately loose tolerance: the integrators differ, and the check exists to catch sign/axis/inertia mapping bugs, not truncation error).

Honest scope & gaps

  • Fixed-base trees of 1-dof joints only (free/ball joints are rejected).
  • Mesh colliders are not exported: CollisionShape::ConvexHull entries are counted (skipped_hull_colliders) rather than silently dropped, so a MuJoCo model can have less collision coverage than caliper-collision on the same robot. MJCF mesh assets are deferred.
  • URDF is not fed to MuJoCo directly (MuJoCo parses URDF but cannot express actuators/solver options there); caliper generates MJCF instead, and joint addressing is resolved by name at load — never by assuming index order.
  • Caliper's Model does not carry URDF <dynamics damping>; MJCF damping is a uniform knob (MjcfOptions::joint_damping), not a translation.
  • Velocity mode is unsupported (as everywhere else in the HAL).

Building with MuJoCo

The default build needs nothing. Enabling the seam links a shared libmujoco 3.9.0 that mujoco-rs does not download on macOS:

scripts/fetch_mujoco.sh                       # pinned official release
export MUJOCO_DYNAMIC_LINK_DIR=~/.cache/caliper/mujoco-3.9.0
export DYLD_LIBRARY_PATH=$MUJOCO_DYNAMIC_LINK_DIR:$DYLD_LIBRARY_PATH  # macOS
cargo test -p caliper-sim-mujoco --features mujoco

CI runs only the default (MuJoCo-free) build of this crate; the feature-gated tests are a local/gated lane until a cached-artifact CI job is added.

Shipping the app with contact sim (macOS)

The recipe above serves development: the dylib lives in a cache directory with an absolute install id, so the binary only runs on the machine that fetched it. To ship a Studio .app/.dmg with the mujoco feature on, the bundle must carry libmujoco itself and resolve it via @rpath:

scripts/bundle_mujoco.sh    # fetch + stage src-tauri/vendor/ (gitignored)
cd apps/studio
MUJOCO_DYNAMIC_LINK_DIR="$PWD/src-tauri/vendor" npm run tauri build -- \
  --features mujoco \
  --config "$PWD/src-tauri/tauri.mujoco.conf.json"

How the pieces fit (each step verified against the tauri 2.x sources):

  1. bundle_mujoco.sh copies the pinned dylib into apps/studio/src-tauri/vendor/, rewrites its install id to @rpath/libmujoco.3.9.0.dylib, and ad-hoc re-signs it (install_name_tool invalidates signatures, which SIGKILLs on Apple Silicon). Linking against this copy is what stamps the relocatable @rpath/... load command into the executable — the bundler never rewrites install names after the fact.
  2. tauri.mujoco.conf.json is a separate overlay config, merged over tauri.conf.json by --config (JSON Merge Patch). It adds bundle.macOS.frameworks = ["vendor/libmujoco.3.9.0.dylib"]. It cannot live in the default config: tauri-build hard-errors whenever a listed dylib is missing, which would break every ordinary build.
  3. With a non-empty frameworks list, tauri-build links the executable with -Wl,-rpath,@executable_path/../Frameworks, and the bundler copies the dylib into Contents/Frameworks/ and signs it with the app. At launch, @rpath/libmujoco.3.9.0.dylib resolves inside the bundle — no DYLD_LIBRARY_PATH, no per-machine paths.

Honest caveats:

  • The .dmg grows by ~9 MB (the universal2 libmujoco.3.9.0.dylib is 8.6 MB).
  • The staged dylib is ad-hoc signed, then re-signed with whatever identity signs the app (currently an Apple Development cert, not notarized) — the usual right-click → Open applies, same as the plain release.
  • --features mujoco without the overlay config produces a binary whose @rpath/libmujoco... load command resolves nowhere — always pass both flags together (or neither).
  • macOS only; the default MuJoCo-free bundle is completely unaffected.

Planning

caliper-planning is a pure-CPU, dependency-light motion-planning crate. It plans collision-free joint-space waypoint paths and, on request, retimes them into playable trajectories.

Sampling-based planners

  • RRT-Connect — bidirectional, joint-space, with caliper_collision::CollisionModel (self + world) plus joint limits as the validity check. This is the default planner (Planner).
  • RRT* — the asymptotically-optimal variant (rrtstar).
  • PRM — a probabilistic roadmap (prm).

All three are deterministic: they use a seeded splitmix64 PRNG (no rand), so a given seed yields the same plan every time and the planners are fully unit-testable with no hardware.

Smoothing and retiming

A raw sampled path is jagged. Planner shortcut-smooths the result (repeatedly attempting to replace sub-paths with collision-free straight-line shortcuts). To play or record the plan, Planner::plan_trajectory (via the motion crate's retime) turns the collision-free waypoint path into a jerk-limited caliper_motion::Trajectory.

Reachability

caliper_planning::reach provides reachability analysis — a three-way classification of goals as reachable / blocked / out-of-reach that is collision-aware.

Trajectory optimization (CHOMP)

caliper-trajopt is a separate, CHOMP-style collision-aware trajectory optimizer. Given an initial waypoint path, it refines the interior waypoints (endpoints held fixed) by gradient descent on

cost(path) = w_smooth · smoothness(path) + w_obs · obstacle(path)
  • smoothness is the classic CHOMP quadratic — summed squared finite-difference accelerations ‖q[i-1] − 2·q[i] + q[i+1]‖² over the interior. Its analytic gradient (the pentadiagonal AᵀA q form) is cross-validated in the tests against a finite-difference of the cost.
  • obstacle is a smooth proximity penalty: each robot collider is reduced to a body sphere, placed by FK and inflated by its bounding radius, and scored against an ObstacleField signed-distance field with the standard CHOMP hinge potential.

How planning is verified

The planners and smoothing are re-derived-correct but self-consistent-only. The important honesty here is the nature of the collision guarantee: it is a sampled-at-resolution guarantee. The planner checks configurations at a discrete resolution along each edge, so a sufficiently narrow passage can be tunneled — the plan can be reported collision-free while a thin obstacle slips between samples. There are also no dedicated narrow-passage / near-π / at-limit stress fixtures yet; coverage is random sampling. These are documented limitations, not defects.

Collision

caliper-collision is a self-contained, pure-nalgebra collision checker — no parry/rapier (they were deliberately rejected to keep the dependency surface lean). CollisionModel builds primitive colliders from a Model's parsed <collision> geometry, places them by forward kinematics at a configuration q, and reports:

  • self-collisions — between link pairs, excluding an auto-seeded adjacency allowlist (adjacent links are expected to touch), and
  • world collisions — against a ground half-space and world boxes.

It implements caliper_hal::SafetyCheck, so the control loop / safety layer can reject a colliding command.

Geometry

  • Box ↔ box — the separating-axis theorem (15 axes, Ericson), including the edge-edge degeneracy.
  • Sphere/box and half-space — closed form.
  • Cylinders — conservatively approximated by their tight oriented bounding box (this errs toward detecting a collision, which is the safe direction).
  • Capsules — swept spheres (a core segment ⊕ a sphere of radius): capsule ↔ sphere/half-space/capsule use closed-form point-segment / segment-segment distances; capsule ↔ box/convex reuse GJK via the capsule's exact support function.
  • Mesh — arrives as the convex hull of its vertices and is checked with GJK (boolean origin-in-Minkowski-difference).

Penetration depth (EPA)

On overlap, CollisionModel::contacts runs EPA (the Expanding Polytope Algorithm) on top of GJK to recover a Contact for each colliding pair: a unit separation normal (the outward normal of the Minkowski difference), a penetration depth, and a witness point.

Honesty about coverage

The checker is re-derived-correct and, by construction, cannot under-report for the geometry it handles. But two things are called out explicitly:

  • Colliders that cannot be reduced to the supported primitives (some mesh / capsule cases) are surfaced loudly via an uncovered_frames report rather than silently dropped — you always know what was not checked.
  • The native Simulator has no collision; collision is only enforced where the SafetyCheck is wired in (the control/safety layer).

Control & safety

caliper-hal is the hardware/simulation abstraction layer plus a deterministic control stack. It turns a robot — real or simulated — into a uniform, tick-driven contract.

The backend contract

RobotBackend is the real backend contract: control modes, lifecycle/safety, atomic state readback, and a tick-driven step(dt). SimBackend is the built-in simulated implementation. Everything is clock-free: nothing advances until the loop calls step(dt), and t == tick * dt. There is no Instant::now and no wall clock, so a rollout is bit-for-bit reproducible and testable without any real robot.

The control loop

ControlLoop is a deterministic computed-torque controller. The design lesson baked in here is important: a fixed-gain PD controller diverges on low-inertia wrists, so Caliper uses computed-torque (model-based) control, which gives one gain pair that works across any robot. The loop saturates the command (not merely the position reference), so limits are actually enforced on what is sent to the actuator.

Safety

SafetyMonitor is a pure (side-effect-free) safety layer. SafetyCheck is the pluggable predicate the collision crate implements, so collision rejection slots directly into the safety path. ControlLoop.step_with_target exposes a last_warn channel so a caller (or the learning sidecar) can see when the safety layer intervened.

Setpoint sources and teleop

Setpoint sources drive the loop, including a teleop leader–follower source (one arm's state commands another). Because everything is tick-driven, teleop is just another deterministic setpoint stream.

Dataset record / replay

Caliper records and replays the LeRobotDataset format — the standard schema used for imitation-learning data — in two versions:

  • v3.0 native (caliper-dataset, the default): the layout lerobot >= 0.4 loads directly — no converter (proven against 0.4.4 AND 0.6.0; on lerobot

    = 0.6 install the dataset extra, pip install "lerobot[dataset]" — see the stability contract). The writer auto-finalizes on drop, so lerobot's "forgot to finalize()" footgun can't truncate a recording. Faces: caliper record (CLI, --format v3 default), RecorderV3 / DatasetReaderV3 (Python).

  • legacy v2.1 (caliper-hal, feature dataset): kept for older toolchains (--format v21, Recorder / DatasetReader); lerobot >= 0.4 needs its official v2.1→v3.0 converter to load these. The Phase-7 learning sidecar's collector still emits this layout.

Both directions are oracle-verified against real lerobot: natively-written v3.0 loads through LeRobotDataset (windowing, padding, a real SGD step), and converter-written v3.0 reads back through Caliper's reader.

Hardware skeletons

Feature-gated CAN and Dynamixel hardware backends exist as skeletons. They are the interface stubs for real actuators; the physics, control, and safety above are fully implemented and tested against the simulated backend.

How control/safety is verified

The computed-torque decoupling and the safety monitor are re-derived-correct, with the control law validated on a 2-DOF pendulum (a case with a known closed-form). They were further hardened during the audit (input validation, NaN/limit guards). The dataset path is validated by pyarrow schema + NumPy statistics — note that lerobot itself is not importable in the test env, so the check is against the schema, not against lerobot's own reader.

Calibration

caliper-calib implements the verifiable core of kinematic calibration: joint-offset (zero) calibration.

The problem

A real robot's encoders read joint angles in a frame whose zero is offset from the kinematic model's zero by an unknown constant vector δ (from mechanical assembly, homing, or encoder mounting). Given observations {(commanded qₖ, measured tip pose Tₖ)}, the crate estimates δ such that FK(qₖ + δ) ≈ Tₖ for every observation.

The method

It solves by damped Gauss–Newton least squares. For each observation the residual is the body-frame error twist

rₖ = log6( FK(qₖ + δ)⁻¹ · Tₖ )      ∈ se(3),   stored [v; ω]

At the true offset δ*, FK(qₖ + δ*) = Tₖ, the error pose is the identity, and every rₖ = 0. Differentiating to first order gives d rₖ / d δ = −J_b(qₖ + δ), where J_b is the LOCAL (body) geometric manipulator Jacobian of the target frame — exactly the Jacobian caliper-kinematics already computes. So calibration reuses the same FK and Jacobian machinery the rest of the engine depends on, which is why it inherits their (Pinocchio-validated) correctness for the forward evaluation.

Scope

This is the joint-offset slice of calibration — the part with a clean, verifiable formulation. Fuller kinematic calibration (link-length / DH-parameter identification, etc.) is not claimed here.

Doctors & trajectory lint

Caliper ships three diagnostic engines. Each one turns a class of silent, late-surfacing failures into an explicit, plain-English report before you pay for them:

DoctorInputCodesCatches
Asset doctor (caliper-doctor)a .urdf / .xacro fileA001A016CAD-export defects that break loading, physics, or collision coverage
Dataset doctor (caliper-dataset::analyze)a LeRobotDataset v3.0 rootD001D016data defects that are invisible at record time and fatal to a trained policy
Trajectory lint (caliper-kinematics::lint_path + face-side collision lint)a sampled trajectoryT001T009limit violations and path-quality hazards before a trajectory runs

Shared contract, all three:

  • Findings are data, not errors. A finding never changes an exit code or throws; the report is the product. Commands only error when the input cannot even be inspected (unreadable file, unparseable dataset).
  • Stable codes. Every check has a fixed code you can filter on in JSON output; the sets below are exhaustive as of this writing.
  • Sorted most-severe-first, with per-severity counts.
  • Severities: Error = broken or actively wrong if ignored; Warning = runs/loads but behaves worse than you think; Info = worth knowing, nothing wrong per se. In machine-readable output (Python dicts, --json) the spelling is lowercase "error" | "warning" | "info" — the Python asset doctor's historical "warn" was unified to "warning" in the pre-1.0 window.
  • Exit codes — the philosophy split. The doctors here never gate: caliper doctor, caliper data doctor and caliper report exit 0 with findings (the report is the product; opt into gating with report --strict). The learning sidecar's verdict console (caliper-learn debug|autopsy|eval|profile) gates by default — exit 1 on any error-severity finding — because those commands sit at the end of a training pipeline where CI is the natural caller. See Verdicts.

Where to run them:

CLIPythonStudio
Asset doctorcaliper doctor robot.urdf [--repair]caliper.doctor(path, repair=…)automatic on robot load — findings appear in the error-banner area, with Repair & reload when a mechanical fix exists
Dataset doctorcaliper data doctor <root>caliper.data_doctor(root)Data mode → Doctor button
Trajectory lintcaliper report … [--strict]caliper.lint_path(robot, …)

Asset doctor (A001A016)

Real-world URDFs — CAD exports above all — routinely carry defects that the rest of the stack surfaces late, one at a time, or not at all: a silently dropped collider here, has_inertia = false there, an MJCF export MuJoCo rejects. diagnose runs every check in one pass; repair emits a repaired copy — the input file is never touched.

The doctor parses XML itself (leniently) instead of going through urdf-rs: half the point is diagnosing files urdf-rs rejects outright, like a <limit> without velocity= or a .urdf full of xacro leftovers. .xacro input is expanded first via caliper_model::xacro.

Check catalog

A001 — missing/zero <inertial> on a non-root link (Error, auto-fixable). Any movable link (or fixed link folded onto one) without a real <inertial> flips the whole model to has_inertia = false, which gates off every dynamics entry point (simulation, computed-torque control, gravity compensation). Repair: compute_inertials fills mass/COM/tensor from the link's collision (else visual) geometry at a uniform density — analytic formulas for primitives, divergence-theorem integrals for meshes. It never overwrites an explicit inertial with positive mass, and never touches the root link (see A010).

A002 — implausible inertia (Error). Non-finite entries, a zero tensor with positive mass, a negative principal moment, or a triangle-inequality violation (any principal moment exceeding the sum of the other two — physically impossible for real mass). Checked on the tensor's eigenvalues, so converter-dropped off-diagonal terms are caught even when the diagonal looks sane. Consequence: integrators go unstable or silently wrong. No auto-fix — the true values live in your CAD; recovering them mechanically would be a guess.

A003 — mesh unresolvable or unloadable (Error on <collision>, Warning on <visual>). The message lists every path that was tried (relative/absolute/file:///package://). A dropped collision mesh is an Error because the engine then checks nothing for that link — a collision query can report "clear" while the real link is in contact. A dropped visual only degrades rendering. No auto-fix (the doctor cannot invent your mesh file), but the finding names exactly what to restore and where it was expected.

A004 — duplicate mesh basenames pointing at different files (Warning, auto-fixable). Two links referencing hand.stl from different directories work locally, but any pipeline that flattens assets into one folder (bundlers, converters, most sim importers) silently makes both links use one of the files. Repair: dedupe_mesh_basenames renames later duplicates (m2__hand.stl, …) in the document and returns a file-copy plan — the engine performs no file writes; executing the copies is the calling face's job (the CLI and Studio both do it).

A005 — link has <visual> but no <collision> (Warning). The link is invisible to collision checking and planning: paths can be planned straight through it. Often intentional (decorative geometry) — hence a Warning — but on an arm segment it usually means the exporter dropped the collision block.

A006 — collision mesh above the 1024-vertex hull cap (Info). Caliper convex-hulls mesh colliders and subsamples above the cap; the hull may be slightly loose. Nothing is wrong — worth knowing when you see near-miss distances that disagree with CAD by millimetres.

A007 — revolute joint without usable position limits (Warning, auto-fixable). Missing <limit>, or a degenerate lower == upper range on a joint that is supposed to move. IK and planning then treat the joint as unbounded (or frozen), and datasets recorded through it can contain wound-up configurations. Deliberately continuous joints are exempt. Repair: inject_limits writes a conservative ±π range, marked as such in the repair log.

A008 — zero-length / unparseable joint axis (Error). An axis of 0 0 0 (or garbage text) makes the joint's motion undefined; most loaders either reject the file or silently substitute a default axis that sends FK to the wrong place. No auto-fix — the doctor cannot know which axis you meant.

A009 — non-unit joint axis (Warning, auto-fixable). Some parsers normalize, some don't: the same file produces different kinematics in different tools, and velocity/effort limits change meaning by the axis norm. Repair: normalize_axes rescales to a unit vector (direction preserved).

A010 — zero-mass root link (Info, heuristic). The signature of onshape-to-robot and several other CAD exporters. Harmless in itself (the root never moves), which is why the repair pipeline deliberately skips the root when computing inertials — flagged so you know the file's provenance.

A011 — mimic references an unknown joint (Error). The mimic joint's motion is undefined; loaders that tolerate it produce a robot whose FK disagrees with the file's intent.

A012 — mimic chain (incl. self-mimic) (Error). A mimic whose source is itself a mimic (or itself). Resolution order is undefined across tools; caliper's compiler rejects it outright.

A013 — xacro leftovers in a .urdf (Error, or Warning when xmlns:xacro is declared and in-process expansion succeeds). An unexpanded $(find …), ${…} or <xacro:…> tag in a plain .urdf means URDF parsers fail on the tags or silently misread the values. With the namespace declared, caliper can expand it in-process (the rest of the report then describes the expanded model) — but most other URDF consumers will not, so it stays a portability Warning. Fix: run the file through xacro once and ship the expanded .urdf. (A real .xacro file is simply expanded — leftovers are its normal content, not a finding.)

A014 — <limit> missing velocity= (Error, auto-fixable). urdf-rs (and therefore every Rust-stack consumer) rejects the whole file over this one attribute; a missing effort= safely defaults to 0 in caliper's own loader but not everywhere. Repair: inject_limits writes the mandatory velocity="1" (conservative) so the file at least loads.

Repair semantics

  • Everything is opt-in. Every repair rewrites physics-relevant fields, so each RepairOpts flag is off by default; RepairOpts::all() (what the CLI --repair, Python repair=True, and Studio's Repair & reload use) enables all four: compute_inertials, normalize_axes, dedupe_mesh_basenames, inject_limits.
  • The input file is never modified. Repair returns the repaired document; faces write it as a sibling <stem>.repaired.urdf so relative mesh references keep resolving.
  • Nothing fails silently. What could not be fixed lands in skipped with the reason (e.g. a link with no geometry to integrate).
  • Verify the copy. The findings in a repair run describe the original file; re-run diagnose on the output (the CLI and Studio do this automatically and show the after-report).

Density caveat. compute_inertials assumes a uniform material density, default 1000 kg/m³ (water — a sane mid-range for printed/machined robot parts; override with --density / density=). Real links are not uniform-density solids: computed inertials are placeholders good enough for stable simulation and roughly-scaled dynamics, not a substitute for CAD-derived values in dynamics-critical control. The repair log marks every computed inertial so you can audit them.


A015 — duplicate link/joint names (Error). Two links (or joints) share a name — lookups become ambiguous, and a name-keyed repair could write one link's computed inertial into another. The doctor flags every duplicate; repair targets links positionally so each duplicate receives the inertial computed from its own geometry. Fix: rename the duplicates in the source.

A016 — unparseable numeric attribute (Error, auto-fixable for revolute ranges). A <limit> float or <origin> xyz/rpy value that does not parse as a number. Parsers that default these to 0.0 silently change the robot; the doctor reports the raw text instead, and repair replaces an unparseable revolute range with conservative injected limits.

Dataset doctor (D001D016)

Pre-training diagnostics over a native LeRobotDataset v3.0. Every check targets a failure mode that is invisible at record time, silent during training, and fatal to the resulting policy. The analyzer makes two streaming passes (one episode resident at a time), recomputes all statistics from the raw bytes, and is fully deterministic (seeded subsampling): a report is a pure function of the dataset bytes and the options. The default thresholds (AnalyzeOptions::default) are tuned so a healthy teleop dataset produces zero findings.

Checks relating actions to observations key off lerobot's conventional feature names action and observation.state; when either is absent those checks are skipped. Everything per-feature runs on every float32 vector feature.

D001 — dead dof (Warning). A dof whose whole-dataset std is ~0 never moves. Why it kills training: std-based normalization divides by ~zero (exploding inputs or NaNs, depending on the stack), and the policy learns the dof is irrelevant — if the joint was supposed to move, that behaviour is unlearnable from this data.

D002 — stale/missing meta/stats.json (Error). The doctor recomputes mean/std per dof and compares against the stored stats (missing file, missing feature entry, wrong length, or values beyond tolerance). Why it kills training: lerobot normalizes with the stored values. Stats that describe different bytes (classic cause: the dataset was edited or concatenated without recomputing) shift and scale every input systematically — the policy trains in one coordinate system and deploys in another. This is the single most common "trained fine, deploys as garbage" cause the doctor can prove.

D003 — saturated/collapsed action dof (Warning). More than half the frames (configurable) pinned at the dof's min, max, or a single histogram bin. Why: the label distribution is nearly constant — the policy mostly sees one value and will slam that value at deployment; usually a teleop gain or command-clipping problem.

D004 — echo/lag action labels (Warning). action is nearly identical to observation.state (RMS difference below a small fraction of the state's spread). Why: the policy can minimize its loss by copying its input — it will never move the robot on its own. Classic cause: logging the measured position as the "action" in high-fps position control. Fix: train on delta actions or one-step-shifted targets.

D005 — numerically tiny actions (Warning). An action dof's range is orders of magnitude below the typical state range (unit mismatch: rad vs deg, normalized vs raw). Why: after normalization, sensor noise dominates the learning signal for that dof.

D006 — contradictory demonstrations (Warning). Near-identical states (on a seeded reservoir subsample) with widely divergent actions. Why: behavior cloning averages the modes into an action nobody demonstrated — the mean of "go left" and "go right" is "drive into the obstacle in the middle". Fix: delete the wrong demo, or condition the policy on the missing context (task/goal) that distinguishes them.

D007 — coverage holes (Info). A dof visits fewer than half its histogram bins between its own min and max. Why: the policy has no data for most of that dof's span and will extrapolate there — fine if the region is intentionally unreachable, dangerous if deployment passes through it.

D008 — corridor-shaped data (Info). Mean |pairwise correlation| across a feature's dofs near 1: the dofs move in lockstep along one path. Why: the dataset spans a 1-D manifold of the workspace; any state off that corridor is out-of-distribution at deployment. Fix: vary starts, goals and speeds.

D009 — episode-length outlier (Info). Robust (MAD-based) z-score on episode lengths. Why: a 10× episode is usually a stuck recording, a concatenated take, or an aborted demo — and it dominates (or starves) the sampling of its task.

D010 — irregular timestamps (Warning). Frame-to-frame dt deviating from 1/fps. Why: delta-timestamp windowing and action-chunking (ACT, delta actions) pair frames by time; misaligned frames mean the model learns from mismatched (state, action) pairs.

D011 — frozen tail (Warning). The last N frames of an episode are bit-identical across every vector feature: the robot froze before the recording stopped. Why: the policy learns to stall at the end of the task — seen as "the arm approaches the goal and stops short" at deployment. Fix: trim via the edit ops (split at the last moving frame, delete the remainder).

D012 — dead camera (Error when frames cannot be decoded; Warning for black/white/constant streams). Why: undecodable frames crash or silently skip in the dataloader; a black stream means a vision policy is blind on an input it is supposed to use — it trains anyway, keying on whatever else correlates.

D013 — duplicated consecutive camera frames (Info). A large fraction of consecutive frames byte-identical: the camera delivered fewer real frames than the recorded fps claims. Why: visual dynamics are slower than labeled, which skews anything trained with frame-stacking or optical flow.

D014 — brightness drift (Info). Mean image brightness drifts substantially start→end within an episode (auto-exposure hunting, lighting change). Why: the policy can key on brightness as a spurious progress signal instead of the scene. Fix: lock exposure/white balance.

D015 — duplicate episodes (Warning). Cross-episode identical state sequences (accidental double-record or copy). Why: duplicates over-weight one demonstration and leak between train/val splits, inflating validation metrics.

The report also carries the recomputed per-feature summaries (dim, mean, std, min, max, per-dof histogram bin occupancy) so you can cross-check the analyzer's arithmetic against your own.

In Studio, findings with an episode reference are clickable — the episode table jumps to the row so the offending take can be inspected, split, or deleted on the spot. Any structural edit clears the report (it described the pre-edit bytes); run the Doctor again after editing.


D016 — non-finite values (Error). A feature holds NaN/inf frames, or an episode's timestamps are non-finite. Every statistic downstream (means, stds, normalization) silently propagates NaN — a single poisoned frame can zero an entire training run. The writer now rejects non-finite values at add_frame, so this fires only on datasets produced elsewhere. Fix: drop or repair the poisoned episodes (delete via the edit ops).

Trajectory lint (T001T009)

Typed findings over a sampled trajectory (times/q/qd/qdd rows — exactly what Trajectory.sample_uniform produces). T001T007 live in the engine (caliper_kinematics::lint_path, layered on path_report); T008/T009 are the collision half, computed face-side (the CLI report verb) because the kinematics crate cannot depend on the collision crate. Errors mean do not run this trajectory; Warnings deserve a second look. Thresholds (LintOptions) are metric-robot engineering defaults, not physics — tune per cell.

CodeSeverityFinding
T001Errorposition limit violated (negative margin), located at the worst sample
T002Errorvelocity utilization > 100 % (+ tolerance) vs vmax
T003Erroracceleration utilization > 100 % (+ tolerance) vs amax
T004Warningsustained dwell within a small margin of a position limit (default > 25 % of samples) — no escape headroom left
T005Warningwrap-around detour: total joint travel ≫ net start→end change (the "360° spin"), located at the peak excursion off the chord
T006Warningfinite-difference jerk spike above 1.5 × jmax (disabled per joint when jmax is infinite, e.g. TOPP output)
T007Warningsingular corridor: one finding per contiguous window with σ_min below threshold
T008Errorpath in collision (self or world), one finding per contiguous time window
T009Warningnear-miss: path passes within the clearance margin of an obstacle or itself (conservative boolean re-query with inflated colliders: pair gaps flag below 2× the margin, ground below 1×)

Every finding carries the offending joint, time, and measured value machine-readably — faces never parse message text. caliper report --strict exits non-zero on any Error-severity finding, which is the CI hook.

Studio dataflow graph

caliper-graph is the Phase-8 dataflow graph executor — the engine behind Studio's Simulink-style node editor, and equally usable from the CLI and Python.

What it is

A deterministic, serde-serializable graph IR (GraphDoc, persisted as the .caliper-graph.json schema) that composes existing engine ops into a runnable pipeline. All three faces (the Studio Tauri backend, the CLI's graph subcommand, and the PyO3 run_graph) (de)serialize a GraphDoc, then call validate and run.

No new math lives here. Every COMPUTE node dispatches to an existing engine free function or type. The crate is pure Rust and lean — serde plus the engine crates plus nalgebra, with no rand and no tokio.

Shape of the IR

  • ir — the persisted schema: PortType, NodeKind, Node, Edge, GraphDoc, PortValue, ClipData, ReportData.
  • validate — returns Diagnostics (per-node / per-edge errors) plus a topological order, or a cycle report.
  • execrun returns a GraphResult or a GraphError.

Node kinds

The NodeKind enum covers source, compute, and sink nodes, each mapping to engine functionality:

  • Sources / configsStartConfig, GoalPose, NamedConfig.
  • ComputeIk, MoveJ, MoveL, PlanRrt, Control (kp/kd), GravityDrop, CollisionCheck.
  • Sinks / viewsView (3D scene), Scope (a named signal plotted over time), Report.

Determinism

The executor is deterministic: PlanRrt is seeded, and control/dynamics rollouts are tick-driven and clock-free (consistent with the rest of the engine). A wired graph like StartConfig → GoalPose → Ik → MoveL → View + Scope produces the same result on every face and every run.

How the graph is verified

The executor's dispatch is verified faithful — a MoveJ/MoveL node produces the same result as calling caliper_motion::move_j / the Cartesian path directly (a parity test), and the executor is deterministic. It has its own oracle coverage. Note the scope of this: it verifies that the graph wrapper faithfully calls the engine, on top of the engine's own verification — it does not add a new independent check of the underlying math.

Learning sidecar

learn/caliper_learn is the Phase-7 behavior-cloning sidecar: a minimal, pure-torch imitation-learning package. It is a Python package outside the Cargo workspace, built on the Caliper PyO3 bindings (caliper.{Robot, Planner, ControlLoop, Recorder, DatasetReader}).

"Pure-torch" is a deliberate constraint: no lerobot, no hydra, no diffusers at runtime. The BC-MLP, an ACT-lite transformer, and an optional DDPM (diffusion) head are hand-written stdlib PyTorch.

Pipeline

collect  →  data  →  policy  →  train  →  checkpoint  →  deploy
  • collect — generate sim demonstrations into a LeRobotDataset v2.1 (one-step lookahead + a terminal frame). (The engine's dataset faces also write the v3.0 native layout — see Control & safety; the sidecar's own collector still emits v2.1.)
  • data — a goal-conditioned torch Dataset with train-only normalization statistics.
  • policybuild_policy for bc_mlp, act_lite, or the diffusion head; normalization stats are stored as model buffers so they round-trip with the weights.
  • trainfit on CPU.
  • checkpoint — save/restore round-trip.
  • deploy — closed-loop in sim via ControlLoop.step_with_target.

Hard-won lessons (baked into the code)

These are documented because getting them wrong produces silently-wrong results:

  • Train and deploy must share cadence. Collecting at fps=50 but deploying at the default dt=1e-3 consumed the one-step lookahead ~20× too fast (only ~42% of the gap closed). Deploy at dt = 1/fps.
  • ACT deploy must mirror the dataset's windowed history. A degenerate repeated-observation history nullifies the temporal encoder.
  • Normalization round-trip tests are false-greens unless the stats are non-identity. The buffer round-trip must use real (non-identity) stats to mean anything.
  • Seed before building the policy. A train-loop seed does not cover weight init, because the model is built before fit runs — call seed_all(0) before build_policy.

Deploying a real lerobot checkpoint (the payoff leg)

The sidecar can also run a real lerobot Hub-convention ACT checkpoint closed-loop in the deterministic sim — no lerobot server, no network, CPU only:

import caliper
from caliper_learn import load_lerobot_policy, run_policy

robot = caliper.Robot.from_urdf("robot.urdf")
policy = load_lerobot_policy("outputs/train/act_reach/checkpoints/last/pretrained_model")
loop = caliper.ControlLoop(robot, dt=1 / 50)   # dt MUST match the training fps
result = run_policy(policy, loop, fps=50, ticks=400)
print(result.warn_ticks, result.times[-1])

Driving Studio's live simcaliper-learn drive CKPT (--urdf PATH | --task FILE) [--device cpu] is the same deploy leg spoken over stdio: it loads the checkpoint, prints one ready JSON line, then answers every observation line with an action line (tick echoed, actions clamped into the URDF limits — except limitless joints, which are never clamped). Studio's "connect policy…" spawns exactly this from a python env you point it at, so a trained policy drives the live session in-app — while you watch, nudge, pause with Space, and even record its rollouts as new episodes. State-based policies only: a checkpoint demanding camera features is refused at load, naming the missing keys (the live session has no camera stream). Protocol chatter from libraries is rerouted to stderr, so stdout stays pure JSON.

  • load_lerobot_policy(path, device="cpu") -> LoadedPolicy — loads a LOCAL checkpoint directory (model.safetensors + config.json + policy_{pre,post}processor.json). Safetensors only: any pickle-format file (.bin/.pt/.pth/.ckpt/.pkl/.pickle) raises CheckpointSecurityError — pickles execute arbitrary code on load. ACT policies with state-like input features are supported this wave; VISUAL features raise a named NotImplementedError.
  • LoadedPolicy — wraps the policy + its pre/post processor pipelines behind reset() / predict(obs_dict) -> action. Action chunking uses lerobot's OWN select_action semantics (one action popped per call, replan every n_action_steps, temporal ensembling when configured), so the in-sim loop consumes chunks exactly like lerobot-eval would.
  • run_policy(policy, control_loop, *, fps, ticks, obs_builder=None) — the closed-loop runner: builds observations from measured state, vets every commanded target through the SafetyMonitor inside ControlLoop, and returns a HubRolloutResult (times/states/actions + warn_ticks). Any object with reset()/predict() works, so home-grown policies use the same runner.

This is the leg the latency profiler and debugger judge: profile_rollout takes the same LoadedPolicy, and analyze_policy takes the same checkpoint directory load_lerobot_policy loads.

Diagnostics on top of the pipeline

The sidecar also carries the W2 verdict stack — the seeded eval harness (E001E003), the deploy-loop latency profiler (L001L003), the policy deploy debugger (P001P008), and the autopsy that merges them with the dataset doctor under a single verdict, plus the caliper-learn console script. They get their own chapter: Verdicts — eval, profiling & the Policy Autopsy.

Honesty about verification

Everything in the sidecar is proven only by seeded CPU oracles — a 2-sample overfit smoke test (loss → 0), a checkpoint round-trip, and a closed-loop sim rollout. Real GPU training of an ACT / diffusion policy is the documented next step and is deliberately never auto-run. No trained policy or learned capability is claimed here — only that the pipeline is correct and reproducible at small scale on CPU.

Verdicts — eval, profiling & the Policy Autopsy

The doctors judge the inputs to learning — assets, datasets, trajectories. The verdict stack judges the output: a trained policy and the deploy loop it runs in. Four tools, all in the caliper_learn sidecar, each answering one question nobody instruments until it is too late:

ToolQuestion it answersCodes
Eval harness (caliper_learn.eval)does the policy actually solve the task, or did the loss just go down?E001E003
Latency profiler (caliper_learn.profile)can the deploy loop honestly hold the requested control rate?L001L003
Policy debugger (caliper_learn.debugger)why does the trained policy do nothing?P001P008
Autopsy (caliper_learn.autopsy)all of the above, one report, one verdictD + P + E + L

They follow the doctors' shared contract: findings are data, not errors (the report is the product), every check has a stable code you can filter on in JSON, every finding carries a plain-English message naming the consequence and a fix_hint saying what to do, sorted most-severe-first. Everything except wall-clock timing is deterministic: the same (checkpoint, dataset, task, seed) produces byte-identical serialized output — the tests assert exact equality.

Where to run them:

caliper CLI (Rust)caliper-learn CLIPythonStudio
Eval / sweep✗ ¹caliper-learn evalevaluate / sweep✗ ¹
Latency profile✗ ¹caliper-learn profileprofile_rollout✗ ¹
Policy debugger✗ ¹caliper-learn debuganalyze_policy✗ ¹
Autopsy✗ ¹caliper-learn autopsyautopsy✗ ¹

¹ Policy inference is Python-side (torch + the safetensors-only hub loader), so neither the Rust CLI nor Studio can host these — the sidecar ships its own console face instead. caliper-learn exits 1 when any error-severity finding was reported, else 0, so CI can gate without parsing output (same hook as caliper report --strict). Every subcommand takes --json.


Eval harness (E001E003)

Training loss predicts almost nothing about closed-loop competence — BC covariate shift, chunk-cadence mismatches, and normalization drift all hide behind a pretty loss curve. The only honest metric is rollouts: evaluate(policy, task, cfg) runs N seeded episodes on VecSimEnv and reports what actually happened.

Aggregate semantics. Episode k runs on seed base_seed + k (seeded init jitter around the joint-range midpoints); success is defined as the task's termination_fn firing within max_steps. Each episode row carries its seed, success, steps, summed episode_return, and — when the task defines a distance_fn (reach_eval_task does) — the final_distance, so a failed episode still says whether it was almost or nowhere near. The success rate is aggregated with a Wilson 95% score interval, chosen over the normal approximation because eval runs are small and rates sit at the edges: 0/N and N/N still get honest, non-degenerate intervals, and a 3/5 result is reported as the coin-flip it is (CI ≈ [0.23, 0.88]) instead of "60%". Deterministic end to end: the same EvalConfig and a deterministic policy produce a byte-identical to_json(result); a policy with its own unseeded RNG breaks that — seed it in reset(), the way the diffusion head does.

Manipulation success predicates. For grasp/place tasks, "the termination fired" is the wrong success definition — you care whether the object ended up where it should. caliper_learn.success provides composable predicates: Lifted(prop, height) (relative to the prop's episode-start pose, or absolute), PlacedInZone(prop, zone) (axis-aligned box, inclusive faces, optional settled-speed requirement — which raises on a velocity-less state rather than silently passing a fly-through), and AllOf/AnyOf combinators. Every predicate round-trips exactly through a JSON schema ({"kind": "lifted", …}) — unknown kinds and unknown keys raise, so a typo in a task file can never silently weaken the success test. Set EvalTask.success_predicate (with extra_xml putting the prop in the scene) and the predicate becomes the episode's success source — it ends the episode when it fires, so steps-to-success stays meaningful; a termination_fn still ends episodes, but ending is not succeeding. Wilson aggregation is unchanged, and the report's success: line prints the predicate's plain-english description. VecSimEnv(success=…) reports the same predicates per step in info["success"] (terminal verdicts move to info["final_success"] on auto-reset, matching the final_* convention).

sweep(checkpoints, task, cfg) is the checkpoint-selection answer: every candidate — Hub checkpoint directories and in-memory policies or scripted callables, in one table — is evaluated under the same seeds and ranked by success rate, then mean return, then name (a stable total order).

E001 — all episodes failed (Warning). 0/N episodes reached termination. Why it matters: this is the "loss went down but the policy does nothing" headline, made unmissable. Fix: check the deploy cadence (eval fps vs collection fps — action chunks consumed at the wrong rate), the observation feature mapping (right robot, right dof count), and sweep() the other checkpoints before blaming the data. If it persists, run the autopsy — the cause is usually upstream.

E002 — seed lottery (Warning). Success flips seed-to-seed: 0 < successes < N and the Wilson interval spans more than 0.5. Why it matters: a run this noisy cannot distinguish checkpoints — picking one on it is picking on luck (2/4 fires at width 0.70; 50/100 stays silent at width < 0.5). Fix: raise EvalConfig.n_episodes; the interval shrinks ~1/√n.

E003 — zero reward signal (Warning). Every episode returned exactly 0.0. Why it matters: only a missing reward_fn produces this, and success alone cannot rank near-misses — returns stay uninformative and E002-style noise cannot be diagnosed away. Fix: wire a reward_fn into EvalTask (e.g. reach_eval_task).

from caliper_learn import EvalConfig, evaluate, reach_eval_task, sweep

task = reach_eval_task(robot, "tool0", [0.4, 0.0, 0.3], tol=0.05, fps=50)
result = evaluate(policy, task, EvalConfig(n_episodes=20, base_seed=0))
ranking = sweep({"ckpt_3k": "runs/003000/pretrained_model", "baseline": my_fn}, task)

Latency profiler (L001L003)

profile_rollout(policy, control_loop, ticks=200, fps=50) drives the policy through the same three stages as the deploy runner — obs build → inference → step_with_target — timing each per tick with perf_counter_ns. The headline is deliberately pessimistic: achievable_hz = 1 / p95(tick total), the rate the loop holds on 95% of ticks, not the average that hides the spikes. The profiler's own scaffold cost is measured on an empty loop first and subtracted, so the report charges the policy and the engine, not the instrumentation.

The split that matters is chunk-aware: lerobot-style select_action pops an internal queue and only re-runs the network every n_action_steps ticks, so mean inference time is a lie — the refill tick is the one that must fit the budget. Refill ticks are identified from the policy's chunk config when available (else detected from timing bimodality, with an absolute floor so scheduler noise on a microsecond-fast policy is never misread as chunking), and refill p95 is reported separately from pop p95.

L001 — budget exceeded (Error). More than 5% of ticks exceeded the 1/fps budget — the p95 tick does not fit, and the requested rate is dishonest. Why it matters: on hardware the loop either slips (cadence mismatch — the exact failure class the debugger's P005 catches at the config level) or back-pressures the controller. Fix: run at ≤ the reported achievable Hz and collect/retrain at that fps — train and deploy must share one cadence — or cut the dominant stage in the table.

L002 — inference dominates (Info). Inference is over 60% of the median tick and the tick is a material fraction (>20%) of the budget — a 5 µs loop that is "90% inference" stays silent. Why it matters: it tells you where optimization effort pays; obs build and the engine step are not the bottleneck. Fix: shrink or torch.compile the model, or raise n_action_steps so the forward pass amortizes — while watching the refill p95, because that single tick still has to fit the budget.

L003 — high jitter (Warning). Tick-period std beyond max(25% of budget, 1 ms): the cadence is unstable even if the average holds. Why it matters: chunked policies assume evenly-spaced actions; jittered delivery deforms every executed trajectory. Fix: look for periodic spikes first (chunk refills — the refill/pop split shows them), then background load and GC pauses; pin the process or lower the fps until the period stabilizes.

import caliper
from caliper_learn import profile_rollout

loop = caliper.ControlLoop(robot, dt=1 / 50, start=[0.0] * robot.ndof)
report = profile_rollout(policy, loop, ticks=200, fps=50)
print(report.render_text())   # per-stage p50/p95/p99/max + refill-vs-pop split

Policy debugger (P001P008)

analyze_policy(policy_dir, dataset_root=None, robot=None) is the deploy debugger: checkpoint in, "why does my trained policy do nothing" out. It inspects a lerobot-Hub-convention checkpoint (via the safetensors-only hub loader — same security stance as deploy), probes its forward pass on dataset-replayed observations, and names the mined failure modes. dataset_root unlocks P002/P004/P005 and dataset-replayed probes; robot unlocks P003. Static config checks run first, so a checkpoint lerobot's own parser would crash on still gets a calm diagnosis instead of a stack trace. Behavioral thresholds are empirically calibrated (a random-init policy measures ≥ 0.17 normalized action spread and ≥ 0.19 dead-input response; collapsed weights measure exactly 0.0 — the 0.05/0.02 cuts sit mid-gap), and every probe forward is preceded by policy.reset() so the chunk queue never serves a stale action.

P001 — action collapse (Error). Every probe state returns (nearly) the same action. Why it predicts deploy failure: the policy is a constant — typically the dataset mean, which is the L2 optimum for unlearnable labels, so training loss looks fine. Fix: check for zeroed/corrupted weights, then whether the state→action map is one-to-many (the sidecar's one-step-lookahead labeling exists precisely to fix that; see also the dataset doctor's D006).

P002 — per-dof collapse (Warning, needs dataset_root). A dof the data moves but the policy never does — the network wrote that joint off. Why: at deploy the joint simply does not track, and nothing errors. Fix: look at the dof's loss contribution and its normalization std — a wrongly large per-dof std makes its normalized targets vanish, and the network learns to ignore the joint.

P003 — joint-limit saturation (Warning, needs robot). Actions land outside a joint's URDF limits on >20% of probes. Why: the SafetyMonitor clamps every tick, so the executed motion is not what the policy "intended" — it runs, but wrong. Fix: almost always an unnormalization-scale problem (check P004 and the action std in the postprocessor stats); genuine limit-riding demonstrations are rare.

P004 — normalization mismatch (Error, needs dataset_root). The processor's train-time stats (read straight from the checkpoint's safetensors — no model load needed) disagree with stats recomputed from the dataset. Why: this is the killer — every input and output is silently shifted and scaled, the policy trains in one coordinate system and deploys in another. It is the checkpoint-side twin of the dataset doctor's D002. Fix: the classic causes are a dataset edited/regrown after training or the wrong checkpoint paired with this dataset — retrain, or regenerate the processor stats from this dataset.

P005 — cadence mismatch (Error, needs dataset_root + a train_config.json that declares an fps). The checkpoint's recorded training fps disagrees with the dataset fps. Why: action chunks are consumed at the wrong rate — each queued action is "worth" a different amount of real time than the one it was trained to be (collecting at 50 Hz and deploying at 1 kHz once closed only ~42% of the gap in this repo). Fix: deploy at dt = 1/fps of the collection cadence; train and deploy must share one fps.

P006 — dead input (Warning). Perturbing one state dimension (±0.25 of its data std) never changes the action across the probe bases. Why: the policy cannot close the loop on that joint's measurement — if the task needs it, it will fail open-loop-style. Fix: check the feature wiring, and whether the dim was constant in training (the dataset doctor's D001 — in which case the dataset, not the policy, is the defect). Image-input probes are honestly NotImplemented this wave; the loader gates VISUAL checkpoints anyway.

P007 — non-finite forward (Error). NaN/inf anywhere in the probed actions. The other behavioral checks are skipped — their math would be garbage-on-garbage. Fix: inspect model.safetensors for NaN/inf tensors and the training run for loss spikes.

P008 — chunk-config anomaly (Error or Info). n_action_steps > chunk_size (the queue would pop more steps than a forward pass produces) or temporal ensembling with n_action_steps != 1 are Errors — lerobot's own parser crashes on these, so the debugger diagnoses them statically from config.json before any model load and skips the load entirely. chunk_size > 1 with n_action_steps = 1 and no ensembling is an Info: the network re-runs every tick and throws away all but one predicted action — legal, just wasteful (the profiler's L002 will usually confirm).

from caliper_learn import analyze_policy, render_policy_findings

findings = analyze_policy("runs/003000/pretrained_model",
                          dataset_root="data/reach_demos", robot=robot)
print(render_policy_findings(findings))   # [] means every reachable check passed

The autopsy

autopsy(policy_dir, dataset_root, robot=None, task=None) merges every diagnostic that applies into one AutopsyReport with one verdict:

  • D-sectioncaliper.data_doctor on the dataset (D001D015; v3.0 on-disk format — the doctor's own error names the converter for v2.x).
  • P-sectionanalyze_policy, dataset-aware.
  • E-sectionevaluate, Wilson-95. Only when robot and task are given (rollouts need a sim).
  • L-sectionprofile_rollout on a fresh ControlLoop. Same gating.

The verdict paragraph is template-based and honest: it leads with the most severe section, and ties break toward the dataset — data problems cause policy problems, not the other way around, so the upstream fix comes first.

Walkthrough

A policy trained on a teleop dataset "converged" (loss looked fine) but does nothing useful in sim. One command:

$ caliper-learn autopsy runs/reach_act/pretrained_model data/reach_demos \
    --urdf arm.urdf --frame tool0 --target 0.40 0.00 0.30 --episodes 20

The report (episode rows and long messages trimmed for width):

== Caliper autopsy ==
policy:  runs/reach_act/pretrained_model
dataset: data/reach_demos

VERDICT: The dataset has 0 error(s) and 4 warning(s) (D001, D004, D009, D011)
that predict training failure; the policy checks are clean; closed-loop: 0/20
episodes succeeded (95% CI [0.00, 0.16]) — the policy never solved the task;
the deploy loop holds 50 Hz with headroom.

-- dataset doctor (D) — 40 episodes, 8000 frames @ 50 fps --
  [D001] (warning) feature=observation.state dof=5 feature 'observation.state'
         dof 5 ('wrist_roll'): constant at 0.000000 across all 8000 frames — …
  [D004] (warning) feature=action 'action' is nearly identical to
         'observation.state' (rms difference 0.000731 vs state spread 0.4120) —
         echo/lag labels; the policy can minimize loss by copying its input …
  [D009] (info) episode=17 episode 17: length 2311 frames vs a median of 190 …
  [D011] (warning) episode=31 episode 31: the last 74 frames are bit-identical …

-- policy debugger (P) --
no findings — every reachable policy check passed.

-- closed-loop eval (E) --
episodes: 0/20 succeeded  success_rate=0.000  wilson95=[0.000, 0.161]
return: mean=-121.0483 median=-118.5210  steps-to-success: mean=-
    seed  success  steps       return  final_dist
       0       no    200    -104.5121      0.4818
       1       no    200    -131.2246      0.6103
       …
[WARN] E001: 0/20 episodes reached termination — the policy never solved the task.
    fix: Training loss says nothing about this. Check the deploy cadence …

-- deploy latency (L) --
Latency profile — 100 ticks @ 50 Hz (budget 20.000 ms/tick)
  achievable: ~152 Hz (1 / p95 tick time); 0.0% of ticks over budget
  …
  chunk queue (config): refills every 8 ticks (13 seen) — refill p95 6.104 ms
  vs pop p95 0.058 ms
no findings — the loop holds 50 Hz with headroom.

How to read the verdict. Read it left to right — it is ordered by blame:

  1. "The dataset has … 4 warning(s) … that predict training failure" leads, because the dataset section is the most severe and ties go upstream. D004 is the actual killer here: the action labels echo the state, so the loss was minimized by copying the input — the policy honestly learned exactly what the data taught.
  2. "the policy checks are clean" — and note what the autopsy did not do: the data-dead wrist_roll (D001) is correctly not blamed on the policy — P002 only judges dofs the data moves. A clean P-section plus a defective D-section says: don't debug the checkpoint, fix the data.
  3. "closed-loop: 0/20 …" quantifies the damage with an honest interval ([0.00, 0.16] — even the best case is bad), and
  4. "the deploy loop holds 50 Hz with headroom" rules out the remaining suspect: this is not a latency problem.

The fix, per the finding hints: retrain on one-step-shifted (or delta) action labels, trim episode 31's frozen tail and review episode 17 with the dataset edit ops, and decide whether wrist_roll should move. Then run the same command again — the report is the regression test.

Programmatic use mirrors the CLI one-to-one:

from caliper_learn import autopsy, reach_eval_task

rep = autopsy("runs/reach_act/pretrained_model", "data/reach_demos",
              robot=robot, task=reach_eval_task(robot, "tool0", [0.4, 0.0, 0.3]))
print(rep.verdict)
rep.to_json(indent=2)   # sorted keys; D/P/E sections byte-deterministic

Honest scope

  • State-only, this wave. Eval observations and debugger probes cover state features; image observations (and P006 image-input probes) arrive with the vision wave — the hub loader gates VISUAL checkpoints with a clear NotImplementedError today, so nothing fails silently.
  • No Studio panel, by design. Policy inference happens Python-side (torch), and Studio's backend is the Rust engine — there is no autopsy button in the app. The terminal face is caliper-learn; its --json output is the integration point if a panel ever wants to render it.
  • The L-section is wall-clock and therefore honestly non-deterministic; everything else in an autopsy (D/P/E) serializes byte-identically for the same inputs.
  • Environment: eval needs mujoco (via VecSimEnv), the profiler and autopsy L-section need a caliper.ControlLoop (the robot needs inertial data — run the asset doctor if has_inertia is false), and loading Hub checkpoints needs torch + lerobot. All of it imports lazily; caliper-learn --help is instant.

Data factory — randomization, coverage, materials & video

Once you can record and doctor datasets, the next problem is making enough good data without a robot. The data factory is the sim-side toolkit for that: vary the world deterministically, fill the holes the dataset doctor finds, give contacts sane material behaviour, and store camera streams as real MP4 video that lerobot reads.

All of this lives in the caliper_learn sidecar (Python) and caliper-sim-mujoco (Rust, the optional mujoco feature). It is a data factory, not an RL framework — you get the substrate; you bring the task.

Domain randomization

caliper_learn.randomize turns one nominal scene into a distribution of scenes, seeded so a run is reproducible and its draw is a diffable JSON record (you can commit the exact randomization a dataset was collected under).

from caliper_learn import RandomizationSpec, sample
from caliper_learn.randomize import apply_to_mjcf, apply_to_env

spec = RandomizationSpec(
    mass=(0.8, 1.2),          # per-body mass multiplier range
    joint_damping=(0.5, 2.0), # multiplier
    gains=(0.9, 1.1),         # kp/kd multiplier
    camera_pos=0.02,          # absolute jitter (m)
    spawn_pose=0.01,          # absolute jitter (m / rad)
    gravity=0.1,              # absolute jitter on |g|
)
draw = sample(spec, rng, ndof)     # a plain, JSON-serializable dict
  • sample(spec, rng, ndof) draws once from every enabled field in a fixed order, so disabling one field never reshuffles the others' random stream — the same integer seed gives byte-identical draws.
  • apply_to_mjcf(draw, mjcf) edits model-level parameters (mass — which also scales inertia — joint damping/frictionloss, gravity) as a structural XML edit and returns a new MJCF string. Only b_-prefixed robot bodies are touched; props are left alone.
  • apply_to_env(draw, env) applies the runtime parameters (controller gains, spawn offset clipped into joint bounds, camera jitter around a snapshotted base pose so resets never drift).

In a vectorized env

VecSimEnv takes a spec directly and draws per environment at every reset:

from caliper_learn import VecSimEnv
env = VecSimEnv(robot, num_envs=8, randomization=spec, seed=0)
obs = env.reset()                 # each env gets its own draw
env.randomization_draws           # the 8 draws, also in info['randomization']

VecSimEnv also takes success= — the manipulation success predicates — reporting info["success"] / info["final_success"] per step, and composes with randomization.

Model-level draws recompile that env's MjModel from the randomized MJCF at reset. That is a real cost — one model plus one XML compile per env per reset — documented in the module so you size num_envs accordingly. Runtime-only randomization (gains, spawn, camera) has no rebuild cost.

The doctor → generator loop

The dataset doctor's D007 coverage finding tells you which joint-limit bins your data never visits. coverage_gen closes that loop: read the finding, plan new episodes whose goals land in the emptiest bins, append them to a new dataset (the input is never mutated), and re-run the doctor to show the occupancy delta.

caliper-learn coverage INPUT_DATASET OUTPUT_DATASET --urdf robot.urdf -n 40 --seed 0
from caliper_learn import generate_coverage
report = generate_coverage(dataset_root, robot, out_root, episodes=40, seed=0)
report.occupancy_before, report.occupancy_after   # min-bin occupancy
report.d007_before, report.d007_after             # finding counts

The histogram updates as episodes are planned, so consecutive episodes chase different holes; it widens the goal window and falls back to free sampling when a bin is hard to reach. Runs are deterministic — the same seed produces a byte-identical output dataset. In a smoke test on a deliberately corridor-shaped dataset it raised min-bin occupancy 0.2 → 0.7 and drove D007 findings 3 → 0.

Contact materials

Tuning MuJoCo's solref/solimp/friction by hand is the classic dark art — stiff contacts jitter or explode, soft ones penetrate. ContactMaterial turns it into a named choice with derivations documented in the source:

PresetUse forCharacter
Rigidmetal-on-metal, hard stopsstiff, near-inelastic
Steeltools, structural partsvery stiff, low friction
Woodprops, fixturesstiff, medium friction
Rubbergrippers, feet, bumperssoft, high friction
Foamsoft props, paddingvery soft, damped
Custom{solref, solimp, friction}your ownvalidated on construction

Set a scene default or override per prop:

#![allow(unused)]
fn main() {
let opts = MjcfOptions {
    default_material: Some(ContactMaterial::Foam),   // ground + unmarked props
    props: vec![PropSpec { material: Some(ContactMaterial::Steel), ..cube }],
    ..Default::default()
};
}

Custom is validated on build (positive solref timeconst/dampratio, solimp dmin/dmax in (0,1) with dmin ≤ dmax) — a bad tuple is rejected loudly, not silently clamped.

From Python, the same presets ride model_to_mjcf's material= kwarg — a preset name or a custom dict:

xml = caliper.model_to_mjcf(robot, ground=0.0, material="rubber")
xml = caliper.model_to_mjcf(robot, ground=0.0, material={
    "solref": (0.01, 1.0),
    "solimp": (0.9, 0.95, 0.001),
    "friction": (1.2, 0.01, 0.0002),
})

Contact stability linter

With the mujoco feature, lint_contact_stability runs a settle rollout and reports how a scene misbehaves, each finding with a concrete fix:

  • C001 explosion|qacc|/energy grows during settling (the "spins uncontrollably" class). Fix: raise the solref timeconst to ≥ 2× the timestep, or reduce the timestep.
  • C002 penetration — persistent contact depth after settling. Fix: stiffen the material or the solimp dmax.
  • C003 jitter — contact force oscillates after settling. Fix: increase solref damping or switch to a damped preset.

C001 suppresses C002/C003 (depth and force stats are meaningless mid blow-up). The classifier core (classify_stability) is pure and always compiled; only the rollout that produces a trace needs MuJoCo.

In Studio (mujoco builds), the linter runs automatically after every contact bake — drop / hold / drive-to — over the same rollout's per-step trace; findings appear under the Simulate panel's contact badges (a clean scene shows a stability ✓ badge).

Convex decomposition seam

A single convex hull is a poor collider for a concave part (a cup collides like a solid blob). The ColliderDecomposer trait is the seam for real convex decomposition (CoACD-class); the shipped NaiveDecomposer is the identity — one piece, the existing hull — and MjcfOptions.hull_decomposer plumbs multi-piece output through to MJCF (<mesh> asset + <geom> per piece). The seam is here so a decomposer can drop in without touching the exporter; the heavy algorithm is deliberately not vendored yet.

MP4 video features

Datasets can store camera streams as real MP4 video (dtype video) instead of per-frame PNGs — the layout modern lerobot policies expect. caliper_learn.video mirrors lerobot's own encode settings (libsvtav1, yuv420p, g=2, crf=30; H.264 alternative — 0.4.4's defaults, still 0.6.0's software-encode defaults) so the output is byte-compatible.

from caliper_learn.video import available, encode_episode_video, VideoRecorder
available()   # (bool, reason) — probes PyAV → ffmpeg → unavailable

VideoRecorder buffers a camera stream per episode and writes the v3.0 videos/{key}/chunk-XXX/file-XXX.mp4 layout (one episode per file, from/to_timestamp bookkeeping). The Rust writer emits video metadata natively: declare a dtype: "video" feature and it writes the four videos/{key}/* episode columns, the info.json feature entry, video_path and the pixel stats in one pass — with coherence gates (the registered span must cover the episode's frame count, every referenced MP4 must exist, no orphaned registrations). Rust never encodes or decodes video; Python supplies the MP4s and the stats. attach_video_metadata remains as a documented repair tool for datasets written without the feature, and a test pins that native and bridge output are equal down to every meta/episodes row. A recorded sim video dataset loads directly in real lerobot and decodes to frames matching the renders within measured codec tolerance (≈0.011 mean-abs-diff vs a 0.05 gate).

Encoder availability. Encoding needs PyAV or an ffmpeg on PATH; the gate test skips honestly when neither is present. Decoding for the lerobot round-trip uses torchcodec, as lerobot itself does.

The three faces

Every face is a thin shell over the same engine. They differ only in how you reach the engine, not in what the engine does.

  • CLI — the fastest way to try the engine from a shell; each subcommand parses arguments and calls the engine.
  • Pythonimport caliper, built with maturin/PyO3; scriptable like MATLAB/NumPy, and the surface the oracle runs through.
  • StudioCaliper Studio, a Tauri + React desktop app with a 3D scene and a dataflow node editor.

Because there is one implementation of the math, a result computed via the CLI, via Python, and via a Studio graph node is the same result.

CLI

The caliper-cli face exposes the engine as a command-line tool. Each subcommand parses its arguments and dispatches to the engine — no math lives in the CLI.

Subcommands

The full verb set (from the clap Cmd enum in crates/caliper-cli/src/main.rs):

CommandPurpose
infoPrint engine version / build info.
loadLoad and summarize a URDF model.
fkForward kinematics for a joint vector.
ikInverse kinematics to a target pose (--analytic for the closed-form 6R solver).
analyzeSingularity / manipulability analysis at a configuration (--json).
moveJerk-limited MOVE_J / MOVE_L / MOVE_C (--via); --topp for time-optimal retiming.
dynDynamics at a configuration (RNEA / CRBA / forward).
simTime-step the passive/forced dynamics (q + energy trace).
runDeterministic control-loop rollout on a physical sim to a goal.
teleopLeader–follower teleoperation demo (pure sim).
record / replayLeRobotDataset record (v3.0 default, --format v21) / replay.
collideSelf/world collision check at a configuration (--contacts for EPA depth, --json).
planCollision-free planning: RRT-Connect, --optimal (RRT*), --prm.
calibrateJoint-zero offset calibration from measured tip poses (--self-test).
reachCollision-aware reachability of a Cartesian pose (--json).
reportCycle-time + path-quality report plus the trajectory lint (T001T009); --strict exits non-zero on Error findings.
mjcfExport the robot as an MJCF (MuJoCo XML) model.
graphrun / validate a .caliper-graph.json dataflow graph.
doctorAsset doctor: diagnose a URDF/xacro (A001A014); --repair writes a repaired copy.
data doctorDataset doctor: pre-training diagnostics over a LeRobotDataset v3.0 (D001D015).
data delete / split / merge / tagDataset edit: offline episode surgery + the caliper tags sidecar (atomic rewrite, always lerobot-loadable).

See Doctors & trajectory lint for the full check catalogs.

doctor — asset doctor

# diagnose only: plain-English findings, most-severe first
cargo run -p caliper-cli -- doctor robot.urdf

# machine-readable
cargo run -p caliper-cli -- doctor robot.urdf --json

# apply every mechanical repair to a COPY (robot.repaired.urdf next to the
# input; the input file is never modified), then re-diagnose the copy
cargo run -p caliper-cli -- doctor robot.urdf --repair

# computed inertials at a custom uniform density (kg/m^3; default 1000)
cargo run -p caliper-cli -- doctor robot.urdf --repair --density 2700 --out fixed.urdf

Findings never change the exit code — the report is the product. The command only errors when the file cannot even be inspected.

data doctor — dataset doctor

# the root is the directory containing meta/ and data/
cargo run -p caliper-cli -- data doctor ~/datasets/pick_place
cargo run -p caliper-cli -- data doctor ~/datasets/pick_place --json

Deterministic: the same dataset bytes always produce the same report. A healthy dataset reports zero findings.

data delete / split / merge / tag — dataset edit

The offline edit ops (caliper-dataset::edit, the same engine behind the Python dataset_* functions and Studio's Data-mode edit bar). Every op rewrites through the native v3.0 writer into a sibling temp dir and swaps in atomically, so the result is always lerobot-loadable; survivors are renumbered densely, tasks remapped, stats recomputed, tags remapped.

# delete episodes 0 and 3 (refuses to delete ALL episodes)
cargo run -p caliper-cli -- data delete ~/datasets/pick_place --episodes 0,3

# split episode 2 at local frame 150 (both halves keep task + tags)
cargo run -p caliper-cli -- data split ~/datasets/pick_place --episode 2 --frame 150

# merge adjacent episodes 4 and 5 (tasks unioned; timestamps continue 1/fps)
cargo run -p caliper-cli -- data merge ~/datasets/pick_place --first 4 --second 5

# tags sidecar (meta/caliper_tags.json — a caliper extension lerobot ignores)
cargo run -p caliper-cli -- data tag ~/datasets/pick_place                          # list
cargo run -p caliper-cli -- data tag ~/datasets/pick_place --episode 2 --add good,retry
cargo run -p caliper-cli -- data tag ~/datasets/pick_place --episode 2 --remove retry
cargo run -p caliper-cli -- data tag ~/datasets/pick_place --episode 2 --clear

report — path report + trajectory lint

# two-segment MOVE_J with a ground plane, near-miss margin 2 cm, CI-strict
cargo run -p caliper-cli -- report robot.urdf \
  --goal 0.5,0.2,-0.3,0,0,0 --goal 0,0,0,0,0,0 \
  --ground 0.0 --clearance 0.02 --strict

Examples

cargo run -p caliper-cli -- info
cargo run -p caliper-cli -- fk    robot.urdf --joints 0.1,0.2,0.0,0.0,0.0,0.0
cargo run -p caliper-cli -- ik    robot.urdf --target 1,0,0,0,1,0,0,0,1,0.3,0.0,0.2
cargo run -p caliper-cli -- move  robot.urdf --target 1,0,0,0,1,0,0,0,1,0.3,0.0,0.2
cargo run -p caliper-cli -- plan  robot.urdf --goal 0.5,0.2,-0.3,0,0,0 --ground 0.0
cargo run -p caliper-cli -- graph run robot.urdf my.caliper-graph.json
cargo run -p caliper-cli -- doctor robot.urdf --repair
cargo run -p caliper-cli -- data doctor ~/datasets/pick_place

Python (maturin)

The caliper-py face builds the engine into a native Python extension with maturin / PyO3, so you can import caliper and script the engine like NumPy/MATLAB. This is also the surface the oracle runs through — validating FK, Jacobians, RNEA, CRBA, forward dynamics, and singularity metrics against Pinocchio/NumPy exercises the shipped Python bindings, not a private test path.

Build

python -m venv .venv && source .venv/bin/activate
pip install maturin
maturin develop -m crates/caliper-py/Cargo.toml

(In this repo the convention is env -u CONDA_PREFIX .venv/bin/maturin develop -m crates/caliper-py/Cargo.toml, building into the repo .venv.)

Use

import caliper

robot = caliper.Robot.from_urdf("robot.urdf")
res   = robot.ik(target, seed)   # target: a 4x4 column-major pose; seed: joints
pose  = robot.fk(res["q"])       # ik returns {success, q, residual, iters, …}

Beyond Robot, the bindings expose Planner, ControlLoop (with step_with_target and last_warn), Recorder, DatasetReader, and a run_graph entry point for the dataflow graph — this is the surface the learning sidecar builds on. The full surface is typed in crates/caliper-py/python/caliper/__init__.pyi; the capability matrix maps every function to its engine capability.

Doctors & lint

The three diagnostic engines (see Doctors & trajectory lint) are plain functions returning plain data — findings never raise; only an uninspectable input does:

import caliper

# Asset doctor: A001–A016 over a URDF/xacro. Findings are dicts with
# {code, severity ("error"|"warning"|"info"), message, fix_hint, auto_fixable}.
rep = caliper.doctor("robot.urdf")
assert rep["clean"] or rep["errors"] == 0

# repair=True writes a repaired COPY (default <input>.repaired.urdf; the
# input is never modified) and reports {out, applied, skipped, mesh_copies}.
rep = caliper.doctor("robot.urdf", repair=True, density=2700.0)
fixed = rep["repair"]["out"]
assert caliper.doctor(fixed)["clean"]          # findings describe the ORIGINAL

# Dataset doctor: D001–D016 over a LeRobotDataset v3.0 root. Also returns the
# recomputed per-feature stats {dim, mean, std, min, max, bin_occupancy}.
dr = caliper.data_doctor("~/datasets/pick_place")
for f in dr["findings"]:
    print(f["severity"], f["code"], f["message"])

# Trajectory lint: T001–T007 over sampled rows (exactly what
# Trajectory.sample_uniform returns); [] means the trajectory lints clean.
robot = caliper.Robot.from_urdf("robot.urdf")
goal = [0.5] * robot.ndof
traj = robot.move_j([0.0] * robot.ndof, goal)
times, q, qd, qdd = traj.sample_uniform(0.01)
findings = caliper.lint_path(robot, times, q, qd, qdd)

(The collision half of the lint, T008/T009, is CLI-only — caliper report.)

Pose convention: unified

Every pose-accepting entry point (Robot.ik / analytic_ik / move_l / move_c, Planner.plan_to_pose, ReachChecker.status / reachable, calibrate_joint_offsets) takes the same input: a 4×4 column-major nested list (or an equivalent flat 16-element column-major list), and frame arguments accept a name everywhere (an integer index is still accepted where it historically was, now bounds-checked).

One legacy form is grandfathered for back-compat: Planner.plan_to_pose also accepts its original flat 12-element row-major pose (9 rotation entries then tx, ty, tz). New code should use the 4×4 form.

fk output is NOT ik input. Robot.fk (and exp6) return 4×4 ROW-major nested lists, while every pose input above is COLUMN-major — so robot.ik(robot.fk(q), seed) does not round-trip: the bytes silently parse as the transpose (rotation inverted, translation read as [0, 0, 0]) and IK "solves" a wrong target with no error raised. Transpose at the boundary: robot.ik(np.array(robot.fk(q)).T.tolist(), seed). Full table + details: Pose forms.

Studio (desktop app)

Caliper Studio is the desktop face: a Tauri (Rust backend) + React (frontend, with react-three-fiber for the 3D scene, @xyflow/react for the node graph, and uplot for scopes/plots) application. Five modes share one persistent 3D canvas (⌘1…⌘5, or the ⌘K command palette):

  • Jog — live FK, per-joint sliders, IK tip gizmo, singularity HUD + manipulability ellipsoid.
  • Motion — jerk-limited MOVE_J / MOVE_L planning, named poses, playback transport.
  • Simulate — gravity drop, computed-torque drive-to-goal, RRT plan, collision check, dynamics readout; MuJoCo contact sim in --features mujoco builds (contact simulation); a live session (Start Live / Pause / Reset / Stop) stepping the sim in real time — see below.
  • Graph — the Simulink-style dataflow editor backed by the dataflow graph (run/validate, save/load, file import/export, live scopes).
  • Data — a LeRobotDataset v3.0 browser/editor (episode table, per-channel plots, camera thumbnails, tags, delete/split/merge) — reachable with no robot loaded. With a matching robot loaded, an episode replays on the 3D robot (the take's own joint rows through FK, frame-accurate in-panel transport; doctor findings that know an instant jump the robot to that pose). The Verdict… button opens any caliper-learn … --json report — eval (success rate with the Wilson-95 interval drawn as a bar), debug, profile, or the full autopsy — rendered with the doctor panel's severity vocabulary.

On first launch a six-step tour points out the mode tabs, Open URDF… and ⌘K. It is a pure frontend overlay: skippable at every step, it never blocks input, never touches the store or session resume, and never shows again once dismissed or finished (the caliper.tourDone localStorage flag). Replay it any time via ⌘K → Show tour.

Doctors

Both diagnostic engines are wired in (see Doctors & trajectory lint):

  • Asset doctor — every robot load is diagnosed in the background. When a load fails, or succeeds with Error-severity findings (e.g. a silently dropped collision mesh), the findings appear in the error-banner area with severity chips. If any finding is mechanically fixable, a Repair & reload button runs the repair, writes a sibling <stem>.repaired.urdf (the input file is never modified), and loads that copy — the HUD then labels the session as running on a repaired copy.
  • Dataset doctor — the Doctor button in Data mode streams the open dataset through every D001D015 check and lists the findings; a finding that names an episode is clickable and jumps the episode table to it, so a bad take can be split or deleted on the spot. Structural edits clear the report (it described the pre-edit bytes).

Live session (Simulate)

Alongside the baked rollouts, Simulate mode runs a live stepped session:

  • Start Live spawns a background thread that steps the sim at a fixed 1 ms physics timestep with a PD servo holding a live-mutable joint target, and streams state to the viewport at render rate (~60 Hz). In mujoco builds this is the full contact sim — free props supported, contact count shown live; default builds fall back to the builtin gravity integrator (no contacts, props rejected with a clear error).

  • Drive it by hand — while live, the joint sliders edit the PD hold target (the measured pose rides along as a ghost tick so the servo lag is visible), the IK gizmo drags the tip, [/] pick a joint and -/= or the arrow keys jog it, and a gamepad drives the tip in cartesian (A pauses, B resets). Space freezes/unfreezes.

  • Pause freezes the sim — stepping and the wall clock both stop, and the arm holds its pose. It is a freeze, not an e-stop: nothing is de-energized, so nothing falls.

  • Reset returns deterministically to the start pose (on MuJoCo, a full mj_resetData including the warmstart) with the session clock back at zero; it works while paused and updates the viewport immediately.

  • Stop ends the session. A stepping error also ends it, with the reason surfaced rather than a silent freeze.

  • Open a taskOpen task… (toolbar or ⌘K) loads a *.caliper-task.json: its robot, its props (materials included), its target zones drawn as translucent boxes, the gripper override, recording pre-filled with the task's name and fps — and while live, a SUCCESS badge judges every streamed state against the task's success predicate (per instant, never latched).

  • Grasp props — robots with a gripper joint (auto-detected by name, or named explicitly) get a gripper open/close control (button, G, or gamepad X); closing on a touching prop welds it to the gripper — the standard sim teleop heuristic, labeled as such — and a HELD badge names what's carried.

  • Connect a policy — point Studio at a python env and a trained checkpoint and the policy drives the live session in-app (obs out, actions in over a pure-JSON stdio bridge; the sim never blocks on inference). You can still nudge with any input mid-drive, Space pauses policy and sim together, and recording while the policy drives yields policy-rollout episodes. State-based policies only — camera checkpoints are refused by name.

  • Record teleop episodes — while live, pick a dataset folder, set a task label and fps (default 50), and record takes straight into a native LeRobotDataset v3.0: stop-and-save or discard per take, episode counter, finish-dataset, then open the result in Data mode. Capture is exact tick decimation in the sim thread (timestamps are k/fps, not wall-clock). Reset discards the current take — a reset invalidates the demonstration.

Bake-then-replay stays for what it is good at — reproducible clips and the C001C003 stability lint. Live is for watching, driving, and recording demonstrations. Details and honest constraints: Live session.

Launch

cd apps/studio
npm install
env -u CONDA_PREFIX npm run tauri dev

⚠️ Not runtime-verified

This is the single most important honesty note in the whole project. The Studio GUI:

  • compiles (the Tauri Rust backend),
  • type-checks and builds (the React/TypeScript frontend, tsc + vite),
  • was statically reviewed, and
  • has FE-logic covered by a vitest harness (coordinate transforms, the store, graph serialize/deserialize) — see the verification chapter.

But it has never been launched at runtime. No human has watched it render. Its 3D rendering, its interactions, and its live behavior are unverified by deliberate choice (build-fast-now, human-review-later). Treat the first tauri dev as the real test.

The Tauri backend has been hardened defensively (lock/path/NaN guards, safe lock-release), and the frontend logic that can be unit-tested off-screen is tested — but none of that substitutes for actually running the app.

Note: the repository's just app recipe mirrors the npm run tauri dev command above. If just is not installed in your environment, use the raw npm command directly.

Capability matrix

Everything the system can do, capability by capability, against where you can do it. Compiled by reading the actual surfaces — the clap verb enum in crates/caliper-cli/src/main.rs, the typed Python surface in crates/caliper-py/python/caliper/__init__.pyi, the sidecar exports in learn/caliper_learn/__init__.py (+ its caliper-learn console script), and the Studio modes/store in apps/studio/src — not from memory. A ✗ is an honest gap: the engine can do it, that face does not expose it (yet).

Faces: CLI · Python · Studio. Engine column links to the capability page.

Model & assets

CapabilityEngineCLIPythonStudio
Load URDF / xacro, summarize structurecaliper-model (architecture)loadRobot.from_urdf, .name/.ndof/.joint_names/.joint_limits/.frame_names/.tip_frame/.has_inertiaOpen URDF… / samples / recents, ⌘O
Asset doctor — diagnose A001A016caliper-doctordoctordoctor(path)automatic on every load (error-banner findings)
Asset repair — repaired copy, never in-placecaliper-doctordoctor --repair [--density] [--out]doctor(path, repair=True, density=…)Repair & reload button
MJCF (MuJoCo XML) export (+ hull-mesh assets)caliper-sim-mujoco::mjcfmjcf (--hull-meshes)model_to_mjcf (no hull-mesh export — primitive colliders only)
Robot zoo — fetch a vendored real URDFcaliper-cli::zoofetch <name> / fetch --list

Kinematics & analysis

CapabilityEngineCLIPythonStudio
Forward kinematics (every frame)caliper-kinematicsfkRobot.fklive in every mode (Jog sliders drive it)
Geometric Jacobian (world/body)caliper-kinematicsRobot.jacobian✗ (internal to the HUD analysis)
Iterative IK (DLS/LM, restarts)caliper-ikikRobot.ikJog tip gizmo (singularity-governed)
Analytic 6R IK (branch set)caliper-ik::analyticik --analyticRobot.analytic_ik
Singularity / manipulability analysiscaliper-kinematicsanalyzeRobot.analyze / manipulability / ellipsoidsingularity HUD + manipulability ellipsoid
Redundancy: nullspace step, resolved-ratecaliper-kinematicsRobot.nullspace_step / Robot.resolved_rate
SE(3) log/exp mapscaliper-spatiallog6 / exp6

Motion

CapabilityEngineCLIPythonStudio
MOVE_J (jerk-limited S-curve)caliper-motionmove --goalRobot.move_jMotion mode / palette "Plan move to home" / poses
MOVE_L (Cartesian line)caliper-motionmove --targetRobot.move_lMotion mode (gizmo target)
MOVE_C (circular arc via a point)caliper-motionmove --target --viaRobot.move_c
Waypoint retimingcaliper-motionPlanner.plan_trajectory (plan → retimed Trajectory)
Time-optimal (TOPP) retimingcaliper-motionmove --time-optimalRobot.retime_time_optimal
Named pose librarycaliper-motion::PoseLibraryMotion mode (save/plan-to/delete poses)
Trajectory lint T001T007caliper-kinematics::lint_pathreportlint_path
Collision lint T008/T009CLI-side over caliper-collisionreport --ground/--obstacle/--clearance [--strict]
Cycle-time + path-quality reportcaliper-kinematics::path_reportreport

Dynamics & simulation

CapabilityEngineCLIPythonStudio
RNEA / CRBA / forward dynamics / gravitycaliper-dynamicsdynRobot.rnea / crba / forward_dynamics / gravity_torque✗ ¹
Passive/forced time-stepped simulationcaliper-dynamics::SimulatorsimSimulator (step/rollout/energy)Simulate: gravity drop
MuJoCo contact simulation (props, ground)caliper-sim-mujoco✗ (use mjcf + MuJoCo)Simulate: contact drop / hold / drive-to (mujoco builds)
Live sim session — fixed-1 ms stepped loop, PD hold target, pause/deterministic-reset/stop, ~60 Hz state streamStudio backend over caliper-hal + caliper-sim-mujoco (Studio-internal IPC, not a public API)Simulate: live session — mujoco builds get contacts + props + live contact count; default builds fall back to the builtin gravity integrator; drivable by hand (sliders / IK gizmo / keyboard jog / gamepad tip drive, Space = freeze); teleop takes record straight into LeRobotDataset v3.0 at exact tick decimation (verified to load in lerobot 0.6.0)
Contact material presets (Rigid/Rubber/Foam/Steel/Wood/Custom)caliper-sim-mujoco::mjcf✗ (mjcf has no material flag)model_to_mjcf(material=…) — preset name or custom dict
Contact stability linter C001C003caliper-sim-mujoco::lintautomatic after every contact bake (findings under the Simulate badges; mujoco builds)
Convex-decomposition seam (identity impl)caliper-sim-mujoco

Collision & planning

CapabilityEngineCLIPythonStudio
Self/world collision querycaliper-collisioncollideCollisionModel.querySimulate: Check collision
EPA penetration contactscaliper-collisioncollide --contactsCollisionModel.contacts
RRT-Connect (+ shortcut smoothing)caliper-planningplanPlanner.plan / verifySimulate: Plan to home (RRT)
RRT* (asymptotically optimal)caliper-planningplan --optimalPlanner.plan_optimal
PRM roadmap planningcaliper-planningplan --prmPlanner.plan_prm
Plan to a Cartesian posecaliper-planningplan --targetPlanner.plan_to_pose
CHOMP-style trajectory optimizationcaliper-trajopt✗ (engine-only)
Collision-aware reachabilitycaliper-planning::reachreachReachChecker.status / reachable✗ ¹

Control, data & learning

CapabilityEngineCLIPythonStudio
Computed-torque control loopcaliper-halrunControlLoop (run_to / rollout_to / step_with_target / run_stream)Simulate: Drive to home (control)
Safety monitor (limits gate, e-stop)caliper-halinside run ²SafetyMonitorinside the control rollout ²
Leader–follower teleopcaliper-halteleopLeaderFollower
Dataset record (LeRobotDataset)caliper-datasetrecord (v3.0 / --format v21)RecorderV3 / Recorder
Dataset replay through a sim backendcaliper-dataset + caliper-halreplay✗ (readers only)
Dataset read / browse / plotcaliper-datasetDatasetReaderV3 / DatasetReader (incl. images)Data mode (table, channel plots, thumbnails)
Dataset edit: delete / split / merge / tagscaliper-dataset::editdata delete / data split / data merge / data tagdataset_delete_episodes / dataset_split_episode / dataset_merge_episodes / dataset_read_tags / dataset_write_tagsData mode edit bar + tag chips
Dataset doctor D001D016caliper-dataset::analyzedata doctordata_doctorData mode: Doctor button (episode-jump findings)
Joint-offset calibration (Gauss-Newton)caliper-calibcalibrate (incl. --self-test)calibrate_joint_offsets
lerobot calibration-file exportPython interopexport_lerobot_calibration
robomimic HDF5 exportPython interopexport_robomimic_hdf5
BC learning (BC-MLP / ACT-lite / DDPM)learn/caliper_learn sidecarseparate caliper_learn package (on top of these bindings)
Deploy a lerobot checkpoint — safetensors-only loader + closed-loop sim runnercaliper_learn.hub/runner✗ ³load_lerobot_policy / LoadedPolicy / run_policy✗ ³
Seeded policy eval — Wilson-95, E001E003, checkpoint sweepcaliper_learn.eval✗ ³ (caliper-learn eval)evaluate / sweep / reach_eval_task✗ ³
Task artifact*.caliper-task.json v1 (scene + zones + gripper + success + horizon), stability-contract covered, Rust/Python parity-testedcaliper-sim-mujoco::task✗ ³ (caliper-learn eval/autopsy --task)caliper_learn.load_task / VecSimEnv.from_taskOpen task… (robot + scene + zones + live SUCCESS badge)
Policy-in-the-loop drive — trained checkpoint drives the live sim over a stdio JSON bridgecaliper_learn.bridge✗ ³ (caliper-learn drive)drive_loop / load_drive_policySimulate: connect policy… (live session; state-based policies)
Manipulation success predicateslifted / placed_in_zone / combinators, exact JSON schemacaliper_learn.success✗ ³Lifted / PlacedInZone / AllOf / AnyOf / VecSimEnv(success=) / EvalTask.success_predicate✗ (Studio grasps via the live weld channel)
Deploy-loop latency profileL001L003, honest achievable Hzcaliper_learn.profile✗ ³ (caliper-learn profile)profile_rollout✗ ³
Policy deploy debugger P001P008caliper_learn.debugger✗ ³ (caliper-learn debug)analyze_policy✗ ³
Policy Autopsy — D+P+E+L under one verdictcaliper_learn.autopsy✗ ³ (caliper-learn autopsy)autopsy✗ ³
Domain randomization (CI-diffable seeded draws)caliper_learn.randomizeRandomizationSpec / sample_randomization / apply_to_mjcf / apply_to_env / VecSimEnv(randomization=)
Coverage generator (doctor→generator loop)caliper_learn.coverage_gen✗ ³ (caliper-learn coverage)generate_coverage
Vectorized sim env (gym-vector semantics)caliper_learn.vec_envVecSimEnv / reach_task / rollout_random
Sim-camera collector (offscreen → image dataset)caliper_learn.sim_cameraSimCameraScene / collect_camera_dataset
MP4 video features (dtype video, lerobot-exact; metadata native in the Rust writer)caliper_learn.video + caliper-datasetencode_episode_video / VideoRecorder / RecorderV3(video_features=…) (attach_video_metadata = repair tool)

Dataflow graph

CapabilityEngineCLIPythonStudio
Run a graphcaliper-graphgraph runrun_graphGraph mode: Run (+ live scopes)
Validate a graph (types, cycles, topo)caliper-graphgraph validatevalidate_graphGraph mode: Validate (inline node/edge errors)
Edit a graph visually— (frontend)Graph mode editor (⌘D duplicate, delete, fit, app-data save/load, file import/export)

Misc

CapabilityEngineCLIPythonStudio
Engine version / build infocaliper::VERSIONinfo / --versionversion() / __version__toolbar readout
First-run guided tour— (frontend)6-step overlay + palette "Show tour"
Lightweight benchmark harness— (scripts)✗ (a shell script, not a verb: scripts/measure_lightweight.sh drives the CLI)

¹ The Studio backend registers dynamics_at / reach_check commands, but no UI control invokes them yet — counted as ✗ until a panel drives them.

² The safety monitor runs inside the control loop on these faces; only Python exposes it as a standalone object.

³ Policy inference is Python-side (torch + the safetensors-only hub loader), so the Rust CLI and Studio cannot host these. The sidecar ships its own console face instead: caliper-learn debug|autopsy|eval|profile (each takes --json; exit code 1 on any error-severity finding) — see Verdicts.

The task artifact — *.caliper-task.json

A task file is the shareable unit of a manipulation problem: robot, scene, gripper, success criterion, horizon — one JSON file that every face consumes. Studio opens it (robot loaded, props placed, zones drawn, live session and recording pre-configured); the learning sidecar builds envs and eval tasks from it (VecSimEnv.from_task, caliper-learn eval --task, autopsy --task); the success criterion inside is judged by the same predicate semantics in Rust and Python, pinned by a shared parity table both suites run.

{
  "version": 1,
  "name": "pick-cube",
  "robot": "../robots/gripper_arm.urdf",
  "q0": [0.0, 0.0, 0.02],
  "scene": {
    "ground": 0.0,
    "props": [
      { "name": "cube", "kind": "box",
        "halfExtents": [0.05, 0.05, 0.05],
        "pos": [0.0, 0.0, 0.05], "mass": 0.05,
        "rgba": [0.9, 0.4, 0.2, 1.0], "material": "wood" }
    ],
    "zones": [
      { "name": "bin", "center": [0.4, 0.2, 0.02],
        "half": [0.05, 0.05, 0.02], "rgba": [0.2, 0.8, 0.4, 0.35] }
    ]
  },
  "gripper": { "joint": "gripper", "closed": "lo" },
  "success": { "kind": "placed_in_zone", "prop": "cube",
               "zone": "bin", "settled_speed": 0.01 },
  "horizonS": 20.0,
  "fps": 50
}

Field semantics

  • version — must be 1. The schema is covered by the stability contract: within version 1 it only grows; loaders reject unknown versions loudly.
  • robot — URDF path, resolved relative to the task file's directory (absolute paths work too). The file must exist at load time.
  • q0 — optional start pose (defaults to zeros).
  • scene.props — the same prop vocabulary the contact sim uses (box/sphere/cylinder, quaternion w-first, analytic inertia from mass); material is a preset name (rigid/rubber/foam/steel/ wood, case-insensitive) or a custom {solref, solimp, friction} dict — identical to the Python face's material= kwarg.
  • scene.zones — named axis-aligned boxes. Zones are evaluator-side: they never enter the physics, they are drawn as translucent boxes in Studio and referenced by success predicates. rgba is a render hint only.
  • gripper — optional override for the gripper channel (auto-detection covers sanely-named robots); closed says which limit end means closed (default "lo").
  • success — a success predicate (lifted / placed_in_zone / all_of / any_of). A zone given as a string resolves by name against scene.zones at load time (and is stored resolved). Semantics are identical in both implementations — plain >= comparisons, inclusive zone faces, and a settled_speed check that errors on a velocity-less state rather than passing a fly-through.
  • horizonS, fps — optional episode horizon and recording fps defaults.

Strictness is the point. Unknown keys — at every level — are rejected, not ignored. A typo in a task file must never silently weaken the success test or drop a prop.

Consumers

FaceWhat a task file does
StudioOpen task… loads the robot, places props (materials included), draws zones, pre-fills the recording task label + fps, passes the gripper override and success predicate to the live session — the SUCCESS badge then judges every streamed state
caliper_learnload_task(path)VecSimEnv.from_task(...) (props + success + q0 + ground) and eval/autopsy --task <file> on the CLI
Rustcaliper_sim_mujoco::task::{load_task, TaskSpec} + SuccessTracker — the same evaluator Studio streams live

Parity, not promises

Rust and Python each implement the predicate evaluator; a shared table of predicate × state → verdict cases (including two knife-edge rows sitting exactly on a zone face and exactly at the settled-speed limit, and two rows that must raise) runs in both suites. If the implementations ever drift, a test goes red on whichever side moved.

The task zoo

Five task artifacts that run on a fresh clone. They live in tasks/ at the repo root, reference a robot that ships with the source, and need no download step:

tasks/01_lift.caliper-task.json
tasks/02_place.caliper-task.json
tasks/03_hold_high.caliper-task.json
tasks/04_sort.caliper-task.json
tasks/05_precise_place.caliper-task.json

The zoo exists so "open a task" and "score a policy against a task" are things you can do in one command, before you have authored anything yourself. It is also the regression suite for the task format: every file is swept through both loaders, built into a real MuJoCo scene and checked for two failure modes a JSON schema cannot see — a verdict that is already true at the start pose, and a verdict that no reachable pose can satisfy.

The tasks

All five drive gripper_arm, a 3-dof arm (j1, j2, and a prismatic gripper) that hangs over the origin, at 50 fps.

FileNameGoalPredicateHorizon
01_liftzoo-lift-cubeRaise the wooden cube 8 cm off the groundlifted (ref: initial)8 s
02_placezoo-place-cubeCarry the cube into a bin 15 cm to one side and let it settleplaced_in_zone + settled_speed15 s
03_hold_highzoo-hold-cube-highHold a heavier steel cube out at arm's length, above 30 cm, and stop movingall_of [lifted (ref: absolute), placed_in_zone]15 s
04_sortzoo-sort-blocksMove either of two blocks across to the dockany_of [two placed_in_zone]30 s
05_precise_placezoo-precise-placeThe same carry as 02, onto a pad half as wide, with a tighter settleplaced_in_zone + settled_speed20 s

Between them they exercise every predicate kind, both lifted references, four of the five contact-material presets (wood, steel, rubber, foam) and both single- and multi-prop scenes.

Difficulty is graduated, and it is a real ordering rather than a label: sweeping the joint grid for poses that satisfy each verdict gives roughly 24 000 for 01, 7 000 for 04, 4 000 for 03, 1 800 for 02 and 800 for 05.

Opening one

In Studio, Open task… (toolbar or ⌘K) loads the robot, places the props with their materials, draws the zones as translucent boxes, pre-fills the recording label and fps, and hands the success predicate to the live session — the SUCCESS badge then judges every streamed state.

From Python:

from caliper_learn.task import load_task
from caliper_learn.vec_env import VecSimEnv

task = load_task("tasks/02_place.caliper-task.json")
with VecSimEnv.from_task(task, num_envs=8) as env:
    obs = env.reset(seed=0)

To score a checkpoint against one:

caliper-learn eval --task tasks/01_lift.caliper-task.json path/to/checkpoint
caliper-learn autopsy --task tasks/01_lift.caliper-task.json path/to/checkpoint

--task supplies the robot, scene, start pose, fps and step budget, so --urdf / --frame / --target are not needed (and mixing the two is a loud error). The eval reports a success rate with a Wilson 95% interval — see verdicts.

Constraints these tasks are built around

The zoo is small and its scenes are modest, for reasons worth stating plainly rather than discovering by hand.

Grasping is a weld heuristic, one prop at a time. Closing the gripper on a prop welds it to the jaw; there is no friction-based grip and no second weld. 04_sort therefore asks for either block via any_of, not both — a task requiring two simultaneous carries would not be expressible, and a task requiring two sequential ones would be scored by a predicate that cannot remember the first.

The robot must ship with the source. Task files resolve robot relative to themselves, and a zoo task that needed caliper fetch first would not run on a fresh clone. gripper_arm is the only in-repo robot with a gripper joint, collision geometry and inertials on every link, so all five use it. The robot zoo behind caliper fetch (so101 and friends) ships visual meshes without collision geometry: those robots can be posed and rendered, but nothing in a scene can ever touch them, so no zoo task references one.

Every prop has to sit where the jaw can reach it. gripper_arm's jaw sweeps an annulus 0.323–0.401 m from its shoulder at (x, z) = (0, 0.5), in the x-z plane only (both hinges turn about Y). At ground level that annulus narrows to a patch a few centimetres wide around the origin, which is why the ground cubes sit at the origin and why 04_sort's blocks are tall — their raised top faces are what brings them back into reach. Props off the ring are decoration; the Rust sweep fails on one.

No task is scored by "return it to where it started". evaluate() latches success — one true step marks the whole episode — so a predicate satisfied at the start pose reports 100% for a policy that does nothing at all. That rules out a literal return-to-origin task, and it is why 05_precise_place tests release precision with a narrow pad offset from the spawn point instead. Both suites assert that every zoo verdict is false at t=0.

What the suites check

crates/caliper-sim-mujoco/tests/task_zoo.rs (no mujoco feature needed — reading and judging a task never touches MuJoCo) sweeps the directory for: every file loading and round-tripping, names unique across the zoo, robot paths relative and resolvable, props passing the engine's own rulebook, scored props and named zones existing, no verdict true at the spawn state, and every prop's top face inside the jaw's annulus.

learn/tests/test_task_zoo.py adds what only a real engine can answer: each task compiles into a VecSimEnv, resets, reports its props where the file put them, steps, and judges live states without raising. It also pins a witness per task — a grasp pose whose jaw meets the prop's top face, plus a carry pose whose welded prop position satisfies the verdict — computed through real forward kinematics, so editing a zone out of the arm's reach turns the task red instead of quietly unsolvable.

Pose forms

Every Cartesian pose in Caliper is a 4×4 homogeneous transform, but the memory layout differs between outputs and inputs — this page is the one table to check before wiring poses between calls. It exists because the mismatch fails silently (see the transpose trap below).

The one rule

FK outputs are row-major. Pose inputs are column-major.

SurfaceDirectionForm
Robot.fk, exp6 (Python)output4×4 ROW-major nested list — m[row][col], np.array(m) is the matrix
log6 (Python)input4×4 ROW-major nested list (same layout fk returns)
Robot.ik / analytic_ik / move_l / move_c, Planner.plan_to_pose, ReachChecker.status / reachable, calibrate_joint_offsets (Python)input4×4 COLUMN-major nested (pose[col][row]) or flat 16-element column-major
Planner.plan_to_pose (Python, legacy only)inputalso grandfathers the flat 12-element row-major form (9 rotation entries, then tx, ty, tz) — new code should use the 4×4 form
ik / move / plan / reach --target (CLI)input12 numbers: 9 row-major rotation entries, then tx, ty, tz
calibrate --observations JSON (CLI)inputper observation: flat-16 column-major, or a 4×4 nested (row-major) matrix
Studio backend DTOs (frames)outputflat-16 column-major Mat4 (the three.js/WebGL layout)

Why the split: fk returning a list of rows is what NumPy/np.array users expect, while the pose-input side is the column-major convention ik() (and the Studio/three.js path) always used — both are frozen under the stability contract, so neither can quietly flip. The flat-16 column-major form is the standard graphics-stack layout (three.js, WebGL, np.flatten(order="F")).

The transpose trap

The most common new-user mistake, and it does not error:

q2 = robot.ik(robot.fk(q), seed)["q"]      # WRONG — silently solves the transpose

fk returns a nested list of ROWS; ik reads a nested list as COLUMNS (pose[col][row]). The same bytes therefore parse as the transpose: the target rotation becomes Rᵀ (the inverse rotation) and the target translation becomes the homogeneous [0, 0, 0] bottom row — so IK happily "solves" toward a wrong orientation at the origin, converging or not, with no exception anywhere.

Transpose at the boundary and the round-trip works:

import numpy as np

T = np.array(robot.fk(q))                       # 4x4, row-major → real matrix
res = robot.ik(T.T.tolist(), seed)              # transpose = column-major nested
# equivalently, the flat-16 column-major form:
res = robot.ik(T.flatten(order="F").tolist(), seed)
# without numpy:
res = robot.ik([list(col) for col in zip(*robot.fk(q))], seed)

The same applies to every pose-accepting call in the table above — move_l, plan_to_pose, ReachChecker.status, calibrate_joint_offsets — they all share ik's input convention, so one T.T habit covers the whole surface.

Rotation handling

Pose inputs project the rotation block onto SO(3) (from_matrix), so a slightly non-orthonormal basis is tolerated and cleaned up. This is a feature for numerically-drifted matrices — and part of why the transpose trap is silent: a transposed rotation is still a perfectly valid rotation. An orthonormality/handedness acceptance check is a candidate future tightening, not current behavior.

Lightweight, measured

"Lightweight" is a claim, and claims ship with numbers. This page is the metrics table for Caliper's footprint, the script that produces every number in it, and the incumbent figures it is being compared against — with citations, because the comparison is only fair if you can check it.

The rule: no value appears here without a measurement behind it. Anything we have not measured yet says TBD — run scripts/measure_lightweight.sh, not a hopeful estimate.

The metrics table

ClaimTargetMeasuredMeasured on
Studio install (.dmg)≤ 250 MB10.4 MB (aarch64 .dmg, MuJoCo dylib bundled)Apple M3 Max, 36 GB, macOS, rev 1dd648c, 2026-07-13
Python wheel≤ 100 MBTBD — run scripts/measure_lightweight.sh
Cold CLI → robot loaded + FK≤ 5 sTBD — run scripts/measure_lightweight.sh ¹
RAM, full app + sim≤ 1 GBTBD (Studio); headless plan+sim peak RSS below ¹
pip install (wheel)≤ 30 sTBD — run scripts/measure_lightweight.sh
Record overhead vs realtime≤ 1.1×TBD — run scripts/measure_lightweight.sh ¹
Seeded rollouts bit-identicalalwaysyes — machine-verified (see below)CI, every push

¹ A smoke run against a debug binary (explicitly tainted as "not representative" by the script — debug Rust is typically 10–100× slower than release) measured: cold CLI→FK on a real 6-dof URDF 6.4 ms, robot load (panda) 8.0 ms, headless plan+sim peak RSS 11.5 MB, record overhead 0.43× realtime (i.e. more than 2× faster than realtime) — every one of them orders of magnitude inside its target before optimization. The release numbers replace these TBDs the first time the script runs against a release build; until then the table refuses to print them as measured.

Bit-identical determinism is not a benchmark artifact but an engine property: the core is clock-free, the one randomized component (sampling planners) uses a seeded splitmix64 PRNG, and it is pinned by tests that run on every push — the graph-executor determinism oracle (oracle/tests/test_graph.py), the seeded-planner tests, and the run-twice byte-compare the measurement script performs (seeded_plan_deterministic in its JSON output).

Reproducing the numbers

# from the repo root; build the artifacts you want measured first:
cargo build --release -p caliper-cli
maturin build --release -m crates/caliper-py/Cargo.toml   # optional: wheel size
bash scripts/measure_lightweight.sh

The script writes target/metrics/lightweight.json (machine-stamped: CPU, RAM, OS, git rev, which binary was measured) and a ready-to-paste markdown row-set (target/metrics/lightweight.md). It is honest by construction:

  • artifacts that are absent are reported as skipped: <reason + how to build>, never guessed;
  • pointing it at a debug binary (CALIPER_BIN=…) taints every timing with a "debug build — not representative" caveat in the provenance;
  • it uses hyperfine when installed and falls back to a median-of-3 wall-clock loop when not, and says which it used;
  • MEASURE_PIP=1 additionally times a pip install of the wheel into a throwaway venv (opt-in because it creates an environment).

The script has its own test suite (scripts/test_measure_lightweight.sh) — positive and negative cases per behavior, including "absent artifact must skip, not invent" and "debug override must taint".

What the incumbents cost (with citations)

These are the vendors' own published figures at the time of writing (2026-07); follow the links, they may have changed.

StackInstallHardware floorTime to first robot
NVIDIA Isaac Sim~10 GB-class download, 50 GB disk recommendedRTX GPU required (min. GeForce RTX 3070-class); 32 GB RAM min, 64 GB recommended [req]first launch compiles shaders — minutes, on qualifying hardware only
MoveIt 2full ROS 2 desktop install (multi-GB); prebuilt binaries are Ubuntu-via-apt, everything else is a colcon source build [install]no GPU, but a supported Ubuntu/ROS 2 pairinga workspace build from source is commonly tens of minutes
lerobot (pip)pip install "lerobot[dataset]" (≥ 0.6 the bare install can't even load datasets) pulls PyTorch (+CUDA wheels on Linux, ~2.5 GB for torch alone [pypi]) plus the av FFmpeg wheel — a multi-GB environment [lerobot]CPU works; GPU needed for serious trainingminutes of dependency resolution + download
Caliperone 10 MB .dmg, one CLI binary, one abi3 wheelno GPU, no ROS, no CUDA — a laptopsee the table above

To be fair to the incumbents: Isaac Sim is a photorealistic GPU simulator, MoveIt is a full ROS 2 planning framework, and lerobot ships an entire training stack — they carry that weight because they do things Caliper does not (rendering-quality sim, ROS integration, GPU training). The comparison is not "Caliper does everything they do, smaller"; it is "for the load-a-robot / plan / simulate / record / deploy loop, you do not have to pay their entry price."

Stability contract

What you can rely on release over release, stated plainly. Caliper is pre-1.0 software; this page says exactly what that does and does not license us to break, so "0.x" never becomes an excuse.

Versioning policy (pre-1.0 semver)

One version number covers the whole surface: the workspace crates, the CLI, the Python package, and Caliper Studio all ship as the same 0.MINOR.PATCH (release CI refuses a tag whose version disagrees with Cargo.toml and pyproject.toml).

  • Patch (0.x.y0.x.y+1) — never breaks. Bug fixes, docs, performance. No public API, CLI flag, wire format, or file format changes behavior-visibly, with one exception: a fix to output that was wrong (a bug is not an interface).
  • Minor (0.x0.x+1) — may break, but only with a receipt. Every breaking change appears in CHANGELOG.md under Changed or Removed with what broke and what to migrate to. A break that is not in the changelog is a bug — report it.
  • Post-1.0 this collapses to standard semver: breaking changes require a major version.

Deprecation policy

Nothing public disappears without a warning you had a release to see:

  1. The release that deprecates something keeps it working and makes it warn — a #[deprecated] attribute in Rust, a DeprecationWarning in Python, a stderr notice in the CLI — naming the replacement.
  2. The next minor release at the earliest may remove it.

Precedent: the record default format flip (v2.1 → v3.0) kept the old behavior reachable (--format v21), announced the change in the flag's own --help text, and documented it in the changelog.

LeRobotDataset compatibility matrix

The dataset formats are an interface with someone else's loader on the other end, so every cell of this matrix is pinned by an oracle test that runs the real lerobot package, not a schema lookalike:

FormatWriteReadProof
v3.0 (native)✓ default — caliper record, RecorderV3DatasetReaderV3, replay auto-detectsour recording loads directly in lerobot 0.4.4 AND 0.6.0 — proven against both: 0.4.4 (windowing + padding asserted, one verified-decreasing SGD step) and 0.6.0 (py3.12 / torch 2.11 / datasets 4.8.5 pairing, 2026-07-28: delta_timestamps windowing, edge padding, task strings and a DataLoader batch all exactly match the recording, zero warnings); cross-direction: our reader reads a lerobot-written dataset; edits stay loadable — oracle/tests/test_dataset_v3.py
v2.1 (legacy)caliper record --format v21, RecorderDatasetReader, replay auto-detectsschema + stats validated via pyarrow (oracle/tests/test_lerobot_dataset.py); full round-trip through lerobot's own v2.1→v3.0 converter and back into a real LeRobotDataset load — oracle/tests/test_lerobot_roundtrip.py

The lerobot-version fine print, pinned by test rather than hoped (oracle/tests/test_lerobot_roundtrip.py): lerobot < 0.4 loads our v2.1 datasets natively; lerobot ≥ 0.4 dropped v2.x reading entirely and rejects them with its own BackwardCompatibilityError version gate — that is lerobot's contract, not a Caliper bug, and it is why v3.0 is the default. Any other parse error against our metadata fails the oracle.

Pairing with modern lerobot, three practical notes:

  • Install the dataset extra. lerobot ≥ 0.6's base install drops the dataset dependencies — pair with pip install "lerobot[dataset]", or LeRobotDataset imports fail on a bare install.
  • Python floor. lerobot ≥ 0.5 requires Python 3.12; a 3.10/3.11 environment is permanently capped at lerobot 0.4.4 (still a proven pairing — see the matrix).
  • Converter location moved. The official v2.1→v3.0 converter lives at lerobot.scripts.convert_dataset_v21_to_v30 in lerobot ≥ 0.5 (0.4.x shipped it at lerobot.datasets.v30.convert_dataset_v21_to_v30) and its root semantics changed with the move — the upgrade path for v2.1 recordings is version-specific, not copy-paste (oracle/tests/test_lerobot_roundtrip.py handles both).

Within a minor release the on-disk bytes we write for a given format do not change meaning; a format-affecting change is a Changed entry by the policy above.

MSRV and language floors

  • Rust: MSRV 1.89 (rust-version in the workspace Cargo.toml). Raising it is a Changed changelog entry in a minor release, never a patch.
  • Python: ≥ 3.10, via a single abi3-py310 wheel — one wheel per platform covers every later CPython, so a new Python release does not need a new Caliper release.

The promise list

These are properties, not aspirations — each is enforced by something that runs in CI:

  • Single artifact per face. One CLI binary, one .dmg, one abi3 wheel. No ROS workspace, no conda environment, no driver installation. (Lightweight, measured keeps the sizes honest.)
  • No GPU requirement, anywhere. The engine is pure CPU; the optional MuJoCo contact backend is CPU; even the learning sidecar trains on CPU (GPU is an optimization, never a floor). Enforced culturally by CI running everything on GPU-less runners, and structurally by scripts/assert-lean.sh, which fails the build if the core facade crate ever pulls a GPU/sim/transport dependency.
  • Offline-capable. Nothing in the engine or faces phones home. The oracle sets HF_HUB_OFFLINE=1 so an accidental network dependency fails loudly; the policy runner loads checkpoints from local directories only.
  • Deterministic seeded simulation. The engine is clock-free — state advances only on step(dt) — and the one randomized component uses a seeded splitmix64 PRNG. Same input + same seed = same bytes out, pinned by determinism tests and usable as a CI assertion (Headless CI recipe).
  • The lerobot pairing is watched, not assumed. A monthly CI canary (pairing-watch.yml) builds the current wheel, installs the newest lerobot from PyPI (deliberately unpinned), and runs the oracle's round-trip gates on py3.12 — with "any skipped gate = failure", so a converter that moves again or an import shape that changes turns the run red instead of silently skipping. Linux runtime is verified on every push by the linux CI job (workspace tests + built wheel + core oracle on ubuntu-latest).

What is not promised (pre-1.0)

Honesty cuts both ways: Rust API shapes, CLI human-readable (non---json) output text, Studio UI layout, Studio-internal IPC (the Tauri commands and events behind the UI, including the live_*/live:// session surface), and the learning sidecar's Python internals may all change at minor releases — with changelog receipts, per the policy above. If you are scripting against the CLI, prefer the --json outputs; their fields only grow within a minor.

Headless CI recipe

Caliper's engine needs no GPU, no display, and no hardware, which means your robot regression tests can run on the free GitHub Actions tier. This page is a copy-paste job that builds the CLI, runs a seeded simulation + eval batch on a plain ubuntu-latest runner, and proves determinism by running the seeded work twice and diffing the bytes.

The job

Drop this into .github/workflows/robot-eval.yml of a project that depends on Caliper (swap the URDF and goals for your robot):

name: robot-eval
on: [push, pull_request]

jobs:
  seeded-sim-eval:
    runs-on: ubuntu-latest        # free tier — no GPU anywhere in this job
    env:
      URDF: oracle/fixtures/corpus/so100.urdf   # your robot here
      GOAL: 0.3,0.2,-0.1,0,0,0                  # in-limit joint goal
    steps:
      # Get Caliper. Until the crates.io / PyPI names are settled, build from
      # the repo (Swatinem/rust-cache makes rebuilds incremental-fast):
      - uses: actions/checkout@v4
        with:
          repository: msannikov03/caliper   # or your fork / a vendored copy
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Build the CLI once
        run: cargo build --release -p caliper-cli

      - name: Eval batch — seeded plans across a seed sweep + machine-readable report
        run: |
          mkdir -p out
          for seed in 1 2 3 4 5; do
            ./target/release/caliper plan "$URDF" --goal "$GOAL" --seed "$seed" \
              > "out/plan_seed$seed.txt"
          done
          ./target/release/caliper report "$URDF" --goal "$GOAL" --json > out/report.json

      - name: Simulate + record an episode, headlessly
        run: |
          ./target/release/caliper sim "$URDF" --duration 2.0 > out/sim.txt
          ./target/release/caliper record "$URDF" --out out/dataset \
            --goal "$GOAL" --ticks 500 --fps 50

      # THE DETERMINISM ASSERTION: run the seeded work twice; the bytes must
      # match. `diff` exits non-zero on any drift, failing the job — flaky
      # simulation cannot hide.
      - name: Determinism — same seed, bit-identical output
        run: |
          ./target/release/caliper plan "$URDF" --goal "$GOAL" --seed 42 > a.txt
          ./target/release/caliper plan "$URDF" --goal "$GOAL" --seed 42 > b.txt
          diff a.txt b.txt
          ./target/release/caliper report "$URDF" --goal "$GOAL" --json > r1.json
          ./target/release/caliper report "$URDF" --goal "$GOAL" --json > r2.json
          diff r1.json r2.json

      # Gate on quality, not just "it ran": --strict makes `report` exit
      # non-zero when the trajectory linter finds errors (limit violations,
      # wrap-around detours, singular corridors…).
      - name: Trajectory lint gate
        run: ./target/release/caliper report "$URDF" --goal "$GOAL" --strict

      - uses: actions/upload-artifact@v4
        with:
          name: eval-out
          path: out/

Total cold time is dominated by the one release build of the CLI (cached across runs by rust-cache); the measured work itself is seconds — see Lightweight, measured.

Why diff is a valid determinism test here

It would not be for most simulators. It is for Caliper because the engine is clock-free (state advances only on step(dt), nothing reads the wall clock) and the only randomness is a seeded splitmix64 PRNG — so a seeded run's output is a pure function of its inputs, byte for byte, and the strictest possible assertion (diff) is also the simplest. If that diff ever fires, it is a real regression in the determinism contract (Stability contract), not noise to be tolerated.

Variants

Python instead of the CLI — same free runner, engine driven through the bindings (this is exactly how Caliper's own python CI job works):

      - uses: astral-sh/setup-uv@v5
      - run: uv venv && uv pip install maturin numpy
      - run: uv run maturin develop -m crates/caliper-py/Cargo.toml
      - run: |
          uv run python - <<'EOF'
          import caliper
          robot = caliper.Robot.from_urdf("oracle/fixtures/corpus/so100.urdf")
          # ... seeded planner / sim / dataset assertions ...
          EOF

Prebuilt abi3 wheels (macOS arm64 + manylinux x86_64) are attached to each GitHub releasepip install <wheel-url> skips the Rust toolchain entirely once you pin a release.

Policy eval batch — the learning sidecar's eval harness (caliper-learn eval --json, seeded success-scored rollouts with Wilson-95 aggregates) runs on the same CPU runners; it additionally needs uv pip install -e learn and CPU torch. Budget accordingly: torch is the one heavyweight download in that variant, and it belongs to the training side of the fence, never to the engine.

Contact simulation — the optional MuJoCo backend is also CPU-only and headless-capable; enable the mujoco feature and fetch the pinned dylib with scripts/fetch_mujoco.sh first. The default recipe above deliberately uses only the built-in simulator so the job stays dependency-free.

Build program (2026-07)

This page is the honest ledger of the July 2026 build program: a two-round research effort (competitive landscape, then a pain-point mining of what people actually struggle with in robotics software) that produced a four-wave plan, and what each wave actually shipped. A ✗ or deferred here is deliberate — the goal is that this table never lies about the state of the app.

The program's thesis: Caliper occupies a two-front position — lighter than everything (one artifact, no GPU, offline) and more legible than everything (doctors and verdicts on top of a single-owner codebase that also owns the dataset format and the sim). The waves build the second front while telling the story of the first.

W1 — Doctors (trust at every input) — ✅ shipped

PlannedShipped
Asset doctor: lint + auto-repair URDF/MJCFcaliper-doctor crate, A001A014, opt-in repair (inertia-from-mesh via divergence-theorem integrals pinned to analytic ground truth)
Dataset doctor: pre-train lintercaliper-dataset::analyze, D001D015, streaming two-pass
Trajectory lintercaliper-kinematics::lint_path, T001T009 (incl. the "360° detour" detector + collision-margin)
Loud-error edge guardrails✅ ~20 CLI/Python messages upgraded to name-the-field/got/expected/fix
Faces + Studio + docs✅ CLI (doctor, data doctor, report --strict), Python, Studio (auto-diagnose + Repair&reload, Data-mode Doctor panel), Doctors chapter, capability matrix

W2 — Verdicts (train→deploy legibility) — ✅ shipped

PlannedShipped
Seeded eval harnesscaliper_learn.eval, E001E003, Wilson-95 CIs, sweep checkpoint ranking, per-episode seeds
Policy deploy debuggercaliper_learn.debugger, P001P008 (incl. normalization-mismatch and cadence-mismatch, the mined killers)
Latency profilercaliper_learn.profile, L001L003, chunk-aware refill-vs-pop p95, honest achievable-Hz
The Policy Autopsy (flagship)caliper_learn.autopsy — data doctor (D) + debugger (P) + eval (E) + latency (L) under one verdict; caliper-learn console script
Verdicts docs✅ Verdicts chapter, capability-matrix rows

Scope note: the autopsy is CLI/Python only — policy inference is Python-side, so there is no Studio autopsy panel. Stated plainly in the chapter.

W3 — Reach (make the won arguments universally true) — ✅ shipped (config items owner-gated)

PlannedShipped
Robot zoocaliper fetch <name>/--list (embedded corpus URDFs; meshes not embedded, doctor-error set documented per robot)
Benchmark harness + metrics pagescripts/measure_lightweight.sh (+ self-test), Lightweight, measured page
Stability contractStability contract page (semver, deprecation, dataset compat matrix), CHANGELOG.md
Headless CI recipeHeadless CI recipe page (run-twice-diff determinism)
Version identitycaliper.__version__, caliper --version, caliper_learn.__version__, CLI↔Python parity smoke
Zero-to-moving quickstart✅ quickstart chapter (every command verified against real surfaces)
Studio first-run tour✅ 6-step dismissible overlay + palette "Show tour"
Notarize macOS / tested Linux runtimeowner-gated — needs the Apple Developer-ID cert; Linux wheel CI exists but is not runtime-verified

W4 — Data factory — ✅ shipped

PlannedShipped
Domain randomization APIcaliper_learn.randomize (CI-diffable seeded draws) + VecSimEnv(randomization=)
Coverage generator (doctor→generator loop)caliper_learn.coverage_gen + caliper-learn coverage
Contact material presets + stability linterContactMaterial presets, lint_contact_stability (C001C003)
Convex decompositionseam onlyColliderDecomposer trait + identity impl; CoACD-class algorithm deliberately not vendored (per the research: leave the seam, don't build it)
MP4 video encodingcaliper_learn.video (dtype video, lerobot-exact, real round-trip); video meta columns now emitted natively by the Rust writer (the pyarrow bridge is a repair tool — see the follow-on table)
Data factory docs + this audit✅ Data factory chapter, capability-matrix rows, this page

Follow-on — human demonstration loop (in progress)

The next program replaces bake-then-replay with hands-on interaction inside Studio. Only what is built is listed as built:

PhaseStatus
A1 — live sim session✅ built — Studio's Simulate mode steps the sim (MuJoCo, or the builtin integrator in default builds) live in a background thread: fixed 1 ms timestep, PD hold target, ~60 Hz state stream, pause-as-freeze, deterministic reset, live contact count (details)
A2 — input layer✅ built — the live session is drivable: joint sliders edit the hold target (measured pose ghosted so PD lag is visible), IK gizmo retargets the tip live, keyboard jog ([/] select, -/=/arrows move), gamepad cartesian tip drive (deadband + cubic response, A = pause, B = reset), Space = freeze. Inputs write only the hold target — never the streamed pose — so input and stream cannot fight
A3 — teleop episode recording✅ built — record takes from a live session straight into a native LeRobotDataset v3.0, captured in the sim thread at exact tick decimation (default 50 fps; timestamps are k/fps, never wall-clock): per-episode task labels, save/discard per take, finish-dataset, open-in-Data. Reset discards the take. Acceptance verified: a Studio-recorded dataset loads in real lerobot 0.6.0
B1/B2 — gripper channel + weld attach-on-grasp✅ built — gripper auto-detection (joint or child-link name, mimic-aware, override supported), open/close on the shared PD hold target, and an explicitly-labeled weld grasp: commanded-closed + contact ⇒ weld with relpose captured at activation (snap-free < 1 mm), open ⇒ natural release; one prop at a time; reset releases (details)
B3 — success predicates✅ built — lifted / placed_in_zone / combinators with an exact JSON schema (the seed of the coming task artifact), reported by VecSimEnv(success=…), scored by the eval harness (wilson unchanged), named in the autopsy verdict
C — task artifact✅ built — *.caliper-task.json v1, every face consumes it, Rust/Python predicate parity pinned by a shared table (details)
D1 — episode replay✅ built — recorded episodes re-perform on the 3D robot in Data mode; located doctor findings jump to their pose
D2 — verdict viewers✅ built — eval/debug/profile/autopsy --json render in Data mode (Wilson bar, findings, verdict line)
D3 — task zoo✅ built — five solvability-witnessed starters in tasks/ (details)
F1 — native video features✅ built — the writer emits video metadata in one pass; the pyarrow bridge is a repair tool now
F2 — load time✅ built — parallel hull priming; so101 5.0 ms release / 0.42 s debug (bit-identical hulls)
F4 — decomposition recon✅ concluded: skip vendoring — the seam is points-only by design, CoACD's dylib alone outweighs the entire dmg and is nondeterministic multicore; if ever needed, parry's pure-Rust VHACD behind an optional feature is the pick. The recon's real yield: the hull-builder orientation bug (48% of real meshes falling back) — found and fixed
G1/G2 — CI✅ built — monthly newest-lerobot pairing watch (skips = failures) + a Linux runtime job
G3 — v0.2.0✅ cut — versions bumped, changelog dated, the Studio dmg (11 MB, MuJoCo inside) signed + notarized + stapled (spctl: Notarized Developer ID)
E1 — policy-in-the-loop✅ built — a trained checkpoint from a user-pointed python env drives the live session over a pure-JSON stdio bridge (caliper-learn drive); sim never blocks on inference; record its rollouts as episodes (details)
Post-program adversarial review✅ run — three independent reviewers over the full diff; 9 findings, 9 independently verified, 9 fixed with regression tests (see the changelog)

Deliberately not built (traps the research flagged)

  • A ROS bridge / ROS-compat layer — the mined value is escape from ROS; interop stays at the artifact level (URDF, MJCF, LeRobotDataset).
  • A photorealistic / GPU renderer or in-house physics — MuJoCo embedded is the ceiling; the weight advantage is the point.
  • VLA / foundation-model training infrastructure — Caliper produces datasets and debugs any policy; it does not compete with H100-scale training.
  • Cloud / fleet features — offline-capable is an invariant.
  • A general RL framework — the vectorized env is a substrate; task and learner are yours.

Owner-gated / owner-supplied (not code)

Apple Developer-ID cert + notarization · PyPI / crates.io tokens · the crates.io umbrella name (caliper is taken) · a GPU training run on real hardware · the human GUI review (now spanning Jog / Motion / Simulate+Contact / Graph / Data modes + the first-run tour).

Verification

Caliper is built "verify as you go." This chapter is the honest trust map. The authoritative snapshot lives in docs/VERIFICATION_REPORT.md; this page summarizes it and points at the caveats.

The short version

  • Engine math: independently verified correct. Ten of eleven algorithm clusters were re-derived from first principles and matched line-by-line at high confidence; the eleventh (numerical-stress) raised robustness/coverage items, not math errors.
  • Headless stack (engine + CLI + PyO3): machine-verified and trustworthy.
  • Studio GUI: built, build-checked, statically reviewed — but never executed. Runtime behavior is unverified, by deliberate choice.

Four independent lines of evidence

1. External cross-validation (Pinocchio + NumPy + Ruckig-class + SciPy)

A Python oracle (oracle/) runs through the PyO3 bindings and compares against reference implementations:

  • Pinocchio — FK, geometric Jacobian (world = LWA, body = LOCAL), RNEA, CRBA, and forward dynamics, with residuals ≈ 1e-9…1e-15.
  • NumPy SVD — singularity metrics (σ, manipulability, condition number).
  • Ruckig-class expectations — motion profiles are sanity-checked against jerk-limited expectations (there is no Ruckig oracle wired in — see the caveat below).
  • LeRobot schema — dataset record/replay validated against the schema via pyarrow + NumPy stats (lerobot itself is not importable in the test env).
  • SciPy/NumPy — control checked where a closed form exists (e.g. the 2-DOF computed-torque case).

Because the oracle goes through the shipped bindings, it validates the Python face and the shared core simultaneously. This external check has caught real bugs — an RNEA sign error was found exactly this way.

2. Property tests

Proptest-style invariants on the math: round-trips, monotonicity, endpoint-exactness, and limit adherence.

3. First-principles re-derivation + multi-agent audit

An independent re-derivation of all eleven algorithm clusters (line-by-line, at high confidence) plus a large multi-agent correctness/safety audit. Every confirmed finding was fixed or explicitly documented. The most recent pass recorded 13 findings, all addressed (Cartesian move validation symmetry, a Simulator::step non-positive-dt guard, a 0-row-Jacobian manipulability guard, and a documented Earth-gravity scope note, among others).

4. Studio FE-logic harness (vitest)

The parts of the Studio frontend that can be tested off-screen are: a vitest harness covers coordinate transforms (coords.test.ts), the app store (store.test.ts), and graph serialize/deserialize (graph/serialize.test.ts). This tests logic, not rendering.

The honest gaps

These are stated plainly because pretending otherwise would be the real defect:

  1. The GUI has never been run. The entire Studio (all its modes plus the node editor) is build-checked and statically reviewed only. Its rendering and interactions are unverified. The first tauri dev is the real test.
  2. Self-consistent-only clusters. Several components are re-derived-correct but validated only against Caliper itself, not an independent reference: SE(3) log6/V⁻¹ and its small-angle branch; the adjoint and 6×6 spatial inertia; the IK solver (validated via FK∘IK closure, not a task-space DLS reference); the manipulability-ellipsoid eigendecomposition; the redundant-arm nullspace; all of caliper-motion (no Ruckig-class oracle); RRT/smoothing; OBB-SAT; the computed-torque decoupling; and the LeRobot dataset (schema, not lerobot). A defect shared between a forward and inverse path (e.g. a compensating error in both exp and log) would not be caught by closure tests — low risk given the re-derivation, but not machine-caught.
  3. By-design limitations (documented, not bugs). The collision guarantee is sampled-at-resolution (narrow passages can tunnel); mesh/capsule colliders that can't be reduced to supported primitives are surfaced via uncovered_frames rather than checked; the native Simulator has no collision; the "singular joint" classification is advisory. (MOVE_C, formerly unwired dead code, is now fixed — short-way arc through the via — wired to the CLI/Python faces and oracle-covered.) There are no dedicated narrow-passage / near-π / at-limit stress fixtures yet — coverage is random sampling.

Repro pointers

just ci        # fmt-check + clippy + test + lean-core check
just test      # cargo test --workspace --exclude studio
just oracle    # Pinocchio/NumPy cross-validation (needs the repo .venv)
just learn     # pure-PyTorch BC sidecar tests

The oracle and learning tests need a Python venv with maturin, pinocchio, numpy, pyarrow (and torch for learn). See the justfile recipe comments.

License plan

Caliper uses a split license, matching the kind of artifact rather than applying one blanket license to everything. The corresponding LICENSE-* files live at the repository root.

ArtifactLicenseFile
Software — the Rust engine, the three faces, and toolingApache-2.0LICENSE-APACHE
Hardware — any open-hardware designsCERN-OHL-W (weakly-reciprocal open hardware)LICENSE-CERN-OHL-W
Documentation — docs and written material (including this book)CC-BYLICENSE-CC-BY

Why split

  • Apache-2.0 is a permissive, patent-grant software license — appropriate for an engine meant to be embedded and built on.
  • CERN-OHL-W is the standard weakly-reciprocal license for open hardware — the right instrument for physical designs, which Apache/CC do not cover well.
  • CC-BY is the natural fit for prose and documentation.

Status

These are repo-level LICENSE-* files per artifact type, not per-crate license fields. During the build-out, individual crate metadata may still carry a permissive MIT OR Apache-2.0 placeholder while the split-license files are finalized; the intent above is the plan of record.