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:
- CLI —
caliper fk | ik | analyze | move | plan | sim | record | graph … - Python —
import caliper, built with maturin / PyO3; scriptable like MATLAB/NumPy. - Studio — Caliper 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:
| Phase | Capability |
|---|---|
| 0–1 | URDF → frozen kinematic model, forward kinematics, geometric Jacobians, SE(3)/SO(3) screw math |
| 2 | DLS/LM inverse kinematics + analytic 6R IK; singularity analysis |
| 3 | Jerk-limited S-curve motion (MOVE_J/L/C) + waypoint retiming + time-optimal (TOPP) parameterization |
| 4 | Inverse dynamics (RNEA), mass matrix (CRBA), forward dynamics, a semi-implicit-Euler Simulator |
| 5 | Real-robot backend contract, computed-torque control loop, safety monitor, teleop, LeRobot dataset record/replay |
| 6 | RRT-Connect / RRT* / PRM planning, shortcut smoothing, reachability, CHOMP-style trajectory optimization |
| 7 | A pure-PyTorch behavior-cloning sidecar (learn/) |
| 8 | A 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 1–6.)
2 · Open it in Studio and jog
Launch Caliper Studio → Open 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
(A001–A014): 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 (T001–T009) 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 (D001–D015) 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
- The three faces — everything above, on CLI, Python, and Studio.
- Doctors & trajectory lint — every check code explained.
- Studio dataflow graph — wire the same engine as a Simulink-style graph (⌘4).
- Verification — why you can trust the numbers: Pinocchio/NumPy cross-validation at 1e-9…1e-15, through the shipped bindings.
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
| Crate | Role |
|---|---|
caliper-spatial | SE(3)/SO(3) screw math — twists, exp6/log6, adjoints, spatial inertia. Twist ordering is [v; ω], Pinocchio-compatible. |
caliper-model | URDF parsing → a frozen struct-of-arrays kinematic Model. |
caliper-kinematics | Forward kinematics, geometric Jacobians (world/body), singularity analysis. |
caliper-ik | Inverse kinematics — damped-least-squares / Levenberg–Marquardt CLIK, plus an analytic 6R solver. |
caliper-dynamics | RNEA (inverse dynamics), CRBA (mass matrix), forward dynamics, and a semi-implicit-Euler Simulator. |
caliper-motion | Jerk-limited S-curve trajectories (MOVE_J/L/C), waypoint retiming, and a time-optimal (TOPP) parameterization. |
caliper-planning | RRT-Connect / RRT* / PRM planners, shortcut smoothing, reachability analysis. |
caliper-collision | Self-contained, pure-nalgebra collision checker (OBB-SAT, GJK, EPA, half-space, capsule, mesh-as-hull). |
caliper-trajopt | CHOMP-style collision-aware trajectory optimization over an initial waypoint path. |
caliper-hal | Hardware/sim abstraction: the RobotBackend contract, computed-torque control loop, SafetyMonitor, teleop, LeRobot dataset record/replay, feature-gated CAN / Dynamixel skeletons. |
caliper-calib | Kinematic (joint-offset / zero) calibration by damped Gauss–Newton. |
caliper-graph | Phase-8 dataflow IR (serde) + deterministic graph executor. |
caliper | Umbrella facade re-exporting the engine modules. |
caliper-cli | The command-line face. |
caliper-py | The Python face (PyO3 / maturin, import caliper). |
apps/studio | Caliper 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+stdin spirit. Collision is pure nalgebra on purpose —parry/rapierwere rejected. Planning uses a hand-rolled seeded PRNG rather than pulling inrand. The graph executor adds onlyserdeon top of the engine crates. - Determinism / clock-free. No
Instant::now, no wall clock anywhere in the engine. Time ist == tick * dt; simulation and control advance only on an explicitstep(dt). Randomized planners take a seed. This is what makes the whole stack bit-for-bit reproducible and testable without hardware. - Frozen model.
caliper-modelparses a URDF once into an immutable struct-of-arraysModelthat 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
(
A001–A014, with mechanical repair), the dataset doctor (D001–D015), and the trajectory lint (T001–T009). - 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 (
E001–E003), the deploy-loop latency profiler (L001–L003), the policy debugger (P001–P008), 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:
-
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.
-
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̇, τ), computeq̈(usingMand the RNEA bias term). Simulator— a semi-implicit (symplectic-style) Euler integrator with gravity, advanced by explicitstep(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
Simulatoris validated as energy-bounded, and itsenergyreporting 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 C001–C003 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 caliperModel: 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 featuremujoco) — a thin safe layer over the pinnedmujoco-rs 5.0.0wrapper (tracks MuJoCo 3.9.0 exactly), plus acaliper_hal::RobotBackendimplementation.
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 mode | Position mode |
|---|---|---|
TorqueDirect (default) | writes qfrc_applied directly — no actuators at all | non-physical teleport (mirrors PhysicsSimBackend) |
PositionServo { kp, kv } | UnsupportedMode — honest error | writes 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 intomjThreadPooland 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 fullmj_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 1–6, 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 C001–C003 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::ConvexHullentries are counted (skipped_hull_colliders) rather than silently dropped, so a MuJoCo model can have less collision coverage thancaliper-collisionon 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
Modeldoes 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):
bundle_mujoco.shcopies the pinned dylib intoapps/studio/src-tauri/vendor/, rewrites its install id to@rpath/libmujoco.3.9.0.dylib, and ad-hoc re-signs it (install_name_toolinvalidates 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.tauri.mujoco.conf.jsonis a separate overlay config, merged overtauri.conf.jsonby--config(JSON Merge Patch). It addsbundle.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.- With a non-empty
frameworkslist, tauri-build links the executable with-Wl,-rpath,@executable_path/../Frameworks, and the bundler copies the dylib intoContents/Frameworks/and signs it with the app. At launch,@rpath/libmujoco.3.9.0.dylibresolves inside the bundle — noDYLD_LIBRARY_PATH, no per-machine paths.
Honest caveats:
- The
.dmggrows by ~9 MB (the universal2libmujoco.3.9.0.dylibis 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 mujocowithout 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 pentadiagonalAᵀA qform) 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
ObstacleFieldsigned-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_framesreport rather than silently dropped — you always know what was not checked. - The native
Simulatorhas no collision; collision is only enforced where theSafetyCheckis 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 layoutlerobot>= 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 tofinalize()" footgun can't truncate a recording. Faces:caliper record(CLI,--format v3default),RecorderV3/DatasetReaderV3(Python). - legacy v2.1 (
caliper-hal, featuredataset): 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:
| Doctor | Input | Codes | Catches |
|---|---|---|---|
Asset doctor (caliper-doctor) | a .urdf / .xacro file | A001–A016 | CAD-export defects that break loading, physics, or collision coverage |
Dataset doctor (caliper-dataset::analyze) | a LeRobotDataset v3.0 root | D001–D016 | data 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 trajectory | T001–T009 | limit 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 doctorandcaliper reportexit 0 with findings (the report is the product; opt into gating withreport --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:
| CLI | Python | Studio | |
|---|---|---|---|
| Asset doctor | caliper 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 doctor | caliper data doctor <root> | caliper.data_doctor(root) | Data mode → Doctor button |
| Trajectory lint | caliper report … [--strict] | caliper.lint_path(robot, …) | — |
Asset doctor (A001–A016)
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
RepairOptsflag is off by default;RepairOpts::all()(what the CLI--repair, Pythonrepair=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.urdfso relative mesh references keep resolving. - Nothing fails silently. What could not be fixed lands in
skippedwith 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
diagnoseon the output (the CLI and Studio do this automatically and show the after-report).
Density caveat.
compute_inertialsassumes 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 (D001–D016)
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 (T001–T009)
Typed findings over a sampled trajectory (times/q/qd/qdd rows —
exactly what Trajectory.sample_uniform produces). T001–T007 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.
| Code | Severity | Finding |
|---|---|---|
T001 | Error | position limit violated (negative margin), located at the worst sample |
T002 | Error | velocity utilization > 100 % (+ tolerance) vs vmax |
T003 | Error | acceleration utilization > 100 % (+ tolerance) vs amax |
T004 | Warning | sustained dwell within a small margin of a position limit (default > 25 % of samples) — no escape headroom left |
T005 | Warning | wrap-around detour: total joint travel ≫ net start→end change (the "360° spin"), located at the peak excursion off the chord |
T006 | Warning | finite-difference jerk spike above 1.5 × jmax (disabled per joint when jmax is infinite, e.g. TOPP output) |
T007 | Warning | singular corridor: one finding per contiguous window with σ_min below threshold |
T008 | Error | path in collision (self or world), one finding per contiguous time window |
T009 | Warning | near-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— returnsDiagnostics(per-node / per-edge errors) plus a topological order, or a cycle report.exec—runreturns aGraphResultor aGraphError.
Node kinds
The NodeKind enum covers source, compute, and sink nodes, each mapping to
engine functionality:
- Sources / configs —
StartConfig,GoalPose,NamedConfig. - Compute —
Ik,MoveJ,MoveL,PlanRrt,Control(kp/kd),GravityDrop,CollisionCheck. - Sinks / views —
View(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
Datasetwith train-only normalization statistics. - policy —
build_policyforbc_mlp,act_lite, or the diffusion head; normalization stats are stored as model buffers so they round-trip with the weights. - train —
fiton 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=50but deploying at the defaultdt=1e-3consumed the one-step lookahead ~20× too fast (only ~42% of the gap closed). Deploy atdt = 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
fitruns — callseed_all(0)beforebuild_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 sim — caliper-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) raisesCheckpointSecurityError— pickles execute arbitrary code on load. ACT policies with state-like input features are supported this wave; VISUAL features raise a namedNotImplementedError.LoadedPolicy— wraps the policy + its pre/post processor pipelines behindreset()/predict(obs_dict) -> action. Action chunking uses lerobot's OWNselect_actionsemantics (one action popped per call, replan everyn_action_steps, temporal ensembling when configured), so the in-sim loop consumes chunks exactly likelerobot-evalwould.run_policy(policy, control_loop, *, fps, ticks, obs_builder=None)— the closed-loop runner: builds observations from measured state, vets every commanded target through theSafetyMonitorinsideControlLoop, and returns aHubRolloutResult(times/states/actions +warn_ticks). Any object withreset()/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
(E001–E003), the deploy-loop latency profiler (L001–L003), the policy
deploy debugger (P001–P008), 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:
| Tool | Question it answers | Codes |
|---|---|---|
Eval harness (caliper_learn.eval) | does the policy actually solve the task, or did the loss just go down? | E001–E003 |
Latency profiler (caliper_learn.profile) | can the deploy loop honestly hold the requested control rate? | L001–L003 |
Policy debugger (caliper_learn.debugger) | why does the trained policy do nothing? | P001–P008 |
Autopsy (caliper_learn.autopsy) | all of the above, one report, one verdict | D + 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 CLI | Python | Studio | |
|---|---|---|---|---|
| Eval / sweep | ✗ ¹ | caliper-learn eval | evaluate / sweep | ✗ ¹ |
| Latency profile | ✗ ¹ | caliper-learn profile | profile_rollout | ✗ ¹ |
| Policy debugger | ✗ ¹ | caliper-learn debug | analyze_policy | ✗ ¹ |
| Autopsy | ✗ ¹ | caliper-learn autopsy | autopsy | ✗ ¹ |
¹ 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 (E001–E003)
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 (L001–L003)
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 (P001–P008)
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-section —
caliper.data_doctoron the dataset (D001–D015; v3.0 on-disk format — the doctor's own error names the converter for v2.x). - P-section —
analyze_policy, dataset-aware. - E-section —
evaluate, Wilson-95. Only whenrobotandtaskare given (rollouts need a sim). - L-section —
profile_rollouton a freshControlLoop. 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:
- "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.
- "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. - "closed-loop: 0/20 …" quantifies the damage with an honest interval ([0.00, 0.16] — even the best case is bad), and
- "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
hubloader gates VISUAL checkpoints with a clearNotImplementedErrortoday, 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--jsonoutput 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(viaVecSimEnv), the profiler and autopsy L-section need acaliper.ControlLoop(the robot needs inertial data — run the asset doctor ifhas_inertiais false), and loading Hub checkpoints needstorch+lerobot. All of it imports lazily;caliper-learn --helpis 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. Onlyb_-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:
| Preset | Use for | Character |
|---|---|---|
Rigid | metal-on-metal, hard stops | stiff, near-inelastic |
Steel | tools, structural parts | very stiff, low friction |
Wood | props, fixtures | stiff, medium friction |
Rubber | grippers, feet, bumpers | soft, high friction |
Foam | soft props, padding | very soft, damped |
Custom{solref, solimp, friction} | your own | validated 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:
C001explosion —|qacc|/energy grows during settling (the "spins uncontrollably" class). Fix: raise thesolreftimeconst to ≥ 2× the timestep, or reduce the timestep.C002penetration — persistent contact depth after settling. Fix: stiffen the material or thesolimpdmax.C003jitter — contact force oscillates after settling. Fix: increasesolrefdamping 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
ffmpegonPATH; 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.
- Python —
import caliper, built with maturin/PyO3; scriptable like MATLAB/NumPy, and the surface the oracle runs through. - Studio — Caliper 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):
| Command | Purpose |
|---|---|
info | Print engine version / build info. |
load | Load and summarize a URDF model. |
fk | Forward kinematics for a joint vector. |
ik | Inverse kinematics to a target pose (--analytic for the closed-form 6R solver). |
analyze | Singularity / manipulability analysis at a configuration (--json). |
move | Jerk-limited MOVE_J / MOVE_L / MOVE_C (--via); --topp for time-optimal retiming. |
dyn | Dynamics at a configuration (RNEA / CRBA / forward). |
sim | Time-step the passive/forced dynamics (q + energy trace). |
run | Deterministic control-loop rollout on a physical sim to a goal. |
teleop | Leader–follower teleoperation demo (pure sim). |
record / replay | LeRobotDataset record (v3.0 default, --format v21) / replay. |
collide | Self/world collision check at a configuration (--contacts for EPA depth, --json). |
plan | Collision-free planning: RRT-Connect, --optimal (RRT*), --prm. |
calibrate | Joint-zero offset calibration from measured tip poses (--self-test). |
reach | Collision-aware reachability of a Cartesian pose (--json). |
report | Cycle-time + path-quality report plus the trajectory lint (T001–T009); --strict exits non-zero on Error findings. |
mjcf | Export the robot as an MJCF (MuJoCo XML) model. |
graph | run / validate a .caliper-graph.json dataflow graph. |
doctor | Asset doctor: diagnose a URDF/xacro (A001–A014); --repair writes a repaired copy. |
data doctor | Dataset doctor: pre-training diagnostics over a LeRobotDataset v3.0 (D001–D015). |
data delete / split / merge / tag | Dataset 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.
⚠
fkoutput is NOTikinput.Robot.fk(andexp6) return 4×4 ROW-major nested lists, while every pose input above is COLUMN-major — sorobot.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 mujocobuilds (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 … --jsonreport — 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
D001–D015check 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
mujocobuilds 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_resetDataincluding 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 task — Open 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, aSUCCESSbadge 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 aHELDbadge 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
C001–C003 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 apprecipe mirrors thenpm run tauri devcommand above. Ifjustis not installed in your environment, use the rawnpmcommand 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
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| Load URDF / xacro, summarize structure | caliper-model (architecture) | load | Robot.from_urdf, .name/.ndof/.joint_names/.joint_limits/.frame_names/.tip_frame/.has_inertia | Open URDF… / samples / recents, ⌘O |
Asset doctor — diagnose A001–A016 | caliper-doctor | doctor | doctor(path) | automatic on every load (error-banner findings) |
| Asset repair — repaired copy, never in-place | caliper-doctor | doctor --repair [--density] [--out] | doctor(path, repair=True, density=…) | Repair & reload button |
| MJCF (MuJoCo XML) export (+ hull-mesh assets) | caliper-sim-mujoco::mjcf | mjcf (--hull-meshes) | model_to_mjcf (no hull-mesh export — primitive colliders only) | ✗ |
| Robot zoo — fetch a vendored real URDF | caliper-cli::zoo | fetch <name> / fetch --list | ✗ | ✗ |
Kinematics & analysis
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| Forward kinematics (every frame) | caliper-kinematics | fk | Robot.fk | live in every mode (Jog sliders drive it) |
| Geometric Jacobian (world/body) | caliper-kinematics | ✗ | Robot.jacobian | ✗ (internal to the HUD analysis) |
| Iterative IK (DLS/LM, restarts) | caliper-ik | ik | Robot.ik | Jog tip gizmo (singularity-governed) |
| Analytic 6R IK (branch set) | caliper-ik::analytic | ik --analytic | Robot.analytic_ik | ✗ |
| Singularity / manipulability analysis | caliper-kinematics | analyze | Robot.analyze / manipulability / ellipsoid | singularity HUD + manipulability ellipsoid |
| Redundancy: nullspace step, resolved-rate | caliper-kinematics | ✗ | Robot.nullspace_step / Robot.resolved_rate | ✗ |
| SE(3) log/exp maps | caliper-spatial | ✗ | log6 / exp6 | ✗ |
Motion
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| MOVE_J (jerk-limited S-curve) | caliper-motion | move --goal | Robot.move_j | Motion mode / palette "Plan move to home" / poses |
| MOVE_L (Cartesian line) | caliper-motion | move --target | Robot.move_l | Motion mode (gizmo target) |
| MOVE_C (circular arc via a point) | caliper-motion | move --target --via | Robot.move_c | ✗ |
| Waypoint retiming | caliper-motion | ✗ | Planner.plan_trajectory (plan → retimed Trajectory) | ✗ |
| Time-optimal (TOPP) retiming | caliper-motion | move --time-optimal | Robot.retime_time_optimal | ✗ |
| Named pose library | caliper-motion::PoseLibrary | ✗ | ✗ | Motion mode (save/plan-to/delete poses) |
Trajectory lint T001–T007 | caliper-kinematics::lint_path | report | lint_path | ✗ |
Collision lint T008/T009 | CLI-side over caliper-collision | report --ground/--obstacle/--clearance [--strict] | ✗ | ✗ |
| Cycle-time + path-quality report | caliper-kinematics::path_report | report | ✗ | ✗ |
Dynamics & simulation
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| RNEA / CRBA / forward dynamics / gravity | caliper-dynamics | dyn | Robot.rnea / crba / forward_dynamics / gravity_torque | ✗ ¹ |
| Passive/forced time-stepped simulation | caliper-dynamics::Simulator | sim | Simulator (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 stream | Studio 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 C001–C003 | caliper-sim-mujoco::lint | ✗ | ✗ | automatic after every contact bake (findings under the Simulate badges; mujoco builds) |
| Convex-decomposition seam (identity impl) | caliper-sim-mujoco | ✗ | ✗ | ✗ |
Collision & planning
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| Self/world collision query | caliper-collision | collide | CollisionModel.query | Simulate: Check collision |
| EPA penetration contacts | caliper-collision | collide --contacts | CollisionModel.contacts | ✗ |
| RRT-Connect (+ shortcut smoothing) | caliper-planning | plan | Planner.plan / verify | Simulate: Plan to home (RRT) |
| RRT* (asymptotically optimal) | caliper-planning | plan --optimal | Planner.plan_optimal | ✗ |
| PRM roadmap planning | caliper-planning | plan --prm | Planner.plan_prm | ✗ |
| Plan to a Cartesian pose | caliper-planning | plan --target | Planner.plan_to_pose | ✗ |
| CHOMP-style trajectory optimization | caliper-trajopt | ✗ | ✗ | ✗ (engine-only) |
| Collision-aware reachability | caliper-planning::reach | reach | ReachChecker.status / reachable | ✗ ¹ |
Control, data & learning
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| Computed-torque control loop | caliper-hal | run | ControlLoop (run_to / rollout_to / step_with_target / run_stream) | Simulate: Drive to home (control) |
| Safety monitor (limits gate, e-stop) | caliper-hal | inside run ² | SafetyMonitor | inside the control rollout ² |
| Leader–follower teleop | caliper-hal | teleop | LeaderFollower | ✗ |
| Dataset record (LeRobotDataset) | caliper-dataset | record (v3.0 / --format v21) | RecorderV3 / Recorder | ✗ |
| Dataset replay through a sim backend | caliper-dataset + caliper-hal | replay | ✗ (readers only) | ✗ |
| Dataset read / browse / plot | caliper-dataset | ✗ | DatasetReaderV3 / DatasetReader (incl. images) | Data mode (table, channel plots, thumbnails) |
| Dataset edit: delete / split / merge / tags | caliper-dataset::edit | data delete / data split / data merge / data tag | dataset_delete_episodes / dataset_split_episode / dataset_merge_episodes / dataset_read_tags / dataset_write_tags | Data mode edit bar + tag chips |
Dataset doctor D001–D016 | caliper-dataset::analyze | data doctor | data_doctor | Data mode: Doctor button (episode-jump findings) |
| Joint-offset calibration (Gauss-Newton) | caliper-calib | calibrate (incl. --self-test) | calibrate_joint_offsets | ✗ |
| lerobot calibration-file export | Python interop | ✗ | export_lerobot_calibration | ✗ |
| robomimic HDF5 export | Python interop | ✗ | export_robomimic_hdf5 | ✗ |
| BC learning (BC-MLP / ACT-lite / DDPM) | learn/caliper_learn sidecar | ✗ | separate caliper_learn package (on top of these bindings) | ✗ |
| Deploy a lerobot checkpoint — safetensors-only loader + closed-loop sim runner | caliper_learn.hub/runner | ✗ ³ | load_lerobot_policy / LoadedPolicy / run_policy | ✗ ³ |
Seeded policy eval — Wilson-95, E001–E003, checkpoint sweep | caliper_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-tested | caliper-sim-mujoco::task | ✗ ³ (caliper-learn eval/autopsy --task) | caliper_learn.load_task / VecSimEnv.from_task | Open task… (robot + scene + zones + live SUCCESS badge) |
| Policy-in-the-loop drive — trained checkpoint drives the live sim over a stdio JSON bridge | caliper_learn.bridge | ✗ ³ (caliper-learn drive) | drive_loop / load_drive_policy | Simulate: connect policy… (live session; state-based policies) |
Manipulation success predicates — lifted / placed_in_zone / combinators, exact JSON schema | caliper_learn.success | ✗ ³ | Lifted / PlacedInZone / AllOf / AnyOf / VecSimEnv(success=) / EvalTask.success_predicate | ✗ (Studio grasps via the live weld channel) |
Deploy-loop latency profile — L001–L003, honest achievable Hz | caliper_learn.profile | ✗ ³ (caliper-learn profile) | profile_rollout | ✗ ³ |
Policy deploy debugger P001–P008 | caliper_learn.debugger | ✗ ³ (caliper-learn debug) | analyze_policy | ✗ ³ |
| Policy Autopsy — D+P+E+L under one verdict | caliper_learn.autopsy | ✗ ³ (caliper-learn autopsy) | autopsy | ✗ ³ |
| Domain randomization (CI-diffable seeded draws) | caliper_learn.randomize | ✗ | RandomizationSpec / 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_env | ✗ | VecSimEnv / reach_task / rollout_random | ✗ |
| Sim-camera collector (offscreen → image dataset) | caliper_learn.sim_camera | ✗ | SimCameraScene / collect_camera_dataset | ✗ |
MP4 video features (dtype video, lerobot-exact; metadata native in the Rust writer) | caliper_learn.video + caliper-dataset | ✗ | encode_episode_video / VideoRecorder / RecorderV3(video_features=…) (attach_video_metadata = repair tool) | ✗ |
Dataflow graph
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| Run a graph | caliper-graph | graph run | run_graph | Graph mode: Run (+ live scopes) |
| Validate a graph (types, cycles, topo) | caliper-graph | graph validate | validate_graph | Graph 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
| Capability | Engine | CLI | Python | Studio |
|---|---|---|---|---|
| Engine version / build info | caliper::VERSION | info / --version | version() / __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 be1. 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 frommass);materialis a preset name (rigid/rubber/foam/steel/wood, case-insensitive) or a custom{solref, solimp, friction}dict — identical to the Python face'smaterial=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.rgbais a render hint only.gripper— optional override for the gripper channel (auto-detection covers sanely-named robots);closedsays 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 againstscene.zonesat load time (and is stored resolved). Semantics are identical in both implementations — plain>=comparisons, inclusive zone faces, and asettled_speedcheck 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
| Face | What a task file does |
|---|---|
| Studio | Open 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_learn | load_task(path) → VecSimEnv.from_task(...) (props + success + q0 + ground) and eval/autopsy --task <file> on the CLI |
| Rust | caliper_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.
| File | Name | Goal | Predicate | Horizon |
|---|---|---|---|---|
01_lift | zoo-lift-cube | Raise the wooden cube 8 cm off the ground | lifted (ref: initial) | 8 s |
02_place | zoo-place-cube | Carry the cube into a bin 15 cm to one side and let it settle | placed_in_zone + settled_speed | 15 s |
03_hold_high | zoo-hold-cube-high | Hold a heavier steel cube out at arm's length, above 30 cm, and stop moving | all_of [lifted (ref: absolute), placed_in_zone] | 15 s |
04_sort | zoo-sort-blocks | Move either of two blocks across to the dock | any_of [two placed_in_zone] | 30 s |
05_precise_place | zoo-precise-place | The same carry as 02, onto a pad half as wide, with a tighter settle | placed_in_zone + settled_speed | 20 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.
| Surface | Direction | Form |
|---|---|---|
Robot.fk, exp6 (Python) | output | 4×4 ROW-major nested list — m[row][col], np.array(m) is the matrix |
log6 (Python) | input | 4×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) | input | 4×4 COLUMN-major nested (pose[col][row]) or flat 16-element column-major |
Planner.plan_to_pose (Python, legacy only) | input | also 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) | input | 12 numbers: 9 row-major rotation entries, then tx, ty, tz |
calibrate --observations JSON (CLI) | input | per observation: flat-16 column-major, or a 4×4 nested (row-major) matrix |
Studio backend DTOs (frames) | output | flat-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
| Claim | Target | Measured | Measured on |
|---|---|---|---|
Studio install (.dmg) | ≤ 250 MB | 10.4 MB (aarch64 .dmg, MuJoCo dylib bundled) | Apple M3 Max, 36 GB, macOS, rev 1dd648c, 2026-07-13 |
| Python wheel | ≤ 100 MB | TBD — run scripts/measure_lightweight.sh | — |
| Cold CLI → robot loaded + FK | ≤ 5 s | TBD — run scripts/measure_lightweight.sh ¹ | — |
| RAM, full app + sim | ≤ 1 GB | TBD (Studio); headless plan+sim peak RSS below ¹ | — |
pip install (wheel) | ≤ 30 s | TBD — run scripts/measure_lightweight.sh | — |
| Record overhead vs realtime | ≤ 1.1× | TBD — run scripts/measure_lightweight.sh ¹ | — |
| Seeded rollouts bit-identical | always | yes — 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=1additionally times apip installof 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.
| Stack | Install | Hardware floor | Time to first robot |
|---|---|---|---|
| NVIDIA Isaac Sim | ~10 GB-class download, 50 GB disk recommended | RTX 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 2 | full 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 pairing | a 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 training | minutes of dependency resolution + download |
| Caliper | one 10 MB .dmg, one CLI binary, one abi3 wheel | no GPU, no ROS, no CUDA — a laptop | see 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."
Related pages
- Stability contract — what these artifacts promise release over release.
- Headless CI recipe — the "runs on a free CPU runner" claim, as a copy-paste job.
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.y→0.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.x→0.x+1) — may break, but only with a receipt. Every breaking change appears inCHANGELOG.mdunder 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:
- The release that deprecates something keeps it working and makes it warn —
a
#[deprecated]attribute in Rust, aDeprecationWarningin Python, a stderr notice in the CLI — naming the replacement. - 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:
| Format | Write | Read | Proof |
|---|---|---|---|
| v3.0 (native) | ✓ default — caliper record, RecorderV3 | ✓ DatasetReaderV3, replay auto-detects | our 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, Recorder | ✓ DatasetReader, replay auto-detects | schema + 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]", orLeRobotDatasetimports 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_v30in lerobot ≥ 0.5 (0.4.x shipped it atlerobot.datasets.v30.convert_dataset_v21_to_v30) and itsrootsemantics changed with the move — the upgrade path for v2.1 recordings is version-specific, not copy-paste (oracle/tests/test_lerobot_roundtrip.pyhandles 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-versionin the workspaceCargo.toml). Raising it is a Changed changelog entry in a minor release, never a patch. - Python: ≥ 3.10, via a single
abi3-py310wheel — 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=1so 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 thelinuxCI job (workspace tests + built wheel + core oracle onubuntu-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 release — pip 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
| Planned | Shipped |
|---|---|
| Asset doctor: lint + auto-repair URDF/MJCF | ✅ caliper-doctor crate, A001–A014, opt-in repair (inertia-from-mesh via divergence-theorem integrals pinned to analytic ground truth) |
| Dataset doctor: pre-train linter | ✅ caliper-dataset::analyze, D001–D015, streaming two-pass |
| Trajectory linter | ✅ caliper-kinematics::lint_path, T001–T009 (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
| Planned | Shipped |
|---|---|
| Seeded eval harness | ✅ caliper_learn.eval, E001–E003, Wilson-95 CIs, sweep checkpoint ranking, per-episode seeds |
| Policy deploy debugger | ✅ caliper_learn.debugger, P001–P008 (incl. normalization-mismatch and cadence-mismatch, the mined killers) |
| Latency profiler | ✅ caliper_learn.profile, L001–L003, 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)
| Planned | Shipped |
|---|---|
| Robot zoo | ✅ caliper fetch <name>/--list (embedded corpus URDFs; meshes not embedded, doctor-error set documented per robot) |
| Benchmark harness + metrics page | ✅ scripts/measure_lightweight.sh (+ self-test), Lightweight, measured page |
| Stability contract | ✅ Stability contract page (semver, deprecation, dataset compat matrix), CHANGELOG.md |
| Headless CI recipe | ✅ Headless CI recipe page (run-twice-diff determinism) |
| Version identity | ✅ caliper.__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 runtime | ⏳ owner-gated — needs the Apple Developer-ID cert; Linux wheel CI exists but is not runtime-verified |
W4 — Data factory — ✅ shipped
| Planned | Shipped |
|---|---|
| Domain randomization API | ✅ caliper_learn.randomize (CI-diffable seeded draws) + VecSimEnv(randomization=) |
| Coverage generator (doctor→generator loop) | ✅ caliper_learn.coverage_gen + caliper-learn coverage |
| Contact material presets + stability linter | ✅ ContactMaterial presets, lint_contact_stability (C001–C003) |
| Convex decomposition | ◑ seam only — ColliderDecomposer trait + identity impl; CoACD-class algorithm deliberately not vendored (per the research: leave the seam, don't build it) |
| MP4 video encoding | ✅ caliper_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:
| Phase | Status |
|---|---|
| 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:
- 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 devis the real test. - 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 ofcaliper-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 bothexpandlog) would not be caught by closure tests — low risk given the re-derivation, but not machine-caught. - 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_framesrather than checked; the nativeSimulatorhas 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.
| Artifact | License | File |
|---|---|---|
| Software — the Rust engine, the three faces, and tooling | Apache-2.0 | LICENSE-APACHE |
| Hardware — any open-hardware designs | CERN-OHL-W (weakly-reciprocal open hardware) | LICENSE-CERN-OHL-W |
| Documentation — docs and written material (including this book) | CC-BY | LICENSE-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.