roostField / Lab
Curriculum
Phase 04Lesson 5 of 5
75 min
Adapt a VLAWeeks 11–14

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.

After this lesson you can
  • 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 Tc=1/fcT_c=1/f_c is the fixed interval at which the client consumes buffered actions; at 50 Hz, Tc=20T_c=20 ms. The fetch latency Te2eT_{e2e} runs from sending an observation request until the resulting chunk is in the client buffer, including network and inference. Actuation latency TactT_{act} 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 B(t)B(t) is a concrete countdown. If rr actions remain, then B(t)=rTcB(t)=rT_c. A request fired at time tfiret_{fire} is safe when the old buffer lasts at least until the new chunk arrives: B(tfire)Te2ep99B(t_{fire}) \ge T_{e2e}^{p99}. Express the threshold as actions remaining, not as an ambiguous executed index: fire when rr reaches kfire=Te2ep99/Tck_{fire}=\lceil T_{e2e}^{p99}/T_c\rceil. The motor pipeline can continue applying already issued commands during its fixed actuation delay; it does not consume an extra future chunk action.

kfire=Te2ep99Tck_{fire} = \left\lceil \frac{T_{e2e}^{p99}}{T_c} \right\rceil
The no-starvation threshold converts request-to-chunk-in-hand latency into actions of playback cover. Track actuation latency separately in the observation-to-motion budget.
SymbolDefinitionTypical Value (WidowX AI)Unit
TcT_cControl Period (1/fc1/f_c)20ms
TactT_{act}Actuation Latency (separate from fetch cover)5ms
Te2ep99T_{e2e}^{p99}99th Percentile End-to-End Latency260ms
kfirek_{fire}Fire Threshold (Actions Remaining)13actions
Temporal Variables and Their Units

Worked Example: Deriving the Fire Threshold

Consider a deployment with a control rate fc=50f_c = 50 Hz (Tc=20T_c = 20 ms), a chunk size H=50H = 50, and measured request-to-chunk-in-hand latency Te2ep99=260T_{e2e}^{p99}=260 ms. Actuation latency is measured separately as 5 ms. Determine the correct kfirek_{fire} for command-buffer starvation, then state where the 5 ms belongs.

  1. The buffer must cover the fetch: Tcover=Te2ep99=260 msT_{cover}=T_{e2e}^{p99}=260\text{ ms}.
  2. Convert time to action count: kraw=260/20=13k_{raw}=260/20=13 actions.
  3. The ceiling changes nothing here: kfire=13=13k_{fire}=\lceil13\rceil=13 actions remaining.
  4. Verify: 13 actions provide 13×20=26013\times20=260 ms of playback. The separate 5 ms actuation latency belongs in observation-to-motion staleness and stability analysis, not in this buffer-arrival inequality.
Checkpoint 01

If request-to-chunk-in-hand Te2ep99=260T_{e2e}^{p99}=260 ms, Tc=20T_c=20 ms, and Tact=5T_{act}=5 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 H×dH \times d array, for π₀ typically H=50H = 50 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 3×150,5283 \times 150{,}528 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 (H,d)(H, d), 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.

Checkpoint 02

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.

DimensionLLM servingPolicy serving
ObjectiveThroughput subject to a latency SLO; saturate the GPUTail latency, full stop; the p99 is the product
Batch sizeContinuous batching across dozens–hundreds of requestsExactly one, forever; the GPU idles between chunks by design
QueueingDeep queues, fairness, prioritiesAt most one request in flight; a stale request is dropped, never served
Load profileBursty, diurnal, multi-tenantMetronomic: one client, one request every ~740 ms, indefinitely
WarmupGradual; caches warm as traffic arrivesMandatory before first motion; the first JAX call triggers XLA compilation measured in tens of seconds
Retry semanticsIdempotent, harmlessA retry carries a newer observation and must supersede the original, not join it in a queue
Failure modeA slow response; the user retriesA stale action executed at full torque; the watchdog is the last line
Design pressures: LLM serving vs. robot policy serving

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 HH actions consumed at control rate fcf_c buys a fixed playback window, and the server's duty cycle follows:

Tchunk  =  Hfc  =  5050Hz  =  1.0s,duty  =  TinferTcycle    110ms740ms    15%T_{\text{chunk}} \;=\; \frac{H}{f_c} \;=\; \frac{50}{50\,\text{Hz}} \;=\; 1.0\,\text{s}, \qquad \text{duty} \;=\; \frac{T_{\text{infer}}}{T_{\text{cycle}}} \;\approx\; \frac{110\,\text{ms}}{740\,\text{ms}} \;\approx\; 15\%
One π₀ chunk buys one second of motion. With the fire threshold derived below, a new request fires every 740 ms, so the GPU that fine-tuned for hours now works 15% of the time — and that idleness is correct, not waste.

When must the client request the next chunk? Derive it. Let B(t)B(t) be playback time remaining in the buffer, tfiret_{\text{fire}} the moment the request is sent, and Te2eT_{\text{e2e}} the full request-to-chunk-in-hand time. The buffer exhausts at tfire+B(tfire)t_{\text{fire}} + B(t_{\text{fire}}); the new chunk lands at tfire+Te2et_{\text{fire}} + T_{\text{e2e}}. 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:

tfire+Te2ep99    texhaustB(tfire)    Te2ep99kfire  =  fcTe2ep99  actionst_{\text{fire}} + T_{\text{e2e}}^{p99} \;\le\; t_{\text{exhaust}} \quad\Longrightarrow\quad B(t_{\text{fire}}) \;\ge\; T_{\text{e2e}}^{p99} \quad\Longrightarrow\quad k_{\text{fire}} \;=\; \left\lceil f_c \, T_{\text{e2e}}^{p99} \right\rceil \;\text{actions}
Fire when the buffer holds fewer than k_fire actions. With p99 = 260 ms at 50 Hz: k_fire = 13. The client executes 37 of every 50 actions and discards the tail.

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 kk executes on information Te2e+k/fcT_{\text{e2e}} + k/f_c 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.

Checkpoint 03

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.

chunk_scheduler.py — latest-wins chunk client with a starvation guardpython
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 t0t_0, server receive t1t_1, server send t2t_2, client receive t3t_3 — plus the inter-clock offset θ\theta. 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 dfd_f and drd_r and the server clock running θ\theta ahead:

t1=t0+df+θ,t3=t2+drθt_1 = t_0 + d_f + \theta, \qquad t_3 = t_2 + d_r - \theta
Two unknown delays, one unknown offset, four measured stamps.

Solve each equation for θ\theta — the first gives θ=(t1t0)df\theta = (t_1 - t_0) - d_f, the second gives θ=(t2t3)+dr\theta = (t_2 - t_3) + d_r — and average them so the unknown delays appear only as their difference:

θ^  =  (t1t0)+(t2t3)2  =  θ+dfdr2,θ^θ    RTTnet2,RTTnet=(t3t0)(t2t1)\hat{\theta} \;=\; \frac{(t_1 - t_0) + (t_2 - t_3)}{2} \;=\; \theta + \frac{d_f - d_r}{2}, \qquad \left|\hat{\theta} - \theta\right| \;\le\; \frac{\text{RTT}_{\text{net}}}{2}, \quad \text{RTT}_{\text{net}} = (t_3 - t_0) - (t_2 - t_1)
The estimate is exact when the path is symmetric; the error is bounded by half the network round trip. On your wired link, RTT is ~1 ms, so the offset is good to ±0.5 ms — far tighter than any latency you care about.
timed_policy.py — clock alignment plus a timing record per requestpython
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"], rec

Run the offset handshake at episode start, log θ^\hat{\theta} 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:

  1. Version pinned. One contract string covering checkpoint, norm-stats, and transform-code revisions; echoed in every response, client hard-fails on mismatch.
  2. Warmup verified. Dummy inferences at production shapes until latency stabilizes; the episode logger refuses to start before steady state.
  3. 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.
  4. 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.
  5. 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.
  6. Clocks aligned. Offset estimated at episode start, logged with its RTT bound, re-checked at episode end to bound drift.
Studio exercise 01

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.