Skip to content

Commit 757aa95

Browse files
fix(examples): policy acts on the freshest observation, not a backlog
A ~500ms glass-to-glass with a ~50ms codec_lag showed the latency was added after the frame arrived: the policy decoded every observation inline, so when decode could not keep up a standing queue built and every reply carried a stale capture time. That defeats the point of Portal. Adopt the inference example's pattern: keep only the freshest observations in a bounded deque and always decode the newest, dropping the backlog. Latency now tracks the network round trip. Codec lag is measured at arrival so the number stays meaningful.
1 parent e3118ad commit 757aa95

2 files changed

Lines changed: 50 additions & 22 deletions

File tree

examples/python/modal-mock-inference/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,11 @@ What each field means:
131131
- **`sent`** frames the robot has published.
132132
- **`replies`** decoded tokens that came back from the policy.
133133
- **`dropped`** frames that never made the round trip. A `seq` gap means the
134-
frame was lost in the video pipe or its QR was too degraded to read. This is
135-
real frame loss the video layer normally hides.
134+
frame was lost in the video pipe, its QR was too degraded to read, or the
135+
policy skipped it. The policy keeps only the freshest observation (a small
136+
`deque`), so if decoding cannot keep up it drops the backlog and acts on the
137+
newest frame. That keeps latency near the network round trip instead of
138+
letting a queue build, which is the whole point of running a policy on Portal.
136139
- **`glass2glass`** the full video round trip, `now - capture_us`. This is the
137140
headline number.
138141
- **`codec_lag`** how much later the camera frame arrived than the joint state

examples/python/modal-mock-inference/policy.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import os
2323
import pathlib
2424
import time
25+
from collections import deque
2526
from typing import Optional
2627

2728
import modal
@@ -90,29 +91,31 @@ async def main() -> None:
9091
policy = Policy()
9192
hits = misses = 0
9293

93-
# When each raw state packet arrived, by seq, on our own clock. Portal pairs
94-
# a frame and a state by the capture time the robot stamped, so the video's
95-
# extra delay never shows up as a timestamp. Instead the fused observation
96-
# fires later than the state, once the matching frame lands. obs-fire minus
97-
# state-arrival is that extra video delay, one way.
94+
# When each raw state packet arrived, by seq, on our own clock.
9895
state_arrival: dict[int, float] = {}
9996

97+
# Only ever hold the freshest observations. If decoding cannot keep up with
98+
# the frame rate, we drop the backlog and act on the newest frame instead of
99+
# working through stale ones. Acting on a stale observation is what would
100+
# defeat the point of Portal, and it shows up as latency that climbs far
101+
# above the network round trip.
102+
latest: deque = deque(maxlen=2)
103+
got_obs = asyncio.Event()
104+
100105
def on_state(state: State) -> None:
101106
state_arrival[int(state.values["seq"])] = time.monotonic()
102107

103108
def on_observation(obs: Observation) -> None:
104-
nonlocal hits, misses
105-
action = policy.get_action(obs) # QR decode is fast, so run it inline
106-
if action is None:
107-
misses += 1
108-
return
109-
arrived = state_arrival.pop(int(action["seq"]), None)
109+
# Measure codec lag here, at arrival, so a decode backlog cannot distort
110+
# it. Portal pairs the frame and state on the capture time the robot
111+
# stamped, so the video's extra delay is not a timestamp gap. It is the
112+
# observation firing later than the state, once the frame lands. That gap
113+
# (obs arrival minus state arrival) is the one-way video-over-data cost.
114+
seq = int(obs.state["seq"])
115+
arrived = state_arrival.pop(seq, None)
110116
lag_us = int((time.monotonic() - arrived) * 1_000_000) if arrived is not None else 0
111-
action["codec_lag_us"] = float(lag_us)
112-
# in_reply_to_ts_us feeds Portal's built-in e2e metric the QR capture
113-
# time, which turns that metric into a glass-to-glass number.
114-
op.send_action(action, in_reply_to_ts_us=int(action["t_capture_us"]))
115-
hits += 1
117+
latest.append((obs, lag_us))
118+
got_obs.set()
116119

117120
op.on_state(on_state)
118121
op.on_observation(on_observation)
@@ -130,12 +133,34 @@ def on_observation(obs: Observation) -> None:
130133
print(f"[policy] robot '{op.robot_identity()}' joined, controlling as '{op.local_identity()}'")
131134

132135
stop_at = time.monotonic() + DURATION_S + 5.0 # outlast the robot a little
136+
last_log = time.monotonic()
133137
try:
134138
while time.monotonic() < stop_at:
135-
await asyncio.sleep(1.0)
136-
total = hits + misses
137-
rate = (hits / total * 100.0) if total else 0.0
138-
print(f"[policy] decoded={hits} missed={misses} decode_rate={rate:.0f}%")
139+
try:
140+
await asyncio.wait_for(got_obs.wait(), timeout=1.0)
141+
except asyncio.TimeoutError:
142+
pass
143+
got_obs.clear()
144+
145+
if latest:
146+
obs, lag_us = latest[-1] # freshest; any older frames are skipped
147+
latest.clear()
148+
action = policy.get_action(obs)
149+
if action is None:
150+
misses += 1
151+
else:
152+
action["codec_lag_us"] = float(lag_us)
153+
# in_reply_to_ts_us feeds Portal's built-in e2e metric the QR
154+
# capture time, making that metric a glass-to-glass number.
155+
op.send_action(action, in_reply_to_ts_us=int(action["t_capture_us"]))
156+
hits += 1
157+
158+
now = time.monotonic()
159+
if now - last_log >= 1.0:
160+
total = hits + misses
161+
rate = (hits / total * 100.0) if total else 0.0
162+
print(f"[policy] decoded={hits} missed={misses} decode_rate={rate:.0f}%")
163+
last_log = now
139164
finally:
140165
print(f"[policy] decoded {hits} frames, disconnecting...")
141166
await op.disconnect()

0 commit comments

Comments
 (0)