Φ INSTITUTE FOR PHYSICAL AI · JBI

Open a robot recording. Nothing leaves the tab.

Ferroscope is the open interface layer for physical AI: a WebGPU 3D viewer, a determinism receipt you can recompute from the file itself, and E_task in joules for every run. Plain MCAP in and out, so it also opens in the viewers you already use. Pure Rust, MIT or Apache-2.0.

The Ferroscope viewer: a 3D panel showing a one-legged hopper mid-stride with ghost copies of earlier instants and a gold trail, a scene tree, an inspector holding the determinism receipt and the joule ledger, stacked plot lanes, and a timeline.

A one-legged hopper mid-stride, drawn on WebGPU. The body is a glTF mesh carried inside the recording as an MCAP attachment, not fetched from anywhere. Ghosts are the same machine at earlier instants, the gold ribbon is the path travelled, the leg turns red in contact, and measure gives you the distance between any two points you click. The strip along the top is a real comparison against a second run: diverged at step 90, on a difference of 2×10⁻⁹.

The landscape · August 2026

Five good tools. What falls between them?

ToolDoes wellStops at
Foxglove The best panel-and-layout viewer in the field. MCAP is theirs and genuinely open. Live streaming, teleoperation, a real data platform. The app is proprietary; Studio 1.x was frozen in February 2024. Seats, storage and device counts are metered. No physics, no scenario execution, no notion of whether a run reproduced.
Lichtblick An actively maintained community fork of Foxglove Studio, browser and desktop, keeping the open-core model. The correction this survey forced: an earlier version of this page implied there was no maintained open alternative to Foxglove's app. There is. Like Foxglove, a viewer. No physics, no scenario execution, no determinism receipt, no energy ledger. Licence is not the differentiator here; those two are.
Rerun Open core, a Rust viewer that runs native and in a browser, real timelines. Its own MCAP support is marked experimental and the viewer is RAM-bounded. A logging layer by design, not a place a run is executed or certified.
Isaac Sim / Lab The strongest physics and rendering primitives available, GPU-parallel environments, OpenUSD, an enormous asset ecosystem. Apache-2.0 source that needs Omniverse Kit under NVIDIA's terms, and an RTX-class GPU. Isaac Lab's own docs state the limit plainly: GPU work scheduling reorders floating-point reductions, so "experiments from the IsaacGym simulator are not perfectly reproducible on a different system."
Antioch The best-designed scenario model in the field, and the reason Ferroscope has one: parameterized scenarios, case grids, verdicts as named checks with measured details, suites as selector unions, history queried by key:op:value. The delivery, in their own documentation. A run needs an ephemeral GPU VM, "allocation is the slow step", and the CLI "polls up to 600 s" when none is warm. Cost is assignment-scoped: "there is no per-run or per-scenario cost figure to report". Reproduction is re-queueing images, "multi-machine interactive runs are not currently rerunnable". And "the CLI has no compare command".

The full survey, across languages and regions, is in the repository: 40-odd simulators with their languages and licences, the CAD-to-URDF pipelines, the control stacks in C++, Java, Julia and MATLAB, the Rust dataflow ecosystem, and the two 2026 systematic reviews of energy measurement.

Nothing there lets a stranger holding only the file establish that a run reproduced, or what it cost. Determinism is asserted by whoever ran it; energy is not modelled at all. Ferroscope is that seam, and only that seam.

Three ideas

A scenario, not a single run.

One recording answers what happened. A scenario answers does it still hold across the range I care about — the question that decides whether something ships. Add cases and checks to any scene, paste it into the box above, and every case is recorded and judged in the tab.

"cases":  { "drop_m": [0.5, 1, 2, 4], "bounce": [0.1, 0.6] },
"motion": { "kind": "fall", "from": [0.8, 0, {"$": "drop_m"}],
            "restitution": {"$": "bounce"} },
"checks": [{ "measure": "settled_s", "of": "crate", "at_most": 1.5 }]

  drop_m=1, bounce=0.1   pass   settled_s[crate] = 0.5000
  drop_m=1, bounce=0.6   FAIL   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 one click from the 3D. 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 ones that are, rather than passing on a number nobody measured.

Say what you want to see.

You should not have to write JSON to watch a crate fall. Type a sentence into the viewer above and it records it — in the tab, in WebAssembly, with no request and nothing uploaded.

$ 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" gives you 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 what it understood, what it assumed because you did not say, and what it could not use — and it prints the scene it built, which is ordinary JSON you can edit and re-run. A starting point you can correct, never a black box.

It is deterministic and offline: no key, no request, no model. Which 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 is how you get one.

Describe the scene you want.

Everything else here reads a file some simulator produced. This goes the other way: a scene is JSON — bodies, how each one moves, a robot from its own URDF — and it records to the same plain MCAP, with the same determinism receipt and the same joule ledger. Every motion has a closed form, so scrubbing to a timestamp gives exactly what playing to it gives, and a described scene carries the same receipt as a simulated one.

{
  "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 } }
  ],
  "robots": [ { "id": "arm", "urdf": "so101.urdf", "sweep": "each" } ]
}

The scene button in the viewer above is that file. And an agent can drive all of it: ferroscope-mcp is an MCP server over stdio with ten tools — author a scene, validate it, record it, check a robot description, weigh a mesh, search the material table, and every read verb the CLI has. No configuration, no network, no account.

The design rule throughout is that the caller is a model that has to fix its own mistakes, so every refusal names the JSON path that was wrong and lists what would have been right, and reports every problem at once — five mistakes should cost one pass, not five:

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"

Three ways in. Same Rust.

A person runs the CLI. An agent on your machine speaks MCP over stdio. And an agent anywhere — or a web page — POSTs to the API, which is the same ferroscope-scene crate compiled to wasm and executed at the edge. No simulator to install, no machine to allocate, no queue, and no per-run cost to explain.

# describe it, and get a recording with a receipt on it
curl -X POST https://physicalai-bmi.org/api/scene/record \
     -d '{"name":"crate beside an arm","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 ground clearance come back in response 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.

And the thing worth saying plainly: a scene recorded by wasm in a Cloudflare Worker verifies, byte for byte, under the native CLI. That is what a receipt recomputable from the file is for — two runtimes with nothing in common but the bytes, agreeing.

// or from any 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 the file carries that others do not.

00 · YOUR ROBOT

Point it at a URDF, and it checks it

ferroscope urdf my_robot.urdf run.mcap --check. Every CAD pipeline in the field writes URDF; this survey found none that reads one back to ask whether it is physically usable. This does, and exits 1 when it is not: a link the renderer draws that the engine cannot touch, a movable link with no inertial, a mass at or below zero, an inertia tensor no rigid body has, principal moments no mass distribution produces. It found nine defects in our own example the first time it ran.

01 · A REAL ARM

The LeRobot SO-101, open in the viewer

The SO-101 button loads the 5-DOF open-hardware arm, carrying the kinematics and inertials from its published calibrated description verbatim: every joint origin, axis, limit, and every link mass, centre of mass and inertia tensor. It checks clean, and our CI asserts it stays clean — if the checker ever starts failing a shipped commercial arm, that is a bug in the checker. 4.7 MB and 23,071 messages reach an orbitable scene in 118 ms, parsed by WebAssembly in your tab.

02 · WHAT IT WEIGHS

The inertial a geometry implies

A description makes two claims about every link — a shape and a mass distribution — and nothing in the usual toolchain checks the second against the first. We integrate volume, centre of mass and the full inertia tensor straight off the triangles by the divergence theorem, exact for any closed mesh, and price it against 437 materials that each cite their source. A link heavier than solid stock of its own outline is impossible, and is refused. So is a tensor describing a different shape at the right mass.

03 · ONE FILE

Meshes travel with the run

A .glb rides in the recording as an MCAP attachment, CRC-checked on read, and the geometry references it by name. Nothing outside the file. 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.

04 · RECEIPT

Recomputable by anyone

A spec digest over everything two runs must share to be comparable, deliberately excluding the platform, and a trace digest at a declared precision. Both recompute from the file's own bytes: no simulator, no source tree. A digest match is proof; a mismatch is a question, and ferroscope diff answers it with the onset (where the bits first parted — the causal step), the crossing (where a difference first exceeded your tolerance, which moves when the flag moves), the shape of the difference over time, the channels ranked by relative difference with each quantity named, and the joules delta per rail. Both files are re-verified first: comparing two stored digest strings would prove that two metadata blocks agree, not that two runs reproduced. And it holds neither trajectory: deciding where two runs parted is a fold over pairs in file order, so both files are walked at once and a 1.3 GB pair costs 95 MB instead of 4 GB. The unit is a step, and it took a measurement to find out why: a channel that fires conditionally — a contact, while the body is against its stop — makes two genuinely diverged runs stop emitting the same samples at the same steps, which is exactly the pair anybody wants compared. And the browser does it too: two 1.3 GB recordings, 2.6 GB the page never held, compared in 122 s, the page holding hundreds of megabytes rather than the 2.6 GB it is comparing, the divergence located at step 400,066 on effort[knee] — the perturbation was injected at step 400,000.

05 · JOULES

E_task = E_compute + E_actuation

From measured power integrated over the run, never a datasheet TDP. On this hopper compute is 24 % of the energy. The ledger also refuses: sample too sparsely and it prints DO NOT QUOTE with the gap that caused it, because a number nobody can stand behind is worse than none.

06 · IT MEASURES

Real counters, or a plain refusal

ferroscope power -- your-command reads Linux RAPL or macOS powermetrics and integrates it. Two traps are handled because both give a plausible wrong number: nested domains double-count (a core's joules are already inside its package, silently 30–60 % high) and the counters wrap, on some parts every minute under load. And when the machine will not say — which since CVE-2020-8694 is the norm, not the exception — it reports no measurement and why, rather than 0 J for a machine drawing 90 W.

07 · REAL DYNAMICS

Produced and certified by one stack

The dynamics button in the viewer is the SO-101 under ferromotion's recursive Newton-Euler dynamics: a PD reach on the calibrated inertials, recorded with the same receipt as everything else. The actuation rail is computed — mechanical shaft power |τ·ω| from the applied torques — and the physics is gated: gravity alone must hold total energy within 5 %, and two identical runs must produce identical digests. The gate caught its first bug before shipping: the missing armature read as 12 % drift and 597 J for a desk arm; with it, 0.31 % and 1.875 J. And the ledger then says the quiet part: on a palm-sized arm, an 8 W SoC out-spends the mechanics 17 to 1. Thinking costs more than moving.

08 · LIVE

The stream is the file

Run ferroscope-motion out.mcap --serve on your machine and press live in the viewer above: every WebSocket frame is one MCAP record in file order, so the tab holds a valid prefix at every instant — receipt honestly reading none — and the moment the producer seals, it holds the byte-identical complete file and verifies the receipt in place. Live viewing and archived evidence are one format at two moments, and CI holds the two moments equal over a real socket. The server is ~300 lines of std, zero dependencies, checked against the RFC 6455 vector every browser checks against — and WebTransport too: the webtransport feature streams the same bytes over one unidirectional HTTP/3 stream, FIN at the seal, with the producer printing a clickable link that carries the self-signed certificate's hash. CI proves the invariant on both transports: cmp byte-identical, receipt VERIFIED. And it runs both directions: ferroscope live run.mcap replays any finished recording as a live stream, paced by the file’s own log clock, waiting for the first viewer before starting — CI’s adversarial case is five clients joining mid-burst, every one of them ending up with the byte-identical file.

09 · WHAT IT COST TO MAKE

A per-run production receipt

Two quantities share the word "compute": the robot's SoC during the task (modelled, labelled an estimate) and what the machine producing the file spent — measurable. Every recording now carries the second in its own ferroscope.production block: joules, duration, source, basis — or the stated reason there is no number, never joules: 0. It sits outside both digests, because production cost varies run to run by nature; CI holds that a measured and an unmeasured run of the same scene agree digest for digest. This is precisely the per-run cost figure the platform column above documents itself as not having.

10 · THREE CLOCKS

Sim, wall, control step

MCAP gives a message two timestamps. A robot has three clocks, and the interesting bugs live in the drift between them: 1 kHz in simulation and 780 Hz on hardware is not a controller that works. Every message carries all three, so real-time factor and loop jitter read straight off the file.

11 · WHERE IT DIVERGED

Not whether. Where, how, and what it cost

Two runs of one spec that do not match get more than a verdict. Onset is the step the bits first parted — the causal one. Crossing is where a difference first exceeded your tolerance, and it moves whenever the flag does; naming them separately is the difference between debugging the physics and debugging a threshold. Shape says settled, growing or fading, judged against each channel’s own scale, because a quantity passing through zero has a meaningless relative error and ranking on it promotes a signal’s own zero crossings above the real finding. Then the channels, ranked and named in the payload’s own terms — effort[knee], not [5] — and the joules delta per rail, which no other comparison tool in the field reports at all. Both files are re-verified first: comparing two stored digest strings would prove that two metadata blocks agree, not that two runs reproduced. And it holds neither trajectory: deciding where two runs parted is a fold over pairs in file order, so both files are walked at once and a 1.3 GB pair costs 95 MB instead of 4 GB. The unit is a step, and it took a measurement to find out why: a channel that fires conditionally — a contact, while the body is against its stop — makes two genuinely diverged runs stop emitting the same samples at the same steps, which is exactly the pair anybody wants compared. And the browser does it too: two 1.3 GB recordings, 2.6 GB the page never held, compared in 122 s, the page holding hundreds of megabytes rather than the 2.6 GB it is comparing, the divergence located at step 400,066 on effort[knee] — the perturbation was injected at step 400,000.

12 · HOW BIG, EXACTLY

The ceiling was the browser's. It is gone.

Every viewer in this field is bounded by memory, this one included, and the number is rarely stated. Here it is, measured. Natively, nothing holds the file any more — all four read verbs stream, and inspect reads a 552 MB recording in 2 MB, 580× less than the reader it replaced. In the browser the limit was never ours: past about 2 GB Chrome will not hand a page one buffer, and a 2.6 GiB recording comes back as NotReadableError instantly. So the page stopped asking for one. File.slice() gives a block at a time, and the framing was rebuilt to be pushed rather than pulled — the same reader the CLI is now a loop over, so there is one definition of what a record is. A 2.6 GiB run, 12.8 million messages, opens in 78.8 seconds under 200 MB of heap, with its receipt recomputed in that browser rather than inherited. On a file small enough to read both ways the two bundles are byte-identical, checked by a job that drives a real headless Chrome. What a block read cannot do it says: the bytes did not stay, so no comparison and no meshes — and ferroscope export is still the faster door, 1.4 MB at about 1800:1, open in a tenth of a second.

13 · THREE MACHINES, ONE SPEC

What does cross-platform determinism actually cost?

The platform is deliberately left out of the spec digest, because comparing across platforms is the entire point — and nobody had measured what that comparison returns. Now CI does, on every push: the same spec recorded on Linux x86-64, macOS arm64 and Windows x86-64. A run whose integrator uses only + − × ÷ and abs comes back identical at bit-exact on all three pairs — not luck, since every one of those operations is exactly specified by IEEE-754, and the fact that they agree says the pipeline between the arithmetic and the digest adds nothing of its own. A scene that sweeps joints with sin and cos does not: a libm is not the same function on three operating systems. Declaring two machines equal across sin, cos and atan2 costs 12 of the 52 mantissa bits — and a scene with a robot in it costs 19–22 — a band, not a number, because changing only the sample rate moves it by three bits. Only one of those eight extra bits is the extra rounding; the rest belongs to the kinematic chain and to a transform component passing near zero, where a relative precision has nothing left to hold on to. The control that showed it is a scene using only + − × ÷, sqrt and rem_euclid: bit-exact on all three machines, as IEEE-754 promises. That is the whole argument for a receipt that declares a precision rather than claiming a bit-exactness no fabric delivers. And it can now be said in units instead of in mantissa bits: a run that declares what it claims — a nanometre, a nanoradian — produces one digest on all three operating systems, at bit-exact precision. drop_bits 20 and 1e-9 of each unit are the same fact, but only one of them is a sentence about a robot.

14 · THE FABRIC

Where does bit-exactness actually stop being available?

The expectation was: a GPU reorders its reductions, floating-point addition is not associative, so a declared precision is all you have left. Measured on real silicon — an Apple GPU through WebGPU, one shader, one set of values, dispatched at 16, 64, 256 and 1024 workgroups — the reordering is the small term. It is 2–6 f32 units in the last place, near-constant in the number of terms, because a GPU reduction is a tree and is usually more accurate than the sequential loop it replaces. What costs you is that the fabric has no f64 to reorder: WGSL has no such type and Metal on Apple silicon exposes none, so values are narrowed before they arrive. On the worst case the narrowing is 560× the reordering. Calling both "GPU nondeterminism" hides which one you are paying for. In the receipt's units: two dispatch shapes agree at 32 dropped mantissa bits, a GPU run agrees with the CPU run it came from at 42. And the GPU is not random — every figure reproduces exactly, run to run. It is deterministic per configuration and differs between configurations, so the reason you cannot claim bit-exactness is not noise: it is that the dispatch shape is not part of your spec.

The harness

How long should a robot test take to start?

1.7 milliseconds, cold, including discovering thirteen cases. A scenario is a function in your binary: no manifest, no services map, no container, no machine. run.tick() is the only bookkeeping call in the loop, and it is what buys the three clocks, the energy ledger, the digest and the verdict at once.

$ 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. A real trade-off, surfaced in a fifth of a second, with a receipt and an energy figure on every row.

Measured on one laptop, release, no GPU, no container, no networkMedian
start, discover 13 cases, print the catalogue1.7 ms
4 runs, 4,000 steps, 4 sealed and re-verified recordings39 ms
13 runs, 13,000 steps, 13 recordings132 ms
query a 105-run history3.7 ms
Antioch's documented machine-allocation ceiling, before any build or boot600 s

The fair framing, since a comparison deserves one: this is a local harness with no GPU and no Isaac, so it is not doing a cloud platform's job and these are not a benchmark against one. They measure the overhead around the physics. Deliberately absent: GPU orchestration, a queue, fan-out, a renderer, an asset catalogue. For a photorealistic sensor sim on twenty machines, Antioch and Isaac are the right tools.

Status · v0.1

What is real, and what is next?

Shipping: the zero-dependency MCAP reader and writer, attachments included, tested against Foxglove's own parser as an oracle; the three-clock model and published schemas; the energy ledger with its refusal; the receipt and comparator; verify recomputing a receipt from bytes alone; the scenario harness; URDF import with its physical-usability checks, ground-clearance report and the LeRobot SO-101 as a demo device; STL-to-glTF with exact mass properties and the LUT-first material bridge; described scenes; the MCP server, HTTP API and browser SDK; ferroscope power reading real counters; and the WebGPU viewer with glTF meshes and a measure tool. 259 tests, clean clippy, three platforms in CI plus wasm32, and jobs that gate the zero-dependency claim, the viewer bundle, the scene format and the MCP protocol surface. Fourteen crates on crates.io.

All of it shipped. Every item this page once listed as future work is in the crates above: real power counters behind an honest refusal when the machine has none, live streaming over WebSocket and WebTransport in both directions, a scenario runner that executes a spec, and Ferromotion coupling so a run is produced and certified by the same stack that draws it. Fourteen crates on crates.io, Rust 1.98 and edition 2024, and a toolchain upgrade that moved no numbers — proven by diffing recordings made by the binaries from before and after it.

Next, in the open: nothing holds a recording any more, and nothing is missing from the page when it does not. The last gap was the meshes: a glTF has to be whole to be a mesh, so a block read kept the geometry's declaration and not its bytes and the 3-D view drew boxes. It now goes back to the file and stops at the attachment — 2.0% of the file for a 76 KB mesh, because geometry is declared before it moves. What a bundle still has over the recording itself is speed, a tenth of a second against a minute and a half, and a file small enough to send to somebody. The next thing worth doing is no longer about size at all.