open-standards/wai/tools/wai_int_world_export.py
Transaction Science fb103ac5ae wai: one registry table, candidate capabilities, public-text checks
Every registered string now lives in one table (codecs/registry.rs) with
its kind, status, media class, registering SPEC section, edition and
registration date. `wai registry export` writes registry/capabilities.json,
its JSON Schema, the generated table in REGISTRY.md and vocab v2; --check
fails on drift. vocab v1 stays stable.

A candidate capability (a registered string whose payload format no
revision has pinned) must not be emitted. The C packers, the browser
writers and `wai wrap` refuse one, and `codecs::is_dispatchable` is the
single dispatch and selection predicate (SPEC §4): candidates,
companion-only capabilities, records and derivations are not
dispatchable, and an unregistered string is decoded only where the sink
advertises it.

The codec glossary is linked to the registry through a reviewed table and
held to its schema by a library test; `org` becomes `body`, from a closed
allowlist. The SPEC gains §5 sections on kinds and candidates, a §8
edition rule, corrected citations and rewritten appendices. The
quantum-ops vectors are regenerated with neutral device ids.

CI declares the feature-gated test targets with required-features, checks
the registry export, builds wai-web for wasm32, and runs two public-text
checks (comparative prose, and private name patterns) with a self-test.
The specification, extensions, research notes, READMEs, glossary and
source comments are rewritten to name no company or product and to make
no comparisons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:38:09 -04:00

298 lines
13 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 small action-conditioned INTERACTIVE WORLD MODEL and export it as an
INTEGER-exact decoder, so that conformance is a hash. A world model that generates
the next frame from an action through a FLOAT network gives a world that is not
reproducible from one platform to another. Here the world model is integer:
frame_0 = g_s(z_0); z_{t+1} = clip( T( z_t ‖ action_t ) ); frame_{t+1} = g_s(z_{t+1})
with BOTH the transition T and the synthesis g_s exported as int_hyper IntModels
(WHM1: per-out-channel int12 weights, fixed-point IGDN/leaky, all i64). The action is
folded in as constant input planes. So given (model, z_0, action trace) every frame is
byte-identical on every machine — a generative interactive world you can put in a
receipt. Paired with wai.world.model's signed/replay-equivalent action trace, both the
trace AND the frames hash-converge.
python tools/wai_int_world_export.py <out-dir>
"""
import sys, os, json, struct
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 (q_weight_pc, q_weight_conv, conv_int, conv_t_raw,
igdn_int, run_int_net, AFRAC, GF, SHIFT)
FRAME, LAT, C, A = 64, 8, 24, 3 # frame px / latent px / channels / action planes
WORLD, SPEED = 96, 4 # global world image + camera step (px/action)
NROLL, ROLL = 40, 8 # training rollouts / steps per rollout
STEPS = 2500 # optimizer steps (CLI-overridable for scale runs)
ONE = 1 << AFRAC
ACTIONS = [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] # stay, right, left, down, up
SLOPE_Q, SLOPE_FRAC = 410, 12 # leaky 0.1 in fixed point (410/4096)
# ---------- procedural world + environment (the "ground truth" dynamics) ----------
def make_world():
rng = np.random.RandomState(7)
lo = torch.tensor(rng.rand(1, 3, WORLD // 8, WORLD // 8).astype(np.float32))
w = F.interpolate(lo, size=(WORLD, WORLD), mode='bilinear', align_corners=True)[0].numpy()
yy, xx = np.mgrid[0:WORLD, 0:WORLD]
w[0] = np.clip(w[0] + 0.25 * np.sin(xx / 6.0), 0, 1) # structure the model must learn
w[1] = np.clip(w[1] + 0.25 * np.cos(yy / 7.0), 0, 1)
w[2] = np.clip(w[2] + 0.20 * ((xx // 12 + yy // 12) % 2), 0, 1)
return w.astype(np.float32)
def crop(world, cx, cy):
x0 = int(round(cx)) - FRAME // 2
y0 = int(round(cy)) - FRAME // 2
x0 = min(max(x0, 0), WORLD - FRAME)
y0 = min(max(y0, 0), WORLD - FRAME)
return world[:, y0:y0 + FRAME, x0:x0 + FRAME]
def rollout(world, rng, acts=None):
cx = cy = WORLD / 2.0
frames = [crop(world, cx, cy)]
chosen = []
for t in range(len(acts) if acts is not None else ROLL):
a = acts[t] if acts is not None else rng.randint(len(ACTIONS))
dx, dy = ACTIONS[a]
cx = min(max(cx + dx * SPEED, FRAME / 2), WORLD - FRAME / 2)
cy = min(max(cy + dy * SPEED, FRAME / 2), WORLD - FRAME / 2)
frames.append(crop(world, cx, cy))
chosen.append(a)
return np.stack(frames), chosen # frames[ROLL+1,3,64,64], acts[ROLL]
def action_planes(a): # [A,8,8] float at unit scale
dx, dy = ACTIONS[a]
p = torch.zeros(A, LAT, LAT)
p[0] = dx; p[1] = dy; p[2] = 0.0
return p
# ---------- learned model (float; the encoder/trainer) ----------
class GDN2d(nn.Module):
def __init__(self, ch):
super().__init__()
self.rg = nn.Parameter(torch.randn(ch, ch) * 0.01)
self.rb = nn.Parameter(torch.zeros(ch))
def gb(self):
return F.softplus(self.rg), F.softplus(self.rb + 1.0)
def forward(self, x): # inverse GDN: x * sqrt(beta + gamma·x²)
g, b = self.gb()
norm = b[None, :, None, None] + torch.einsum('ij,njhw->nihw', g, x * x)
return x * torch.sqrt(norm)
class World(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, a):
ap = torch.stack([action_planes(ai) for ai in a]).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): # frames[B,T+1,3,64,64], acts[B][T]
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)
# ---------- WHM1 writer (matches int_hyper_synth::from_bytes) ----------
def whm1(layers):
out = bytearray(b'WHM1') + struct.pack('<IIII', AFRAC, GF, SHIFT, len(layers))
for L in layers:
if L['type'] == 'convT':
out += struct.pack('<BIIIIII', 0, L['in_ch'], L['out_ch'], L['k'], L['stride'], L['pad'], L['opad'])
out += np.array(L['weight'], dtype='<i2').tobytes()
out += np.array(L['mult'], dtype='<i8').tobytes() + np.array(L['bias'], dtype='<i8').tobytes()
elif L['type'] == 'conv':
out += struct.pack('<BIIIII', 1, L['in_ch'], L['out_ch'], L['k'], L['stride'], L['pad'])
out += np.array(L['weight'], dtype='<i2').tobytes()
out += np.array(L['mult'], dtype='<i8').tobytes() + np.array(L['bias'], dtype='<i8').tobytes()
elif L['type'] == 'igdn':
out += struct.pack('<BI', 2, L['c'])
out += np.array(L['gamma'], dtype='<i4').tobytes() + np.array(L['beta'], dtype='<i8').tobytes()
elif L['type'] == 'leaky':
out += struct.pack('<B', 4) + np.array([L['slope_q']], dtype='<i8').tobytes() + struct.pack('<I', L['slope_frac'])
return bytes(out)
def ptq_conv(layer): # nn.Conv2d → int dict
wi, sc = q_weight_conv(layer.weight.detach().numpy())
return {'type': 'conv', 'in_ch': wi.shape[1], 'out_ch': wi.shape[0], 'k': wi.shape[2],
'stride': layer.stride[0], 'pad': layer.padding[0], 'weight': wi.reshape(-1).tolist(),
'mult': np.round(sc * (2 ** SHIFT)).astype(np.int64).tolist(),
'bias': np.round(layer.bias.detach().numpy() * ONE).astype(np.int64).tolist()}
def ptq_convT(layer):
wi, sc = q_weight_pc(layer.weight.detach().numpy())
return {'type': 'convT', 'in_ch': wi.shape[0], 'out_ch': wi.shape[1], 'k': wi.shape[2],
'stride': layer.stride[0], 'pad': layer.padding[0], 'opad': layer.output_padding[0],
'weight': wi.reshape(-1).tolist(),
'mult': np.round(sc * (2 ** SHIFT)).astype(np.int64).tolist(),
'bias': np.round(layer.bias.detach().numpy() * ONE).astype(np.int64).tolist()}
def ptq_igdn(layer):
with torch.no_grad():
g, b = layer.gb()
return {'type': 'igdn', 'c': b.shape[0],
'gamma': np.round(g.numpy() * (2 ** GF)).astype(np.int64).reshape(-1).tolist(),
'beta': np.round(b.numpy() * (2 ** (2 * AFRAC))).astype(np.int64).tolist()}
# ---------- integer reference decode (mirrors the Rust int_world loop) ----------
def planes_int(a):
dx, dy = ACTIONS[a]
p = np.zeros((A, LAT, LAT), np.int64)
p[0] = dx * ONE; p[1] = dy * ONE
return p
def gs_decode(gs, z): # z[C,8,8]@2^afrac → frame[64,64,3] u8
x = run_int_net(gs, z)
half = 1 << (AFRAC - 1)
x = np.clip(x, 0, ONE)
pix = ((x * 255 + half) >> AFRAC).clip(0, 255).astype(np.uint8) # [3,64,64]
return np.ascontiguousarray(pix.transpose(1, 2, 0)) # HWC
def fnv1a(buf):
h = 14695981039346656037
for b in buf:
h = ((h ^ b) * 1099511628211) & 0xFFFFFFFFFFFFFFFF
return h
def decode_world(T, gs, z0, acts):
z = z0.copy()
frames = [gs_decode(gs, z)]
for a in acts:
inp = np.concatenate([z, planes_int(a)], 0) # [C+A,8,8]
z = run_int_net(T, inp)
z = np.clip(z, -ONE, ONE)
frames.append(gs_decode(gs, z))
return frames
def main(outdir):
os.makedirs(outdir, exist_ok=True)
torch.manual_seed(0)
world = make_world()
rng = np.random.RandomState(123)
# Half random walks, half OUT-AND-BACK (go some direction, then reverse) so the
# model is trained toward long-horizon consistency: reversing a path should
# return where you came from.
data = []
for i in range(NROLL):
if i % 2 == 0:
data.append(rollout(world, rng))
else:
half = ROLL // 2
d = rng.randint(1, len(ACTIONS)) # a non-stay direction
rev = [2, 1, 4, 3][d - 1] # its reverse (x↔, y↔)
data.append(rollout(world, rng, acts=[d] * half + [rev] * (ROLL - half)))
frames = torch.tensor(np.stack([d[0] for d in data]))
acts = [d[1] for d in data]
# GPU when available (torch's `mps` backend) — the free-roll recurrent backprop DEPTH is the
# training cost, so the GPU is what unblocks a longer horizon (bigger ROLL).
dev = 'mps' if torch.backends.mps.is_available() else ('cuda' if torch.cuda.is_available() else 'cpu')
print(f' training on {dev} (ROLL={ROLL}, C={C}, steps={STEPS})')
m = World().to(dev)
frames = frames.to(dev)
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().item() / ((m(frames, acts) - frames) ** 2).sum().item() + 1e-12)
m.cpu(); frames = frames.cpu() # back to CPU for PTQ + the integer reference decode
# PTQ both nets → WHM1
T_layers = [ptq_conv(m.Tc[0]), {'type': 'leaky', 'slope_q': SLOPE_Q, 'slope_frac': SLOPE_FRAC}, 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}
# a longer demo walk (showcases the horizon): wander out and partway back
demo_acts = [1, 1, 1, 3, 3, 1, 4, 2, 2, 2, 3, 4, 1, 0, 2, 4]
df, _ = rollout(world, None, acts=demo_acts)
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_world(T_model, gs_model, z0_int, demo_acts)
# quality vs ground-truth crops
gt = (df.transpose(0, 2, 3, 1) * 255).astype(np.float64)
pr = np.stack(frames_int).astype(np.float64)
snr_i = 10 * np.log10((gt ** 2).sum() / ((pr - gt) ** 2).sum() + 1e-12)
# LONG-HORIZON CONSISTENCY probe: walk out and back the same distance; does the
# integer world return to where it started? (reported honestly — consistency is
# the hard problem for world models; we measure, not assume.)
n = max(2, ROLL // 2)
back = [1] * n + [2] * n # +x n, then −x n
bf = decode_world(T_model, gs_model, z0_int, back)
ret_match = bool((bf[0] == bf[-1]).all())
ret_snr = 10 * np.log10((bf[0].astype(np.float64) ** 2).sum()
/ ((bf[-1].astype(np.float64) - bf[0].astype(np.float64)) ** 2).sum() + 1e-12)
print(f' walk-and-return ({n}+{n}): start==end byte-exact? {ret_match} (return SNR {ret_snr:.1f} dB)')
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}/world_T.bin', 'wb').write(whm1(T_layers))
open(f'{outdir}/world_gs.bin', 'wb').write(whm1(gs_layers))
json.dump({'c': C, 'lat': LAT, 'frame': FRAME, 'a': A, 'afrac': AFRAC,
'z0': z0_int.reshape(-1).tolist(), 'actions': demo_acts,
'frame_hashes': hashes, 'clip_hash': clip_hash},
open(f'{outdir}/world.json', 'w'))
# raw RGB frames for the demo (optional viewing)
np.stack(frames_int).astype(np.uint8).tofile(f'{outdir}/world_frames.rgb')
print(f'int world model: {len(frames_int)} frames {FRAME}x{FRAME}, T {len(whm1(T_layers))}B + g_s '
f'{len(whm1(gs_layers))}B, float SNR {float(snr_f):.1f} dB / integer {snr_i:.1f} dB')
print(f' clip_hash {clip_hash} frame0 {hashes[0]}')
if __name__ == '__main__':
out = sys.argv[1] if len(sys.argv) > 1 else '/tmp/int_world'
# optional full-scale overrides: FRAME LAT C ROLL WORLD (FRAME must = LAT·8)
if len(sys.argv) > 2:
FRAME = int(sys.argv[2])
if len(sys.argv) > 3:
LAT = int(sys.argv[3])
if len(sys.argv) > 4:
C = int(sys.argv[4])
if len(sys.argv) > 5:
ROLL = int(sys.argv[5])
if len(sys.argv) > 6:
WORLD = int(sys.argv[6])
if len(sys.argv) > 7:
STEPS = int(sys.argv[7])
main(out)