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]>
168 lines
7.1 KiB
Python
168 lines
7.1 KiB
Python
#!/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')
|