World-Action-Verifier / src /interactive_uncertainty.py
ffeng1017's picture
WAV interactive demo (ZeroGPU)
23a59ea verified
Raw
History Blame Contribute Delete
64.8 kB
# interactive_uncertainty.py
"""Browser interface for open-ended, interactive rollouts of the trained world
model, with the three hallucination predictors (u_r, u_f, u_s) reported and
displayed as independent signals rather than merged into one worst-of state
(cf. interactive.py). Serves a local web UI (default http://localhost:7860).
Run from inside ``src/`` (flat imports), e.g.
``python interactive_uncertainty.py``.
"""
import os
import math
import json
import time
import argparse
import asyncio
import concurrent.futures
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, List, Set, Tuple, Union
import numpy as np
import torch
import torch.nn.functional as F
from aiohttp import web, WSMsgType
from PIL import Image
import io
from task_set import TASK_SET, UNSEEN_TASK_SET
# Metadata fallback for the unseen test tasks. Maps each entry of
# UNSEEN_TASK_SET to a TASK_SET task whose tasks_json entry (language
# embedding, action_dim) should be borrowed when the UNSEEN task itself
# isn't in tasks.json. Keys are kept in lockstep with UNSEEN_TASK_SET
# (10 entries).
TEST_TASK_SET: Dict[str, str] = {
# DMControl visual variants — same dynamics as the named base.
'cup-catch-var1': 'cup-catch',
'finger-turn-easy-var1': 'finger-turn-easy',
# ManiSkill object swap.
'ms-push-banana': 'ms-push-cube',
# OGBench layout swap.
'og-point-var1': 'og-point-maze',
'og-point-var2': 'og-point-maze',
# PyGame point-maze layout swap.
'pygame-point-maze-var4': 'pygame-point-maze-var3',
# PyGame "completely unseen" entries borrow from the closest analog.
'pygame-reacher-easy': 'pygame-air-hockey',
'pygame-dungeon-explorer1': 'pygame-point-maze-var1',
'pygame-foraging': 'pygame-rocket-collect',
'pygame-whirlpool': 'pygame-rocket-collect',
}
from model import (
Encoder, Decoder, Tokenizer, Dynamics,
temporal_patchify, temporal_unpatchify,
RewardHeadMTP, PolicyHeadMTP, symexp,
)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
def pack_bottleneck_to_spatial(z_btLd: torch.Tensor, *, n_spatial: int, k: int) -> torch.Tensor:
# (B,T,L,Db) -> (B,T,n_spatial,k*Db) with L == n_spatial*k
B, T, L, Db = z_btLd.shape
assert L == n_spatial * k, f"L={L} != n_spatial*k={n_spatial*k}"
return z_btLd.view(B, T, n_spatial, k, Db).reshape(B, T, n_spatial, k * Db)
def unpack_spatial_to_bottleneck(z_packed: torch.Tensor, *, k: int, d_bottleneck: int) -> torch.Tensor:
# (B,T,n_spatial,k*Db) -> (B,T,n_spatial*k,Db)
B, T, n_spatial, Dz = z_packed.shape
assert Dz == k * d_bottleneck, f"Dz={Dz} != k*Db={k*d_bottleneck}"
return z_packed.view(B, T, n_spatial, k, d_bottleneck).reshape(B, T, n_spatial * k, d_bottleneck)
def _as_2d_packed(z: torch.Tensor) -> torch.Tensor:
# ensure (n_spatial, d_spatial)
if z.dim() == 2:
return z
if z.dim() == 3 and z.shape[0] == 1:
return z[0]
raise RuntimeError(f"Unexpected packed latent shape: {tuple(z.shape)}")
def _is_pow2_frac(x: float) -> bool:
if x <= 0 or x > 1:
return False
inv = round(1.0 / x)
return abs(1.0 / inv - x) < 1e-8 and (inv & (inv - 1)) == 0
def make_tau_schedule(*, k_max: int, schedule: str = "finest", d: Optional[float] = None) -> Dict[str, Any]:
"""
Returns:
K: Euler steps
e: log2(K) (rounded)
dt: step size
tau: [i/K]
tau_idx: discrete indices on k_max grid
"""
schedule = str(schedule)
if schedule == "finest":
K = int(k_max)
dt = 1.0 / float(K)
elif schedule == "shortcut":
assert d is not None and _is_pow2_frac(float(d)), "shortcut requires d = 1/(power of two)"
dt = float(d)
K = int(round(1.0 / dt))
if dt < 1.0 / float(k_max):
raise ValueError(f"shortcut d={dt} is finer than finest 1/k_max={1.0/k_max}")
else:
raise ValueError(f"Unknown schedule: {schedule}")
e = int(round(math.log2(K)))
tau = [i / float(K) for i in range(K)]
stride = k_max // K
if stride <= 0:
raise ValueError(f"k_max={k_max} must be >= K={K}")
tau_idx = [i * stride for i in range(K)]
return {"K": K, "e": e, "dt": dt, "tau": tau, "tau_idx": tau_idx}
def reward_from_reward_head_output(logits_lk: torch.Tensor, centers_symlog: torch.Tensor) -> float:
"""
logits_lk: (L,K) or (1,L,K)
centers_symlog: (K,) (RewardHeadMTP centers_log)
Returns: scalar reward in original reward space (inverse symlog)
"""
if logits_lk.dim() == 3:
logits_lk = logits_lk[0]
logits_k = logits_lk[0] # l=0 head
probs = logits_k.float().softmax(dim=-1)
symlog_hat = (probs * centers_symlog.float()).sum(dim=-1)
return float(symexp(symlog_hat).item())
@torch.inference_mode()
def sample_one_timestep_packed(
dyn: Dynamics,
*,
past_packed: torch.Tensor, # (B,t,n_spatial,d_spatial)
k_max: int,
sched: Dict[str, Any],
actions: Optional[torch.Tensor] = None, # (B,t+1,A) (action[0]=0)
act_mask: Optional[torch.Tensor] = None, # (B,t+1,A) or (A,)
use_amp: bool = True,
return_h: bool = False,
tau_ctx: float = 0.0, # context corruption level
lang_emb: Optional[torch.Tensor] = None, # (B,lang_dim) task embedding
z_prev: Optional[torch.Tensor] = None, # (B,n_spatial,d_spatial) previous latent for warm start
tau_init: float = 0.0, # warm-start noise level (0 = pure noise)
use_kv_cache: bool = False, # enable KV caching for context tokens
) -> Union[Tuple[torch.Tensor, float], Tuple[torch.Tensor, torch.Tensor, float]]:
"""
Generate next packed latent z_{t}: (B,n_spatial,d_spatial) given past length t.
Always returns a trailing `instability` scalar (float): the mean RMS change in
x1_hat across the tail half of executed Euler steps. Low = confident denoising
(x1_hat stabilizes); high = the model keeps revising its prediction and is
likely hallucinating / in OOD territory.
If return_h=True, also returns h_last for the *new* timestep only: (B,1,...)
aligned with z_t.
When z_prev is provided and tau_init > 0, the denoising is warm-started by
initializing z as a blend of noise and z_prev at level tau_init, then skipping
denoising steps below that level. This reduces frame-to-frame jitter by
anchoring the initial state to the previous prediction.
When use_kv_cache=True and t > 0, the context tokens (positions 0..t-1) are
processed once in a prefill pass and their time-attention K,V are cached. Each
denoising step then only runs the single new token through the transformer,
attending to the cached context. This reduces per-step attention cost from
O(t+1) to O(1).
"""
device = past_packed.device
dtype = past_packed.dtype
B, t = past_packed.shape[:2]
n_spatial, d_spatial = past_packed.shape[2], past_packed.shape[3]
K = int(sched["K"])
e = int(sched["e"])
tau = sched["tau"]
tau_idx = sched["tau_idx"]
dt = float(sched["dt"])
# Initialize from noise, optionally warm-started toward z_prev
z = torch.randn((B, 1, n_spatial, d_spatial), device=device, dtype=dtype)
if z_prev is not None and tau_init > 0.0:
zp = z_prev.unsqueeze(1) if z_prev.dim() == 3 else z_prev # (B,1,n_spatial,d_spatial)
z = ((1.0 - tau_init) * z.float() + tau_init * zp.float()).to(dtype)
emax = int(round(math.log2(int(k_max))))
# Slightly corrupt past context tokens for robustness to autoregressive errors.
if tau_ctx > 0.0 and t > 0:
z0_ctx = torch.randn_like(past_packed)
past_input = ((1.0 - tau_ctx) * past_packed.float() + tau_ctx * z0_ctx.float()).to(dtype)
ctx_sig_idx = min(int(round((1.0 - tau_ctx) * k_max)), k_max)
else:
past_input = past_packed
ctx_sig_idx = k_max
if act_mask is not None and act_mask.dim() == 1:
act_mask = act_mask.view(1, 1, -1).expand(B, t + 1, -1)
actions_in = None if actions is None else actions[:, : t + 1]
actmask_in = None if act_mask is None else act_mask[:, : t + 1]
# --- KV cache: prefill context tokens once ---
kv_cache = None
if use_kv_cache and t > 0:
ctx_step_idxs = torch.full((B, t), emax, device=device, dtype=torch.long)
ctx_signal_idxs = torch.full((B, t), ctx_sig_idx, device=device, dtype=torch.long)
ctx_actions = None if actions_in is None else actions_in[:, :t]
ctx_actmask = None if actmask_in is None else actmask_in[:, :t]
with torch.autocast(device_type=device.type, enabled=(use_amp and device.type == "cuda")):
_, _, kv_cache = dyn(
ctx_actions,
ctx_step_idxs,
ctx_signal_idxs,
past_input,
act_mask=ctx_actmask,
agent_tokens=None,
lang_emb=lang_emb,
return_kv_cache=True,
)
h_last_full = None
# Track denoising-trajectory instability: step-to-step RMS change in x1_hat.
x1_hat_prev: Optional[torch.Tensor] = None
step_deltas: List[torch.Tensor] = []
for i in range(K):
tau_i = float(tau[i])
if tau_i + dt <= tau_init:
continue # skip steps below warm-start level
sig_i = int(tau_idx[i])
with torch.autocast(device_type=device.type, enabled=(use_amp and device.type == "cuda")):
if kv_cache is not None:
# Decode mode: only process the new token (position t)
new_step_idxs = torch.full((B, 1), e, device=device, dtype=torch.long)
new_signal_idxs = torch.full((B, 1), sig_i, device=device, dtype=torch.long)
new_actions = None if actions_in is None else actions_in[:, -1:]
new_actmask = None if actmask_in is None else actmask_in[:, -1:]
x1_hat, h_t_full = dyn(
new_actions,
new_step_idxs,
new_signal_idxs,
z,
act_mask=new_actmask,
agent_tokens=None,
lang_emb=lang_emb,
kv_cache=kv_cache,
)
else:
# Full sequence mode (no cache or t == 0)
step_idxs_full = torch.full((B, t + 1), emax, device=device, dtype=torch.long)
step_idxs_full[:, -1] = e
signal_idxs_full = torch.full((B, t + 1), ctx_sig_idx, device=device, dtype=torch.long)
signal_idxs_full[:, -1] = sig_i
packed_seq = torch.cat([past_input, z], dim=1) # (B,t+1,...)
x1_hat_full, h_t_full = dyn(
actions_in,
step_idxs_full,
signal_idxs_full,
packed_seq,
act_mask=actmask_in,
agent_tokens=None,
lang_emb=lang_emb,
)
x1_hat = x1_hat_full[:, -1:, :, :]
if return_h:
h_last_full = h_t_full
x1_hat_f = x1_hat.float()
if x1_hat_prev is not None:
step_deltas.append((x1_hat_f - x1_hat_prev).pow(2).mean().sqrt())
x1_hat_prev = x1_hat_f
denom = max(1e-4, 1.0 - tau_i)
b = (x1_hat_f - z.float()) / denom
z = (z.float() + b * dt).to(dtype)
# Instability score = mean RMS(x1_hat_i - x1_hat_{i-1}) over the tail half of
# executed steps. Tail-only: the model is always "uncertain" at high noise
# levels; only late-step flux reflects true disagreement.
if step_deltas:
all_deltas = torch.stack(step_deltas)
tail = all_deltas[len(all_deltas) // 2 :] if len(all_deltas) > 1 else all_deltas
instability = float(tail.mean().item())
else:
instability = 0.0
z_next = z[:, 0] # (B,n_spatial,d_spatial)
if not return_h:
return z_next, instability
if h_last_full is None:
raise RuntimeError("return_h=True but dyn returned h_t_full=None (check n_agent / dyn impl).")
# Return representation for the *new* timestep only (the appended position).
h_new = h_last_full[:, -1:] # (B,1,...) e.g. (B,1,n_agent,D) or (B,1,D)
return z_next, h_new, instability
class _EnvCfg:
"""Minimal config object satisfying envs.make_env(cfg) requirements.
Seeds each episode's initial frame from a live Gymnasium env's reset().
"""
def __init__(self, task: str, img_size: int = 224, seed: int = 0):
self.task = task
self.obs = 'rgb'
self.seed = seed
self.child_env = True
self.num_envs = 1
self.save_video = False
self.rank = 0
self.render_size = img_size
self.obs_shape = None
self.action_dim = None
self.episode_length = None
def get(self, key, default=None):
return getattr(self, key, default)
def env_obs_to_frame_chw01(obs: Any, *, H: int, W: int) -> torch.Tensor:
"""Convert an env observation (dict with 'rgb' or raw array) to (C,H,W) float32 in [0,1].
Bilinearly resizes if the rendered frame doesn't match (H, W), matching
the preprocess_dataset.py recipe.
"""
if isinstance(obs, dict):
frame = obs.get('rgb', obs)
else:
frame = obs
arr = np.asarray(frame)
if arr.ndim == 3 and arr.shape[2] == 3 and arr.shape[0] != 3:
arr = np.transpose(arr, (2, 0, 1))
if arr.dtype != np.uint8:
if float(arr.max()) <= 1.5:
arr = (arr * 255.0).clip(0, 255).astype(np.uint8)
else:
arr = arr.clip(0, 255).astype(np.uint8)
t = torch.from_numpy(arr).float() / 255.0
if t.shape[-2] != H or t.shape[-1] != W:
t = F.interpolate(
t.unsqueeze(0), size=(H, W), mode="bilinear", align_corners=False
)[0].clamp(0.0, 1.0)
return t.contiguous()
def load_task_action_dim(tasks_json: str, task: str, *, default_dim: int = 16) -> int:
try:
with open(tasks_json, "r") as f:
meta = json.load(f)
if task in meta and "action_dim" in meta[task]:
return int(meta[task]["action_dim"])
except Exception:
pass
return int(default_dim)
def _strip_prefix(sd: dict, prefix: str) -> dict:
if not any(k.startswith(prefix) for k in sd.keys()):
return sd
return {k[len(prefix):]: v for k, v in sd.items()}
def _looks_like_state_dict(d: dict) -> bool:
if not isinstance(d, dict) or len(d) == 0:
return False
k0 = next(iter(d.keys()))
v0 = d[k0]
return isinstance(k0, str) and (torch.is_tensor(v0) or isinstance(v0, torch.nn.Parameter))
def _get_state_dict(ckpt: dict) -> dict:
if _looks_like_state_dict(ckpt):
sd = ckpt
else:
for k in ("dynamics", "dyn_model", "model", "dyn", "state_dict"):
v = ckpt.get(k, None)
if isinstance(v, dict):
if "state_dict" in v and isinstance(v["state_dict"], dict) and _looks_like_state_dict(v["state_dict"]):
v = v["state_dict"]
if _looks_like_state_dict(v):
sd = v
break
else:
raise KeyError(f"Could not find state dict in checkpoint keys={list(ckpt.keys())}")
for pfx in ("_orig_mod.", "module.", "dynamics.", "dyn."):
sd = _strip_prefix(sd, pfx)
return sd
def load_tokenizer_from_ckpt(tokenizer_ckpt: str, device: torch.device):
ckpt = torch.load(tokenizer_ckpt, map_location="cpu")
a = ckpt.get("args", {}) or {}
H = int(a.get("H", 224))
W = int(a.get("W", 224))
C = int(a.get("C", 3))
patch = int(a.get("patch", 4))
d_model = int(a.get("d_model", 256))
n_heads = int(a.get("n_heads", 4))
depth = int(a.get("depth", 6))
n_latents = int(a.get("n_latents", 16))
d_bottleneck = int(a.get("d_bottleneck", 32))
dropout = float(a.get("dropout", 0.0))
mlp_ratio = float(a.get("mlp_ratio", 4.0))
time_every = int(a.get("time_every", 1))
assert H % patch == 0 and W % patch == 0
n_patches = (H // patch) * (W // patch)
d_patch = patch * patch * C
enc = Encoder(
patch_dim=d_patch,
d_model=d_model,
n_latents=n_latents,
n_patches=n_patches,
n_heads=n_heads,
depth=depth,
d_bottleneck=d_bottleneck,
dropout=dropout,
mlp_ratio=mlp_ratio,
time_every=time_every,
mae_p_min=0.0,
mae_p_max=0.0,
)
dec = Decoder(
d_bottleneck=d_bottleneck,
d_model=d_model,
n_heads=n_heads,
depth=depth,
n_latents=n_latents,
n_patches=n_patches,
d_patch=d_patch,
dropout=dropout,
mlp_ratio=mlp_ratio,
time_every=time_every,
)
tok = Tokenizer(enc, dec).to(device)
tok.load_state_dict(_get_state_dict(ckpt), strict=True)
tok.eval()
for p in tok.parameters():
p.requires_grad_(False)
info = dict(H=H, W=W, C=C, patch=patch, n_latents=n_latents, d_bottleneck=d_bottleneck)
return tok, info
def _get_rew_head_state_dict(ckpt: dict) -> dict:
for k in ("rew_head", "reward_head"):
v = ckpt.get(k, None)
if isinstance(v, dict):
if "state_dict" in v and isinstance(v["state_dict"], dict) and _looks_like_state_dict(v["state_dict"]):
v = v["state_dict"]
if _looks_like_state_dict(v):
sd = v
break
else:
raise KeyError(f"Could not find reward head state dict in ckpt keys={list(ckpt.keys())}")
for pfx in ("module.", "rew_head.", "reward_head."):
sd = _strip_prefix(sd, pfx)
return sd
def _get_policy_head_state_dict(ckpt: dict) -> dict:
for k in ("policy_head", "bc_head"):
v = ckpt.get(k, None)
if isinstance(v, dict):
if "state_dict" in v and isinstance(v["state_dict"], dict) and _looks_like_state_dict(v["state_dict"]):
v = v["state_dict"]
if _looks_like_state_dict(v):
sd = v
break
else:
raise KeyError(f"Could not find policy head state dict in ckpt keys={list(ckpt.keys())}")
for pfx in ("module.", "policy_head.", "bc_head."):
sd = _strip_prefix(sd, pfx)
return sd
def load_dynamics_from_ckpt(
dynamics_ckpt: str,
*,
device: torch.device,
d_bottleneck: int,
n_latents: int,
packing_factor: int,
):
ckpt = torch.load(dynamics_ckpt, map_location="cpu")
a = ckpt.get("args", {}) or {}
# dynamics
d_model = int(a.get("d_model_dyn", a.get("dyn_d_model", a.get("d_model", 256))))
n_heads = int(a.get("n_heads", 4))
depth = int(a.get("dyn_depth", a.get("depth", 8)))
dropout = float(a.get("dropout", 0.0))
mlp_ratio = float(a.get("mlp_ratio", 4.0))
time_every = int(a.get("time_every", 4))
k_max = int(a.get("k_max", 8))
n_register = int(a.get("n_register", 4))
n_agent = int(a.get("n_agent", 0))
lang_dim = int(a.get("lang_dim", 0))
# reward
reward_L = int(a.get("reward_L", 8))
reward_num_bins = int(a.get("reward_num_bins", 101))
reward_log_low = float(a.get("reward_log_low", -8.0))
reward_log_high = float(a.get("reward_log_high", 8.0))
reward_mlp_ratio = float(a.get("reward_mlp_ratio", 2.0))
reward_pool_agent = str(a.get("reward_pool_agent", "attn"))
# bc policy
bc_L = int(a.get("bc_L", 8))
bc_act_dim_max = int(a.get("bc_act_dim", 16))
bc_mlp_ratio = float(a.get("bc_mlp_ratio", 2.0))
bc_pool_agent = str(a.get("bc_pool_agent", "attn"))
assert n_latents % packing_factor == 0
n_spatial = n_latents // packing_factor
d_spatial = d_bottleneck * packing_factor
dyn = Dynamics(
d_model=d_model,
d_bottleneck=d_bottleneck,
d_spatial=d_spatial,
n_spatial=n_spatial,
n_register=n_register,
n_agent=n_agent,
n_heads=n_heads,
depth=depth,
k_max=k_max,
dropout=dropout,
mlp_ratio=mlp_ratio,
time_every=time_every,
lang_dim=lang_dim,
).to(device)
dyn.load_state_dict(_get_state_dict(ckpt), strict=True)
dyn.eval()
# reward head (optional — may not be present in older checkpoints)
rew_head = None
try:
rew_sd = _get_rew_head_state_dict(ckpt)
rew_head = RewardHeadMTP(
d_model=d_model,
L=int(reward_L),
num_bins=int(reward_num_bins),
log_low=float(reward_log_low),
log_high=float(reward_log_high),
mlp_ratio=float(reward_mlp_ratio),
dropout=0.0,
pool_agent=str(reward_pool_agent),
).to(device)
rew_head.load_state_dict(rew_sd, strict=True)
rew_head.eval()
for p in rew_head.parameters():
p.requires_grad_(False)
except KeyError:
pass
# policy head (optional — BC-finetuned ckpts have this; earlier ckpts do not)
policy_head = None
try:
pol_sd = _get_policy_head_state_dict(ckpt)
policy_head = PolicyHeadMTP(
d_model=d_model,
L=int(bc_L),
act_dim_max=int(bc_act_dim_max),
mlp_ratio=float(bc_mlp_ratio),
dropout=0.0,
pool_agent=str(bc_pool_agent),
).to(device)
policy_head.load_state_dict(pol_sd, strict=True)
policy_head.eval()
for p in policy_head.parameters():
p.requires_grad_(False)
except KeyError:
pass
return dyn, rew_head, policy_head, {"k_max": k_max, "n_spatial": n_spatial, "d_spatial": d_spatial, "d_model": d_model, "lang_dim": lang_dim}
@torch.inference_mode()
def decode_single_packed_frame(
decoder: Decoder,
*,
z_packed: torch.Tensor, # (n_spatial,d_spatial) or (1,n_spatial,d_spatial)
H: int, W: int, C: int, patch: int,
packing_factor: int,
d_bottleneck: int,
) -> torch.Tensor:
z2 = _as_2d_packed(z_packed)
z_bt = z2.unsqueeze(0).unsqueeze(0) # (1,1,n_spatial,d_spatial)
z_btLd = unpack_spatial_to_bottleneck(z_bt, k=packing_factor, d_bottleneck=d_bottleneck)
patches = decoder(z_btLd) # (1,1,Np,Dp)
frames = temporal_unpatchify(patches, H, W, C, patch) # (1,1,C,H,W)
return frames[0, 0].clamp(0, 1)
def frame_to_jpeg_bytes(frame_chw_01: torch.Tensor, *, quality: int = 85) -> bytes:
fr_u8 = (frame_chw_01.clamp(0, 1) * 255.0).to(torch.uint8).detach().cpu().numpy()
hwc = np.transpose(fr_u8, (1, 2, 0))
im = Image.fromarray(hwc, mode="RGB")
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=int(quality), optimize=True)
return buf.getvalue()
def frame_to_uint8_hwc(frame_chw_01: torch.Tensor) -> np.ndarray:
"""(C,H,W) float [0,1] -> (H,W,3) uint8 — used to buffer recorded frames."""
return (
frame_chw_01.clamp(0, 1).float().permute(1, 2, 0).detach().cpu().numpy() * 255.0
).astype(np.uint8)
def save_recording_mp4(path: Path, frames_hwc: List[np.ndarray], fps: float) -> None:
"""Write a list of (H,W,3) uint8 frames to ``path`` as an mp4 (libx264).
macro_block_size=1 avoids
silent padding for 224x224 frames, quality=8 is visually lossless-ish.
"""
if not frames_hwc:
return
import imageio.v2 as imageio
imageio.mimwrite(
str(path), frames_hwc,
fps=max(1, int(round(float(fps)))),
codec="libx264",
quality=8,
macro_block_size=1,
)
# Key-pair -> action-dimension bindings: single source of truth server-side
# (the client's shouldCapture list in interactive_uncertainty.html mirrors it).
KEY_BINDINGS: List[Tuple[str, str]] = [
("ArrowRight", "ArrowLeft"), # dim 0
("ArrowUp", "ArrowDown"), # dim 1
("d", "a"), # dim 2
("w", "s"), # dim 3
]
# The only keys a client can legitimately hold down (derived from the
# bindings, plus uppercase variants). Other keydown values are ignored
# server-side so junk can't grow session state.
ACTION_KEYS: Set[str] = (
{k for pair in KEY_BINDINGS for k in pair}
| {k.upper() for pair in KEY_BINDINGS for k in pair if len(k) == 1}
)
def build_action_from_keys(keys_down: Set[str], *, act_dim: int, A: int = 16) -> torch.Tensor:
a = torch.zeros(A, dtype=torch.float32)
if act_dim <= 0:
return a
# Each pair of keys maps to one action dimension; opposing keys cancel out.
# Signs chosen so that visual direction matches key direction for common tasks.
for dim, (pos_key, neg_key) in enumerate(KEY_BINDINGS):
if dim >= act_dim:
break
pos = (pos_key in keys_down) or (pos_key.upper() in keys_down if len(pos_key) == 1 else False)
neg = (neg_key in keys_down) or (neg_key.upper() in keys_down if len(neg_key) == 1 else False)
if pos and not neg:
a[dim] = +1.0
elif neg and not pos:
a[dim] = -1.0
return a
def classify_uncertainty(
value: float,
samples: List[float],
*,
z_yellow: float = 1.0,
z_red: float = 2.5,
mad_rel_floor: float = 0.05,
) -> str:
"""Classify `value` as green/yellow/red against a median/MAD baseline fitted
on `samples`. Robust to a single transient spike inside the calibration
window (median is unaffected; MAD is not inflated the way std is).
If MAD is too small relative to the median (nearly-constant calibration), we
fall back to a multiplicative threshold so we don't hair-trigger on tiny
deviations: red when value > (1 + z_red * mad_rel_floor) * median.
"""
if not samples:
return "green"
arr = np.asarray(samples, dtype=np.float64)
med = float(np.median(arr))
mad = float(np.median(np.abs(arr - med))) * 1.4826 # Gaussian-consistent scale
floor = max(abs(med) * mad_rel_floor, 1e-8)
scale = max(mad, floor)
z = (float(value) - med) / scale
if z < z_yellow:
return "green"
if z < z_red:
return "yellow"
return "red"
def load_html(path: Optional[str], *, fallback: str = "") -> str:
if not path:
return fallback
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return fallback
@dataclass
class SessionState:
task: str
keys_down: Set[str]
paused: bool
reset_requested: bool
step: int
cum_reward: float
last_reward_pred: float
last_u_f: float
last_u_r: float
last_u_s: float
calib_f_samples: List[float]
calib_r_samples: List[float]
calib_s_samples: List[float]
calib_done: bool
z0_packed: torch.Tensor
z_hist: List[torch.Tensor]
a_hist: List[torch.Tensor]
act_dim: int
act_mask_1d: torch.Tensor
lang_emb: Optional[torch.Tensor] # (1, lang_dim) or None
action_beta: float
a_smooth: torch.Tensor # (16,)
ctx_window: int
fps: float
cached_frame_id: int
cached_jpeg: Optional[bytes]
# --- Recording (only populated when --record is passed). One mp4 is
# written per episode boundary (reset / task switch / disconnect);
# `recorded_frames` accumulates uint8 HWC frames between flushes.
session_ts: str = ""
episode_id: int = 0
recorded_frames: List[np.ndarray] = field(default_factory=list)
class InteractiveServer:
def __init__(self, args: argparse.Namespace):
self.args = args
torch.manual_seed(args.seed)
np.random.seed(args.seed)
self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
self.use_amp = (not bool(args.no_amp)) and (self.device.type == "cuda")
self.infer_lock = asyncio.Lock()
self.session_seq = 0
# Live-env seeding: each episode's initial frame comes from a Gymnasium
# env's reset(). The rollout itself is pure world model (no in-rollout
# env stepping) — the env only supplies the starting frame, so no
# offline dataset is needed. MuJoCo's EGL contexts are thread-local, so
# all env touches are pinned to a single dedicated worker thread to
# avoid EGL_BAD_ACCESS across threads.
os.environ.setdefault('MUJOCO_GL', 'egl')
self.envs: Dict[str, Any] = {}
self._env_lru: List[str] = [] # least- to most-recently used
self.env_cache_max = int(getattr(args, "env_cache_max", 8) or 0)
# Overridden by a server that tracks sessions, so eviction never closes
# an env a live session is still stepping.
self.env_in_use = lambda: set()
self.env_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="env-thread"
)
# HTML
self.html = load_html(args.html, fallback="<html><body>missing html</body></html>")
# Dropdown task list = 200 training tasks + 10 UNSEEN tasks. UNSEEN
# tasks borrow lang_emb / action_dim metadata via TEST_TASK_SET; only
# the live env is fresh.
self.tasks = list(TASK_SET) + list(UNSEEN_TASK_SET)
self.initial_task = args.task if args.task in self.tasks else self.tasks[0]
# tokenizer
tok, tok_info = load_tokenizer_from_ckpt(args.tokenizer_ckpt, self.device)
self.encoder: Encoder = tok.encoder
self.decoder: Decoder = tok.decoder
self.d_bottleneck = int(tok_info["d_bottleneck"])
self.n_latents = int(tok_info["n_latents"])
self.H = int(tok_info["H"])
self.W = int(tok_info["W"])
self.C = int(tok_info["C"])
self.patch = int(tok_info["patch"])
# dynamics
self.dyn, self.rew_head, self.policy_head, dyn_info = load_dynamics_from_ckpt(
args.dynamics_ckpt,
device=self.device,
d_bottleneck=self.d_bottleneck,
n_latents=self.n_latents,
packing_factor=args.packing_factor,
)
self.k_max = int(dyn_info["k_max"])
self.n_spatial = int(dyn_info["n_spatial"])
self.d_spatial = int(dyn_info["d_spatial"])
self.sched = make_tau_schedule(
k_max=self.k_max,
schedule=args.schedule,
d=(args.eval_d if args.schedule == "shortcut" else None),
)
self.tau_ctx = float(args.tau_ctx)
self.tau_init = float(args.tau_init)
self.use_kv_cache = bool(args.kv_cache)
self.n_samples_u = max(1, int(args.n_samples_u))
# Recording: one mp4 per episode, written to recordings_dir on
# reset / task switch / ws disconnect. No-op when --record is off.
self.recordings_dir = Path(args.recordings_dir)
if args.record:
self.recordings_dir.mkdir(parents=True, exist_ok=True)
print(f"[record] enabled — writing per-episode mp4s to {self.recordings_dir}")
if args.compile:
print("[compile] compiling dynamics and decoder (first few frames will be slow)...")
self.dyn = torch.compile(self.dyn, mode="default")
self.decoder = torch.compile(self.decoder, mode="default")
# task metadata (language embeddings)
self.task_meta = None
if args.tasks_json and os.path.exists(args.tasks_json):
try:
with open(args.tasks_json, "r") as f:
self.task_meta = json.load(f)
except Exception:
pass
self.lang_dim = int(dyn_info["lang_dim"])
# The initial latent is deferred to the first new_session() call (which
# runs on env_executor) — creating the env here on the main thread would
# bind MuJoCo's EGL context to MainThread and then fail when
# env_executor later tries to render.
self.z0_packed = torch.zeros(
(self.n_spatial, self.d_spatial), device=self.device,
)
self.act_dim, self.act_mask_1d = self._compute_act_mask(self.initial_task)
def _get_or_make_env(self, task: str):
"""Lazy per-task env cache, bounded by `--env_cache_max`.
Imports envs lazily so importing this module stays cheap.
The cache used to grow without limit, which is fine for a single-user
session but not for a long-lived server: every task the viewer tries
leaves a simulator (and its threads and GL context) alive forever. It is
now LRU-bounded. Eviction closes the env, so it must never take one that
a live session still holds -- `env_in_use()` reports those, and the app
layer that owns the session registry overrides it.
"""
env = self.envs.get(task)
if env is not None:
self._env_lru = [t for t in self._env_lru if t != task] + [task]
return env
from envs import make_env as _make_env # local import: env mode only
cfg = _EnvCfg(task, img_size=self.W, seed=int(self.args.seed))
print(f"[env] creating env for task={task!r} at {self.W}x{self.H}")
env = _make_env(cfg)
self.envs[task] = env
self._env_lru = [t for t in self._env_lru if t != task] + [task]
self._evict_envs()
return env
def _evict_envs(self) -> None:
"""Drop least-recently-used envs beyond the cap, skipping in-use ones."""
cap = int(self.env_cache_max)
if cap <= 0:
return
protected = set(self.env_in_use()) | {self._env_lru[-1]} if self._env_lru else set()
i = 0
while len(self.envs) > cap and i < len(self._env_lru):
t = self._env_lru[i]
if t in protected:
i += 1
continue
env = self.envs.pop(t, None)
self._env_lru.pop(i)
print(f"[env] evicting cached env for task={t!r} (cap={cap})")
try:
if env is not None and hasattr(env, "close"):
env.close()
except Exception as e:
print(f"[env] close failed for {t!r}: {type(e).__name__}: {e}")
@torch.inference_mode()
def _encode_initial_latent(self, task: str) -> torch.Tensor:
env = self._get_or_make_env(task)
obs, _info = env.reset()
frame0 = env_obs_to_frame_chw01(obs, H=self.H, W=self.W).to(self.device)
return self._encode_frame_to_packed(frame0)
@torch.inference_mode()
def _encode_frame_to_packed(self, frame_chw_01: torch.Tensor) -> torch.Tensor:
"""Encode a single (C,H,W) frame in [0,1] to a packed latent matching z_next."""
patches = temporal_patchify(
frame_chw_01.view(1, 1, self.C, self.H, self.W), self.patch
)
with torch.autocast(device_type=self.device.type, enabled=self.use_amp):
z_btLd, _ = self.encoder(patches)
z_packed = pack_bottleneck_to_spatial(
z_btLd, n_spatial=self.n_spatial, k=self.args.packing_factor
)[0, 0]
return z_packed.to(torch.float32).detach()
def _get_lang_emb(self, task: str) -> Optional[torch.Tensor]:
"""Returns (1, lang_dim) language embedding for a task, or None.
For UNSEEN tasks (TEST_TASK_SET), borrow the embedding from the
registered SEEN base task — most UNSEEN tasks aren't in tasks.json.
"""
if self.task_meta is None:
return None
lookup = TEST_TASK_SET.get(task, task)
if lookup not in self.task_meta:
return None
te = self.task_meta[lookup].get("text_embedding", None)
if te is None:
return None
emb = torch.tensor(te, dtype=torch.float32).to(self.device)
if emb.numel() != self.lang_dim:
return None
return emb.unsqueeze(0) # (1, lang_dim)
def _compute_act_mask(self, task: str, env=None) -> Tuple[int, torch.Tensor]:
"""Return (act_dim, mask) for `task`.
- SEEN tasks: read from tasks_json (matches what the WM was trained on).
- UNSEEN tasks: prefer the live env's action_space when available;
fall back to the registered SEEN base task's tasks_json entry. The
UNSEEN task's own tasks_json entry can have a different action_dim
than the borrowed lang_emb base (e.g. pygame-dungeon-explorer1 is
mapped to pygame-point-maze-var1 for lang_emb but is itself a
different action arity).
"""
act_dim: Optional[int] = None
if task in TEST_TASK_SET:
if env is not None:
try:
act_dim = int(env.action_space.shape[0])
except Exception:
pass
if act_dim is None:
base = TEST_TASK_SET[task]
act_dim = int(load_task_action_dim(
self.args.tasks_json, base, default_dim=16,
))
else:
act_dim = int(load_task_action_dim(
self.args.tasks_json, task, default_dim=16,
))
act_dim = max(0, min(16, int(act_dim)))
mask = torch.zeros(16, dtype=torch.float32)
if act_dim > 0:
mask[:act_dim] = 1.0
return act_dim, mask.to(self.device)
def new_session(self) -> SessionState:
task = self.initial_task
# Compute the act mask off the live env so UNSEEN tasks pick up the
# right action_dim from the env's action space.
env = self._get_or_make_env(task)
act_dim, act_mask = self._compute_act_mask(task, env=env)
z0 = _as_2d_packed(self._encode_initial_latent(task))
beta = float(self.args.action_smooth_beta)
a0 = torch.zeros(16, device=self.device, dtype=torch.float32)
return SessionState(
task=task,
keys_down=set(),
paused=False,
reset_requested=False,
step=0,
cum_reward=0.0,
last_reward_pred=0.0,
last_u_f=0.0,
last_u_r=0.0,
last_u_s=0.0,
calib_f_samples=[],
calib_r_samples=[],
calib_s_samples=[],
calib_done=False,
z0_packed=z0,
z_hist=[z0],
a_hist=[torch.zeros(16, device=self.device, dtype=torch.float32)],
act_dim=act_dim,
act_mask_1d=act_mask,
lang_emb=self._get_lang_emb(task),
action_beta=beta,
a_smooth=a0,
ctx_window=int(self.args.ctx_window),
fps=float(self.args.fps),
cached_frame_id=-1,
cached_jpeg=None,
session_ts=time.strftime("%Y%m%d_%H%M%S"),
episode_id=0,
recorded_frames=[],
)
def _flush_recording(self, st: SessionState) -> Optional[Path]:
"""Write the buffered episode to mp4 and clear the buffer.
Called at every episode boundary — reset, task switch, and ws
shutdown. Silently no-ops when --record is off or the buffer is
empty (e.g. a reset with no preceding steps).
"""
if not self.args.record or not st.recorded_frames:
if st.recorded_frames:
st.recorded_frames = []
return None
filename = f"{st.task}_{st.session_ts}_ep{st.episode_id:03d}.mp4"
out_path = self.recordings_dir / filename
n = len(st.recorded_frames)
save_recording_mp4(out_path, st.recorded_frames, st.fps)
st.recorded_frames = []
print(f"[record] wrote {n} frames @ {st.fps:.1f} fps -> {out_path}")
return out_path
def _reset_session(self, st: SessionState):
self._flush_recording(st)
st.episode_id += 1
st.z0_packed = self._encode_initial_latent(st.task)
z0 = _as_2d_packed(st.z0_packed.detach())
st.z_hist = [z0]
st.a_hist = [torch.zeros(16, device=self.device, dtype=torch.float32)]
st.a_smooth = torch.zeros(16, device=self.device, dtype=torch.float32)
st.keys_down.clear()
st.paused = False
st.reset_requested = False
st.step = 0
st.cum_reward = 0.0
st.last_reward_pred = 0.0
st.last_u_f = 0.0
st.last_u_r = 0.0
st.last_u_s = 0.0
st.calib_f_samples = []
st.calib_r_samples = []
st.calib_s_samples = []
st.calib_done = False
st.cached_frame_id = -1
st.cached_jpeg = None
def _switch_task_sync(self, st: SessionState, new_task: str):
if new_task not in self.tasks:
return
self._flush_recording(st)
st.episode_id += 1
st.task = new_task
env = self._get_or_make_env(new_task)
st.act_dim, st.act_mask_1d = self._compute_act_mask(new_task, env=env)
st.lang_emb = self._get_lang_emb(new_task)
st.z0_packed = _as_2d_packed(self._encode_initial_latent(new_task))
st.z_hist = [st.z0_packed]
st.a_hist = [torch.zeros(16, device=self.device, dtype=torch.float32)]
st.a_smooth = torch.zeros(16, device=self.device, dtype=torch.float32)
st.keys_down.clear()
st.reset_requested = False
st.step = 0
st.cum_reward = 0.0
st.last_reward_pred = 0.0
st.last_u_f = 0.0
st.last_u_r = 0.0
st.last_u_s = 0.0
st.calib_f_samples = []
st.calib_r_samples = []
st.calib_s_samples = []
st.calib_done = False
st.cached_frame_id = -1
st.cached_jpeg = None
def _build_local_window(self, st: SessionState) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Returns:
past: (1,t,n_spatial,d_spatial)
actions_local: (1,t+1,16) — actions_local[:,k] = action that produced past[k]
actmask_local: (1,t+1,16)
Backward convention (matches dynamics training): actions_local[:,k] is the action
that produced frame k. actions_local[:,0] = 0 (first context frame has no
producing action), actions_local[:,t] = current action (will produce the new frame).
"""
g = len(st.z_hist) # next frame index
s = max(0, g - int(st.ctx_window))
past_list = st.z_hist[s:g] # list of (n_spatial,d_spatial)
if len(past_list) == 0:
past = torch.empty((1, 0, self.n_spatial, self.d_spatial),
device=self.device, dtype=st.z_hist[-1].dtype)
else:
past = torch.stack(past_list, dim=0).unsqueeze(0) # (1,t,...)
t = past.shape[1]
actions_local = torch.zeros((1, t + 1, 16), device=self.device, dtype=torch.float32)
if t >= 1:
# Backward convention: actions_local[k] = action that produced past[k].
# a_hist[s+k] produced z_hist[s+k] = past[k], so:
# actions_local[0..t-1] = a_hist[s..s+t-1] (actions that produced past[0..t-1])
# actions_local[t] = a_hist[-1] = current action (will produce the new frame)
# Note: a_hist[0] = 0, so when s=0 actions_local[0] is correctly zero.
actions_local[0, 0:t] = torch.stack(st.a_hist[s: s + t], dim=0)
actions_local[0, t] = st.a_hist[-1]
actmask_local = st.act_mask_1d.view(1, 1, 16).expand(1, t + 1, 16).contiguous()
return past, actions_local, actmask_local
def _render_step_sync(self, st: SessionState) -> Tuple[bytes, Dict[str, Any]]:
"""
Runs at most one WM step (if not paused), then decodes the current frame.
Called via asyncio.to_thread.
"""
if st.reset_requested:
self._reset_session(st)
# action (raw from keys)
a_raw = build_action_from_keys(
st.keys_down, act_dim=st.act_dim, A=16
).to(self.device)
a_raw = (a_raw.clamp(-1, 1) * st.act_mask_1d).to(torch.float32)
# EMA smoothing
beta = float(st.action_beta)
if beta > 0.0:
beta = min(max(beta, 0.0), 0.999)
st.a_smooth = (beta * st.a_smooth + (1.0 - beta) * a_raw).to(torch.float32)
a = st.a_smooth
else:
a = a_raw
# Decoded frame for the current z_next; shared between display and u_r.
frame_cur: Optional[torch.Tensor] = None
stepped: bool = False
if not st.paused and st.act_dim >= 0:
stepped = True
st.a_hist.append(a)
past, actions_local, actmask_local = self._build_local_window(st)
# Batch N independent denoising seeds (same context + action) so we
# get the inter-seed denoising-variance signal (u_s) for free
# alongside the single continuation the rollout actually takes.
# Seed 0 is that continuation; u_f (denoising instability) and the
# reward head both fold over the batch / read seed 0 as before.
need_h = self.rew_head is not None
N = self.n_samples_u
z_prev_1 = st.z_hist[-1] # (Sz,Dz), pre-step latent
past_N = past.expand(N, -1, -1, -1).contiguous()
actions_N = actions_local.expand(N, -1, -1).contiguous()
actmask_N = actmask_local.expand(N, -1, -1).contiguous()
z_prev_N = (
z_prev_1.unsqueeze(0).expand(N, -1, -1).contiguous()
if self.tau_init > 0.0 else None
)
lang_N = None if st.lang_emb is None else st.lang_emb.expand(N, -1).contiguous()
result = sample_one_timestep_packed(
self.dyn,
past_packed=past_N,
k_max=self.k_max,
sched=self.sched,
actions=actions_N,
act_mask=actmask_N,
use_amp=self.use_amp,
return_h=need_h,
tau_ctx=self.tau_ctx,
lang_emb=lang_N,
z_prev=z_prev_N,
tau_init=self.tau_init,
use_kv_cache=self.use_kv_cache,
)
if need_h:
z_next_N, h_N, instability = result
else:
z_next_N, instability = result
st.last_u_f = float(instability)
# u_s: RMS spread of the N predicted latents around their mean,
# normalized by the predicted step size (motion) so it's
# comparable across fast- and slow-moving scenes — the same
# motion-normalization style as u_r (see uncertainty.py).
z_mean_N = z_next_N.float().mean(dim=0) # (Sz,Dz)
u_s_raw = z_next_N.float().var(dim=0).mean().clamp(min=0).sqrt().item()
motion = (z_mean_N - z_prev_1.float()).pow(2).mean().sqrt().item()
st.last_u_s = u_s_raw / max(motion, 1e-3)
z_next = z_next_N[0] # canonical continuation: seed 0
h = h_N[0:1] if need_h else None
st.z_hist.append(_as_2d_packed(z_next.detach()))
st.step += 1
# Cap the history so a long-lived session can't grow GPU memory
# without bound: inference only ever reads the last ctx_window
# frames (see _build_local_window), so anything older is dead weight.
# z_hist and a_hist stay index-aligned, so trim both equally.
cap = int(st.ctx_window) + 1
if len(st.z_hist) > cap:
st.z_hist = st.z_hist[-cap:]
st.a_hist = st.a_hist[-cap:]
# Tokenizer round-trip residual: decode z_next, re-encode, compare.
# Motion-invariant: off-manifold latents produce persistent residual
# even when the dynamics is "confidently" predicting no change.
# --u_every N amortizes the extra encoder pass across N steps (the
# display border updating at a few Hz is indistinguishable; u_f
# still updates every step for free from the denoising loop).
if self.args.uncertainty_overlay and (st.step % max(1, int(self.args.u_every)) == 0):
z_cur = st.z_hist[-1]
frame_cur = decode_single_packed_frame(
self.decoder,
z_packed=z_cur,
H=self.H, W=self.W, C=self.C, patch=self.patch,
packing_factor=self.args.packing_factor,
d_bottleneck=self.d_bottleneck,
)
z_recon = self._encode_frame_to_packed(frame_cur)
diff = z_cur.to(torch.float32) - z_recon
st.last_u_r = float(diff.pow(2).mean().sqrt().item())
# Collect calibration samples over the first N computed values
# post-reset (with u_every > 1 the wall-clock window stretches
# accordingly).
if not st.calib_done:
st.calib_f_samples.append(st.last_u_f)
st.calib_r_samples.append(st.last_u_r)
st.calib_s_samples.append(st.last_u_s)
if len(st.calib_f_samples) >= int(self.args.calibration_steps):
st.calib_done = True
# reward for *current* state (after stepping)
if need_h:
logits_btlk, centers = self.rew_head(h[:, -1:]) # (1,1,L,K)
st.last_reward_pred = reward_from_reward_head_output(logits_btlk[0, 0], centers)
st.cum_reward += st.last_reward_pred
frame_id = st.step # stable, monotonic id for "current displayed frame" (survives history cap)
need_encode = (st.cached_jpeg is None) or (st.cached_frame_id != frame_id)
jpeg: Optional[bytes] = None
if need_encode:
if frame_cur is None:
frame_cur = decode_single_packed_frame(
self.decoder,
z_packed=st.z_hist[-1],
H=self.H, W=self.W, C=self.C, patch=self.patch,
packing_factor=self.args.packing_factor,
d_bottleneck=self.d_bottleneck,
)
st.cached_jpeg = frame_to_jpeg_bytes(frame_cur, quality=int(self.args.jpeg_quality))
st.cached_frame_id = frame_id
jpeg = st.cached_jpeg
# Buffer the freshly decoded WM frame for the per-episode mp4.
# Only stepped frames go in; paused ticks would just dupe the
# last image and stretch the recording.
if self.args.record and stepped:
st.recorded_frames.append(frame_to_uint8_hwc(frame_cur))
else:
# Frame unchanged: don't resend bytes; the client keeps the last image.
jpeg = None
# Classify each signal independently against its own median/MAD
# baseline — unlike interactive.py, these are NOT merged into a
# single worst-of state; the client renders u_r/u_f/u_s separately.
u_r_state = "off"
u_f_state = "off"
u_s_state = "off"
u_suffix = ""
if self.args.uncertainty_overlay:
if not st.calib_done:
u_r_state = u_f_state = u_s_state = "calibrating"
u_suffix = f" [cal {len(st.calib_f_samples)}/{int(self.args.calibration_steps)}]"
else:
u_r_state = classify_uncertainty(st.last_u_r, st.calib_r_samples)
u_f_state = classify_uncertainty(st.last_u_f, st.calib_f_samples)
u_s_state = classify_uncertainty(st.last_u_s, st.calib_s_samples)
rec_suffix = (
f" | rec={len(st.recorded_frames)} (ep{st.episode_id})"
if self.args.record else ""
)
status = {
"type": "status",
"task": st.task,
"paused": bool(st.paused),
"act_dim": int(st.act_dim), # lets clients show only the bindable keys
"u_r_state": u_r_state,
"u_f_state": u_f_state,
"u_s_state": u_s_state,
"text": (
f"step={st.step} | "
f"r={st.last_reward_pred:+.2f} | "
f"R={st.cum_reward:+.2f} | "
f"u_r={st.last_u_r:.3f} u_f={st.last_u_f:.3f} u_s={st.last_u_s:.3f}{u_suffix} | "
f"fps={st.fps:.1f}"
f"{rec_suffix}"
),
}
return jpeg, status
async def status(self, request: web.Request) -> web.Response:
return web.json_response(
{
"tasks": len(self.tasks),
"task_list": self.tasks,
"initial_task": self.initial_task,
},
headers={"Access-Control-Allow-Origin": "*"},
)
async def healthz(self, request: web.Request) -> web.Response:
return web.Response(text="ok")
async def index(self, request: web.Request) -> web.Response:
html = self.html
html = html.replace("__TASK_SET__", json.dumps(self.tasks))
html = html.replace("__INITIAL_TASK__", self.initial_task)
return web.Response(text=html, content_type="text/html")
async def _run_blocking(self, fn, *args):
"""Run a sync method on the env worker thread.
MuJoCo's EGL contexts are pinned to a single thread, so anything that
may eventually touch the env (env.reset on session start / reset / task
switch — which can happen inside _render_step_sync when reset_requested
is true) must run on env_executor.
"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self.env_executor, fn, *args)
async def ws_handler(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
self.session_seq += 1
sid = self.session_seq
async def _send_json(obj: Dict[str, Any]) -> bool:
try:
await asyncio.wait_for(ws.send_str(json.dumps(obj)), timeout=2.0)
return True
except Exception:
return False
end_reason = "disconnect"
junk_count = 0
last_set_task = 0.0
last_reset = 0.0
st: Optional[SessionState] = None
try:
# new_session() may touch the env (env.reset, render); run on the
# env worker thread when in env mode.
st = await self._run_blocking(self.new_session)
print(f"[{time.strftime('%F %T')}] [session {sid}] open task={st.task}")
# Best-effort: the client may have vanished between grant and here;
# the loops below exit promptly on a closed socket.
await _send_json({
"type": "status",
"task": st.task,
"paused": bool(st.paused),
"text": "connected",
})
async def recv_loop():
nonlocal end_reason, junk_count, last_set_task, last_reset
def junk() -> bool:
nonlocal junk_count, end_reason
junk_count += 1
if junk_count >= 20:
end_reason = "junk"
return True
return False
def try_reset(now: float):
# One debounce for both reset paths (key and button).
nonlocal last_reset
if now - last_reset >= 0.3:
last_reset = now
st.reset_requested = True
async for msg in ws:
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.ERROR):
return
now = time.monotonic()
if msg.type != WSMsgType.TEXT:
if junk():
return
continue
try:
data = json.loads(msg.data)
assert isinstance(data, dict)
except Exception:
if junk():
return
continue
t = str(data.get("type", ""))
if t == "keydown":
k = str(data.get("key", ""))
if k == "Space":
st.paused = not st.paused
elif k in ("r", "R"):
try_reset(now)
elif k in ("q", "Q", "Escape"):
await ws.close()
return
elif k in ACTION_KEYS:
st.keys_down.add(k)
elif t == "keyup":
k = str(data.get("key", ""))
st.keys_down.discard(k)
elif t == "set_task":
# Min interval: switching re-seeds under the GPU lock;
# mashing the dropdown must not starve other sessions.
# (The client gates only its dropdown sync on the ack,
# so a dropped switch is cosmetic, not a wedge.)
if now - last_set_task < 1.0:
continue
last_set_task = now
new_task = str(data.get("task", ""))[:128]
async with self.infer_lock:
await self._run_blocking(self._switch_task_sync, st, new_task)
elif t == "toggle_pause":
st.paused = not st.paused
elif t == "reset":
try_reset(now)
elif t == "disconnect":
await ws.close()
return
else:
if junk():
return
async def send_loop():
nonlocal end_reason
dt = 1.0 / max(1e-6, float(st.fps))
next_t = time.monotonic()
while not ws.closed:
now = time.monotonic()
if now < next_t:
await asyncio.sleep(min(next_t - now, 0.25))
continue
next_t += dt
if now - next_t > 1.0:
# Resync after long stalls (lock contention, warm
# starts) instead of bursting to catch up.
next_t = now
step_t0 = time.monotonic()
async with self.infer_lock:
jpeg, status = await self._run_blocking(self._render_step_sync, st)
step_ms = (time.monotonic() - step_t0) * 1000.0
if ws.closed:
break
status["ms"] = round(step_ms, 1)
try:
# Send timeouts guard against slow readers ballooning
# the write buffer.
await asyncio.wait_for(ws.send_str(json.dumps(status)), timeout=2.0)
if jpeg is not None:
await asyncio.wait_for(ws.send_bytes(jpeg), timeout=2.0)
except Exception:
end_reason = "slow"
return
recv = asyncio.create_task(recv_loop())
send = asyncio.create_task(send_loop())
done, pending = await asyncio.wait({recv, send}, return_when=asyncio.FIRST_COMPLETED)
for p in pending:
p.cancel()
try:
await ws.close()
except Exception:
pass
finally:
steps = st.step if st is not None else 0
task = st.task if st is not None else "?"
print(f"[{time.strftime('%F %T')}] [session {sid}] closed reason={end_reason} "
f"steps={steps} task={task}")
# Final flush: write whatever was buffered for the in-progress episode.
if st is not None:
try:
await asyncio.to_thread(self._flush_recording, st)
except Exception as e:
print(f"[record] final flush failed: {e}")
return ws
def build_parser() -> argparse.ArgumentParser:
"""The full CLI surface, importable so tools (benchmarks, renderers) can
construct a defaults-accurate args namespace without mirroring it."""
p = argparse.ArgumentParser()
# task + metadata
p.add_argument("--task", type=str, default="og-point-maze",
help="initial task (any of the 210); switchable live in the UI")
p.add_argument("--tasks_json", type=str, default="../tasks.json",
help="task metadata (language embeddings, action dims)")
# checkpoints
p.add_argument("--tokenizer_ckpt", type=str,
default="./logs/tokenizer_ckpts/latest.pt")
p.add_argument("--dynamics_ckpt", type=str,
default="./logs/dynamics_ckpts/latest.pt")
# rollout
p.add_argument("--fps", type=float, default=10.0)
p.add_argument("--packing_factor", type=int, default=2)
p.add_argument("--ctx_window", type=int, default=24)
p.add_argument("--env_cache_max", type=int, default=8,
help="max simulators kept alive across task switches; least-recently-used "
"ones are closed beyond this. 0 disables eviction (the old unbounded "
"behaviour, which leaks a simulator per task the viewer tries).")
p.add_argument("--schedule", type=str, default="shortcut", choices=["finest", "shortcut"])
p.add_argument("--eval_d", type=float, default=0.125)
p.add_argument("--no_amp", action="store_true", help="disable mixed-precision inference")
p.add_argument("--jpeg_quality", type=int, default=90)
p.add_argument("--action_smooth_beta", type=float, default=0.817)
p.add_argument("--tau_ctx", type=float, default=0.01) # context corruption at inference
p.add_argument("--tau_init", type=float, default=0.125) # warm-start denoising toward previous frame (0 = pure noise)
# web server
p.add_argument("--host", type=str, default="127.0.0.1")
p.add_argument("--port", type=int, default=7860)
p.add_argument("--html", type=str, default="interactive_uncertainty.html")
# uncertainty overlay
p.add_argument("--uncertainty_overlay", action="store_true",
help="show u_r/u_f/u_s as three independent per-step indicators (off by default)")
p.add_argument("--calibration_steps", type=int, default=50,
help="number of steps used to calibrate the per-episode uncertainty baseline")
p.add_argument("--u_every", type=int, default=1,
help="compute the u_r tokenizer round-trip every N stepped frames "
"(amortizes its encoder pass; 1 = every step)")
p.add_argument("--n_samples_u", type=int, default=2,
help="denoising seeds sampled per step (batched) for the u_s "
"inter-seed-variance signal; the rollout continues from "
"seed 0. Must be >=2 for u_s to be meaningful; each "
"extra seed adds roughly proportional dynamics-model cost.")
# misc
p.add_argument("--compile", action="store_true", help="torch.compile dynamics and decoder for faster inference")
p.add_argument("--kv_cache", action="store_true", help="cache context KV in time-attention during denoising")
p.add_argument("--seed", type=int, default=0)
# recording
p.add_argument("--record", action="store_true",
help="Buffer every WM-rollout frame and save one mp4 per "
"episode boundary (reset, task switch, disconnect). "
"Files are named <task>_<session_ts>_ep<NNN>.mp4.")
p.add_argument("--recordings_dir", type=str,
default="./logs/interactive_recordings",
help="Where to write per-episode mp4s when --record is set.")
return p
def main():
args = build_parser().parse_args()
server = InteractiveServer(args)
app = web.Application()
app.router.add_get("/", server.index)
app.router.add_get("/ws", server.ws_handler)
app.router.add_get("/status", server.status)
app.router.add_get("/healthz", server.healthz)
print(f"[web] serving on http://{args.host}:{args.port} (task={args.task})")
web.run_app(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()