int_world::{decode, decode_deltas, decode_planes} render through world/1 and
return a Result; int_world::{load, World} hold the set lowered and loaded once,
with step, step_delta, step_action and render for the live path, which replace
the per-row step functions of a parsed model. int_world::legacy is test-only
(legacy_int). wai-web's WorldModel loads its set once and renders through it;
its methods throw on a refusal.
Callers moved: tests int_world, int_world2, int_world_shared,
int_kernels_vectors; the int_world_decode example; the decode bench's world
row. tests/int_world_memory.rs (its own binary; both fast-kernel steps): a
clip, a step and a frame hold no more than the direct renderer's.
Text: integer-operators' status note, §13.5 (before the first frame: the
plan, T giving the shape back, the informative codes), §15.3 items 2 and 3;
SPEC §8's planned breaks; CHANGELOG.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
215 lines
9.4 KiB
Rust
215 lines
9.4 KiB
Rust
//! In-browser **byte-exact interactive world model** — zero JS ML runtime.
|
||
//!
|
||
//! The same Rust that renders `wai.world.model`'s integer reference world
|
||
//! (`wai::int_world`) compiles to wasm32 and produces the IDENTICAL frames here: an
|
||
//! action-conditioned next-frame model whose transition and synthesis run through the
|
||
//! integer operator set (`extensions/integer-operators.md` §13.5, `world/1`), loaded once
|
||
//! when the model is made. So the page can drive the world with the arrow keys, render
|
||
//! every frame in integers, and re-check each frame's hash against the native reference.
|
||
//! And every keystroke is a signed action op whose trace replays to one portable
|
||
//! **session hash** ([`world_session_hash`]) — replay-equivalence, a generative
|
||
//! interactive world you can put in a receipt.
|
||
|
||
use wasm_bindgen::prelude::*;
|
||
|
||
use wai::int_hyper_synth::{IntModel, Refused};
|
||
use wai::int_world::World;
|
||
|
||
fn refused(r: Refused) -> JsError {
|
||
JsError::new(&r.to_string())
|
||
}
|
||
|
||
/// A stateful integer world model: hold the transition `T` + synthesis `g_s`, lowered and
|
||
/// loaded once, carry the latent state `z`, and advance it one action at a time — the
|
||
/// live-play path.
|
||
#[wasm_bindgen]
|
||
pub struct WorldModel {
|
||
world: World,
|
||
lat: usize,
|
||
z: Vec<i64>,
|
||
frame_px: usize,
|
||
}
|
||
|
||
impl WorldModel {
|
||
/// Take `z` as the latent state and render it.
|
||
fn advance(&mut self, z: Vec<i64>) -> Result<Box<[u8]>, JsError> {
|
||
let (f, h, _) = self.world.render(&z, self.lat).map_err(refused)?;
|
||
self.z = z;
|
||
self.frame_px = h;
|
||
Ok(f.into_boxed_slice())
|
||
}
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
impl WorldModel {
|
||
/// Load the two WHM1 models (`world_T.bin`, `world_gs.bin`); `c`/`lat` from
|
||
/// `world.json`. A set `world/1` refuses (`afrac` outside `1..=30` or unequal, no
|
||
/// action plane, a network's rules) is refused here.
|
||
#[wasm_bindgen(constructor)]
|
||
pub fn new(t_bin: &[u8], gs_bin: &[u8], c: u32, lat: u32) -> Result<WorldModel, JsError> {
|
||
let t = IntModel::from_bytes(t_bin).map_err(JsError::new)?;
|
||
let gs = IntModel::from_bytes(gs_bin).map_err(JsError::new)?;
|
||
let world = wai::int_world::load(&t, &gs, c as usize).map_err(refused)?;
|
||
Ok(WorldModel { world, lat: lat as usize, z: Vec::new(), frame_px: 0 })
|
||
}
|
||
|
||
/// Reset the world to an initial latent `z0` (`c·lat·lat` ints at `2^afrac`);
|
||
/// returns the rendered frame 0 (HWC RGB `u8`).
|
||
pub fn reset(&mut self, z0: &[i32]) -> Result<Box<[u8]>, JsError> {
|
||
self.advance(z0.iter().map(|&v| v as i64).collect())
|
||
}
|
||
|
||
/// Advance the transition by one `action` (index into the reference action set:
|
||
/// 0 stay, 1 +x, 2 −x, 3 +y, 4 −y) and render, byte-exact.
|
||
pub fn step(&mut self, action: u32) -> Result<Box<[u8]>, JsError> {
|
||
let z = self.world.step_action(&self.z, action as usize, self.lat).map_err(refused)?;
|
||
self.advance(z)
|
||
}
|
||
|
||
/// Advance the transition by raw `(dx, dy)` deltas and render — the shared-world
|
||
/// path (a merged multi-actor log decodes to a delta sequence via
|
||
/// [`replay_world_ops`]).
|
||
#[wasm_bindgen(js_name = stepDelta)]
|
||
pub fn step_delta(&mut self, dx: i32, dy: i32) -> Result<Box<[u8]>, JsError> {
|
||
let z = self.world.step_delta(&self.z, dx as i64, dy as i64, self.lat).map_err(refused)?;
|
||
self.advance(z)
|
||
}
|
||
|
||
/// Advance the transition by an explicit set of action **planes** (one value per plane
|
||
/// of `T`, at `2^afrac`) and render — the multi-agent path, where a step carries every
|
||
/// agent's action (e.g. `[dxA,dyA,dxB,dyB]` for two agents).
|
||
#[wasm_bindgen(js_name = stepPlanes)]
|
||
pub fn step_planes(&mut self, planes: &[i32]) -> Result<Box<[u8]>, JsError> {
|
||
let q: Vec<i64> = planes.iter().map(|&v| v as i64).collect();
|
||
let z = self.world.step(&self.z, &q, self.lat).map_err(refused)?;
|
||
self.advance(z)
|
||
}
|
||
|
||
#[wasm_bindgen(getter, js_name = framePx)]
|
||
pub fn frame_px(&self) -> u32 {
|
||
self.frame_px as u32
|
||
}
|
||
}
|
||
|
||
/// Replay a signed action trace → the portable **session hash** (hex), in the
|
||
/// browser. `actions_json` is `[{lamport,tick,dx,dy,buttons}]`; each is signed with
|
||
/// the actor key derived from `key_seed_hex` (32-byte seed), then replayed under
|
||
/// canonical total order — the same `wai.world.model` replay-equivalence the native
|
||
/// reference computes. Lets the page show that the keystrokes the user just made
|
||
/// hash to one portable, signed session identity.
|
||
#[wasm_bindgen(js_name = worldSessionHash)]
|
||
pub fn world_session_hash(
|
||
model_id_hex: &str, seed: u64, width: u32, height: u32, fps: u32, key_seed_hex: &str, actions_json: &[u8],
|
||
) -> Result<String, JsError> {
|
||
use ed25519_dalek::SigningKey;
|
||
use wai::world_model::{hex32, replay, sign_action, ModelHeader};
|
||
|
||
let model_id = hex32_to_bytes(model_id_hex).ok_or_else(|| JsError::new("bad model_id hex"))?;
|
||
let seed_bytes = hex32_to_bytes(key_seed_hex).ok_or_else(|| JsError::new("bad key seed hex"))?;
|
||
let key = SigningKey::from_bytes(&seed_bytes);
|
||
let header = ModelHeader { model_id, seed, width, height, fps };
|
||
|
||
let acts: serde_json::Value =
|
||
serde_json::from_slice(actions_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||
let mut ops = Vec::new();
|
||
for a in acts.as_array().ok_or_else(|| JsError::new("actions must be an array"))? {
|
||
ops.push(sign_action(
|
||
&key,
|
||
a["lamport"].as_u64().unwrap_or(0),
|
||
a["tick"].as_u64().unwrap_or(0),
|
||
a["dx"].as_i64().unwrap_or(0) as i32,
|
||
a["dy"].as_i64().unwrap_or(0) as i32,
|
||
a["buttons"].as_u64().unwrap_or(0) as u32,
|
||
b"",
|
||
));
|
||
}
|
||
let session = replay(&header, &ops, true).map_err(JsError::new)?;
|
||
Ok(hex32(&session.session_hash))
|
||
}
|
||
|
||
/// Sign one action op for a SHARED world and return its serialized bytes (a
|
||
/// `CausalOp`). Each peer signs with its own actor key (`actor_seed_hex`) and
|
||
/// broadcasts these bytes; peers concatenate everyone's ops into one log and
|
||
/// [`replay_world_ops`] it. The signature binds the action to the actor — an op can
|
||
/// only claim the identity that signed it.
|
||
#[wasm_bindgen(js_name = signWorldAction)]
|
||
pub fn sign_world_action(actor_seed_hex: &str, lamport: u64, tick: u64, dx: i32, dy: i32, buttons: u32) -> Result<Box<[u8]>, JsError> {
|
||
use ed25519_dalek::SigningKey;
|
||
let seed = hex32_to_bytes(actor_seed_hex).ok_or_else(|| JsError::new("bad actor seed hex"))?;
|
||
let key = SigningKey::from_bytes(&seed);
|
||
let op = wai::world_model::sign_action(&key, lamport, tick, dx, dy, buttons, b"");
|
||
Ok(op.to_bytes().into_boxed_slice())
|
||
}
|
||
|
||
/// The result of replaying a merged multi-actor action log: the convergent session
|
||
/// hash + the canonical `(dx, dy)` delta sequence to render.
|
||
#[wasm_bindgen]
|
||
pub struct WorldReplay {
|
||
session_hash: String,
|
||
deltas: Vec<i32>,
|
||
slots: Vec<u32>,
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
impl WorldReplay {
|
||
#[wasm_bindgen(getter, js_name = sessionHash)]
|
||
pub fn session_hash(&self) -> String {
|
||
self.session_hash.clone()
|
||
}
|
||
/// Flattened `[dx0, dy0, dx1, dy1, …]` in canonical total order.
|
||
#[wasm_bindgen(getter)]
|
||
pub fn deltas(&self) -> Box<[i32]> {
|
||
self.deltas.clone().into_boxed_slice()
|
||
}
|
||
/// Per-op agent SLOT (the op's actor's index in canonical actor order) — so a
|
||
/// peer can place each op's `(dx,dy)` in its agent's plane lane for multi-agent
|
||
/// co-control. Same on every peer (the actor set + ordering is canonical).
|
||
#[wasm_bindgen(getter)]
|
||
pub fn slots(&self) -> Box<[u32]> {
|
||
self.slots.clone().into_boxed_slice()
|
||
}
|
||
}
|
||
|
||
/// Replay a MERGED multi-actor action log (`log` = everyone's `CausalOp`s
|
||
/// concatenated, any order) → the convergent session hash + the canonical delta
|
||
/// sequence. Every peer that holds the same op set computes the IDENTICAL result —
|
||
/// replay-equivalence — so rendering the deltas yields the same byte-exact world on
|
||
/// every tab. Signatures are verified.
|
||
#[wasm_bindgen(js_name = replayWorldOps)]
|
||
pub fn replay_world_ops(
|
||
model_id_hex: &str, seed: u64, width: u32, height: u32, fps: u32, log: &[u8],
|
||
) -> Result<WorldReplay, JsError> {
|
||
use wai::world::CausalOp;
|
||
use wai::world_model::{hex32, replay, ModelHeader};
|
||
|
||
let model_id = hex32_to_bytes(model_id_hex).ok_or_else(|| JsError::new("bad model_id hex"))?;
|
||
let ops = if log.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
CausalOp::parse_log(log).map_err(|_| JsError::new("malformed action log"))?
|
||
};
|
||
let header = ModelHeader { model_id, seed, width, height, fps };
|
||
let session = replay(&header, &ops, true).map_err(JsError::new)?;
|
||
let deltas = session.actions.iter().flat_map(|a| [a.dx, a.dy]).collect();
|
||
// Assign each distinct actor a stable slot by canonical actor order, so every
|
||
// peer maps the same op to the same agent lane.
|
||
let mut actors: Vec<[u8; 32]> = session.actions.iter().map(|a| a.actor).collect();
|
||
actors.sort_unstable();
|
||
actors.dedup();
|
||
let slots = session.actions.iter().map(|a| actors.iter().position(|x| x == &a.actor).unwrap() as u32).collect();
|
||
Ok(WorldReplay { session_hash: hex32(&session.session_hash), deltas, slots })
|
||
}
|
||
|
||
fn hex32_to_bytes(s: &str) -> Option<[u8; 32]> {
|
||
let s = s.as_bytes();
|
||
if s.len() != 64 {
|
||
return None;
|
||
}
|
||
let mut o = [0u8; 32];
|
||
for (i, b) in o.iter_mut().enumerate() {
|
||
let hi = (s[i * 2] as char).to_digit(16)?;
|
||
let lo = (s[i * 2 + 1] as char).to_digit(16)?;
|
||
*b = (hi * 16 + lo) as u8;
|
||
}
|
||
Some(o)
|
||
}
|