Download code/dualrate/predictor.py from AlexWortega/dualrate-physics: direct link, hf CLI and curl.
- Browser
- Download file 5.66 kB
-
https://huggingface.co/AlexWortega/dualrate-physics/resolve/main/code/dualrate/predictor.py
- Command line
-
hf download hf://AlexWortega/dualrate-physics/code/dualrate/predictor.py
-
curl -L -o predictor.py https://huggingface.co/AlexWortega/dualrate-physics/resolve/main/code/dualrate/predictor.py
5.66 kB
| """Stateful dual-rate rollout predictor. | |
| Keeps the object state as float32 internally (no text round-trip loss); | |
| the Qwen semantic latent is refreshed from the text context only every | |
| `tick_k` steps and reused (stale) in between — that is the dual rate. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import numpy as np | |
| import torch | |
| from .normalization import wrap_angle | |
| def static_features_from_header(header: dict) -> tuple[np.ndarray, np.ndarray]: | |
| """Returns (static (N, 8) float32, ids (N,) int32) in header object order.""" | |
| objs = header["objects"] | |
| static = np.zeros((len(objs), 8), dtype=np.float32) | |
| ids = np.zeros(len(objs), dtype=np.int32) | |
| for i, obj in enumerate(objs): | |
| ids[i] = obj["id"] | |
| is_circle = 1.0 if obj.get("type") == "circle" else 0.0 | |
| mat = obj.get("material", {}) | |
| if "radius" in obj: | |
| s0 = s1 = float(obj["radius"]) | |
| else: | |
| s0 = float(obj.get("width", obj.get("size", 0.0)) or 0.0) | |
| s1 = float(obj.get("height", obj.get("size", 0.0)) or 0.0) | |
| static[i] = [is_circle, 1.0 - is_circle, | |
| math.log(max(mat.get("mass", 1.0), 1e-3)), | |
| mat.get("friction", 0.0), mat.get("elasticity", 0.0), | |
| s0 / 100.0, s1 / 100.0, 0.0] | |
| return static, ids | |
| class DualRatePredictor: | |
| def __init__(self, model, tokenizer, cfg, normalizer, device="cuda", | |
| denoise_steps: int | None = None, sample_seed: int | None = 0): | |
| self.model = model | |
| self.tokenizer = tokenizer | |
| self.cfg = cfg | |
| self.norm = normalizer | |
| self.device = device | |
| self.denoise_steps = denoise_steps or cfg.denoise_steps | |
| self.sample_seed = sample_seed | |
| self.latent_refreshes = 0 | |
| self._reset_fields() | |
| def _reset_fields(self): | |
| self.state = None | |
| self.static_t = None | |
| self.obj_mask = None | |
| self.gravity_dt = None | |
| self.ids = None | |
| self.frame_num = 0 | |
| self.steps = 0 | |
| self.latent = None | |
| self.latent_refreshes = 0 | |
| def reset_from_arrays(self, static: np.ndarray, ids: np.ndarray, gravity, | |
| dt: float, init_state: np.ndarray, start_frame: int): | |
| """Seed directly from cached arrays (static (N,8), ids (N,), gravity (2,)).""" | |
| self._reset_fields() | |
| n = init_state.shape[0] | |
| self.state = init_state.astype(np.float32).copy() | |
| self.ids = ids[:n] | |
| self.static_t = torch.from_numpy(static[:n]).float()[None].to(self.device) | |
| self.obj_mask = torch.ones((1, n), dtype=torch.bool, device=self.device) | |
| self.gravity_dt = torch.tensor( | |
| [[float(gravity[0]) / 981.0, float(gravity[1]) / 981.0, float(dt) * 60.0]], | |
| dtype=torch.float32).to(self.device) | |
| self.frame_num = start_frame | |
| def reset(self, header: dict, init_state: np.ndarray, start_frame: int): | |
| """init_state: (N, 6) raw units; start_frame: frame number to emit first.""" | |
| self._reset_fields() | |
| static, ids = static_features_from_header(header) | |
| n = init_state.shape[0] | |
| self.state = init_state.astype(np.float32).copy() | |
| self.ids = ids[:n] | |
| self.static_t = torch.from_numpy(static[:n]).float()[None].to(self.device) | |
| self.obj_mask = torch.ones((1, n), dtype=torch.bool, device=self.device) | |
| g = header.get("gravity", {"x": 0.0, "y": -981.0}) | |
| dt = header.get("timestep", 1 / 60) | |
| self.gravity_dt = torch.tensor( | |
| [[g["x"] / 981.0, g["y"] / 981.0, dt * 60.0]], dtype=torch.float32, | |
| ).to(self.device) | |
| self.frame_num = start_frame | |
| def _refresh_latent(self, context_text: str): | |
| ids = self.tokenizer(context_text, add_special_tokens=False)["input_ids"] | |
| ids = ids[-self.cfg.max_ctx_tokens:] | |
| t = torch.tensor([ids], dtype=torch.long, device=self.device) | |
| mask = torch.ones_like(t) | |
| self.latent = self.model.compute_latent(t, mask) | |
| self.latent_refreshes += 1 | |
| def step(self, context_text: str) -> str: | |
| """Predict the next frame; refresh the semantic latent every tick_k steps.""" | |
| if self.latent is None or self.steps % self.cfg.tick_k == 0: | |
| self._refresh_latent(context_text) | |
| cur_feat = self.state.copy() | |
| cur_feat[:, 4] = wrap_angle(cur_feat[:, 4]) | |
| cur_t = torch.from_numpy(self.norm.norm_state(cur_feat))[None].float().to(self.device) | |
| gen = None | |
| if self.sample_seed is not None: | |
| gen = torch.Generator(device=self.device) | |
| gen.manual_seed(self.sample_seed + self.steps) | |
| delta_norm = self.model.sample_delta( | |
| self.latent, cur_t, self.static_t, self.obj_mask, self.gravity_dt, | |
| steps=self.denoise_steps, generator=gen, | |
| ) | |
| delta = self.norm.denorm_delta(delta_norm[0].float().cpu().numpy()) | |
| self.state = self.state + delta | |
| text = self._render_prediction() | |
| self.frame_num += 1 | |
| self.steps += 1 | |
| return text | |
| def _render_prediction(self) -> str: | |
| """Matches the baseline prediction output format (parsed by _extract_physics).""" | |
| lines = [f"Frame {self.frame_num}:"] | |
| for i in range(self.state.shape[0]): | |
| px, py, vx, vy, a, av = (float(x) for x in self.state[i]) | |
| s = f" obj_{int(self.ids[i])}: pos=({px:.4f}, {py:.4f}), vel=({vx:.4f}, {vy:.4f})" | |
| if abs(a) > 0.001 or abs(av) > 0.001: | |
| s += f", a={a:.4f}, av={av:.4f}" | |
| lines.append(s) | |
| return "\n".join(lines) | |