Serving a policy: the inference contract
A fine-tuned checkpoint is not a policy until it lives behind a contract: a server on the GPU box, a thin client in the control loop, an explicit spec for what crosses the wire. This lesson turns your model-serving instincts into a robot inference service — instrumented so Phase 05 has something to measure.
- Stand up the openpi remote-serving pattern: policy server on the RTX workstation, websocket client inside the robot host's control loop.
- Write the inference contract for your fine-tuned checkpoint — observation schema, action schema, normalization ownership, version pin — and catch drift before it reaches the motors.
- Derive the chunk-cadence condition for when to request the next action chunk, and compute the fire threshold from your measured p99.
- Instrument every request with dual-clock timestamps via the Phase 01 offset-estimation technique, and wire the missed-deadline path into the watchdog.
The previous lesson ended with a checkpoint directory on your RTX workstation: fine-tuned weights, normalization statistics, a transform stack that turns WidowX AI observations into model inputs. You have stood at exactly this point in your day job — a trained model is a file, not a service, until someone defines the interface, pins the version, and measures the latency under load. Robotics keeps that discipline and sharpens it: your only client is a control loop holding a physical arm, and a bad response does not degrade an experience, it moves metal. This lesson is about the interface — what π₀ promises the robot, what the robot promises π₀, and what happens when either side breaks the promise.
Temporal Foundations: Control Periods and Actuation Latency
Before deriving the fire threshold, separate three clocks. The control period is the fixed interval at which the client consumes buffered actions; at 50 Hz, ms. The fetch latency runs from sending an observation request until the resulting chunk is in the client buffer, including network and inference. Actuation latency runs from issuing one buffered command until the plant responds. Actuation latency matters to observation age and closed-loop stability, but it does not delay the arrival of the next chunk and therefore is not added to the command-buffer starvation threshold.
The buffer state is a concrete countdown. If actions remain, then . A request fired at time is safe when the old buffer lasts at least until the new chunk arrives: . Express the threshold as actions remaining, not as an ambiguous executed index: fire when reaches . The motor pipeline can continue applying already issued commands during its fixed actuation delay; it does not consume an extra future chunk action.
| Symbol | Definition | Typical Value (WidowX AI) | Unit |
|---|---|---|---|
| Control Period () | 20 | ms | |
| Actuation Latency (separate from fetch cover) | 5 | ms | |
| 99th Percentile End-to-End Latency | 260 | ms | |
| Fire Threshold (Actions Remaining) | 13 | actions |
Worked Example: Deriving the Fire Threshold
Consider a deployment with a control rate Hz ( ms), a chunk size , and measured request-to-chunk-in-hand latency ms. Actuation latency is measured separately as 5 ms. Determine the correct for command-buffer starvation, then state where the 5 ms belongs.
- The buffer must cover the fetch: .
- Convert time to action count: actions.
- The ceiling changes nothing here: actions remaining.
- Verify: 13 actions provide ms of playback. The separate 5 ms actuation latency belongs in observation-to-motion staleness and stability analysis, not in this buffer-arrival inequality.
If request-to-chunk-in-hand ms, ms, and ms, why is the command-buffer fire threshold 13 actions?
Two boxes, one loop: the remote policy server pattern
The standard decomposition — the one openpi ships as serve_policy plus openpi-client, and the one Trossen documents for their arms (opens in a new tab) — puts the policy behind a websocket server on the GPU machine and a thin client inside the robot host's control loop. When the client needs a new chunk, it sends one observation (camera images, joint state, task prompt) and receives one action chunk: an array, for π₀ typically actions intended for 50 Hz execution — one second of motion per round trip. The client plays the chunk out through the arm driver; the server idles until the next request.
Why is this the standard shape, even when both processes could share one machine? Three familiar reasons. Dependency isolation: the server side is JAX, CUDA, and a multi-gigabyte checkpoint; the client side is camera drivers, the arm's UDP driver (libtrossen_arm), and a loop that must tick on schedule — an XLA recompile and a joint-command write should not share a process. Independent iteration: swap checkpoints and restart the server; the robot code never changes. The interface forces the contract: once observations cross a socket, someone has to write down exactly what an observation is. That document is the real subject of this lesson.
Size the wire before trusting it. Three 224×224 RGB uint8 images — base, wrist, and a second exterior view — are bytes, about 441 KB per request before msgpack overhead: roughly 3.6 ms on wired gigabit Ethernet, negligible over loopback. Send raw uint8 — shipping float32 quadruples the payload for zero information gain.
The inference contract is an interface spec
Everything that crosses the socket needs a written schema, because the failure mode of an undocumented field is not an exception — it is a robot that moves slightly wrong. Treat the contract like an API spec published to another team:
- Camera keys — exact key names matching the transform stack you trained with last lesson (a renamed key is the most common serving bug), plus resolution, channel order, and dtype: 224×224×3 uint8, HWC.
- State vector — dimension, ordering, units. For your WidowX AI: six joint positions in radians in joint-index order (J0–J5), plus gripper width in meters. Padding to the model's 32-dim state width is the server's business, not the client's.
- Prompt — the task string, spelled exactly as in the training data; a policy fine-tuned on pick up the red block conditions measurably differently on a paraphrase.
- Action chunk — shape , per-dimension semantics (absolute joint targets vs. deltas, which entries are padding), units, and the control rate the horizon assumes.
- Version — one string pinning checkpoint hash, normalization-stats hash, and transform-code revision, echoed in every response so the client can reject a mismatch loudly.
Normalization ownership deserves its own paragraph because it is where fine-tuned deployments die. The stats you computed last lesson are training artifacts: they belong to the checkpoint, and openpi correctly stores them in the checkpoint's assets and applies them server-side. The resulting contract is clean — the client sends and receives raw physical units; everything learned stays behind the socket. Any other split invites version skew: a client normalizing against last month's stats while the server denormalizes against this month's produces actions plausibly scaled and consistently wrong — the worst combination for debugging.
Both sides of this contract are living code, so plan for drift. Some violations fail loudly — a missing camera key raises server-side, a wrong state dimension breaks a matrix shape. The dangerous ones type-check: a camera resize from a driver update (subtly different framing than training), a reordered state vector, a unit change. Schema validation catches shape and dtype, never semantics. The defense is round-trip verification — replay a training observation through the live server and check the answer against the dataset, as you will in this lesson's exercise.
After a refactor, your robot client starts sending joint state in degrees instead of radians. The server was fine-tuned on radians and applies normalization from the checkpoint's stats. What happens at inference time?
Robot serving is not LLM serving
You have spent years optimizing serving around one economic fact: GPUs amortize, so you batch. Robot policy serving inverts nearly every design pressure you know; see the assumptions side by side.
| Dimension | LLM serving | Policy serving |
|---|---|---|
| Objective | Throughput subject to a latency SLO; saturate the GPU | Tail latency, full stop; the p99 is the product |
| Batch size | Continuous batching across dozens–hundreds of requests | Exactly one, forever; the GPU idles between chunks by design |
| Queueing | Deep queues, fairness, priorities | At most one request in flight; a stale request is dropped, never served |
| Load profile | Bursty, diurnal, multi-tenant | Metronomic: one client, one request every ~740 ms, indefinitely |
| Warmup | Gradual; caches warm as traffic arrives | Mandatory before first motion; the first JAX call triggers XLA compilation measured in tens of seconds |
| Retry semantics | Idempotent, harmless | A retry carries a newer observation and must supersede the original, not join it in a queue |
| Failure mode | A slow response; the user retries | A stale action executed at full torque; the watchdog is the last line |
Two rows deserve expansion. Queueing: in an LLM server, a queued request is deferred value — served late, the user still gets their answer. In a policy server, a queued request is a photograph of a world that no longer exists. If a second request arrives while one is processing (a retry, a reconnect), the correct behavior is latest-wins: drop the older work, serve the newest observation, never deliver two chunks for interleaved execution. Warmup: openpi's server is JAX; the first infer call compiles, taking tens of seconds to reach steady state. The startup ritual is non-negotiable — send dummy observations at production shapes until latency stabilizes, then let the arm move.
Now make the cadence quantitative — the when-to-request decision is the heart of chunked serving. A chunk of actions consumed at control rate buys a fixed playback window, and the server's duty cycle follows:
When must the client request the next chunk? Derive it. Let be playback time remaining in the buffer, the moment the request is sent, and the full request-to-chunk-in-hand time. The buffer exhausts at ; the new chunk lands at . Starvation is the chunk landing after exhaustion, so the no-starvation condition — evaluated at the p99, the latency you refuse to be beaten by, not the mean — is:
A cost hides in that ceiling, and it is the doorway to your capstone. Every action in a chunk was computed from one observation, so action executes on information seconds old — the deepest action you execute (index 36, with the threshold above) runs on an observation from roughly one second ago. By the Phase 01 staleness formula, at 0.25 m/s end-effector speed that is 25 cm of world motion. Quasi-static tabletop picks survive because the world mostly waits for you; anything dynamic does not — which is why real-time chunking (opens in a new tab) is an active research problem and why your capstone exists. Today you serve the naive version cleanly and measure it honestly; Phase 05 makes it adaptive.
Your π₀ server returns 50-action chunks executed at 50 Hz. Measured from the robot host, request-to-chunk-in-hand latency is p50 = 110 ms and p99 = 260 ms. To guarantee (at p99) that the buffer never runs dry, at what buffer level must the client fire the next request?
Deadlines, watchdogs, and the safe-stop contract
The cadence math says when starvation shouldn't happen; reliability engineering decides, in advance, what happens when it does. The server gets OOM-killed, the Ethernet cable gets kicked — and at 1% p99 you also just plain lose sometimes. The contract needs a failure clause as precise as its schema clause: if no valid chunk is in hand when the buffer empties, the client commands a safe stop within one control period. On the WidowX AI, safe stop means ramping to zero velocity and holding position with gravity compensation active — not cutting torque (the arm drops), not repeating the last action (the arm drifts), and never extrapolating the chunk (the arm invents motion from stale data). The gripper holds its last commanded width; dropping a grasped object is itself a failure.
This is where the Phase 01 watchdog stops being scaffolding and becomes load-bearing. It once guarded against a hung script; now it enforces deadlines for a learned policy, with two timers. A liveness check — a health endpoint polled every second or two, verifying process up, model loaded, contract version matched — catches slow-building failures before an episode starts. A per-request deadline — a hard timeout of your p99 plus margin, say 400 ms — catches failures mid-episode and routes them to safe-stop. The deadline is not a retry trigger for the same observation: a request that missed it was computed for a world that has moved on, so the recovery path is a fresh observation or a stop, never a rerun.
import numpy as np
class ChunkScheduler:
"""Buffered chunk execution with derived fire threshold.
policy : async wrapper around the websocket client
watchdog : Phase 01 watchdog, now enforcing the serving deadline
"""
def __init__(self, policy, watchdog, ctrl_hz=50.0, e2e_p99_s=0.26):
self.policy = policy
self.watchdog = watchdog
self.fire_level = int(np.ceil(ctrl_hz * e2e_p99_s)) # 13 here
self.buffer = [] # actions awaiting execution
self.pending = None # id of the newest in-flight request
self.seq = 0
def on_response(self, req_id, actions):
if req_id != self.seq:
return # superseded: drop the stale chunk, never queue it
self.buffer = [a for a in actions]
self.pending = None
def tick(self, obs, capture_time):
"""Called once per control period. Returns one action or None."""
if len(self.buffer) <= self.fire_level and self.pending is None:
self.seq += 1
self.pending = self.seq
self.policy.infer_async(
obs, capture_time, req_id=self.seq, on_done=self.on_response
)
if not self.buffer:
# Deadline missed. Do not extrapolate, do not replay: stop.
self.watchdog.safe_stop(reason="chunk starvation")
return None
self.watchdog.pet() # loop is alive and fed
return self.buffer.pop(0)Twenty lines that encode most of the lesson: the fire threshold comes from a measured p99, not a hardcoded guess; the response handler enforces latest-wins via sequence id; starvation routes to the watchdog instead of improvising; and the watchdog is petted only by successful ticks, so a hung camera trips the same safety path as a dead server. One mechanism, every failure mode.
Measurement hooks: timestamps on every request
Phase 05 will ask: was that failed grasp caused by a latency spike, a stale chunk boundary, or the policy itself? You can only answer if every request carries timestamps from both hosts — the catch being that the robot host and GPU box have different clocks. Client stamps alone cannot separate network time from inference time; server stamps alone live in the wrong domain. You need four stamps per request — client send , server receive , server send , client receive — plus the inter-clock offset . This is the round-trip estimation from Phase 01 that aligned camera and host clocks; derive it once more, now over your serving socket, with forward and reverse network delays and and the server clock running ahead:
Solve each equation for — the first gives , the second gives — and average them so the unknown delays appear only as their difference:
import time
import numpy as np
def estimate_offset(probe, n=64):
"""probe(t0) -> (t1, t2): server receive/send stamps on the server clock.
Returns (theta, best_rtt): server_time = client_time + theta.
"""
samples = []
for _ in range(n):
t0 = time.monotonic()
t1, t2 = probe(t0)
t3 = time.monotonic()
theta = ((t1 - t0) + (t2 - t3)) / 2.0
rtt = (t3 - t0) - (t2 - t1)
samples.append((rtt, theta))
samples.sort() # fastest round trips carry the least asymmetry error
keep = [theta for _, theta in samples[: max(4, n // 8)]]
return float(np.median(keep)), float(samples[0][0])
class TimedPolicy:
"""Wraps the websocket client; emits one timing record per request."""
def __init__(self, client, theta):
self.client = client
self.theta = theta # server clock minus client clock, seconds
self.records = []
def infer(self, obs, capture_time):
t0 = time.monotonic()
result = self.client.infer(obs) # server echoes t1, t2, infer time
t3 = time.monotonic()
t1 = result["t1"] - self.theta # map server stamps to client clock
t2 = result["t2"] - self.theta
rec = {
"capture": capture_time,
"obs_age_at_send_s": t0 - capture_time,
"wire_out_s": t1 - t0,
"server_s": t2 - t1, # queueing should be ~0: verify it
"wire_back_s": t3 - t2,
"e2e_s": t3 - t0,
"contract_version": result["version"],
}
self.records.append(rec)
return result["actions"], recRun the offset handshake at episode start, log and its RTT bound in the episode metadata, and write one timing record per request into the episode file itself, next to observations and actions — not a separate log you will fail to join later. The decomposition earns its keep immediately: e2e_s spiking while server_s stays flat implicates the network or client event loop; a spike in server_s sends you hunting in JAX-land; growing obs_age_at_send_s means the camera pipeline is congesting before the socket is involved. And because each record joins to a robot outcome, latency becomes a feature of the episode you can correlate with success — the empirical substrate of your capstone.
The lesson compresses into a checklist you run before calling a deployment real — a launch review where every line is a measurement or an artifact, not a feeling:
- Version pinned. One contract string covering checkpoint, norm-stats, and transform-code revisions; echoed in every response, client hard-fails on mismatch.
- Warmup verified. Dummy inferences at production shapes until latency stabilizes; the episode logger refuses to start before steady state.
- Latency measured from the robot host. p50/p95/p99 over 500+ requests under episode-like load (cameras streaming, logger writing), saved with the run; fire threshold recomputed from that p99.
- Action-space round trip verified. One training observation replayed through the live server; the returned chunk is in physical units, inside joint limits, consistent with the dataset's recorded action.
- Failure path tested. Kill the server mid-episode on purpose; confirm the watchdog stops the arm within budget and the episode record marks the event.
- Clocks aligned. Offset estimated at episode start, logged with its RTT bound, re-checked at episode end to bound drift.
Write and enforce your inference contract
Produce two artifacts for your own deployment — against your fine-tuned checkpoint, or a mock server that sleeps 120 ms and returns zero-filled 50×32 chunks if training is still running. First, the contract, one page: every observation key with shape, dtype, units, and frame; the action array's shape, per-dimension semantics, and units; who owns normalization; the pinned version string. Second, a verification script that (a) replays one observation from your fine-tuning dataset through the live server and compares the returned first action against the dataset's recorded action; (b) sends two deliberately corrupted observations — joint state in degrees, and a renamed camera key — and records exactly what the server does with each; and (c) reports p50/p95/p99 over 500 requests measured from the robot host, plus the recomputed fire threshold.
Need a hint?
The dataset-replay check is your sharpest instrument: double-applied normalization shows up as actions scaled by roughly the per-dimension std; a units mismatch shows up as an out-of-distribution first action despite passing shape checks. Expect agreement in direction and magnitude, not equality — the policy samples. For (b), the most valuable outcome is usually silence: a corruption the server accepts is a documented contract gap, and the deliverable is the loud client-side check you add in response.
Where this goes next: “Fine-tuning OpenPI on your robot” produced the checkpoint; this lesson wrapped it in a contract, a cadence, and a safety clause, and started logging a timing record for every chunk served. The next phase opens with “The critical path: tracing observation to actuation”, which stretches the four timestamps you now capture per request into a full end-to-end trace — photons to torque. The p50/p95/p99 you pinned in the checklist stop being a report and become the baseline your latency-aware chunking research has to beat.