open-standards/wai/tools/wai_int_world2_export.py
Transaction Science 647af0289d wai: two-agent co-control world.model — both agents drive one byte-exact frame
True simultaneous multiplayer for the generative world: the transition consumes BOTH
agents' actions per step (A=4 planes: dxA,dyA,dxB,dyB), so two participants drive two
distinct entities in ONE world and co-render the same frame — not serialized. Paired
with wai.world.model's signed action trace (each actor → a canonical slot), the merged
log renders the IDENTICAL world on every peer (convergence), with both agents moving
at once.

- int_world.rs: generalized to step_planes() / decode_planes() — the transition takes
  an explicit set of action planes (A = plane count), so A=3 (one agent) and A=4 (two)
  share one path; step_delta/decode (A=3) now wrap them. Single-agent decode unchanged.
- tools/wai_int_world2_export.py: trains a two-agent world (two markers, each agent
  independently controlled), PTQs T+g_s to WHM1, integer reference decode → hashes
  (float 27.0 / integer 21.4 dB). Reuses the 1-agent tool's WHM1/PTQ/decode helpers.
- tests/int_world2.rs (never-skip) + committed vector tests/vectors/int_world2/:
  decode_planes regenerates the committed co-control clip byte-for-byte (clip
  66b5821899323ff5). Rust == Python.
- wai-web: WorldModel::stepPlanes (advance by explicit planes) + per-op slot
  (actor→canonical lane) in replayWorldOps, so each tab drives its own agent.

Live (/world-shared, two BroadcastChannel tabs): each tab a distinct actor → a
distinct agent; both replay the merged log and converge on the same session hash +
byte-exact two-agent world. Playwright-verified: A drove 3 + B drove 2 → identical
session 6748220a…, frame 4751a9d4…, 0 errors.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-28 03:14:57 -04:00

168 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Train a TWO-AGENT co-control interactive world model and export it INTEGER-exact.
The transition consumes BOTH agents' actions per step (A=4 planes: dxA,dyA,dxB,dyB),
so two participants drive two distinct entities in ONE world and co-render the same
frame — true simultaneous multiplayer, byte-exact. Paired with wai.world.model's
signed action trace (each actor = a slot), the merged log renders the IDENTICAL world
on every peer (convergence), and here both agents move at once rather than serializing.
Same integer discipline as the 1-agent world (wai_int_world_export): transition T +
synthesis g_s are int_hyper IntModels (WHM1), the action folded in as constant input
planes; the whole decode is i64. Reuses that tool's WHM1 writer + PTQ + integer decode.
python tools/wai_int_world2_export.py <out-dir>
"""
import sys, os, json
import numpy as np, torch, torch.nn as nn, torch.nn.functional as F, warnings
warnings.filterwarnings('ignore')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wai_sota_int_synth_export import run_int_net, AFRAC, GF, SHIFT
from wai_int_world_export import whm1, ptq_conv, ptq_convT, ptq_igdn, gs_decode, fnv1a, GDN2d
FRAME, LAT, C, A = 64, 8, 24, 4 # A=4: two agents × (dx,dy)
ONE = 1 << AFRAC
SPEED, R = 5, 4.5 # agent step (px) / marker radius
ACTIONS = [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)]
NROLL, ROLL, STEPS = 48, 8, 2500
COLA, COLB = np.array([1.0, 0.25, 0.25]), np.array([0.25, 1.0, 0.35]) # agent tints
def make_bg():
rng = np.random.RandomState(7)
lo = torch.tensor(rng.rand(1, 3, FRAME // 8, FRAME // 8).astype(np.float32))
bg = F.interpolate(lo, size=(FRAME, FRAME), mode='bilinear', align_corners=True)[0].numpy()
return (bg * 0.45).astype(np.float32) # dim background so markers pop
YY, XX = np.mgrid[0:FRAME, 0:FRAME]
def render(bg, pa, pb):
f = bg.copy()
for pos, col in ((pa, COLA), (pb, COLB)):
m = np.exp(-(((XX - pos[0]) ** 2 + (YY - pos[1]) ** 2) / (2 * R * R)))
for c in range(3):
f[c] = np.clip(f[c] * (1 - m) + col[c] * m, 0, 1)
return f.astype(np.float32)
def clampp(p):
return [min(max(p[0], R), FRAME - R), min(max(p[1], R), FRAME - R)]
def rollout(bg, rng, pairs=None):
pa, pb = [FRAME * 0.3, FRAME * 0.5], [FRAME * 0.7, FRAME * 0.5]
frames = [render(bg, pa, pb)]
chosen = []
n = len(pairs) if pairs is not None else ROLL
for t in range(n):
aa, ab = pairs[t] if pairs is not None else (rng.randint(5), rng.randint(5))
pa = clampp([pa[0] + ACTIONS[aa][0] * SPEED, pa[1] + ACTIONS[aa][1] * SPEED])
pb = clampp([pb[0] + ACTIONS[ab][0] * SPEED, pb[1] + ACTIONS[ab][1] * SPEED])
frames.append(render(bg, pa, pb))
chosen.append((aa, ab))
return np.stack(frames), chosen
def planes_t(aa, ab): # [A,LAT,LAT] float
p = torch.zeros(A, LAT, LAT)
p[0], p[1] = ACTIONS[aa]
p[2], p[3] = ACTIONS[ab]
return p
class World2(nn.Module):
def __init__(self):
super().__init__()
self.E = nn.Sequential(nn.Conv2d(3, C, 4, 2, 1), nn.ReLU(),
nn.Conv2d(C, C, 4, 2, 1), nn.ReLU(), nn.Conv2d(C, C, 4, 2, 1))
self.Tc = nn.ModuleList([nn.Conv2d(C + A, C, 3, 1, 1), nn.Conv2d(C, C, 3, 1, 1)])
self.G = nn.ModuleList([nn.ConvTranspose2d(C, C, 4, 2, 1), GDN2d(C),
nn.ConvTranspose2d(C, C, 4, 2, 1), GDN2d(C),
nn.ConvTranspose2d(C, 3, 4, 2, 1)])
def trans(self, z, pairs):
ap = torch.stack([planes_t(aa, ab) for aa, ab in pairs]).to(z.device)
x = F.leaky_relu(self.Tc[0](torch.cat([z, ap], 1)), 0.1)
return torch.clamp(self.Tc[1](x), -1, 1)
def synth(self, z):
x = z
for m in self.G:
x = m(x)
return x
def forward(self, frames, acts):
z = self.E(frames[:, 0])
preds = [self.synth(z)]
for t in range(ROLL):
z = self.trans(z, [a[t] for a in acts])
preds.append(self.synth(z))
return torch.stack(preds, 1)
def planes_int(aa, ab): # [dxA,dyA,dxB,dyB] @ 2^afrac
return [ACTIONS[aa][0] * ONE, ACTIONS[aa][1] * ONE, ACTIONS[ab][0] * ONE, ACTIONS[ab][1] * ONE]
def decode_world2(T, gs, z0, pairs):
z = z0.copy()
frames = [gs_decode(gs, z)]
for aa, ab in pairs:
row = np.array(planes_int(aa, ab), dtype=np.int64)[:, None, None] * np.ones((A, LAT, LAT), np.int64)
z = np.clip(run_int_net(T, np.concatenate([z, row], 0)), -ONE, ONE)
frames.append(gs_decode(gs, z))
return frames
def main(outdir):
os.makedirs(outdir, exist_ok=True)
torch.manual_seed(0)
bg = make_bg()
rng = np.random.RandomState(123)
data = [rollout(bg, rng) for _ in range(NROLL)]
frames = torch.tensor(np.stack([d[0] for d in data]))
acts = [d[1] for d in data]
m = World2()
opt = torch.optim.Adam(m.parameters(), 1.5e-3)
for step in range(STEPS):
pred = m(frames, acts)
loss = F.mse_loss(pred, frames)
opt.zero_grad(); loss.backward(); opt.step()
m.eval()
with torch.no_grad():
snr_f = 10 * np.log10((frames ** 2).sum() / ((m(frames, acts) - frames) ** 2).sum() + 1e-12)
T_layers = [ptq_conv(m.Tc[0]), {'type': 'leaky', 'slope_q': 410, 'slope_frac': 12}, ptq_conv(m.Tc[1])]
gs_layers = [ptq_convT(m.G[0]), ptq_igdn(m.G[1]), ptq_convT(m.G[2]), ptq_igdn(m.G[3]), ptq_convT(m.G[4])]
T_model = {'afrac': AFRAC, 'gf': GF, 'shift': SHIFT, 'layers': T_layers}
gs_model = {'afrac': AFRAC, 'gf': GF, 'shift': SHIFT, 'layers': gs_layers}
# committed co-control walk: A circles, B zig-zags (both move most ticks)
demo = [(1, 4), (1, 2), (3, 1), (3, 1), (2, 4), (2, 3), (4, 1), (0, 2)]
df, _ = rollout(bg, None, pairs=demo)
with torch.no_grad():
z0 = m.E(torch.tensor(df[None, 0]))[0].numpy()
z0_int = np.round(z0 * ONE).astype(np.int64)
frames_int = decode_world2(T_model, gs_model, z0_int, demo)
gt = (df.transpose(0, 2, 3, 1) * 255).astype(np.float64)
snr_i = 10 * np.log10((gt ** 2).sum() / ((np.stack(frames_int).astype(np.float64) - gt) ** 2).sum() + 1e-12)
hashes = [format(fnv1a(f.tobytes()), '016x') for f in frames_int]
clip_hash = format(fnv1a(b''.join(bytes.fromhex(h) for h in hashes)), '016x')
open(f'{outdir}/world2_T.bin', 'wb').write(whm1(T_layers))
open(f'{outdir}/world2_gs.bin', 'wb').write(whm1(gs_layers))
json.dump({'c': C, 'lat': LAT, 'frame': FRAME, 'a': A, 'afrac': AFRAC, 'agents': 2,
'z0': z0_int.reshape(-1).tolist(), 'pairs': demo,
'frame_hashes': hashes, 'clip_hash': clip_hash},
open(f'{outdir}/world2.json', 'w'))
np.stack(frames_int).astype(np.uint8).tofile(f'{outdir}/world2_frames.rgb')
print(f'2-agent world: {len(frames_int)} frames {FRAME}x{FRAME}, T {len(whm1(T_layers))}B + g_s '
f'{len(whm1(gs_layers))}B, float {float(snr_f):.1f} / integer {snr_i:.1f} dB')
print(f' clip_hash {clip_hash} frame0 {hashes[0]}')
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else '/tmp/int_world2')