Live sessions and mime.live/1#
A live session runs a built experiment while you watch it: it steps the run in chunks of one fixed size, hands each
chunk’s state to its subscribers as a frame, takes the controller’s live inputs between ticks, and records the stage
on request. In a script or a notebook it is built.live(); in a process of its own, for a viewer such as
MICROROBOTICA, it is mime serve, which speaks the protocol below.
In a script#
from mime import Experiment
from mime.units import ms
built = Experiment.load("a0_saved").build()
session = built.live(chunk=ms(16.5), pace=1.0, out="a0_live") # 33 base steps a chunk, at real time
session.subscribe(lambda frame: print(frame.step, frame.values["umr.pose"][:3]))
session.step(10) # ten chunks now, on this thread
session.start() # or step on a thread of its own, paced, until pause() or stop()
session.pause()
session.record(True) # the recording layer, from the state as it stands
session.step(60)
session.stop() # the run folder: a0_live/
One chunk size for the session. Each new length of a compiled run compiles again, so a session keeps one: about sixty chunks a simulated second when
chunk=is not given, rounded to whole base steps, and a whole number of control periods when a controller is called back.Frames. A frame is the state after a chunk: its number in the session (from zero), the base steps taken, the time, and the fields its schema names (
select=,<node>.<field>; by default every pose and vector the stage binding moves, every per-step output of an observation, and every flag). Frame zero is the state the session starts from. A frame’s values are the run’s own, in its own float type: a batch run of the same steps gives the same bits.Pace.
pace=is a factor of real time (the default holds the loop to real time), andpace=Noneruns it as fast as it runs. A session that falls more than a chunk behind its pace (its first chunk compiles; a slow machine) takes the time it is at as its new start, rather than running ahead to make the time up. So pacing guarantees one thing on every machine, which a test holds: no frame is ever ahead of real time. Keeping up with real time is the machine’s, measured byscripts/live_pace_benchmark.pyand never a CI check: on a laptop’s i7-12850HX (JAX on its CPU), timed from the first frame after the compile, A0 throughmime serveran at 3.59 times real time unpaced and at 1.005 paced at real time. A slow subscriber is its own concern: the session never waits on one.Live inputs are the controller’s only. A controller declares them beside its reads and writes (
Controller.live, each aLiveInputwith its units, shape, meaning anddefault, the value at which it changes nothing the step does: zeros unless declared); in the callback mode,session.set(label, value)hands one to its step under its label from the next tick on. A session hands every live input to the step from its first tick, at its default until it is set, so that a step compiled once (ajax.jit, asArmTracking’s is) compiles at the session’s first chunk with every input it will read, each a float of its declared shape, and nosetcompiles it again. A session that sets nothing is a batch run (which calls the step with its reads alone) bit for bit, as a test holds for A0. A physical setting is never a live input: changing one is a rebuild, a new session from the saved folder.ArmTrackingdeclaresoffset, which moves the point it holds the magnet above along its axis.Every command is logged with its step (
commands.jsonlin the run folder), so a session replays:mime run <saved folder> --commands <run folder>/commands.jsonl --out <dir>steps the same chunks, sets the same inputs and records at the same steps, and writes the same run folder. The log is written as the session goes: its header when the session opens, each command before it acts, and after each chunk the step it reached ({"reached": <step>}, before that chunk’s frame is sent), each line flushed. A session that dies (a crash, a kill) leaves a log that replays to the last step it reached, every frame a client received included.--outis refused when it is the folder the session or the replay reads, inside it, or holds it (run.out-over-source).Every session and every run starts from the declared inputs. A live input lasts as long as its session: it is cleared when a session opens and when it stops, and a batch run of the same built starts with none set.
Stop always stops, in every state: ready, running, paused, or taking a step (on another thread, or in
mime serve’s loop), within one chunk. It sends a last frame marked final and writes the run folder.pauseends a step in hand after its chunk, as it stops a running session.A chunk in flight holds back only what changes the run. A command that changes what a run does (
set,record,pace,start,step,pause,stop) waits for the chunk running on another thread and acts at its end;status()andhello()answer at once, the status’sactivitysaying what the session is doing:compilingwhile its first chunk runs (which compiles the run’s programs, unless this process compiled them for an earlier session of the same built experiment),steppingwhile a later one runs,idlebetween chunks, and itsstepthe one the chunk started at.
The run folder#
Every live session that is given out= writes a minimal run folder when it stops (mime serve always does):
File |
What it holds |
|---|---|
|
the saved folder the session was loaded from, copied as it is (or the built experiment saved) |
|
its header (the protocol, the chunk, the experiment’s input hash), then one line per command with its step and one per chunk with the step it reached, written as the session goes |
|
the state at the end and its manifest, as |
|
the session’s record: steps, frames, wall time, the environment |
|
the built graph in MIME’s names ( |
|
the build report’s summary ( |
|
the recording layer, when recording was on (recordings) |
mime run’s run folders (run_to) hold report.json too. A run folder is self-contained: it carries its own stage,
and nothing outside it. A folder saved by an earlier stage
writer (before chains’ frames, visuals and vectors were drawn) loads and verifies as it was written, and a live session
of one whose stage lacks a prim the binding moves is refused (live.stage-writer): save it again with this MIME.
mime serve#
mime serve a0_saved # commands on tcp://127.0.0.1:5555, frames on tcp://127.0.0.1:5556
mime serve a0_saved --commands tcp://127.0.0.1:0 --frames tcp://127.0.0.1:0 --ready-file ready.json
mime serve a0_saved --start --unpaced --out runs/a0_live
mime serve a0_saved --idle-timeout 15min --max-wall 2h --watch umr.pose[0:3]
It loads and builds the saved folder, opens its session and serves it until a stop command, SIGTERM or SIGINT, all
of which stop it cleanly and write its run folder. It does not outlive the process that launched it: when that
process is gone (a viewer that crashed, a test runner killed), the session stops as on SIGTERM, writes its run folder
and the server exits. On Linux the kernel signals it (PR_SET_PDEATHSIG); on every platform the loop also looks between
chunks at whether its launcher is alive (on POSIX its parent’s pid changes when it is adopted; on Windows the
launcher’s process handle is signalled). --outlive-launcher serves on regardless, for a server started to run
detached (with nohup, say). It binds to localhost. A session has no authentication of its
own: reach a remote one through an SSH tunnel, which authenticates and encrypts; --remote binds another address all
the same.
ssh -L 5555:localhost:5555 -L 5556:localhost:5556 <host> # then connect to localhost as if it ran here
The ready file#
--ready-file <file> is how a program that starts mime serve learns where to connect, whatever the server prints: it is
written as one JSON object once the sockets listen. Its format is part of mime.live/1 and follows the protocol’s rule
below, that fields may be added and a reader ignores what it does not know.
Key |
What it holds |
|---|---|
|
|
|
the endpoint the commands are sent to, |
|
the endpoint the frames are received on |
|
the run folder the session writes when it stops |
A key that is absent means what it meant before it existed. The transport’s security is the first: an absent
mechanism means NULL, the mechanism a session speaks today (no authentication; reach a remote one through an SSH
tunnel). Two keys are reserved and not written yet, for a later session that serves with CurveZMQ, where the
server proves who it is and a client is known by its key: mechanism, which is curve there, and server_key, the
server’s long-term public key as Z85 text of 40 characters (as zmq_curve_keypair writes it and a libzmq client takes it),
present only when
mechanism is curve. A client that finds a mechanism it cannot speak refuses; it never falls back to
connecting without the security the server asked for. The key travels in this file, or in a client’s own settings for an
address it was given by hand, and never in hello, which goes over the connection the key secures.
What the terminal shows#
On a terminal, mime serve draws a live view on standard error (anywhere else it prints what it always printed: the load
report, the line when it listens and the line when it stops, on standard output, byte for byte; --plain, NO_COLOR and
TERM=dumb ask for the same). It is a strip of a few lines: the state in a word (READY, COMPILING, RUNNING,
PAUSED, STOPPING), the step, the simulated time against the wall time, the speed as a multiple of real time and the
frames a second (over the last few seconds), the clients, whether it is recording and the flags seen raised, and how to stop.
Above it the scrollback keeps an event for each client that connects or leaves and each command with what came of it (a
connection that is refused before its handshake is counted and said once with the others, not one by one). It is drawn about
four times a second whatever the frame rate: the loop that answers clients only stores into a plain record of the
session’s state, and the view reads it. A terminal that is not read never keeps the server from stopping: serve writes to
it through a writer of its own that waits a quarter of a second for it once and then not at all, and drops what the terminal
does not take (the view says how many writes it dropped, if the terminal takes that), so --max-wall, --idle-timeout,
SIGTERM and Ctrl+C each write the run folder and exit whatever the terminal does (Ctrl+S, a stalled ssh session, a
launcher that never drains its pseudo-terminal). There are no keys; a client is how a session is steered (a command is logged and
replays like any other).
Under the strip is a table of values: by default each scalar output of an observation, each flag, and the position, the
first three numbers of the pose, of each body the stage binds; at most twelve rows, fewer in a short window, the rest said in
one line. --watch <path> (repeatable) names the rows instead: a field of the frames, with an index where it is not one
number, <node>.<field>[<low>:<high>], one integer or slice for each leading dimension of the field; all adds every scalar and three-number field; none hides the
table. A name the frames do not carry is refused (live.watch) with the nearest name, and --watch adds nothing to the
frames and changes none: they are what --select makes them. A row of one number shows its unit as the schema states it
(or says it declares none), its value now, its minimum and maximum over every frame the server published (a frame is
the state at a chunk’s end, so these are not the extremes over steps), and the last ten seconds as a sparkline of the values
drawn. A row of two or three numbers shows them and no extremes. At a narrow width the sparkline goes first, then the minimum
and maximum, then the unit, and no number is ever wrapped.
--idle-timeout <duration> stops the session when no client has been connected for that long (counted from when it
listens, and again from the last client to leave), and --max-wall <duration> when its wall time reaches it (counted from
when the session opened, as wall_time in the status is, so the loading and building before it are not). Each stops it as
SIGTERM does, writing the run folder, and says why on standard output (mime serve: no client has been connected for <seconds> s (--idle-timeout); stopping). A duration is written as mime run’s --duration is, with a unit or as seconds;
one that is not above zero is refused (live.timeout). Neither limit stops a session at the second:
as SIGTERM does, it finishes the chunk in hand and stops at its end, so the stop comes at the end of the chunk in
hand, later than the limit by what is left of that chunk (the first chunk compiles, which can take many seconds, and a limit that
comes while it is in hand is passed by them). A session
that is not stepping (ready, paused) has no chunk in hand and stops at the limit. Both limits are for a session that
nobody is watching: a forgotten one, or one on a machine that is billed by the second.
It never goes silent. Each chunk runs on a thread of its own while the server’s loop moves bytes and answers, so a client is answered while a chunk compiles or steps:
helloandstatusare answered at once, with the status as the chunk runs (activitycompilingorstepping,stepthe chunk’s start);stopandpauseare answered at once too,okwith the status as the chunk runs and the command inpending, and act at the chunk’s end, as between chunks. Astoptaken this way is pending, not done: its reply hastaken: true, amessagethat says “stop pending”, the command inpending, and nofolder(the run folder does not exist yet; the reply of astopbetween chunks, which has taken effect, has it). The session’s last frame, marked final, or the closed connection says it has stopped; astatussent after that is never answered, since the server has exited;every other command is answered at the chunk’s end, where it acts.
The commands taken while a chunk runs act at its end in the order they came, so the command log holds them in that
order at the chunk’s end step. Measured by scripts/live_responsiveness.py on a laptop’s i7-12850HX (JAX on its CPU),
A0 with its controller called back, three runs: while its first chunk compiled (3.3 s), the server answered 252 to 265
status requests, the median in 0.4 ms and the slowest in 82 to 97 ms, so within 0.1 s; the step after its first
set took 3.5 to 3.9 ms, as any later step does. Earlier in development the first set compiled the controller again: in a
script on the same laptop, A0’s apparatus closed by ArmTracking took 1.9 s for the chunk after it. These are a
machine’s measurements, never a CI check; a test holds the server answering status, hello and pause while a
first chunk is held open, and its control, a server stepping on its loop’s own thread, answering none.
The transport is ZMQ: a REP socket for commands and a PUB socket for frames. MIME speaks ZMQ’s wire protocol itself
(ZMTP with the NULL mechanism), so mime serve needs no extra package, and any ZMQ library connects to it as it would
to libzmq’s own sockets: a REQ (or a DEALER) for commands, a SUB for frames. mime.live.LiveClient is a Python client.
The protocol: mime.live/1#
The messages are written independent of their transport: a WebSocket or a WebRTC data channel could carry them as
they are. Within mime.live/1 fields may be added (a new schema id; a client ignores what it does not know); any other
change is a new major version.
Requests and replies#
A request is a JSON object, {"command": "<name>", ...} (with "protocol": "mime.live/1", optional). A reply is a
JSON object with protocol, ok and command; on success the session’s status (state, step, time, frames,
pace, recording, non_finite; stepping, the chunks a step in hand has still to take; activity, what the
session is doing, compiling, stepping or idle; and pending, the commands taken while a chunk runs that act at
its end, in order; and three optional keys below); on a refusal,
errors, each in the shape of MIME’s errors: code, class, message, why, action, where and
page. A request a client gets wrong is refused with its code (an argument of the wrong type included: live.step,
live.input-value, live.record, live.pace), and the session goes on; an error of MIME’s own inside a command is
answered in the same shape, the session going on. The server never stops for one peer’s bytes: a peer that does not
speak ZMTP, does not finish its handshake in time, sends a message over its socket’s limit, or sends requests without
reading their replies is closed, and the others are served on (the limits are mime.live._zmtp’s constants).
Three optional keys of the status, which mime serve always sends and a client that does not read them is unaffected by:
Key |
What it holds |
|---|---|
|
the seconds since the session opened (the loading and building before it are not counted) |
|
|
|
the last five commands the session took, oldest first, each |
They carry no address of a peer and no text of a peer’s but what the session accepted: recent is read from the
session’s command log, which holds only commands that were accepted, so a request that was refused is not in it, and a
connection is a count. A session that has no server (built.live() in a script) has no clients. A watcher that renders
the view from status and the frames shows what these keys let it, and no more than a server tells every client.
Command |
Arguments |
What it does |
|---|---|---|
|
describes the session (below) |
|
|
the session’s status |
|
|
starts or resumes stepping, paced |
|
|
stops stepping after the chunk in hand (taken while a chunk runs: answered at once, |
|
|
|
steps that many chunks now, from a session that is not running; answered when they are stepped, or when a |
|
stops in every state, sends the last frame, writes the run folder (between chunks its reply holds the run folder’s path; taken while a chunk runs it is answered at once as pending, with no |
|
|
|
sets the controller’s live inputs from its next tick |
|
|
turns the recording layer on or off |
|
|
the real-time factor, or unpaced |
hello returns MIME’s version, the experiment’s name and its hashes, the base step and the chunk in base steps, the
controller (its entry, mode, period and latency) and its live inputs (each with its units, shape, meaning and
default), the build report’s summary (report, below), the path of the stage the session serves
(stage), what the asset cache draws of its visuals (visuals: the overlay to add over the stage, the visual sets
it lacks with the command that fetches each, fetch, and every set’s notice, its credit and terms, whether its meshes
are drawn or not: see recordings), the stage binding (binding), the topology, the frame schema (schema), the commands,
and the status. stage is the path on the machine that serves the session: a client on another machine opens its own
copy of the saved folder (the one it gave mime serve, or a mime pull of it) and checks it is the same experiment
by hashes, the lock’s.
Frames#
Each frame is one message on the PUB socket: binary by default, JSON with --json-frames. A binary frame is a header,
then each field of the schema in its order, its bytes little-endian in the run’s own type:
offset size field
0 4 magic "MIMF"
4 2 version, u16 (1)
6 2 flags, u16 (bit 0: the session's last frame)
8 4 schema id, u32 (the first four bytes of the sha256 of the schema's text)
12 4 frame number, u32 (from 0, one per frame sent: a gap is a dropped frame)
16 8 step, u64 (base steps taken)
24 8 time, f64 (seconds)
32 ... the fields, in the schema's order
The schema (in hello) lists each field: path (<node>.<field>), node, field, shape, dtype as NumPy spells
it (<f4), unit where MIME knows one, and its byte offset after the header; with frame_bytes, the whole frame’s
length. It is fixed for the session. A JSON frame holds the same keys as the header (frame, step, time,
schema, final) and fields, each as nested lists. A client checks each frame’s number against the last
(mime.live.FrameOrder): a gap is a dropped frame, a number at or below one seen is one out of order. A ZMQ
subscriber that connects just before start may miss the first frames, as ZMQ’s subscriptions take a moment to reach
the publisher (its “slow joiner”): connect the frames’ socket first, then send hello, and read the first frame’s
number rather than assume it is zero. The server moves bytes while a chunk runs, so a ZMQ heartbeat
(ZMQ_HEARTBEAT_TIMEOUT) is answered while a chunk compiles. A frame’s header carries the step it is at: a client’s
status line that follows the frames is current between its requests.
The build report’s summary#
report in hello, and report.json in every run folder (built.report_summary(); RunResult.read(folder).report),
is the build report as a client shows its claims, plain JSON of format mime.report/1:
Key |
What it holds |
|---|---|
|
|
|
the profile ( |
|
each validity line, with its |
|
what each run is compared with after it (a flag on the result, never a refusal) |
|
each flag a run may raise: its |
|
each setting by |
|
the report’s summary, its lines as |
A run folder written before this has no report.json, and RunResult.read gives report as None. A report of a
format this MIME does not read (a newer mime.report/N) does not stop the run being read: RunResult.read gives
report as None and report_unreadable as the format the file states ("not JSON" for one that cannot be parsed);
it is None when the report was read, or there was none.
The stage binding#
binding says which field moves which prim of the stage (built.binding()), so a client needs no knowledge of MIME’s
state layout. Each entry has the prim, the node and field, the row of a stacked field (index), its kind, the thing
it belongs to, its public name and its units. Every bound prim is placed in the world (a chain’s frame prims reset
their transform stack), so a pose (seven numbers: x, y, z, then the quaternion w, x, y, z) is the prim’s translate
and orient as it stands, applied with no composition. A vector (a field a law reads at a body, three numbers in the
world’s axes) is drawn at the position of the pose at names.
Frames carry metres; the stage has its own units. A pose’s x, y and z are in metres (as its unit says) whatever
the stage is written in, and a stage’s translate is in the stage’s units. So a client reads the stage’s
metersPerUnit once (UsdGeom.GetStageMetersPerUnit(stage), the root layer’s metersPerUnit) and writes
translate = (x, y, z) / metersPerUnit; the orientation is unitless and goes in as it is. In a stage in metres, as
A0’s is, the two are the same numbers; in one in millimetres the translate is a thousand times the frame’s numbers.
A vector’s prim is translated the same way (the body’s position divided by
metersPerUnit); its arrow is a metre long in the stage’s own units already, so its uniform scale is |v| times the
prim’s mime:vector:metres_per_unit, with no conversion. The recording layer is written so already
(recordings).
What this does not do yet#
Pixel streaming of the stage (WebRTC, Selkies) is MADDENING’s next release: a session’s frames are for local rendering, by a client with the stage.
A controller inside the compiled step takes no live input: its state is in the graph. Declare it as a callback to steer it live.
Batch runs on Modal play from their run folders (
mime modal, then the folder), not live.