#mcap #energy #determinism #observability

app ferroscope-cli

The Ferroscope CLI: inspect, verify and compare robot recordings. Determinism receipts and a joules axis, installed as the ferroscope binary.

26 releases

Uses new Rust 2024

new 0.1.26 Aug 28, 2026
0.1.25 Aug 28, 2026

#967 in Visualization

MIT/Apache

695KB
14K SLoC

Ferroscope

The open interface layer for physical AI. Pure Rust. Reads and writes plain MCAP. Adds the two axes every robotics viewer is missing: a joules lane and a determinism receipt.

license

cargo install ferroscope-cli

ferroscope demo   a.mcap
ferroscope demo   b.mcap --platform "x86_64-linux / vulkan" --drift 320
ferroscope diff   a.mcap b.mcap
  digests differ, comparing step by step
  diverged at step 392, /robot/joints[2]: 3.88603112635621584e-1 vs 3.88603098094233157e-1
                                          (|Δ| 1.454e-8, rel 3.742e-8)
  → the runs agreed for 39.2 % of the trajectory, then did not.

That is the whole idea. Two machines ran the same declared experiment. One of them drifted. The tool does not say "results differ". It says which step, which channel, which index, and both numbers.

And look at the size of it: 1.454e-8. In this demo the perturbation never explodes. It settles into a persistent offset around 10⁻⁹ m that no plot can show and no eye can catch. Plotted against each other, the two runs are the same picture. That is the whole reason the digest exists.


Why this exists

Robotics tooling in 2026 is good and getting better. A full survey of the field, across languages and regions, is in docs/LANDSCAPE.md. The short version, five tools:

what it does well where it stops
Foxglove The best panel-and-layout viewer in the field. MCAP is theirs and it is genuinely open (Apache-2.0). The SDK core is Rust, MIT. Live WebSocket streaming, teleop, a real data platform. The app itself is proprietary: Studio 1.x (MPL-2.0) was frozen in February 2024 and the current product is closed. Cloud storage, seats, and device counts are metered. Visualization only: no physics, no scenario execution, no notion of whether a run reproduced.
Rerun Open-source core, Rust viewer that runs native and in a browser, an entity-component data model with real timelines, and a good embedding story. Its own MCAP support is marked experimental; the viewer is bounded by RAM. It is a logging and visualization layer, by design, not a place where a run is executed, gated, or certified.
NVIDIA Isaac Sim / Isaac Lab The strongest physics-and-rendering primitives available, GPU-parallel environments, OpenUSD throughout, an enormous asset ecosystem. Apache-2.0 source that needs the Omniverse Kit SDK under NVIDIA's own license; redistributing it or offering it as a service to third parties pulls in NVIDIA AI Enterprise. Needs an RTX-class GPU. And Isaac Lab's own docs state the limitation plainly: GPU work scheduling reorders floating-point reductions, so "experiments from the IsaacGym simulator are not perfectly reproducible on a different system."
Lichtblick An actively maintained community fork of Foxglove Studio, browser and desktop, preserving the open-core model. The honest correction to the row above: licence is not the differentiator here. Like Foxglove, it is a viewer. No physics, no scenario execution, no determinism receipt, no energy ledger.
Antioch The best-designed scenario model in the field, and the reason this repository has one. A scenario is a parameterized 3-D integration test; cases and grids turn it into many comparable runs; a verdict is named checks with measured details; suites are unions of selector clauses; history is queryable with key:op:value predicates; telemetry lands in Rerun. Every one of those ideas is worth porting, and this repository ports them. The delivery, not the design. Its own documentation is the source: a run needs an ephemeral GPU VM, "allocation is the slow step", and when none is warm the CLI "polls up to 600 s". Simulator imports are banned at module scope because discovery must happen "before requesting a machine". Cost is assignment-scoped, "idle time included … there is no per-run or per-scenario cost figure to report". Reproduction means re-queueing saved images, and "multi-machine interactive runs are not currently rerunnable": there is no digest and no divergence step. And "the CLI has no compare command".

Put the columns side by side and the gap has a shape:

Nothing in that list answers, from a file alone, either of the two questions that decide whether a robot ships.

  1. Did this run reproduce, and if not, where?
  2. What did the task cost in joules, compute and actuation, in one ledger?

Ferroscope answers both, offline, from the recording, with no account and no daemon. The second one is not a guess about a competitor: Antioch's documentation says in as many words that the platform has no per-run cost figure to report.


The three ideas

1. A physical-AI run has three clocks, not one

MCAP gives a message a log_time and a publish_time. Enough for a log; not enough for a robot. A run has simulated time, wall time, and a control step index, and the interesting bugs live in the drift between them. A controller that holds 1 kHz in simulation and 780 Hz on hardware is not a controller that works, and on a single-clock timeline that failure is invisible.

Every Ferroscope message carries all three. Real-time factor and control-loop jitter read straight off the recording:

  clocks      worst wall−sim drift 61.745 ms at sim t=0.999 s

2. Energy is a lane, not an afterthought

E_task = E_compute + E_actuation

Both terms come from measured power integrated over the run, never from a datasheet TDP. ferroscope energy prints the split, and the split is the design decision:

  E_task = E_compute + E_actuation
  ---------------------------------------------
  compute             8.357 J    23.9 %
  actuation          25.182 J    72.1 %
  overhead            1.399 J     4.0 %
  ---------------------------------------------
  total              34.938 J
  peak               87.253 W   actuation/leg

  coverage     sound (3000 samples, median interval 1.0 ms)

Note the last line. Integrating power over time is trivial; integrating it honestly is not. A series with a four-second hole in it under-reports by exactly whatever happened in the hole. So the ledger carries a coverage verdict, and when the sampling cannot support the number it says so:

  coverage     DO NOT QUOTE: actuation/hip has a 4900 ms gap against a 10.0 ms median
               interval, so a transient in that hole is invisible to the integral
  → this number is reported but must not be cited as a measurement.

The number is still printed. A flagged measurement beats a missing one, as long as it is flagged.

And now it actually measures

ferroscope power reads the machine's own counters — Linux RAPL through /sys/class/powercap, macOS powermetrics — and integrates them over a real command:

ferroscope power                             # what can this machine tell me?
ferroscope power --out run.mcap -- cargo build --release
power source
  Linux RAPL, 1 top-level domain(s): package-0

  E_compute    37.984 J  (measured, 20 samples)
  mean power   19.216 W
  coverage     sound (20 samples, median interval 105.1 ms)

Two traps are handled, because both produce a plausible wrong number rather than an error:

  • Nested domains double-count. intel-rapl:0 is the package and intel-rapl:0:0 is the core inside it. Summing every directory under powercap counts those joules twice — silently, by 30–60 %, always in the flattering direction. Only top-level domains are summed.
  • The counters wrap, on some parts every minute or two under load. A naive subtraction goes hugely negative, and clamping that to zero drops a whole interval. Each domain's declared max_energy_range_uj is used to unwrap.

And when the machine will not say, it says that:

no power interface: powermetrics is installed but this process cannot run it (powermetrics must
be invoked as the superuser). macOS has no unprivileged power interface, so run under sudo to
measure. Reporting no measurement rather than zero joules.

That case is the common one, not the exception: since the mitigation for CVE-2020-8694, RAPL's energy_uj is root-only on most distributions, so a meter that shrugs reports 0 J for a machine drawing 90 W. The command still runs, its exit status still propagates, and no joules figure is printed at all.

FERROSCOPE_POWERCAP overrides the sysfs root, which is how CI exercises the measuring path on every runner against a synthetic counter with a known right answer — a 20 W counter must read back as 20 W. A code path that only runs on hardware nobody in the loop owns is a code path nobody has run.

Every recording now says what it cost to make

Two different quantities share the word "compute", and conflating them would be a category error. The compute rail inside a described scene models the robot — an embedded SoC drawing ~8 W during the task — and stays clearly labelled an estimate. What the machine producing the file spent is a separate, measurable fact, and every recording now carries it in its own ferroscope.production metadata block:

  production
    joules                 0.616237
    duration_s             0.0293
    source                 Linux RAPL, 1 top-level domain(s): package-0
    basis                  cumulative energy counter

When the machine will not say — no root, no counters — the block names the reason instead, and never writes joules: 0. A sweep sums per-case deltas off the cumulative counter, and refuses to print a total if any case went unmeasured, because a sum with a hole in it reads as smaller than the truth.

The block lives outside both digests, deliberately: production cost varies run to run by nature — the same scene on a busier machine costs more — so it can never be part of the determinism claim. The receipt says "this is the same experiment"; the production block says "and here is what making this copy of it cost". CI holds that as an invariant: a measured run and an unmeasured run of the same scene must agree digest for digest.

This is also, concretely, the figure the platform column above lacks: a per-run production cost, measured, in the file, recomputable by nobody because it is not a claim — it is a receipt of spend.

3. The receipt is recomputable from the file

Every run is sealed with a receipt stored in the recording's own metadata. It has two halves:

  • a spec digest over everything that must match for two runs to be comparable at all: scenario, seed, timestep, integrator, solver, asset digests, physics config, build. The platform is deliberately excluded, because comparing across platforms is the entire point.
  • a trace digest over the trajectory at a declared precision. Bit-exact if you can afford it; otherwise mantissa-quantized, so a receipt says "identical to 2⁻⁴⁰ relative" instead of pretending to a bit-exactness no GPU fabric delivers.

Because the trace digest is defined over what is in the file, anyone holding the file can recompute it, with no simulator, no source tree, and no access to the machine that produced it:

$ ferroscope verify a.mcap
  spec digest     4d46e750af663f1c684f275038d420c8a2cf19a26c6aa7dd473742954e7cb9cb  ok
  trace digest    8d5b26d74bf395c8d1f74a87cccbb0e12d2f53d6241743acb7212939294d1681  ok

  VERIFIED: this file still stands behind its own receipt.

And the rule that keeps it sound:

A digest match is proof. A digest mismatch is a question.

Quantization boundaries alone can split two values a nanometre apart, so a mismatch never reports as a divergence. It escalates to the comparator, which walks both traces and returns one of: BitExact, IdenticalAtPrecision, WithinTolerance (with the worst case named), Diverged (with the step named), NonFinite, or Incomparable.

A NaN outranks every tolerance question. A run that produced one is reported, never hashed into a match.


Scenarios, cases, suites, verdicts

ferroscope-run is the harness. It keeps Antioch's model and throws away everything between the engineer and the answer: no manifest, no services map, no engine image, no container, no machine to wait for. A scenario is a function in your binary.

use ferroscope_run::prelude::*;
use ferroscope_run::Case;

fn main() -> std::process::ExitCode {
    Harness::new()
        .scenario(
            Scenario::new("hop")
                .describe("One leg, one hop. Did it leave the ground and land on its feet?")
                .tags(["locomotion", "smoke"])
                .param("stiffness", 8000.0)
                .param("restitution", 0.4)
                .steps(1_000)
                .dt(1.0 / 1000.0)
                .cases(Case::sweep("stiffness", [4000.0, 8000.0, 16000.0]))
                .body(hop),
        )
        .suite(Suite::new("smoke").tags(["smoke"]))
        .main()
}

fn hop(run: &mut Run) -> Result<(), Halt> {
    let k = run.param("stiffness");
    let (mut z, mut vz) = (0.32, 1.5);
    let mut peak = z;
    while run.running() {
        let t = run.tick();                       // <- the only bookkeeping call in the loop
        let f = (k * (0.32 - z).max(0.0)).max(0.0);
        vz += (-9.80665 + f / 12.0) * run.dt();
        z += vz * run.dt();
        peak = peak.max(z);
        run.position("/robot/base", t, [0.0, 0.0, z]);
        run.energy("/energy/leg", t, Rail::Actuation, "leg", (f * vz).abs() * 0.45);
        run.energy("/energy/soc", t, Rail::Compute, "soc", 7.8);
    }
    run.result("peak_height_m", peak);
    run.result("gate_peak_m", 0.36);              // record what "passed" meant
    run.check("left the ground", peak > 0.36, format!("peak {peak:.4} m > 0.360 m"));
    Ok(())
}

run.tick() is the whole trick. It advances simulated time by the declared dt, reads the wall clock, and carries the control-step index, so the three clocks, the energy ledger, the trace digest and the verdict all advance together. You write the physics; the plumbing is not yours to write.

Your binary now has the surface:

collect  [--scenario S] [--tag T] [--exclude-tag T] [--case C] [--json]
run      [--suite NAME] [--scenario S] [--tag T] [--case C] [--set k=v] [--json]
suites   [--json]
list     [--outcome O] [-q TEXT] [--param k:op:v] [--result k:op:v] [--limit N] [--json]
show     RUN_ID [--json]
compare  RUN_A RUN_B [--abs F] [--rel F]
$ cargo run --release --example hopper -- run --suite acceptance
  hop[stiffness=4000]                      failed   3 check(s), 1 failed     5.0 ms    28.70 J
      x leg did not bottom out: worst penetration 0.0886 m <= 0.0600 m
  hop[stiffness=8000]                      passed   3 check(s)               4.6 ms    26.28 J
  hop[stiffness=16000]                     passed   3 check(s)               4.7 ms    24.29 J
  …
13 run(s), 8 passed, 5 not, 338.32 J total, in 197 ms

A softer leg bottoms out; a bouncier one costs more joules. That is a design trade-off the harness surfaced in a fifth of a second, with a receipt per run.

Measured

On an M-series laptop, release build, one process, no GPU, no container, no network:

median
process start, discover 13 cases, print (collect) 1.7 ms
run --suite smoke: 4 runs, 4,000 physics steps, 4 sealed and re-verified recordings 39 ms
run --suite acceptance: 13 runs, 13,000 steps, 13 recordings 132 ms
list over a 105-run history 3.7 ms

That is about 10 ms per run, and each run writes a ~740 KB MCAP file, seals a SHA-256 receipt into it, and then recomputes that receipt from the bytes it just wrote before recording the verdict.

The honest framing: this is a local harness with no GPU and no Isaac, so it is not doing the same work as a cloud platform booting Kit on an RTX machine, and these numbers are not a benchmark against one. What they are is the overhead around the physics (declaration, dispatch, recording, sealing, verification, history) and that overhead is milliseconds rather than a machine allocation. Antioch's documented allocation ceiling alone, before any build or boot, is 600 seconds.

What is deliberately not here

No GPU orchestration, no queue, no fan-out across machines, no Isaac, no renderer, no asset catalog, no organization. If the job needs a photorealistic RTX sensor sim on twenty machines, Antioch and Isaac are the right tools and this is not trying to be them. What this is: the layer that says what a run was, what it decided, what it cost, and whether it reproduced, in one file, on your machine, in milliseconds.


Point it at your robot

ferroscope urdf my_robot.urdf run.mcap --steps 400

That reads your description, declares one drawable per <visual>, sweeps every movable joint through its declared limits, runs forward kinematics per step, and writes a recording with a receipt. Then open it in the viewer and your robot is there, because the file says what your robot is.

$ ferroscope urdf examples/robots/arm.urdf arm.mcap --steps 400
  robot        bench_arm
  root link    base
  links        5 (7 visuals)
  joints       4 total, 4 movable

wrote arm.mcap (854445 bytes)
  spec digest  f9ecf5286aabbd…
  energy       14.48 J estimated (21.5 % compute)

It checks the description before it draws it

ferroscope urdf my_robot.urdf out.mcap --check

Every CAD pipeline in the field writes URDF. This survey found none that reads one back and asks whether it is physically usable. So this does, and exits 1 when it is not:

  CHECKS
    FAIL no-collision           no_collision   1 visual(s), 0 collision(s): the renderer can draw
                                               this link and the physics engine cannot touch it
    FAIL no-inertial            no_inertial    a movable link with no <inertial>: engines
                                               substitute a default, and the default is not your robot
    FAIL zero-inertia           zero_inertia   mass = 2 kg with an all-zero inertia tensor
    FAIL triangle-inequality    impossible     I1 + I2 = 2.000000e-3 < I3 = 5.000000e-1: no mass
                                               distribution produces these principal moments
    FAIL not-positive-definite  indefinite     smallest principal moment -4.600000e-2 <= 0
    FAIL bad-mass               negative_mass  mass = -1 kg, which is not positive

Each of those is a real bug that has shipped in real robot descriptions, each produces a policy that works in simulation and not on hardware, and each is checkable from the file in milliseconds. The inertia checks are eigenvalues of the tensor: positive definiteness, and the triangle inequality on the principal moments that every physically realisable rigid body satisfies.

It found nine defects in this repository's own example URDF the first time it ran. examples/robots/broken.urdf carries one of each class and CI asserts every one is caught.

A real arm, not only a toy one

examples/robots/so101.urdf is the LeRobot SO-101, the 5-DOF open-hardware arm with a gripper. Its kinematics and inertials are verbatim from the published calibrated description (TheRobotStudio SO-ARM100 / LeRobot, Apache-2.0): every joint origin, rpy, axis, limit, effort and velocity, and every link mass, centre of mass and inertia tensor. What is not verbatim is the geometry — upstream ships STL meshes of the printed parts, and this file substitutes boxes sized to the real link extents. So the arm moves exactly like an SO-101 and does not look like one, which the file says at the top rather than leaving you to discover it.

ferroscope urdf examples/robots/so101.urdf so101.mcap --steps 1440 --rate 240 --sweep each

That is the recording behind the SO-101 button in the viewer, and it is the one that matters for how this behaves at size: 4.7 MB, 23,071 messages, 8 links across 6 joints. In the browser the WebAssembly parser takes it from bytes to a scene you can orbit in 118 ms, and it plays back at the display's refresh rate. No upload, no server, no decode step in JavaScript.

It checks clean, and CI asserts that it stays clean: the SO-101 is the closest thing here to a third-party description, so if the checker ever starts failing a shipped commercial arm, that is a bug in the checker and it surfaces there.

--sweep chooses how the description is exercised. all (the default) drives every joint at once, which moves the whole tree but is also a knot — a kinematic sweep has no collision check, so a real arm folds through its own base and through the floor. each drives one joint at a time out of the home pose and back, which is what you want when the question is "does this joint go where the file says": everything else holds still, so what you see moving is the joint being asked about.

The recording also carries collision geometry and inertial properties as their own layers, translucent over the visuals, with a centre-of-mass marker and an inertia ellipsoid whose semi-axes come from the principal moments. Toggle them in the viewer: seeing what the physics engine sees next to what the renderer draws is where sim-to-real gaps hide.

ferroscope-urdf is its own crate and has no dependencies beyond Ferroscope, including no XML library: the dialect URDF uses is elements, attributes and comments, and a robot description is not worth an XML stack. Boxes, cylinders, spheres and meshes; fixed-axis rpy origins; fixed, revolute, continuous and prismatic joints; <material><color> for colour, with a palette per link when a material is absent.

It is strict where being loose would cost you later. Joint limits are enforced, not advisory. A broken kinematic tree is named rather than half-drawn: a joint pointing at an undeclared link says which link, two roots say which links, and a cycle says so. A joint type it does not model (floating, planar) is held fixed and reported in notes rather than dropped. A <mesh> keeps its filename so an attachment under that name draws it, and the CLI tells you when a mesh is referenced and not attached, instead of leaving a hole in the scene for you to find.

let robot = Robot::parse(&std::fs::read_to_string("arm.urdf")?)?;
robot.declare(&mut rec, t0, "/scene")?;                 // visuals, once
for step in 0..steps {
    let t = Stamp::sim(step * dt, step);
    robot.log_pose(&mut rec, t, &joint_positions, "/scene")?;   // link transforms, per step
}

Watch it live — the stream is the file

ferroscope-motion out.mcap --serve       # then press  live  in the viewer

ferroscope-live streams a recording as it is written: a zero-dependency WebSocket server whose every frame is one MCAP record, in file order, starting with the magic. A client that appends what it receives holds, at every instant, a valid prefix of the recording — the viewer opens the prefix live, message counts climbing, with the receipt honestly reading none — and the moment the producer seals, the tab holds the byte-identical complete file and verifies its receipt with the same code that verifies one read from disk. Live viewing and archived evidence are not two formats; they are one format at two moments, and CI holds the two moments equal over a real socket: cmp says identical, verify says VERIFIED.

A viewer that connects mid-run is caught up first — schemas and channels live at the front of the stream, and a client that missed them could draw nothing. A viewer that stalls is dropped without ceremony: a browser tab must never back-pressure a simulation.

Two transports, one invariant. The default is WebSocket — ~300 lines of std, SHA-1 included, checked against the RFC 6455 handshake vector every browser checks against, zero dependencies. WebTransport (HTTP/3 over QUIC) is the webtransport feature: each viewer gets one unidirectional QUIC stream carrying the raw recording bytes in file order — no framing layer at all — FIN when the recording seals. TLS is mandatory even on localhost, and the designed answer for local tooling is a fresh self-signed certificate the browser accepts through serverCertificateHashes; the producer prints the whole connection story as one clickable link:

ferroscope-motion out.mcap --serve-wt
  webtransport https://127.0.0.1:4433
               https://ferroscope.physicalai-bmi.org/viewer?wt=https://127.0.0.1:4433&hash=9f2c…

The QUIC stack (wtransport, quinn, rustls) is real weight, which is why it is opt-in and the default build of ferroscope-live keeps the zero-dependency claim CI measures it by. Two QUIC-specific lessons are pinned in the code: UDP has no lingering socket, so a session must outlive its own FIN until the peer closes or unacked packets die with the process; and a Notify::notify_waiters wakes only current waiters, so the seal signal is a flag checked after creating the notified future — the first version delivered every byte and no FIN, and every stream died at the QUIC idle timeout.

One permission stands between the hosted viewer and a local producer, and it is the browser's to grant, not ours to route around: Chrome's Local Network Access policy makes a public site ask before reaching localhost (the block surfaces as ERR_BLOCKED_BY_LOCAL_NETWORK_ACCESS_CHECKS). Allow it when prompted and the live link works; mixed-content rules are not the obstacle — ws://localhost is exempt from those — and the viewer's error message says all of this instead of shrugging. A viewer served from localhost itself needs no permission at all.

Any file replays as a live stream

ferroscope live run.mcap            # WebSocket; waits for the first viewer, then plays
ferroscope live run.mcap --wt       # WebTransport, with the one-click link printed

Until this verb, only a producer mid-run could stream; a finished recording could only be opened. ferroscope live serves an existing file over the same two transports, whole records in file order, paced by the file's own log clock (--rate 2 plays it double speed). The invariant is untouched: the bytes a viewer accumulates are the bytes on disk, so at the seal it holds the byte-identical file, receipt and all. It waits for the first viewer before starting the clock — a replay exists to be seen, and a short file could otherwise stream out and exit inside the window a browser needs to connect (--no-wait starts immediately).

Replay is also the honest reason recordings are chunked at 64 KiB rather than the writer's 1 MiB default: a chunk is one record, a record is the unit a stream frames and a replay paces, and a megabyte batches half a run into a single burst. Pacing is still bounded by that granularity, and the verb says so rather than promising otherwise — a recording whose messages all fit one chunk has a single pacing instant and streams in one burst, and that is what it prints.

Three failure modes here are worth naming, because an adversarial audit found all three in shipped code and each is now held by its own gate:

  • A lagging QUIC viewer used to be handed a truncated file that looked sealed. On QUIC, dropping a send stream is a graceful FIN — and FIN is exactly how this protocol says "the recording sealed, what you hold is the file". A session that gave up on a viewer 1024 records behind therefore delivered a structurally valid prefix wearing the seal's own signal, while producer and viewer both reported success. A session that falls behind now re-reads what it missed straight from history (the server holds every byte it ever sent, so recovery is exact), and any session that must be abandoned resets its stream so a partial transfer can never be mistaken for the recording.
  • A stalled viewer used to freeze the producer. "A browser tab must never back-pressure a simulation" was aspirational: a blocking write to a peer that stopped reading blocks forever, and the single accept thread does the catch-up write, so one paused tab wedged every later viewer too. Sockets now carry write and handshake timeouts, which is what makes the sentence true. CI runs a viewer that completes the handshake and then reads nothing, and requires a healthy viewer to still finish cmp-identical.
  • The producer used to claim the invariant over an audience of nobody. Both transports now report what actually happened — how many viewers received the sealed file, how many were abandoned — and a replay whose viewers did not all receive it exits non-zero.

CI's adversarial case for the join paths is five clients joining at staggered instants mid-burst: a record broadcast while a joiner snapshots history must reach it exactly once — the join paths hold one lock across extend-and-send so nothing lands between a snapshot and a subscription — and every joiner must end up cmp-identical.

Real dynamics, same receipt

ferroscope-motion closes the last gap between "described" and "simulated": ferromotion's recursive Newton-Euler dynamics drive the SO-101's calibrated inertials through a PD reach, and every step lands in a recording with the same receipt as everything else here — the run is produced and certified by the same stack that renders it.

cargo install ferroscope-motion
ferroscope-motion reach.mcap                # PD reach under real dynamics
ferroscope-motion drop.mcap --passive      # gravity only; exit 1 if energy drifts > 5 %

Two things are real here that are models everywhere else:

  • the actuation rail is computed, per joint, as mechanical shaft power |τ · ω| from the torques the controller actually applied — stated as mechanical, because electrical would need a motor model this crate does not have;
  • the physics is gated: --passive drops the arm under gravity alone and fails past 5 % total-energy drift, and CI runs the same experiment twice and requires identical digests — the determinism claim on an actual integrator, not a closed form.

The gate has already caught one real bug: the first build omitted the armature — a geared servo's reflected rotor inertia, which dominates palm-sized links — and reported 12.31 % drift and 597 J of "actuation" for a desk arm. With the armature on the mass-matrix diagonal the drift is 0.31 % and the reach costs 1.875 J, which is what a small arm actually spends.

And the ledger then says something worth hearing: on this arm, an 8 W SoC model out-spends the measured mechanics 17 to 1. For palm-sized robots, thinking costs more than moving.

The dynamics button in the viewer is this recording.

Describe the scene you want

Everything above reads a file some simulator produced. This goes the other way.

A scene is JSON — bodies, how each one moves, and optionally a robot from its own URDF — and it records to the same plain MCAP, with the same determinism receipt and the same energy ledger:

{
  "name": "a crate dropped beside a sweeping arm",
  "duration_s": 4.0, "rate_hz": 120,
  "bodies": [
    { "id": "crate", "shape": "box", "size": [0.3, 0.3, 0.3], "material": "6061-T6",
      "motion": { "kind": "fall", "from": [0.6, 0, 1.8], "restitution": 0.35 } },
    { "id": "beacon", "shape": "sphere", "size": [0.06, 0.06, 0.06],
      "motion": { "kind": "orbit", "center": [0, 0, 0.9], "radius": 0.8, "period_s": 3.0 } }
  ],
  "robots": [ { "id": "arm", "urdf": "examples/robots/so101.urdf", "sweep": "each" } ]
}
ferroscope scene examples/scenes/warehouse.json warehouse.mcap
ferroscope scene --schema          # the format, its defaults, and a worked example

Every motion has a closed form, so the pose at a timestamp costs the same whether you played to it or scrubbed to it, and two runs of one scene agree bit for bit without anything having to be careful about ordering. That is what lets a described scene carry the same receipt as a simulated one. fall is the exception worth naming: it is a real ballistic arc that bounces and comes to rest, and it still has a closed form because each bounce is a fixed fraction of the last.

A scenario, not a single run

One recording answers "what happened". A scenario answers "does it still hold, across the range I care about" — which is the question that decides whether something ships. Add cases and checks to any scene:

{
  "cases": { "drop_m": [0.5, 1.0, 2.0, 4.0], "bounce": [0.1, 0.6] },
  "bodies": [
    { "id": "crate", "shape": "box", "size": [0.3, 0.3, 0.3],
      "motion": { "kind": "fall", "from": [0.8, 0, {"$": "drop_m"}],
                  "restitution": {"$": "bounce"} } }
  ],
  "checks": [
    { "name": "settles within 1.5 s", "measure": "settled_s", "of": "crate", "at_most": 1.5 }
  ]
}
$ ferroscope scene examples/scenes/drop-height.json out.mcap --sweep

  drop_m=1, bounce=0.1           300     41.86  pass
      ok   settles within 1.5 s: settled_s[crate] = 0.5000
  drop_m=1, bounce=0.6           300     41.86  FAIL
      FAIL settles within 1.5 s: settled_s[crate] = 1.6583, above 1.5

  8 case(s), 5 passed, 3 failed          # and exit 1

Each case is its own recording with its own receipt, so a failure is a file you can open. The measured number prints on a pass as well as a failure — a column of "pass" with no numbers is a table nobody can sanity-check.

of is not optional detail. Put a robot in the scene and the scene-wide minimum is the robot's, so a check named "the crate stays on the floor" quietly becomes a statement about the arm. Scoping it to a body is what makes it mean what it says, and a check naming a body that is not there fails and lists the ids that are, rather than passing on a number nobody measured.

Or just say it

You should not have to write JSON to see a crate fall.

ferroscope say "drop three red crates from 2 m beside an SO-101 arm for 6 seconds"
  understood   3 boxes each of 0.3 m, falling from 2 m
  understood   arm: the so101 description, sweeping one joint at a time
  assumed      box size 0.3 m (not stated)
  assumed      120 Hz (say "at 60 Hz" to change it)
  NOT USED     "belt", "conveyor" — no meaning in the scene vocabulary

That last line is the whole design. Any phrase reader fails on language it was not built for; what separates a useful one from an infuriating one is whether it tells you which words it threw away. A sentence that silently loses "onto a conveyor belt" produces a scene with no conveyor and no explanation, and you are left comparing your sentence against a picture, guessing which half arrived.

So it always reports three things — what it understood, what it assumed because you did not say, and what it could not use — and --json prints the scene it built, which is ordinary scene JSON you can edit and re-run. It is a starting point you can correct, never a black box.

It is deterministic and offline: no key, no request, no model. That also means it is small — shapes, five motions, counts, units, colours, materials, and a few worlds with different gravity. For anything more open-ended, a model is the right tool, and the MCP server below is how you get one. The viewer runs this parser in WebAssembly, so typing a sentence into the page and getting a recording back never leaves the tab.

An agent can drive all of it

ferroscope-mcp is an MCP server over stdio. Point a client at the binary — no configuration, no network, no account:

tool what it does
scene_schema the format, with defaults and a worked example
scene_from_text an English phrase, with what it understood, assumed and ignored
scene_sweep a grid of cases, judged, with the number that decided each
scene_validate every problem at once, each with the JSON path that was wrong
scene_record records it, and returns the receipt, the joules and the clearance
robot_check is this URDF physically usable
mesh_check what an STL is, and what it would weigh
materials_search 437 materials, each with the source it is cited from
run_inspect · run_verify · run_energy · run_diff the CLI's read verbs

On ACP

Ferroscope deliberately does not implement the Agent Client Protocol. ACP connects an editor to a coding agent, and its session/new carries an mcpServers list that the client hands to that agent. So an ACP editor already delivers this server: configure ferroscope-mcp as an MCP server in Zed, JetBrains or Kiro and the agent gets all ten tools. Implementing ACP here would mean pretending to be a coding agent, which Ferroscope is not.

The design rule for all of it is that the caller is a model that has to fix its own mistakes, so a refusal that does not say how is a wasted round trip:

4 problem(s) in this scene:
  bodies[0].shape: unknown shape "cube"; expected one of box, sphere, cylinder, plane
  bodies[0].size: expected 3 numbers, found 2
  bodies[0].motion.kind: unknown motion "drop"; expected one of static, linear, orbit, oscillate, fall
  bodies[0].color: "brown" is not a hex colour; expected "#rrggbb"

Every problem at once, not the first one — five mistakes should cost one pass, not five.

Or over HTTP, with nothing installed

The same crate compiled to wasm runs at the edge, so a scene can be recorded by anything that can make a request:

curl -X POST https://physicalai-bmi.org/api/scene/record \
     -d '{"duration_s":3,"rate_hz":100,
          "bodies":[{"id":"crate","shape":"box","size":[0.3,0.3,0.3],
                     "motion":{"kind":"fall","from":[0.6,0,1.8]}}],
          "robots":[{"id":"arm","urdf":"so101"}]}' -o scene.mcap
x-ferroscope-trace-digest:  64adb806a715535f3dd6d16ce9bf0626…
x-ferroscope-joules:        50.232
x-ferroscope-lowest-point:  -0.2057 arm/moving_jaw_so101_v1_link

The receipt, the joules and the clearance come back in headers, so none of it costs a second request or a parse of the file you were just handed. GET /api/scene/schema is the format, POST /api/scene/validate checks without recording, CORS is open, and there is no key. A short list of robots is built in, so "urdf": "so101" resolves without you shipping a description.

And the part worth saying plainly: a scene recorded by wasm in a Cloudflare Worker verifies, byte for byte, under the native CLI. Two runtimes with nothing in common but the bytes, agreeing — which is the entire reason the receipt is defined over the file rather than over the process.

From a page, with no build step:

import { record } from 'https://physicalai-bmi.org/assets/ferroscope/ferroscope.js';

const run = await record({ duration_s: 3, bodies: [ /**/ ] });
run.receipt.traceDigest;  // recomputable from run.bytes alone
run.joules;               // E_task, estimated

What a mesh weighs

ferroscope-mesh reads STL (both dialects, deciding by arithmetic rather than by the leading word, because a binary STL may legally begin with solid), writes glTF, and integrates volume, centre of mass and the full inertia tensor straight off the triangles by the divergence theorem. Exact for any closed mesh; no sampling, no voxels.

ferroscope urdf my_robot.urdf out.mcap --meshes ./meshes

That resolves the meshes a URDF names, reports each one's triangle count, volume and whether it closes, converts it to glTF and carries it inside the recording as an attachment.

It matters because a robot description makes two claims about every link — a shape and a mass distribution — and nothing in the usual toolchain checks that the second is consistent with the first. ferroscope-cad closes that loop against CadFuture's material tables:

AS 6061-T6 (Lut tier, ASM Handbook Vol 2, MatWeb)
  density     2710 kg/m3
  mass        0.1301 kg
  inertia     ixx=2.1680e-5  iyy=4.3360e-5  izz=5.6368e-5

That is the <inertial> block this geometry implies, about its centre of mass.

So a declared inertial can be compared with the geometry it claims to describe: a link heavier than solid stock of its own outline is impossible and is refused; a tensor that describes a different shape at the right mass is caught too, because the tensor is normalised by mass before comparison.

The tier is part of the answer

CadFuture resolves every engineering query at the cheapest tier that can answer it — LUT, then closed-form formula, then solver, then a model. Which tier answered is not an implementation detail. It is the difference between a number that cost picojoules and one that cost joules, and between a number with a citation and one with a residual. Every value that crosses this bridge carries its tier and its source into the recording, because a quantity whose origin is not in the file is a quantity nobody can audit.

The viewer: WebGPU 3D in a tab

Live: ferroscope.physicalai-bmi.org/viewer

./viewer/build.sh                               # rebuild the wasm (already committed)
python3 -m http.server 8080 --directory viewer  # a module import of .wasm needs http, not file
open http://localhost:8080

A dockable workspace with a three.js WebGPU viewport, and it says which backend is live rather than leaving you to guess (WebGPU where the browser has it, WebGL2 where it does not):

  • 3D: orbit, pan and zoom with OrbitControls; a z-up ground grid and world axes, because robotics is z-up and a viewer that disagrees with the data fights you; PBR materials, one shadow-casting sun and an environment map, because metalness with nothing to reflect renders black and real robot glTFs are frequently metallic; ghosts, the same machine at earlier instants, drawn rather than scrubbed for; the path travelled as a Line2 ribbon; contact markers sized by force, instanced so a thousand of them cost one draw call; and a measure tool.
  • Scene tree: every declared part with its shape, whether it moves, its colour swatch, and a checkbox. Every channel with its schema and message count.
  • Inspector: the comparison verdict, the receipt with the trace digest recomputed from the bytes you just dropped, the joule ledger, and every pose and scalar at the current instant.
  • Plots: power stacked by rail, each scalar as a lane, wall-minus-sim drift, and the log-scale divergence lane when a second run is loaded. All on one playhead.
  • Timeline: scrub, play, and a speed selector.

The 3-D panel draws what the recording declares, through the ferroscope.Geometry schema: boxes, spheres, cylinders, planes, polylines and glTF meshes, attached to frames, with size and colour on the per-step track. So a leg whose length is its compression, and a part that turns red on contact, are things the file says rather than things the viewer guesses. A recording with no geometry still opens; it just has nothing to draw.

Meshes travel inside the recording. Shape::Mesh names an MCAP attachment, and the viewer pulls the glTF straight out of the file it already opened:

rec.attach("arm.glb", "model/gltf-binary", &glb_bytes, t)?;
rec.geometry("/scene/arm", t, &Geometry::mesh("/robot/link3", "arm", "arm.glb", [1.0; 3]))?;

Nothing outside the file is referenced, which is the point: a viewer that fetches a robot's meshes from somewhere else stops working the moment somewhere else moves, and a recording whose geometry lives in a sibling directory is not evidence. Attachments are checked against their own CRC on read, and Foxglove's reference reader finds them through the summary index like any other MCAP.

Measure (m, or the toolbar button) raycasts against the drawn geometry: click two points and get the distance and the per-axis deltas. It measures what you can see, including a glTF the viewer has never been told anything about.

Drop a file on A to read it, a second on B to compare. No bundler, no worker, no upload, no account. Turn your network off and it still works: three.js is vendored into the repository rather than pulled from a CDN, for exactly that reason.

The same functions are callable from any page:

import init, { open, diff, divergence_curve, verify_receipt, version }
  from './pkg/ferroscope_wasm.js';
await init();
const bundle  = JSON.parse(open(bytesA));            // lanes, energy, receipt, verify
const verdict = JSON.parse(diff(bytesA, bytesB, 0, 0));  // 0 = default 1e-9 tolerance
const curve   = JSON.parse(divergence_curve(bytesA, bytesB, '/robot/joints'));

Install

cargo install ferroscope-cli      # installs a binary called `ferroscope`

The bare name ferroscope on crates.io belongs to an unrelated Rust debugger, published in July 2025. The libraries keep the namespace; the CLI crate carries the -cli suffix and the binary it installs is still called ferroscope.

# or the libraries
[dependencies]
ferroscope-schema = "0.1"         # recorder + well-known schemas (pulls the three below)
ferroscope-mcap    = "0.1"        # MCAP reader/writer, zero dependencies
ferroscope-ledger  = "0.1"        # the joules arithmetic
ferroscope-receipt = "0.1"        # digests and the comparator
ferroscope-run     = "0.1"        # scenarios, cases, suites, verdicts, local history

Record a run

use ferroscope_schema::{Recorder, Stamp, JointState};
use ferroscope_ledger::Rail;
use ferroscope_receipt::{RunSpec, Precision};

let mut rec = Recorder::new(std::fs::File::create("run.mcap")?, Precision::Quantized { drop_bits: 12 });

for step in 0..1000 {
    let t = Stamp::at(sim_ns, wall_ns, step);       // three clocks, always
    rec.transform("/robot/base", t, "world", "base", position, orientation)?;
    rec.joints("/robot/joints", t, &JointState { names, position, velocity, effort })?;
    rec.energy("/energy/leg", t, Rail::Actuation, "leg", watts)?;
    rec.energy("/energy/soc", t, Rail::Compute,  "soc", soc_watts)?;
    rec.scalar("/control/error", t, err, "m")?;
}

let spec = RunSpec::new("pick-and-place", 42)
    .dt_ns(1_000_000).steps(1000)
    .integrator("semi-implicit-euler").solver("pgs-30")
    .asset("panda.urdf", sha_of_the_urdf)
    .config("gravity_z", "-9.80665")
    .build(env!("CARGO_PKG_VERSION"));

let (file, receipt, energy) = rec.seal(spec, "aarch64-apple-darwin / Metal")?;

That is it. The receipt is written into the file. The energy ledger is closed. The recording is plain MCAP, and it opens in Foxglove, in Rerun, or in mcap cat with no plugin, because every channel carries a published JSON Schema. Being readable by the incumbent is the price of asking anyone to try a new one.

The CLI

ferroscope inspect <run.mcap>            topics, schemas, clocks, receipt
ferroscope verify  <run.mcap>            recompute the receipt from the file itself
ferroscope energy  <run.mcap>            E_task = E_compute + E_actuation
ferroscope diff    <a.mcap> <b.mcap>     did the replay reproduce the run
                   [--abs <f>] [--rel <f>]
ferroscope export  <run.mcap> <out.json> viewer bundle for the browser
ferroscope live    <run.mcap>            REPLAY it as a live stream, on its own clock
                   [--port <n>] [--wt] [--rate <x>] [--hold <s>] [--no-wait]
                   binds 8737, the port the viewer's live button dials
ferroscope demo    <out.mcap>            write a synthetic run
                   [--seed <n>] [--steps <n>] [--drift <step>] [--platform <s>]
ferroscope urdf    <robot.urdf> <out.mcap>   record YOUR robot from its description
                   [--steps <n>] [--rate <hz>]

Exit codes are the point of the CLI existing:

code meaning
0 the answer is yes
1 the answer is no: verification failed, or the runs diverged
2 the tool could not answer: bad file, bad arguments

A CI gate is one line:

- run: ferroscope diff baseline.mcap $(pwd)/run.mcap --rel 1e-9

Architecture

Five crates carry zero runtime dependenciesstd and nothing else — and build for wasm32-unknown-unknown unchanged. Everything above them is additive, and the two crates that reach outside are the two that are not published, so nothing you install pulls in a tree.

ferroscope-mcap      MCAP v0 reader + writer.       0 deps.  wasm-clean.
ferroscope-ledger    E_task arithmetic + coverage.  0 deps.  wasm-clean.
ferroscope-receipt   SHA-256, digests, comparator.  0 deps.  wasm-clean.
ferroscope-mesh      STL in, glTF out, and what     0 deps.  wasm-clean.
                     a mesh weighs.
ferroscope-power     RAPL and powermetrics, or a    0 deps.  native only.
                     clear reason there is nothing.
ferroscope-schema    Recorder, schemas, verify().   depends only on the crates above.
ferroscope-urdf      URDF to scene, plus FK,        0 external deps, wasm-clean.
                     validation and clearance.
ferroscope-scene     Described scenes to MCAP.      0 external deps.
ferroscope-run       Scenarios, cases, suites,      native only: it reads clocks and
                     verdicts, local history.       writes files, and says so.
ferroscope-cli       The CLI (binary: `ferroscope`).
ferroscope-wasm      Browser bindings.              + wasm-bindgen.
ferroscope-cad       The LUT-first bridge.          + CadFuture. Not published.
ferroscope-mcp       The MCP server.                + the above. Not published.
viewer/              The WebGPU workspace.          + three.js, vendored, not a CDN.

ferroscope-cad and ferroscope-mcp are marked publish = false on purpose: they consume CadFuture's physical-* crates from git, and those are not on crates.io yet. Everything you get from cargo install ferroscope-cli is the dependency-free half.

Why reimplement MCAP?

The reference mcap crate is good, and it is Foxglove's. It also links zstd and lz4, both of them C libraries, for compression it carries whether or not a given file is compressed. That is precisely what keeps robotics log tooling off the browser.

ferroscope-mcap writes uncompressed chunks, which is a conforming MCAP profile every reader must accept, and stays std-only. Compression is a codec decision, not a format decision; if you want the bytes smaller, the transport already compresses them. What you get in exchange is a recording layer that runs in a tab.

And an implementation that only round-trips against itself proves nothing: two matching bugs read as a pass. So the reference crate is a dev-dependency, and the test suite is an oracle:

  • every file this writer produces is parsed by Foxglove's mcap crate: messages, chunk CRCs, summary, statistics, metadata;
  • every file that crate produces is read back by this one;
  • a byte flipped inside a chunk must surface as a CRC mismatch, not a short read;
  • a truncated file must be named as truncated, not returned as a shorter run.

Verification

Everything above is a test, and the tests are the negative cases:

test what would otherwise go unnoticed
reference_reader_accepts_our_files a writer that is only self-consistent
a_flipped_byte_inside_a_chunk_is_caught silent corruption
truncation_is_named_not_guessed half a run read as a whole one
re_encoding_the_file_with_one_number_changed_fails_the_trace_digest a structurally perfect file, correct CRCs, one value nudged by 1 part in 10⁶
editing_the_receipt_metadata_is_caught a receipt edited after the run
platform_is_not_part_of_the_spec_digest cross-platform reproduction becoming unstateable
a_field_cannot_be_smuggled_across_a_boundary "ab"+"c" hashing like "a"+"bc"
nan_never_hashes_into_a_match_silently a diverged run passing because both sides went NaN
a_hole_in_the_telemetry_is_refused_not_smoothed an energy number quoted from gappy sampling
viability_cost_is_undefined_without_a_success a policy that never succeeds looking merely expensive
one_topic_cannot_carry_two_schemas a channel carrying payloads a reader decodes with the wrong shape. The URDF exporter did exactly this, writing transforms onto geometry channels, and nothing stopped it
a_broken_tree_is_named_rather_than_half_drawn a URDF whose joints point at links that do not exist, or that has two roots
joint_limits_are_enforced_rather_than_advisory a commanded angle past a limit quietly being obeyed
a_recording_opens_in_a_viewer_that_never_heard_of_ferroscope schema drift breaking third-party viewers
cargo test                                        # all of it
cargo build --target wasm32-unknown-unknown       # the four libraries, unchanged

Status

Real, tested, and shipping in 0.1: the MCAP reader and writer with the reference-oracle suite, the three-clock recording model, the well-known schemas, the energy ledger with its coverage refusal, the determinism receipt and comparator, verify recomputing a receipt from bytes alone, the CLI's nine verbs, the in-browser WebGPU viewer with glTF meshes and a measure tool, the scenario harness, URDF import with its physical-usability checks and ground-clearance report, the LeRobot SO-101 as a demo device, STL-to-glTF with exact mass properties, the LUT-first material bridge, described scenes, the MCP server, the HTTP API and browser SDK, and ferroscope power reading real counters. 204 tests, clean clippy, three platforms in CI plus wasm32, and jobs that gate the zero-dependency claim, the viewer bundle's export surface, the scene format and the MCP protocol surface.

The original "next" list is now empty.

Nothing here is feature-gated, and nothing here will be.


License

MIT OR Apache-2.0, at your option. Free to use, study, fork, and build on, which is the point.

Part of the open ecosystem from the Institute for Physical AI @ BMI alongside Ferric (compute), Ferralloy (fleet), and Ferromotion (kinematics and dynamics).

Dependencies

~0–13MB
~115K SLoC