Determinism — 1000 FFTs, one hash

The substrate runs a Cooley–Tukey radix-2 FFT 256-pt 1000 times per frame on a fixed two-tone input. Each output is hashed with FNV-1a-64 and the unique-hash count is displayed live. On a deterministic kernel the count is 1: every cell on the grid is emerald, the receipt strip flips ≡, and the canonical hash is stable across reloads. Toggle the perturbation checkbox to inject a per-iteration input kick that mimics the batch-size-variance failure mode Horace He's post identifies — the cells diverge into multiple colours and the ≠ indicator fires.

view the kernel that wrote this receipt crates/mathground-view/src/web.rs
//! JS surface — `wasm` feature only.
//!
//! Exposes three `mount_*` entry points. Each takes a canvas ID + a small
//! parameter struct (passed as JSON from JS), drives the kernel + draw loop
//! via `requestAnimationFrame`, and emits a per-frame `Receipt` back to the
//! page through an updater callback.
//!
//! The page is responsible for: providing the `<canvas>` and a receipt
//! sink. The crate is responsible for: math, drawing, timing.

use std::cell::RefCell;
use std::rc::Rc;

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, WebGl2RenderingContext};

use mgai_meter_web::{build_receipt, primitive_from_measurement, now_ns, DEFAULT_TDP_W};

use mgai_active_inference::structure::StructureLearner;
use mgai_active_inference::{Agent as AiAgent, GenerativeModel as AiModel};

use mgai_voxel::{Aabb as VoxAabb, Vec3 as VoxVec3, VoxelGrid};
use mgai_cea::{
    search as cea_search, ConstrainedProblem, Constraint, Design, MaxMass, MinimizeVolume,
    ParametricSphere, SearchParams,
};

use crate::cascade::{self, CascadeTier, SubExpert, TimeFn};

// ── JSON-shaped cascade walk for the escalate exhibit ────────────────
//
// The lazy-load demo at /exhibits/escalate is driven entirely from JS:
// it imports this function to walk the cascade, decides from
// `resolved_at` whether to lazy-import the l2-leaf bundle, and renders
// the trace itself. The walk is the same `cascade::walk` the on-canvas
// reason exhibit uses.

#[wasm_bindgen]
pub fn cascade_walk_json(query: &str) -> JsValue {
    let time = TimeFn { now_ns };
    let trace = cascade::walk(query, &time);
    serde_wasm_bindgen::to_value(&trace).unwrap_or(JsValue::NULL)
}
use crate::fft::{fft_in_place, fft_power_spectrum};
use crate::fft2d::{fft2d_in_place, log_magnitude_shifted};
use crate::graddescent::{gd_step, rosenbrock, GdParams, GdState};
use crate::heat::{heat_step, HeatParams};
use crate::hdc::Hv;
use crate::lorenz::{lorenz_rk4_step, LorenzParams, LorenzState};
use crate::modal::{encode_audio, encode_image, encode_text, encode_video};
use crate::pfilter::{pfilter_step, PFilterParams, PFilterState};
use crate::sdf::marching_squares;
use crate::sdf3d;

#[wasm_bindgen(start)]
pub fn _start() {
    console_error_panic_hook::set_once();
}

fn ctx_for(canvas_id: &str) -> Option<(HtmlCanvasElement, CanvasRenderingContext2d)> {
    let win = web_sys::window()?;
    let doc = win.document()?;
    let canvas = doc.get_element_by_id(canvas_id)?;
    let canvas: HtmlCanvasElement = canvas.dyn_into().ok()?;
    let ctx: CanvasRenderingContext2d = canvas
        .get_context("2d")
        .ok()??
        .dyn_into()
        .ok()?;
    Some((canvas, ctx))
}

fn emit_receipt(
    sink: &js_sys::Function,
    label: &str,
    v_class: &str,
    iters: u64,
    wall_ns: f64,
    bit_ops_per_op: u64,
) {
    let prim = primitive_from_measurement(label, v_class, iters, wall_ns, bit_ops_per_op, DEFAULT_TDP_W);
    let receipt = build_receipt(vec![prim], DEFAULT_TDP_W);
    let js = serde_wasm_bindgen::to_value(&receipt).unwrap_or(JsValue::NULL);
    let _ = sink.call1(&JsValue::NULL, &js);
}

/// Emit a receipt + carry an `output_hash` on the primitive. The
/// determinism exhibit uses this so the receipt strip on the page can
/// flip its ≡ / ≠ indicator on the substrate-reported hash instead of
/// re-hashing on the JS side.
fn emit_receipt_with_hash(
    sink: &js_sys::Function,
    label: &str,
    v_class: &str,
    iters: u64,
    wall_ns: f64,
    bit_ops_per_op: u64,
    output_hash: u64,
) {
    let prim = primitive_from_measurement(label, v_class, iters, wall_ns, bit_ops_per_op, DEFAULT_TDP_W)
        .with_output_hash(output_hash);
    let receipt = build_receipt(vec![prim], DEFAULT_TDP_W);
    let js = serde_wasm_bindgen::to_value(&receipt).unwrap_or(JsValue::NULL);
    let _ = sink.call1(&JsValue::NULL, &js);
}

/// FNV-1a 64-bit over a `&[f32]`'s raw bytes. Used by the determinism
/// exhibit to fingerprint each FFT output cheaply — the same family as
/// the proxy's resolver fingerprint (`lux_worlds_faeble::receipt::
/// fnv1a_64`). Re-implemented here so mathground-view stays free of
/// runtime deps on the faeble side; FNV-1a's offset basis and prime
/// are public constants.
fn fnv1a_64_f32_slice(xs: &[f32]) -> u64 {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for x in xs {
        // Hash the IEEE-754 bit pattern so +0/-0 and NaN payloads are
        // distinguishable across runs. `to_bits()` is the canonical
        // f32 → u32 byte view.
        let bits = x.to_bits().to_le_bytes();
        for &b in &bits {
            h ^= b as u64;
            h = h.wrapping_mul(0x100_0000_01b3);
        }
    }
    h
}

fn schedule(closure: &Closure<dyn FnMut()>) {
    let _ = web_sys::window()
        .expect("window")
        .request_animation_frame(closure.as_ref().unchecked_ref());
}

// ── FFT exhibit ──────────────────────────────────────────────────────

/// Mount the FFT exhibit on `canvas_id`. The kernel reuses a stationary
/// test signal (sum of two sines) so the spectrum is interpretable; in
/// future iterations the page can push live samples.
#[wasm_bindgen]
pub fn mount_fft(canvas_id: &str, n: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let n_usize = n as usize;
    if !n_usize.is_power_of_two() || n_usize < 16 {
        return Err(JsValue::from_str("n must be a power of two ≥ 16"));
    }

    let buf = Rc::new(RefCell::new(vec![0.0f32; 2 * n_usize]));
    let spec = Rc::new(RefCell::new(vec![0.0f32; n_usize / 2]));
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let buf2 = buf.clone();
    let spec2 = spec.clone();

    let mut phase = 0.0f32;

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        // ── Signal: two sines, swept phase to keep it visually alive ──
        let mut b = buf2.borrow_mut();
        for k in 0..n_usize {
            let t = k as f32 / n_usize as f32;
            let s = (2.0 * core::f32::consts::PI * 4.0 * t + phase).sin()
                + 0.5 * (2.0 * core::f32::consts::PI * 12.0 * t).sin();
            b[2 * k] = s;
            b[2 * k + 1] = 0.0;
        }
        phase += 0.05;

        // ── Compute pass — timed ──
        let t0 = now_ns();
        let report = fft_in_place(&mut b).expect("power-of-two");
        let mut s = spec2.borrow_mut();
        fft_power_spectrum(&b, &mut s);
        let wall_ns = now_ns() - t0;

        // ── Draw pass — not metered (page chrome) ──
        let w = canvas.width() as f64;
        let h = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, w, h);
        ctx.set_stroke_style_str("rgba(120, 220, 255, 0.95)");
        ctx.set_line_width(1.5);
        ctx.begin_path();
        let bins = s.len();
        for i in 0..bins {
            let x = (i as f64 / bins as f64) * w;
            // s[i] is dB ∈ [-120, 0] roughly. Map to canvas.
            let db = s[i].clamp(-120.0, 0.0) as f64;
            let y = h - (db + 120.0) / 120.0 * h;
            if i == 0 {
                ctx.move_to(x, y);
            } else {
                ctx.line_to(x, y);
            }
        }
        ctx.stroke();

        // ── Live-indicator cursor: a thin vertical line that sweeps
        //    across the canvas. Drives the "live" intuition that the
        //    receipt next to this kernel updates every frame, even when
        //    the spectrum itself looks stationary at a glance. ──
        let cursor_x = ((phase * 60.0) as f64).rem_euclid(w);
        ctx.set_stroke_style_str("rgba(255, 220, 140, 0.85)");
        ctx.set_line_width(1.0);
        ctx.begin_path();
        ctx.move_to(cursor_x, 0.0);
        ctx.line_to(cursor_x, h);
        ctx.stroke();
        // A small dot at the spectrum at the cursor x — reads as "the
        // measurement just sampled this bin".
        let bin_at_cursor = ((cursor_x / w) * (bins as f64)).floor() as usize;
        let bin_at_cursor = bin_at_cursor.min(bins.saturating_sub(1));
        if bins > 0 {
            let db = s[bin_at_cursor].clamp(-120.0, 0.0) as f64;
            let y = h - (db + 120.0) / 120.0 * h;
            ctx.set_fill_style_str("rgba(255, 220, 140, 0.95)");
            ctx.begin_path();
            ctx.arc(cursor_x, y, 2.5, 0.0, std::f64::consts::TAU).ok();
            ctx.fill();
        }

        // ── Emit receipt for the compute pass only ──
        // Hash the per-frame power spectrum so /receipts/05's
        // reproducibility receipt has live data per frame. Spectrum
        // evolves with the sweeping phase, so consecutive hashes differ
        // by design; the field is populated, not claiming ≡-stability.
        emit_receipt_with_hash(
            &sink,
            "fft_radix2",
            "L0Closed",
            1,
            wall_ns,
            report.bit_ops,
            fnv1a_64_f32_slice(&s),
        );

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    // Keep the closure alive forever.
    std::mem::forget(g);
    Ok(())
}

// ── Lorenz exhibit ───────────────────────────────────────────────────

/// Mount the Lorenz attractor on `canvas_id`. Draws a trail in the (x, z)
/// projection; advances `steps_per_frame` RK4 steps per animation frame.
#[wasm_bindgen]
pub fn mount_lorenz(canvas_id: &str, steps_per_frame: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let params = LorenzParams::default();
    let state = Rc::new(RefCell::new(LorenzState::default()));
    let trail: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::with_capacity(4096)));
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let state2 = state.clone();
    let trail2 = trail.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let t0 = now_ns();
        let mut s = state2.borrow_mut();
        let mut bit_ops_total: u64 = 0;
        let mut tr = trail2.borrow_mut();
        for _ in 0..steps_per_frame {
            bit_ops_total += lorenz_rk4_step(&mut s, &params);
            tr.push((s.x as f32, s.z as f32));
        }
        // Cap trail length.
        let cap = 4096usize;
        if tr.len() > cap {
            let drop = tr.len() - cap;
            tr.drain(0..drop);
        }
        let wall_ns = now_ns() - t0;

        let w = canvas.width() as f64;
        let h = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, w, h);

        // Centre + scale: x ∈ ~[-25, 25], z ∈ ~[0, 50].
        let cx = w * 0.5;
        let cy = h * 0.5;
        let scale = (w.min(h) / 60.0) as f32;
        ctx.set_stroke_style_str("rgba(255, 180, 80, 0.7)");
        ctx.set_line_width(1.0);
        ctx.begin_path();
        for (i, (x, z)) in tr.iter().enumerate() {
            let px = cx as f32 + x * scale;
            let py = cy as f32 - (z - 25.0) * scale;
            if i == 0 {
                ctx.move_to(px as f64, py as f64);
            } else {
                ctx.line_to(px as f64, py as f64);
            }
        }
        ctx.stroke();

        emit_receipt(
            &sink,
            "lorenz_rk4",
            "L0Closed",
            steps_per_frame as u64,
            wall_ns,
            bit_ops_total / steps_per_frame.max(1) as u64,
        );

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── SDF exhibit ──────────────────────────────────────────────────────

/// Mount the 2-D SDF exhibit. Renders the iso-contour of a slowly drifting
/// scalar field (sum of two moving circles' SDFs, deformed by a sine) via
/// marching squares.
#[wasm_bindgen]
pub fn mount_sdf(canvas_id: &str, grid_w: u32, grid_h: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let cells = Rc::new(RefCell::new(vec![0.0f32; (grid_w * grid_h) as usize]));
    let segs = Rc::new(RefCell::new(Vec::with_capacity(8192)));
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let cells2 = cells.clone();
    let segs2 = segs.clone();
    let mut tau = 0.0f32;

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        // Update field.
        let mut c = cells2.borrow_mut();
        let cx1 = grid_w as f32 * (0.4 + 0.1 * (tau).cos());
        let cy1 = grid_h as f32 * 0.5;
        let cx2 = grid_w as f32 * (0.6 + 0.1 * (tau + 1.0).sin());
        let cy2 = grid_h as f32 * 0.5;
        let r = grid_w.min(grid_h) as f32 * 0.15;
        for y in 0..grid_h {
            for x in 0..grid_w {
                let dx1 = x as f32 - cx1;
                let dy1 = y as f32 - cy1;
                let d1 = (dx1 * dx1 + dy1 * dy1).sqrt() - r;
                let dx2 = x as f32 - cx2;
                let dy2 = y as f32 - cy2;
                let d2 = (dx2 * dx2 + dy2 * dy2).sqrt() - r;
                // Smooth-min union (k = 4) — Quílez.
                let k = 4.0_f32;
                let h_ = ((k - (d1 - d2).abs()).max(0.0) / k).powi(2) * 0.5;
                let d = d1.min(d2) - h_ * k * 0.5;
                c[(y * grid_w + x) as usize] = d;
            }
        }
        tau += 0.02;

        let t0 = now_ns();
        let mut s = segs2.borrow_mut();
        let report = marching_squares(&c, grid_w, grid_h, 0.0, &mut s);
        let wall_ns = now_ns() - t0;

        // Draw.
        let w = canvas.width() as f64;
        let h_can = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, w, h_can);
        let sx = w / grid_w as f64;
        let sy = h_can / grid_h as f64;
        ctx.set_stroke_style_str("rgba(180, 255, 200, 0.95)");
        ctx.set_line_width(1.5);
        ctx.begin_path();
        let mut i = 0;
        while i + 3 < s.len() {
            ctx.move_to(s[i] as f64 * sx, s[i + 1] as f64 * sy);
            ctx.line_to(s[i + 2] as f64 * sx, s[i + 3] as f64 * sy);
            i += 4;
        }
        ctx.stroke();

        emit_receipt(&sink, "sdf_marching_squares", "L0Closed", 1, wall_ns, report.bit_ops);

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Heat-equation exhibit ────────────────────────────────────────────

#[wasm_bindgen]
pub fn mount_heat(canvas_id: &str, grid_w: u32, grid_h: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let n = (grid_w * grid_h) as usize;
    let mut u0 = vec![0.0f32; n];
    u0[(grid_h / 2 * grid_w + grid_w / 2) as usize] = 200.0;
    for k in 0..6 {
        let cx = (grid_w as f32 * (0.2 + 0.12 * k as f32)).min((grid_w - 1) as f32) as u32;
        let cy = (grid_h as f32 * (0.7 - 0.04 * k as f32)).max(0.0) as u32;
        u0[(cy * grid_w + cx) as usize] = 200.0;
    }
    let u = Rc::new(RefCell::new(u0));
    let u_next = Rc::new(RefCell::new(vec![0.0f32; n]));
    let params = HeatParams::default();
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let u2 = u.clone();
    let u_next2 = u_next.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let t0 = now_ns();
        let mut a = u2.borrow_mut();
        let mut b = u_next2.borrow_mut();
        let steps = 4u32;
        let mut bit_ops_total: u64 = 0;
        for _ in 0..steps {
            let r = heat_step(&a, &mut b, grid_w, grid_h, &params);
            bit_ops_total += r.bit_ops;
            std::mem::swap(&mut *a, &mut *b);
        }
        let wall_ns = now_ns() - t0;

        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        let sx = cw / grid_w as f64;
        let sy = ch / grid_h as f64;
        let mut maxv = 1e-9f32;
        for &v in a.iter() {
            if v > maxv { maxv = v; }
        }
        for y in 0..grid_h {
            for x in 0..grid_w {
                let v = (a[(y * grid_w + x) as usize] / maxv).clamp(0.0, 1.0);
                let r = (255.0 * v.powf(0.5)) as u32;
                let g_ = (60.0 + 120.0 * v) as u32;
                let bb = (160.0 * (1.0 - v)) as u32;
                ctx.set_fill_style_str(&format!("rgb({r},{g_},{bb})"));
                ctx.fill_rect(x as f64 * sx, y as f64 * sy, sx + 1.0, sy + 1.0);
            }
        }

        emit_receipt(&sink, "heat_forward_euler", "L0Closed", steps as u64, wall_ns, bit_ops_total / steps as u64);
        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Particle-filter exhibit ──────────────────────────────────────────

#[wasm_bindgen]
pub fn mount_pfilter(canvas_id: &str, n_particles: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let mut params = PFilterParams::default();
    params.n_particles = n_particles.max(32);
    let state = Rc::new(RefCell::new(PFilterState::new(&params, 0xC0DE_BEEF_F00Du64)));
    let truth_trail: Rc<RefCell<Vec<f32>>> = Rc::new(RefCell::new(Vec::with_capacity(800)));
    let obs_trail: Rc<RefCell<Vec<f32>>> = Rc::new(RefCell::new(Vec::with_capacity(800)));
    let mean_trail: Rc<RefCell<Vec<f32>>> = Rc::new(RefCell::new(Vec::with_capacity(800)));
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let state2 = state.clone();
    let truth2 = truth_trail.clone();
    let obs2 = obs_trail.clone();
    let mean2 = mean_trail.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let t0 = now_ns();
        let mut s = state2.borrow_mut();
        let r = pfilter_step(&mut s, &params, 0.1);
        let wall_ns = now_ns() - t0;

        let mut t = truth2.borrow_mut();
        let mut o = obs2.borrow_mut();
        let mut m = mean2.borrow_mut();
        t.push(r.truth);
        o.push(r.obs);
        m.push(s.posterior_mean);
        let cap = 800usize;
        for v in [&mut t, &mut o, &mut m] {
            if v.len() > cap { let d = v.len() - cap; v.drain(0..d); }
        }

        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);
        let amp = params.amplitude as f64;
        let y_of = |v: f32| ch * 0.5 - (v as f64) * (ch * 0.35 / amp);
        let x_of = |i: usize, n: usize| cw * (i as f64) / (n.max(1) as f64);
        let n_trail = t.len();

        ctx.set_fill_style_str("rgba(255, 180, 80, 0.5)");
        for (i, &v) in o.iter().enumerate() {
            let x = x_of(i, n_trail);
            let y = y_of(v);
            ctx.fill_rect(x - 1.0, y - 1.0, 2.0, 2.0);
        }
        ctx.set_stroke_style_str("rgba(120, 220, 255, 0.95)");
        ctx.set_line_width(2.0);
        ctx.begin_path();
        for (i, &v) in t.iter().enumerate() {
            let x = x_of(i, n_trail);
            let y = y_of(v);
            if i == 0 { ctx.move_to(x, y); } else { ctx.line_to(x, y); }
        }
        ctx.stroke();
        ctx.set_stroke_style_str("rgba(180, 255, 200, 0.9)");
        ctx.set_line_width(1.5);
        ctx.begin_path();
        for (i, &v) in m.iter().enumerate() {
            let x = x_of(i, n_trail);
            let y = y_of(v);
            if i == 0 { ctx.move_to(x, y); } else { ctx.line_to(x, y); }
        }
        ctx.stroke();
        ctx.set_fill_style_str("rgba(255, 180, 220, 0.45)");
        let x_now = cw - 4.0;
        for &x_val in s.particles.iter() {
            ctx.fill_rect(x_now, y_of(x_val) - 1.0, 3.0, 3.0);
        }

        emit_receipt(&sink, "particle_filter_systematic", "L0Closed", 1, wall_ns, r.bit_ops);
        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Gradient-descent exhibit ─────────────────────────────────────────

#[wasm_bindgen]
pub fn mount_grad(canvas_id: &str, steps_per_frame: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let p = GdParams::default();
    let state = Rc::new(RefCell::new(GdState::default()));
    let trail: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::with_capacity(8192)));
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let state2 = state.clone();
    let trail2 = trail.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let t0 = now_ns();
        let mut s = state2.borrow_mut();
        let mut tr = trail2.borrow_mut();
        let mut bit_ops_total: u64 = 0;
        let mut loss = 0.0f32;
        for _ in 0..steps_per_frame {
            let r = gd_step(&mut s, &p);
            bit_ops_total += r.bit_ops;
            loss = r.loss;
            tr.push((s.x, s.y));
        }
        let cap = 4096usize;
        if tr.len() > cap { let drop = tr.len() - cap; tr.drain(0..drop); }
        if !s.x.is_finite() || !s.y.is_finite() || s.x.abs() > 5.0 || s.y.abs() > 8.0 || loss > 1e8 {
            *s = GdState::default();
            tr.clear();
        }
        let wall_ns = now_ns() - t0;

        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        let x_min = -2.0f32; let x_max = 2.0f32;
        let y_min = -1.5f32; let y_max = 3.5f32;
        let to_px = |x: f32, y: f32| (
            ((x - x_min) / (x_max - x_min)) as f64 * cw,
            (1.0 - (y - y_min) / (y_max - y_min)) as f64 * ch,
        );

        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        let gw = 40u32; let gh = 30u32;
        for j in 0..gh {
            for i in 0..gw {
                let x = x_min + (i as f32 + 0.5) * (x_max - x_min) / gw as f32;
                let y = y_min + (j as f32 + 0.5) * (y_max - y_min) / gh as f32;
                let v = rosenbrock(x, y, p.a, p.b).max(1e-3).ln();
                let t = ((v + 4.0) / 12.0).clamp(0.0, 1.0) as f64;
                let r = (40.0 + 130.0 * t) as u32;
                let gg = (40.0 + 100.0 * (1.0 - t)) as u32;
                let bb = (90.0 + 80.0 * (1.0 - t)) as u32;
                ctx.set_fill_style_str(&format!("rgb({r},{gg},{bb})"));
                let (px0, py0) = to_px(x_min + i as f32 * (x_max - x_min) / gw as f32,
                                       y_min + (j + 1) as f32 * (y_max - y_min) / gh as f32);
                let (px1, py1) = to_px(x_min + (i + 1) as f32 * (x_max - x_min) / gw as f32,
                                       y_min + j as f32 * (y_max - y_min) / gh as f32);
                ctx.fill_rect(px0, py0, px1 - px0 + 1.0, py1 - py0 + 1.0);
            }
        }

        ctx.set_stroke_style_str("rgba(255, 240, 140, 0.92)");
        ctx.set_line_width(2.0);
        ctx.begin_path();
        for (i, &(x, y)) in tr.iter().enumerate() {
            let (px, py) = to_px(x, y);
            if i == 0 { ctx.move_to(px, py); } else { ctx.line_to(px, py); }
        }
        ctx.stroke();

        let (mpx, mpy) = to_px(1.0, 1.0);
        ctx.set_fill_style_str("rgba(255, 80, 120, 0.95)");
        ctx.begin_path();
        ctx.arc(mpx, mpy, 4.0, 0.0, std::f64::consts::TAU).unwrap();
        ctx.fill();

        emit_receipt(&sink, "gd_momentum_rosenbrock", "L0Closed", steps_per_frame as u64, wall_ns, bit_ops_total / steps_per_frame.max(1) as u64);
        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── 2-D FFT exhibit (visual-fft-revolution lineage) ──────────────────

#[wasm_bindgen]
pub fn mount_fft2d(canvas_id: &str, dim: u32, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let dim_us = dim as usize;
    if !dim_us.is_power_of_two() || dim_us < 32 {
        return Err(JsValue::from_str("dim must be a power of two ≥ 32"));
    }

    let buf = Rc::new(RefCell::new(vec![0.0f32; 2 * dim_us * dim_us]));
    let img = Rc::new(RefCell::new(vec![0.0f32; dim_us * dim_us]));
    let mag = Rc::new(RefCell::new(vec![0.0f32; dim_us * dim_us]));
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let buf2 = buf.clone();
    let img2 = img.clone();
    let mag2 = mag.clone();
    let mut tau = 0.0f32;

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let mut im = img2.borrow_mut();
        for y in 0..dim_us {
            for x in 0..dim_us {
                let fx = (x as f32 / dim_us as f32 - 0.5) * 2.0;
                let fy = (y as f32 / dim_us as f32 - 0.5) * 2.0;
                let s1 = (12.0 * fx + tau).sin();
                let s2 = (10.0 * fy + 0.7 * tau).sin();
                let s3 = (9.0 * fx + 9.0 * fy + tau * 0.3).sin();
                let pulse = (-((fx * fx + fy * fy) * 4.0)).exp() * (tau * 0.5).cos();
                im[y * dim_us + x] = 0.33 * (s1 + s2 + s3) + 0.5 * pulse;
            }
        }
        let mut b = buf2.borrow_mut();
        for k in 0..(dim_us * dim_us) {
            b[2 * k] = im[k];
            b[2 * k + 1] = 0.0;
        }

        let t0 = now_ns();
        let report = fft2d_in_place(&mut b, dim, dim).expect("power-of-two");
        let mut m = mag2.borrow_mut();
        let _ = log_magnitude_shifted(&b, dim, dim, &mut m);
        let wall_ns = now_ns() - t0;

        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);
        let panel_w = cw * 0.5;
        let sx = panel_w / dim as f64;
        let sy = ch / dim as f64;

        let mut min_v = f32::INFINITY;
        let mut max_v = f32::NEG_INFINITY;
        for &v in im.iter() {
            if v < min_v { min_v = v; }
            if v > max_v { max_v = v; }
        }
        let scale = (max_v - min_v).max(1e-6);
        for y in 0..dim_us {
            for x in 0..dim_us {
                let v = (im[y * dim_us + x] - min_v) / scale;
                let r = (255.0 * v) as u32;
                let g_ = (120.0 + 100.0 * v) as u32;
                let bb = (220.0 - 100.0 * v) as u32;
                ctx.set_fill_style_str(&format!("rgb({r},{g_},{bb})"));
                ctx.fill_rect(x as f64 * sx, y as f64 * sy, sx + 1.0, sy + 1.0);
            }
        }
        let mut min_l = f32::INFINITY;
        let mut max_l = f32::NEG_INFINITY;
        for &v in m.iter() {
            if v.is_finite() {
                if v < min_l { min_l = v; }
                if v > max_l { max_l = v; }
            }
        }
        let scale2 = (max_l - min_l).max(1e-6);
        for y in 0..dim_us {
            for x in 0..dim_us {
                let v = ((m[y * dim_us + x] - min_l) / scale2).clamp(0.0, 1.0);
                let r = (80.0 + 175.0 * v) as u32;
                let g_ = (30.0 + 120.0 * v) as u32;
                let bb = (160.0 * v) as u32;
                ctx.set_fill_style_str(&format!("rgb({r},{g_},{bb})"));
                ctx.fill_rect(panel_w + x as f64 * sx, y as f64 * sy, sx + 1.0, sy + 1.0);
            }
        }

        tau += 0.05;

        emit_receipt_with_hash(
            &sink,
            "fft2d_radix2",
            "L0Closed",
            1,
            wall_ns,
            report.bit_ops,
            fnv1a_64_f32_slice(&m),
        );
        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── 3-D SDF (WebGL2 sphere-trace) exhibit ────────────────────────────

fn compile_shader(
    gl: &WebGl2RenderingContext,
    kind: u32,
    src: &str,
) -> Result<web_sys::WebGlShader, String> {
    let shader = gl.create_shader(kind).ok_or("create_shader failed")?;
    gl.shader_source(&shader, src);
    gl.compile_shader(&shader);
    let ok = gl
        .get_shader_parameter(&shader, WebGl2RenderingContext::COMPILE_STATUS)
        .as_bool()
        .unwrap_or(false);
    if !ok {
        return Err(gl.get_shader_info_log(&shader).unwrap_or_default());
    }
    Ok(shader)
}

fn link_program(
    gl: &WebGl2RenderingContext,
    vs: &web_sys::WebGlShader,
    fs: &web_sys::WebGlShader,
) -> Result<web_sys::WebGlProgram, String> {
    let prog = gl.create_program().ok_or("create_program failed")?;
    gl.attach_shader(&prog, vs);
    gl.attach_shader(&prog, fs);
    gl.link_program(&prog);
    let ok = gl
        .get_program_parameter(&prog, WebGl2RenderingContext::LINK_STATUS)
        .as_bool()
        .unwrap_or(false);
    if !ok {
        return Err(gl.get_program_info_log(&prog).unwrap_or_default());
    }
    Ok(prog)
}

// ── Reason exhibit (cascade walk visualised + per-hop receipts) ──────

const REASON_QUERIES: &[&str] = &[
    "2 + 2",
    "6!",
    "pi",
    "pythagorean theorem",
    "circumference of a unit circle",
    "rms of a sine wave amplitude a",
    "compose pi times e",
    "what is the meaning of life?",
];

fn tier_color(t: CascadeTier) -> &'static str {
    match t {
        CascadeTier::L0Closed => "rgba(120, 220, 255, 0.95)",
        CascadeTier::L0Retrieved => "rgba(180, 255, 200, 0.95)",
        CascadeTier::L0Identifiable => "rgba(190, 200, 255, 0.95)",
        CascadeTier::L1 => "rgba(255, 220, 140, 0.95)",
        CascadeTier::L2 => "rgba(255, 140, 160, 0.95)",
    }
}

#[wasm_bindgen]
pub fn mount_reason(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let query_idx = Rc::new(RefCell::new(0usize));
    let frame_in_query = Rc::new(RefCell::new(0u32));
    let qi = query_idx.clone();
    let fi = frame_in_query.clone();

    let time = TimeFn { now_ns };

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let mut i = qi.borrow_mut();
        let mut fr = fi.borrow_mut();
        let q = REASON_QUERIES[*i];
        let trace = cascade::walk(q, &time);

        // Draw.
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        // Query banner.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("query", 28.0, 32.0).ok();
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("18px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(q, 28.0, 56.0).ok();

        // Hops, ladder-rendered. Each tier may contribute one (reasoning
        // only) or two (reasoning + generation) hops. Generation hops are
        // indented to make the MoT split visible at a glance.
        let mut y = 100.0;
        let row_h = 44.0;
        for hop in trace.hops.iter() {
            let is_gen = matches!(hop.expert, SubExpert::Generation);
            let indent = if is_gen { 18.0 } else { 0.0 };

            // Side bar: tier color (solid for reasoning, dashed/lower-alpha for generation).
            let bar_alpha = if is_gen { 0.55 } else { 1.0 };
            let bar_color = tier_color(hop.tier).replace("0.95", &format!("{:.2}", bar_alpha));
            ctx.set_fill_style_str(&bar_color);
            ctx.fill_rect(28.0 + indent, y - 14.0, 4.0, 36.0);

            // Tier + expert label.
            ctx.set_fill_style_str(tier_color(hop.tier));
            ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
            let header = format!("{} · {}", hop.tier.label(), hop.expert.label());
            ctx.fill_text(&header, 44.0 + indent, y).ok();

            // Action.
            ctx.set_fill_style_str("rgba(180, 188, 208, 1)");
            ctx.fill_text(&hop.action, 44.0 + indent, y + 16.0).ok();

            // Result on the right: ✓ / ✗ + answer if any.
            let status = if hop.matched { "✓ matched" } else { "✗ no match" };
            ctx.set_fill_style_str(if hop.matched {
                "rgba(180, 255, 200, 1)"
            } else {
                "rgba(255, 180, 180, 0.6)"
            });
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text(status, cw - 220.0, y).ok();
            if let Some(a) = &hop.answer {
                ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
                let short = if a.chars().count() > 28 {
                    let mut s: String = a.chars().take(28).collect();
                    s.push('…');
                    s
                } else {
                    a.clone()
                };
                ctx.fill_text(&short, cw - 220.0, y + 16.0).ok();
            }

            // Bit-ops + wall_ns/op.
            ctx.set_fill_style_str("rgba(120, 130, 150, 1)");
            ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
            let cost = format!("{} bit-ops · {:.0} ns/op", hop.bit_ops, hop.wall_ns_per_op);
            ctx.fill_text(&cost, 44.0 + indent, y + 30.0).ok();

            y += row_h;
        }

        // Footer.
        let resolved = match trace.resolved_at {
            Some(t) => format!("resolved at {}", t.label()),
            None => "refused at L2 (no model shipped)".to_string(),
        };
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("13px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(&resolved, 28.0, ch - 36.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        let total = format!("total bit-ops across hops: {}", trace.total_bit_ops);
        ctx.fill_text(&total, 28.0, ch - 18.0).ok();

        // Emit a receipt summarising the whole trace as one primitive.
        // Use the matched tier's name (or L2-refused) as the primitive id.
        let prim_name = format!(
            "cascade_walk[{}]",
            trace.resolved_at.map(|t| t.label()).unwrap_or("L2_refused")
        );
        let v_class = match trace.resolved_at {
            Some(CascadeTier::L0Closed) => "L0Closed",
            Some(CascadeTier::L0Retrieved) => "L0Retrieved",
            Some(CascadeTier::L0Identifiable) => "L0Identifiable",
            Some(CascadeTier::L1) => "L1",
            Some(CascadeTier::L2) => "L2",
            None => "L2_refused",
        };
        // Sum wall_ns across hops.
        let wall_ns_total: f64 = trace
            .hops
            .iter()
            .map(|h| h.wall_ns_per_op * (h.bit_ops.max(1) as f64))
            .sum();
        emit_receipt(&sink, &prim_name, v_class, 1, wall_ns_total, trace.total_bit_ops);

        // Rotate to next query after ~60 frames (~1 s at 60 fps).
        *fr += 1;
        if *fr > 60 {
            *fr = 0;
            *i = (*i + 1) % REASON_QUERIES.len();
        }

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

#[wasm_bindgen]
pub fn mount_sdf3d(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let win = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
    let doc = win.document().ok_or_else(|| JsValue::from_str("no document"))?;
    let canvas = doc
        .get_element_by_id(canvas_id)
        .ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let canvas: HtmlCanvasElement = canvas
        .dyn_into()
        .map_err(|_| JsValue::from_str("not a canvas"))?;
    let gl = canvas
        .get_context("webgl2")
        .map_err(|_| JsValue::from_str("webgl2 unsupported"))?
        .ok_or_else(|| JsValue::from_str("webgl2 unsupported"))?
        .dyn_into::<WebGl2RenderingContext>()
        .map_err(|_| JsValue::from_str("webgl2 ctx wrong type"))?;

    let vs = compile_shader(&gl, WebGl2RenderingContext::VERTEX_SHADER, sdf3d::VERTEX_SRC)
        .map_err(|e| JsValue::from_str(&format!("vs: {e}")))?;
    let fs = compile_shader(&gl, WebGl2RenderingContext::FRAGMENT_SHADER, sdf3d::FRAGMENT_SRC)
        .map_err(|e| JsValue::from_str(&format!("fs: {e}")))?;
    let prog = link_program(&gl, &vs, &fs)
        .map_err(|e| JsValue::from_str(&format!("link: {e}")))?;

    let verts: [f32; 6] = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0];
    let vbo = gl.create_buffer().ok_or_else(|| JsValue::from_str("vbo"))?;
    gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&vbo));
    unsafe {
        let arr = js_sys::Float32Array::view(&verts);
        gl.buffer_data_with_array_buffer_view(
            WebGl2RenderingContext::ARRAY_BUFFER,
            &arr,
            WebGl2RenderingContext::STATIC_DRAW,
        );
    }
    let vao = gl.create_vertex_array().ok_or_else(|| JsValue::from_str("vao"))?;
    gl.bind_vertex_array(Some(&vao));
    let a_pos = gl.get_attrib_location(&prog, "a_pos") as u32;
    gl.enable_vertex_attrib_array(a_pos);
    gl.vertex_attrib_pointer_with_i32(a_pos, 2, WebGl2RenderingContext::FLOAT, false, 0, 0);
    let u_time = gl
        .get_uniform_location(&prog, "u_time")
        .ok_or_else(|| JsValue::from_str("u_time"))?;
    let u_res = gl
        .get_uniform_location(&prog, "u_resolution")
        .ok_or_else(|| JsValue::from_str("u_resolution"))?;

    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let start_ns = now_ns();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let t0 = now_ns();
        let t_sec = (t0 - start_ns) * 1e-9;
        let w = canvas.width();
        let h = canvas.height();
        gl.viewport(0, 0, w as i32, h as i32);
        gl.clear_color(0.03, 0.05, 0.08, 1.0);
        gl.clear(WebGl2RenderingContext::COLOR_BUFFER_BIT);
        gl.use_program(Some(&prog));
        gl.uniform1f(Some(&u_time), t_sec as f32);
        gl.uniform2f(Some(&u_res), w as f32, h as f32);
        gl.bind_vertex_array(Some(&vao));
        gl.draw_arrays(WebGl2RenderingContext::TRIANGLES, 0, 3);
        let wall_ns = now_ns() - t0;

        let bit_ops = sdf3d::estimate_bit_ops_per_frame(w, h);
        emit_receipt(&sink, "sdf3d_sphere_trace", "L0Closed", 1, wall_ns, bit_ops);

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Omnimodal exhibit ────────────────────────────────────────────────
//
// Four deterministic encoders project text, image, audio, and video into
// the same HDC shape-key space. The exhibit cycles through three scenes;
// each scene supplies one sample per modality, and the page renders four
// panels + a 4×4 cosine matrix proving the cross-modal projection works.

#[derive(Clone, Copy)]
struct Scene {
    label: &'static str,
    text: &'static str,
    image: ImgKind,
    audio: AudKind,
    video: VidKind,
}

#[derive(Clone, Copy)]
enum ImgKind { Circle, Square, Diagonal }

#[derive(Clone, Copy)]
enum AudKind { SineTone, SquareWave, FreqSweep }

#[derive(Clone, Copy)]
enum VidKind { ExpandingCircle, PulsingSquare, ScrollingDiagonal }

const SCENES: &[Scene] = &[
    Scene {
        label: "circle",
        text: "circle of radius one",
        image: ImgKind::Circle,
        audio: AudKind::SineTone,
        video: VidKind::ExpandingCircle,
    },
    Scene {
        label: "square",
        text: "square grid pattern",
        image: ImgKind::Square,
        audio: AudKind::SquareWave,
        video: VidKind::PulsingSquare,
    },
    Scene {
        label: "diagonal",
        text: "diagonal stripes scan",
        image: ImgKind::Diagonal,
        audio: AudKind::FreqSweep,
        video: VidKind::ScrollingDiagonal,
    },
];

fn make_image(kind: ImgKind) -> [u8; 64] {
    let mut g = [0u8; 64];
    match kind {
        ImgKind::Circle => {
            for r in 0..8 {
                for c in 0..8 {
                    let dx = c as f32 - 3.5;
                    let dy = r as f32 - 3.5;
                    let d = (dx * dx + dy * dy).sqrt();
                    g[r * 8 + c] = if (d - 2.5).abs() < 1.0 { 220 } else { 30 };
                }
            }
        }
        ImgKind::Square => {
            for r in 0..8 {
                for c in 0..8 {
                    let on_ring = r == 1 || r == 6 || c == 1 || c == 6;
                    let inside = r >= 1 && r <= 6 && c >= 1 && c <= 6;
                    g[r * 8 + c] = if on_ring && inside { 220 } else { 30 };
                }
            }
        }
        ImgKind::Diagonal => {
            for r in 0..8 {
                for c in 0..8 {
                    g[r * 8 + c] = if (r + c) % 3 == 0 { 220 } else { 30 };
                }
            }
        }
    }
    g
}

fn make_audio(kind: AudKind, n: usize) -> Vec<f32> {
    let mut out = vec![0.0f32; n];
    match kind {
        AudKind::SineTone => {
            for k in 0..n {
                let t = k as f32 / n as f32;
                out[k] = (2.0 * core::f32::consts::PI * 4.0 * t).sin();
            }
        }
        AudKind::SquareWave => {
            for k in 0..n {
                let t = k as f32 / n as f32;
                let phase = (2.0 * core::f32::consts::PI * 4.0 * t).sin();
                out[k] = if phase >= 0.0 { 1.0 } else { -1.0 };
            }
        }
        AudKind::FreqSweep => {
            for k in 0..n {
                let t = k as f32 / n as f32;
                let f = 2.0 + 12.0 * t;
                out[k] = (2.0 * core::f32::consts::PI * f * t).sin();
            }
        }
    }
    out
}

fn make_video(kind: VidKind, frames: usize) -> Vec<u8> {
    let mut out = Vec::with_capacity(64 * frames);
    for t in 0..frames {
        let f = t as f32 / frames as f32;
        let frame = match kind {
            VidKind::ExpandingCircle => {
                let mut g = [0u8; 64];
                let radius = 1.5 + f * 2.5;
                for r in 0..8 {
                    for c in 0..8 {
                        let dx = c as f32 - 3.5;
                        let dy = r as f32 - 3.5;
                        let d = (dx * dx + dy * dy).sqrt();
                        g[r * 8 + c] = if (d - radius).abs() < 0.8 { 220 } else { 30 };
                    }
                }
                g
            }
            VidKind::PulsingSquare => {
                let mut g = [0u8; 64];
                let pulse = (0.5 + 0.5 * (2.0 * core::f32::consts::PI * f).sin()) * 220.0;
                for r in 0..8 {
                    for c in 0..8 {
                        let on = (r == 1 || r == 6 || c == 1 || c == 6)
                            && r >= 1 && r <= 6 && c >= 1 && c <= 6;
                        g[r * 8 + c] = if on { pulse as u8 } else { 30 };
                    }
                }
                g
            }
            VidKind::ScrollingDiagonal => {
                let mut g = [0u8; 64];
                let shift = (f * 6.0) as usize;
                for r in 0..8 {
                    for c in 0..8 {
                        g[r * 8 + c] =
                            if ((r + c + shift) % 3) == 0 { 220 } else { 30 };
                    }
                }
                g
            }
        };
        out.extend_from_slice(&frame);
    }
    out
}

fn draw_grid8(
    ctx: &CanvasRenderingContext2d,
    grid: &[u8],
    x0: f64,
    y0: f64,
    cell: f64,
) {
    for r in 0..8 {
        for c in 0..8 {
            let v = grid[r * 8 + c] as f64 / 255.0;
            let shade = (40.0 + 180.0 * v) as u32;
            ctx.set_fill_style_str(&format!("rgb({shade},{shade},{shade})"));
            ctx.fill_rect(x0 + c as f64 * cell, y0 + r as f64 * cell, cell, cell);
        }
    }
}

fn draw_waveform(
    ctx: &CanvasRenderingContext2d,
    samples: &[f32],
    x0: f64,
    y0: f64,
    w: f64,
    h: f64,
) {
    ctx.set_stroke_style_str("rgba(120, 220, 255, 0.95)");
    ctx.set_line_width(1.2);
    ctx.begin_path();
    let n = samples.len();
    for (i, &s) in samples.iter().enumerate() {
        let x = x0 + (i as f64 / n.max(1) as f64) * w;
        let y = y0 + h * 0.5 - (s as f64 * 0.5 * h);
        if i == 0 {
            ctx.move_to(x, y);
        } else {
            ctx.line_to(x, y);
        }
    }
    ctx.stroke();
}

fn draw_hv_sig(
    ctx: &CanvasRenderingContext2d,
    hv: &Hv,
    x0: f64,
    y0: f64,
    w: f64,
    h: f64,
) {
    let take = 64usize.min(hv.0.len());
    let bw = w / take as f64;
    for i in 0..take {
        let v = hv.0[i];
        let color = if v >= 0 { "rgba(120, 220, 255, 0.9)" } else { "rgba(255, 140, 160, 0.9)" };
        ctx.set_fill_style_str(color);
        let mag = h * 0.5;
        if v >= 0 {
            ctx.fill_rect(x0 + i as f64 * bw, y0 + h * 0.5 - mag, bw - 0.5, mag);
        } else {
            ctx.fill_rect(x0 + i as f64 * bw, y0 + h * 0.5, bw - 0.5, mag);
        }
    }
}

fn cosine_color(c: f32) -> String {
    let v = c.clamp(-1.0, 1.0);
    if v >= 0.0 {
        let t = v.powf(0.6);
        let r = (40.0 + 200.0 * t) as u32;
        let g = (60.0 + 160.0 * t) as u32;
        let b = (80.0 + 80.0 * t) as u32;
        format!("rgb({r},{g},{b})")
    } else {
        let t = (-v).powf(0.6);
        let r = (80.0 - 40.0 * t) as u32;
        let g = (80.0 - 40.0 * t) as u32;
        let b = (60.0 + 100.0 * t) as u32;
        format!("rgb({r},{g},{b})")
    }
}

#[wasm_bindgen]
pub fn mount_omnimodal(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let frame = Rc::new(RefCell::new(0u32));
    let fr = frame.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let mut fc = fr.borrow_mut();
        let scene_idx = ((*fc / 180) as usize) % SCENES.len();
        let scene = SCENES[scene_idx];

        // ── Compute pass: encode all four modalities ──
        let t0 = now_ns();
        let img = make_image(scene.image);
        let img_r = encode_image(&img);
        let aud = make_audio(scene.audio, 128);
        let aud_r = encode_audio(&aud);
        let vid = make_video(scene.video, 4);
        let vid_r = encode_video(&vid, 4);
        let txt_r = encode_text(scene.text);
        let wall_ns_total = now_ns() - t0;

        let bit_ops_total =
            img_r.bit_ops + aud_r.bit_ops + vid_r.bit_ops + txt_r.bit_ops;

        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;

        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        // Scene banner.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(&format!("scene: {} ({}/{})", scene.label, scene_idx + 1, SCENES.len()), 24.0, 26.0).ok();

        // Four panels across the top half.
        let panel_w = (cw - 80.0) / 4.0;
        let panel_h = ch * 0.5 - 50.0;
        let panel_y = 44.0;
        let names = ["text", "image", "audio", "video"];
        let hvs = [&txt_r.hv, &img_r.hv, &aud_r.hv, &vid_r.hv];

        for i in 0..4 {
            let x = 24.0 + i as f64 * (panel_w + 8.0);
            ctx.set_stroke_style_str("rgba(40, 50, 70, 1)");
            ctx.set_line_width(1.0);
            ctx.stroke_rect(x, panel_y, panel_w, panel_h);

            // Header.
            ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
            ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text(names[i], x + 10.0, panel_y + 18.0).ok();

            // Preview area (top half).
            let preview_y = panel_y + 26.0;
            let preview_h = panel_h * 0.45;
            ctx.set_fill_style_str("rgba(14, 18, 28, 1)");
            ctx.fill_rect(x + 10.0, preview_y, panel_w - 20.0, preview_h);
            match i {
                0 => {
                    ctx.set_fill_style_str("rgba(200, 210, 230, 1)");
                    ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
                    let mut yy = preview_y + 16.0;
                    for line in scene.text.split_whitespace() {
                        ctx.fill_text(line, x + 14.0, yy).ok();
                        yy += 12.0;
                    }
                }
                1 => {
                    let cell = ((panel_w - 30.0) / 8.0).min(preview_h / 8.0);
                    let off_x = x + (panel_w - cell * 8.0) * 0.5;
                    let off_y = preview_y + (preview_h - cell * 8.0) * 0.5;
                    draw_grid8(&ctx, &img, off_x, off_y, cell);
                }
                2 => {
                    draw_waveform(&ctx, &aud, x + 14.0, preview_y + 4.0, panel_w - 28.0, preview_h - 8.0);
                }
                3 => {
                    // Show 4 mini frames side-by-side.
                    let mini_cell = ((panel_w - 30.0) / 32.0).min(preview_h / 8.0);
                    let off_y = preview_y + (preview_h - mini_cell * 8.0) * 0.5;
                    for t in 0..4 {
                        let off_x = x + 14.0 + (t as f64) * (mini_cell * 8.0 + 4.0);
                        let frame_slice = &vid[t * 64..(t + 1) * 64];
                        draw_grid8(&ctx, frame_slice, off_x, off_y, mini_cell);
                    }
                }
                _ => {}
            }

            // Hv signature (bottom half).
            let sig_y = preview_y + preview_h + 8.0;
            let sig_h = panel_h - (preview_h + 32.0);
            ctx.set_fill_style_str("rgba(160, 170, 190, 0.9)");
            ctx.set_font("9px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text("shape-key (first 64 dims)", x + 10.0, sig_y - 2.0).ok();
            draw_hv_sig(&ctx, hvs[i], x + 10.0, sig_y, panel_w - 20.0, sig_h);
        }

        // ── 4×4 cosine matrix ──
        let mat_x = 24.0;
        let mat_y = ch * 0.5 + 16.0;
        let mat_avail_h = ch - mat_y - 56.0;
        let cell_w = ((cw - 48.0 - 80.0) / 4.0).min(110.0);
        let cell_h = (mat_avail_h / 5.0).min(36.0);
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("cross-modal cosine — same HDC shape-key space", mat_x, mat_y - 4.0).ok();

        // Column headers.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
        for c in 0..4 {
            ctx.fill_text(names[c], mat_x + 80.0 + c as f64 * cell_w + cell_w * 0.4, mat_y + 14.0).ok();
        }
        // Row labels + cells.
        for r in 0..4 {
            let row_y = mat_y + 22.0 + r as f64 * cell_h;
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(names[r], mat_x + 20.0, row_y + cell_h * 0.6).ok();
            for c in 0..4 {
                let x = mat_x + 80.0 + c as f64 * cell_w;
                let value = hvs[r].cosine(hvs[c]);
                ctx.set_fill_style_str(&cosine_color(value));
                ctx.fill_rect(x, row_y, cell_w - 4.0, cell_h - 4.0);
                ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
                ctx.fill_text(&format!("{:+.3}", value), x + 8.0, row_y + cell_h * 0.6).ok();
            }
        }

        // Footer summary.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
        let footer_y = ch - 24.0;
        ctx.fill_text(
            &format!(
                "bit-ops across 4 encoders: {}   ·   wall_ns: {:.0}",
                bit_ops_total,
                wall_ns_total
            ),
            mat_x,
            footer_y,
        ).ok();

        // Receipt: sum across the four encoders, V-class = L0-identifiable.
        emit_receipt(
            &sink,
            "omnimodal_encoders[text|image|audio|video]",
            "L0Identifiable",
            1,
            wall_ns_total,
            bit_ops_total,
        );

        *fc = fc.wrapping_add(1);
        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Determinism exhibit ─────────────────────────────────────────────
//
// Runs a fixed-input FFT 256-pt 1000 times per frame, hashes each
// output, counts uniques. With the substrate's deterministic kernel,
// all 1000 outputs hash identically — the live readout reads
// "1 / 1000 unique hashes". A `get_perturb` callback returns a bool
// flag; when true, a per-iteration scrambling of the input phase is
// injected which mimics the failure mode Horace He's
// `batch-invariant-ops` post calls out — same prompt, different
// numerical path → divergent hash counts.
//
// The receipt this exhibit emits carries the FFT's output_hash on its
// primitive, so the receipt strip's ≡/≠ indicator updates from the
// substrate-reported field instead of being re-hashed on the JS side.
// That is the load-bearing demonstration of the 05 receipt shape on
// `/receipts`.

#[wasm_bindgen]
pub fn mount_determinism(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let n: usize = 256;
    let runs_per_frame: usize = 1000;

    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let mut frame_count: u32 = 0;

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        // Read the page-side perturbation toggle each frame. Checkbox
        // ID is by convention `determinism-perturb`; missing element
        // → perturb stays false (substrate's deterministic default).
        let perturb = web_sys::window()
            .and_then(|w| w.document())
            .and_then(|d| d.get_element_by_id("determinism-perturb"))
            .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
            .map(|cb| cb.checked())
            .unwrap_or(false);

        // Fixed input every frame so any drift is from the kernel, not
        // the signal. Two-tone, length 256, deterministic.
        let mut hashes: Vec<u64> = Vec::with_capacity(runs_per_frame);
        let t0 = now_ns();
        let mut total_bit_ops: u64 = 0;
        let mut buf = vec![0.0f32; 2 * n];
        let mut spec = vec![0.0f32; n / 2];

        for run in 0..runs_per_frame {
            // Reset to canonical input each iteration.
            for k in 0..n {
                let t = k as f32 / n as f32;
                let s = (2.0 * core::f32::consts::PI * 4.0 * t).sin()
                    + 0.5 * (2.0 * core::f32::consts::PI * 12.0 * t).sin();
                buf[2 * k] = s;
                buf[2 * k + 1] = 0.0;
            }

            // Perturbation: scramble the input by an iteration-dependent
            // offset that breaks the input's bit-identity across runs.
            // This is the "non-batch-invariant" failure mode in
            // demonstration form — same nominal task, slightly different
            // numerical path per iteration → divergent output hash.
            if perturb {
                let kick = (run as f32 * 1e-7) as f32;
                for k in 0..n {
                    buf[2 * k] += kick;
                }
            }

            let report = fft_in_place(&mut buf).expect("power-of-two");
            fft_power_spectrum(&buf, &mut spec);
            total_bit_ops = total_bit_ops.saturating_add(report.bit_ops);
            hashes.push(fnv1a_64_f32_slice(&spec));
        }
        let wall_ns = now_ns() - t0;

        // Count unique hashes the cheap way — sort + dedup. 1000 u64s
        // is microseconds.
        let mut sorted = hashes.clone();
        sorted.sort_unstable();
        sorted.dedup();
        let unique = sorted.len();
        let canonical = hashes[0];

        // ── Draw the 50 × 20 grid of run cells, coloured by hash
        //    equivalence class. All matching → solid emerald; divergent
        //    → coloured by the first-seen index of each unique hash. ──
        let w = canvas.width() as f64;
        let h = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, w, h);

        // Cell layout: 50 cols × 20 rows = 1000 cells.
        let cols = 50.0_f64;
        let rows = 20.0_f64;
        let pad = 1.0_f64;
        let cell_w = (w - (cols + 1.0) * pad) / cols;
        let cell_h = (h - (rows + 1.0) * pad) / rows;

        // Build a hash → palette-index map in observation order so the
        // first hash always reads green (the substrate's commitment).
        let mut palette_for: std::collections::HashMap<u64, usize> =
            std::collections::HashMap::new();
        let palette: &[&str] = &[
            "#22c55e", // emerald — the canonical hash
            "#fb7185", // rose — first divergence
            "#fbbf24", // amber — second
            "#a78bfa", // violet — third
            "#38bdf8", // sky — fourth
            "#f472b6", // pink — fifth
            "#94a3b8", // slate — overflow
        ];
        for &h_ in &hashes {
            if !palette_for.contains_key(&h_) {
                let next = palette_for.len().min(palette.len() - 1);
                palette_for.insert(h_, next);
            }
        }

        for i in 0..runs_per_frame {
            let r = (i / 50) as f64;
            let c = (i % 50) as f64;
            let x = pad + c * (cell_w + pad);
            let y = pad + r * (cell_h + pad);
            let idx = *palette_for.get(&hashes[i]).unwrap_or(&0);
            ctx.set_fill_style_str(palette[idx]);
            ctx.fill_rect(x, y, cell_w, cell_h);
        }

        // ── Heading overlay: hash + unique count + canonical readout ──
        ctx.set_fill_style_str("rgba(10, 14, 24, 0.85)");
        let banner_h = 28.0;
        ctx.fill_rect(0.0, 0.0, w, banner_h);
        ctx.set_fill_style_str("rgba(220, 240, 255, 0.95)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        let label = if unique == 1 {
            format!("≡  byte-reproducible · {} / {} unique hash · 0x{:016x}",
                unique, runs_per_frame, canonical)
        } else {
            format!("≠  diverged · {} / {} unique hashes (toggle off to restore)",
                unique, runs_per_frame)
        };
        let _ = ctx.fill_text(&label, 8.0, 18.0);

        // ── Emit a receipt carrying the canonical output hash. The
        //    receipt strip's ≡/≠ indicator reads this directly. ──
        let v_class = if perturb { "L1" } else { "L0Closed" };
        emit_receipt_with_hash(
            &sink,
            "fft_256_x1000",
            v_class,
            runs_per_frame as u64,
            wall_ns,
            total_bit_ops / runs_per_frame as u64,
            canonical,
        );

        frame_count = frame_count.wrapping_add(1);
        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── mgai-graph event-log persistence bindings ─────────────────────
//
// Used by /exhibits/persistent-graph. The page holds the mutation log
// in localStorage; on every change it sends the log here and renders
// the (count, count, hash) summary. The "byte-reproducible across
// reloads" claim is: hash(log_replayed_to_graph) is stable, even
// across full page reloads.

#[wasm_bindgen]
pub fn pg_log_summary(log_json: &str) -> Result<JsValue, JsValue> {
    let mutations: Vec<mgai_graph::Mutation> = serde_json::from_str(log_json)
        .map_err(|e| JsValue::from_str(&format!("log parse: {e}")))?;
    let g = mgai_graph::Graph::from_log(&mutations)
        .map_err(|e| JsValue::from_str(&format!("log replay: {e}")))?;
    let canonical = g
        .to_json()
        .map_err(|e| JsValue::from_str(&format!("graph serialise: {e}")))?;
    // FNV-1a 64-bit over the canonical JSON bytes — same family as
    // every other output_hash on the substrate.
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in canonical.as_bytes() {
        h ^= *b as u64;
        h = h.wrapping_mul(0x100_0000_01b3);
    }
    let result = serde_json::json!({
        "nodes": g.node_count(),
        "edges": g.edge_count(),
        "log_len": mutations.len(),
        "state_hash": format!("0x{:016x}", h),
        "state_hash_u64": h,
    });
    serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&format!("{e}")))
}

/// Render the live edge list from a log (as `{from_label, to_label,
/// edge_kind}` triples), used by /exhibits/persistent-graph to draw the
/// current graph state without re-implementing the replay on the JS side.
#[wasm_bindgen]
pub fn pg_edge_list(log_json: &str) -> Result<JsValue, JsValue> {
    let mutations: Vec<mgai_graph::Mutation> = serde_json::from_str(log_json)
        .map_err(|e| JsValue::from_str(&format!("log parse: {e}")))?;
    let g = mgai_graph::Graph::from_log(&mutations)
        .map_err(|e| JsValue::from_str(&format!("log replay: {e}")))?;
    let edges: Vec<serde_json::Value> = g
        .edges()
        .filter_map(|e| {
            let from_label = g.node(e.from)?.label.clone();
            let to_label = g.node(e.to)?.label.clone();
            Some(serde_json::json!({
                "from": from_label,
                "to": to_label,
                "kind": e.kind,
            }))
        })
        .collect();
    serde_wasm_bindgen::to_value(&edges).map_err(|e| JsValue::from_str(&format!("{e}")))
}

// ── Combined cascade-memory exhibit ───────────────────────────────
//
// Runs BOTH memory paths on the same query so a visitor sees the
// substrate's routing decision live:
//
//   • Structural path  — cascade::walk fires probe_l0_retrieved which
//     tries the mgai-graph typed-edge pattern. Resolves at L0-retrieved
//     for queries like "uses softmax" or "HDC uses".
//
//   • Similarity path  — HDC cosine over the 30-item corpus, like the
//     /exhibits/code-memory exhibit.
//
// The canvas renders both side-by-side. The banner at the top reports
// which path the cascade considers the canonical resolution; the
// receipt carries the resolved tier as its V-class and the FNV-1a of
// the combined output as its output_hash.

#[wasm_bindgen]
pub fn mount_cascade_memory(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    use crate::cascade::{walk, CascadeTier, SubExpert, TimeFn};
    use crate::modal::encode_text;

    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;

    // Pre-encode corpus once (same 30 items as code-memory).
    let encoded: Vec<(String, String, crate::hdc::Hv)> = CODE_MEMORY_CORPUS
        .iter()
        .map(|(id, text)| {
            let report = encode_text(text);
            (id.to_string(), text.to_string(), report.hv)
        })
        .collect();
    let encoded = Rc::new(encoded);
    let n_items = encoded.len();

    let time = TimeFn { now_ns };
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let last_query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let query = web_sys::window()
            .and_then(|w| w.document())
            .and_then(|d| d.get_element_by_id("cascade-memory-query"))
            .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
            .map(|cb| cb.value())
            .unwrap_or_default();
        let effective_query = if query.trim().is_empty() {
            "uses softmax".to_string()
        } else {
            query.clone()
        };

        // Only re-render the static parts when the query changes.
        let mut last = last_query.borrow_mut();
        let _changed = *last != effective_query;
        *last = effective_query.clone();
        drop(last);

        // ── Structural path: cascade walk (which internally tries the
        //    mgai-graph sub-strategy at L0-retrieved). ──
        let t0 = now_ns();
        let trace = walk(&effective_query, &time);
        let cascade_wall = now_ns() - t0;

        // ── Similarity path: HDC cosine over the 30-item corpus. ──
        let t1 = now_ns();
        let q_report = encode_text(&effective_query);
        let mut scores: Vec<(usize, f32)> = encoded
            .iter()
            .enumerate()
            .map(|(i, (_, _, hv))| (i, q_report.hv.cosine(hv)))
            .collect();
        scores
            .sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
        let hdc_wall = now_ns() - t1;
        let top3: Vec<(usize, f32)> = scores.iter().take(3).copied().collect();

        // Combined output hash: cascade resolved-tier + HDC top-3 indices.
        let mut bytes: Vec<u8> = Vec::with_capacity(16);
        bytes.push(match trace.resolved_at {
            Some(CascadeTier::L0Closed) => 0,
            Some(CascadeTier::L0Retrieved) => 1,
            Some(CascadeTier::L0Identifiable) => 2,
            Some(CascadeTier::L1) => 3,
            Some(CascadeTier::L2) => 4,
            None => 255,
        });
        for (i, _) in &top3 {
            bytes.extend_from_slice(&(*i as u32).to_le_bytes());
        }
        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
        for &b in &bytes {
            h ^= b as u64;
            h = h.wrapping_mul(0x100_0000_01b3);
        }

        // ── Render ──
        let w = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, w, ch);

        // Header
        ctx.set_fill_style_str("rgba(220, 240, 255, 0.95)");
        ctx.set_font("13px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            &format!("query · \"{}\"", effective_query),
            16.0,
            24.0,
        );
        let resolved_str = trace
            .resolved_at
            .map(|t| t.label())
            .unwrap_or("(no L0/L1 match)");
        ctx.set_fill_style_str(if trace.resolved_at.is_some() {
            "rgba(34, 197, 94, 0.95)"
        } else {
            "rgba(160, 170, 200, 0.7)"
        });
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            &format!("cascade resolved at {}   ·   hash 0x{:016x}", resolved_str, h),
            16.0,
            42.0,
        );

        // Split canvas: left half = structural / cascade trace; right = HDC top-3.
        let mid_x = w * 0.5;
        ctx.set_stroke_style_str("rgba(50, 60, 80, 0.7)");
        ctx.set_line_width(1.0);
        ctx.begin_path();
        ctx.move_to(mid_x, 60.0);
        ctx.line_to(mid_x, ch - 30.0);
        ctx.stroke();

        // ── Left panel: cascade trace (structural path) ──
        ctx.set_fill_style_str("rgba(140, 200, 255, 0.85)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            "STRUCTURAL · mgai-graph + cascade",
            16.0,
            72.0,
        );

        let mut y = 92.0;
        for hop in trace.hops.iter().take(8) {
            // Tier badge
            let badge_color = match hop.tier {
                CascadeTier::L0Closed | CascadeTier::L0Retrieved | CascadeTier::L0Identifiable => {
                    if hop.matched {
                        "rgba(34, 197, 94, 0.95)"
                    } else {
                        "rgba(100, 110, 130, 0.55)"
                    }
                }
                CascadeTier::L1 => "rgba(125, 211, 252, 0.95)",
                CascadeTier::L2 => "rgba(251, 191, 36, 0.95)",
            };
            ctx.set_fill_style_str(badge_color);
            ctx.fill_rect(16.0, y - 12.0, 76.0, 20.0);
            ctx.set_fill_style_str("rgba(10, 14, 24, 0.95)");
            ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
            let _ = ctx.fill_text(hop.tier.label(), 22.0, y + 2.0);

            // Sub-expert + action
            ctx.set_fill_style_str("rgba(220, 240, 255, 0.9)");
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            let symbol = match hop.expert {
                SubExpert::Reasoning => "?",
                SubExpert::Generation => "→",
            };
            let mark = if hop.matched { "✓" } else { "·" };
            let line = format!("{} {} {}", symbol, mark, hop.action);
            let line = if line.len() > 48 { format!("{}…", &line[..47]) } else { line };
            let _ = ctx.fill_text(&line, 100.0, y + 2.0);

            y += 22.0;
            if y > ch - 60.0 {
                break;
            }
        }

        // Final answer (if any)
        if let Some(ans) = trace.final_answer.as_ref() {
            ctx.set_fill_style_str("rgba(34, 197, 94, 0.95)");
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            let mut shown = ans.clone();
            if shown.len() > 52 {
                shown.truncate(52);
                shown.push('…');
            }
            let _ = ctx.fill_text(&format!("answer · {}", shown), 16.0, ch - 40.0);
        }

        // ── Right panel: HDC top-3 (similarity path) ──
        ctx.set_fill_style_str("rgba(140, 200, 255, 0.85)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            "SIMILARITY · HDC cosine over 30 items",
            mid_x + 16.0,
            72.0,
        );

        let bar_x = mid_x + 16.0;
        let bar_w_max = w - bar_x - 70.0;
        let mut y2 = 96.0;
        for (rank, (idx, score)) in top3.iter().enumerate() {
            let (id, _, _) = &encoded[*idx];
            ctx.set_fill_style_str(if rank == 0 {
                "rgba(34, 197, 94, 0.95)"
            } else {
                "rgba(125, 211, 252, 0.85)"
            });
            ctx.fill_rect(bar_x, y2 - 12.0, bar_w_max * (score.clamp(0.0, 1.0) as f64), 22.0);
            ctx.set_fill_style_str("rgba(220, 240, 255, 0.95)");
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            let _ = ctx.fill_text(
                &format!("{}  cos {:+.3}", id, score),
                bar_x + 6.0,
                y2 + 2.0,
            );
            y2 += 32.0;
        }

        // Footer: combined cost summary
        ctx.set_fill_style_str("rgba(160, 170, 200, 0.85)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            &format!(
                "cascade {:.0}ns   ·   HDC {:.0}ns   ·   corpus {}",
                cascade_wall, hdc_wall, n_items
            ),
            mid_x + 16.0,
            ch - 16.0,
        );

        // Receipt: V-class = resolved cascade tier label; bit_ops = sum
        // of cascade and HDC paths; output_hash = combined fingerprint.
        let v_class = match trace.resolved_at {
            Some(CascadeTier::L0Closed) => "L0Closed",
            Some(CascadeTier::L0Retrieved) => "L0Retrieved",
            Some(CascadeTier::L0Identifiable) => "L0Identifiable",
            Some(CascadeTier::L1) => "L1",
            Some(CascadeTier::L2) => "L2",
            None => "L1",
        };
        let total_bit_ops = trace.total_bit_ops + q_report.bit_ops
            + (n_items as u64) * (crate::hdc::HDC_DIM as u64);
        emit_receipt_with_hash(
            &sink,
            "cascade_memory_dual_path",
            v_class,
            1,
            cascade_wall + hdc_wall,
            total_bit_ops,
            h,
        );

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Code-memory exhibit ──────────────────────────────────────────────
//
// Mathground's response to "give my AI a queryable memory layer over a
// corpus, without an LLM per query". Graphify-shaped surface, HDC-shaped
// substrate: each corpus item is encoded with `modal::encode_text` into
// a 10 000-dim hypervector; query lookup is cosine over the 30-item
// list. The cascade tier reported per query is `L0-retrieved` when the
// top cosine clears a threshold, otherwise `L1` (deferred to a layer
// the exhibit doesn't ship). No model at any tier.
//
// The corpus is small + fixed so the exhibit is self-contained. The
// receipt-grammar shipped this session lets the exhibit publish a
// per-query `output_hash` (fingerprint of the top-K item indices) so a
// consumer can confirm two identical queries returned identical
// rankings.

const CODE_MEMORY_CORPUS: &[(&str, &str)] = &[
    ("fft", "Cooley-Tukey radix-2 FFT decomposition into log-N butterfly stages"),
    ("matmul", "Matrix multiplication via inner-product reduction across an axis"),
    ("attention", "Attention layer multiplies queries with keys, softmaxes, and weights values"),
    ("rmsnorm", "Root-mean-square normalisation rescales activations element-wise"),
    ("softmax", "Softmax exponentiates and normalises a vector to sum to one"),
    ("cosine", "Cosine similarity is the dot product divided by the product of L2 norms"),
    ("bind", "Bind two bipolar hypervectors with element-wise XOR for compositional memory"),
    ("bundle", "Bundle multiple hypervectors via sign of their elementwise sum"),
    ("permute", "Permute a hypervector by cyclic shift to encode positional order"),
    ("unbind", "Unbind a bound hypervector by binding again with one of the operands"),
    ("hypervector", "Bipolar plus-or-minus-one vector at ten-thousand dimensions, MAP-VSA style"),
    ("receipt", "Picojoule receipt: wall time times TDP envelope, against the Landauer floor"),
    ("landauer", "Landauer floor: kT ln 2 joules per irreversible bit erasure, the physics bound"),
    ("vclass", "V-class label tags a primitive: L0-closed, L0-retrieved, L1, L2"),
    ("cascade", "Cascade walker resolves a query left-to-right through tiered experts"),
    ("escalate", "Escalation to L2 happens only when the deterministic spine cannot place a query"),
    ("output_hash", "Output hash on a receipt confesses whether the kernel was byte-reproducible"),
    ("determinism", "Batch-invariant kernels make outputs byte-identical across batch sizes"),
    ("batch_invariant", "Batch-size variance, not concurrent atomic adds, drives LLM nondeterminism"),
    ("byte_reproducible", "Two runs of the same primitive that hash identically are byte-reproducible"),
    ("knowledge_graph", "Knowledge graph: typed edges between entity nodes with community labels"),
    ("graph_traversal", "Graph traversal walks edges from a source node breadth or depth first"),
    ("leiden", "Leiden clustering partitions a graph into communities by modularity refinement"),
    ("tree_sitter", "Tree-sitter parses source code into a typed concrete syntax tree"),
    ("embedding", "Embedding maps a token or chunk into a learned dense vector representation"),
    ("retrieval", "Retrieval picks top-K corpus items by similarity to a query embedding"),
    ("hdc", "Hyperdimensional computing composes meaning by binding and bundling high-dim vectors"),
    ("omnimodal", "Four deterministic encoders project text, image, audio, video to one HDC space"),
    ("cosine_matrix", "Cross-modal cosine matrix is the substrate property of omnimodal routing"),
    ("anytime_valid", "Anytime-valid evidence: an e-process stays a martingale across stopping times"),
];

#[wasm_bindgen]
pub fn mount_code_memory(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    use crate::modal::encode_text;

    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;

    // ── Pre-encode the corpus once ──
    let encoded: Vec<(String, String, crate::hdc::Hv, u64)> = CODE_MEMORY_CORPUS
        .iter()
        .map(|(id, text)| {
            let report = encode_text(text);
            (id.to_string(), text.to_string(), report.hv, report.bit_ops)
        })
        .collect();
    let n_items = encoded.len();

    let encoded = Rc::new(encoded);
    let sink = sink.clone();
    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let last_query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
    let last_ranking: Rc<RefCell<Option<(Vec<usize>, Vec<f32>, f64, u64, u64)>>> =
        Rc::new(RefCell::new(None));

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let query = web_sys::window()
            .and_then(|w| w.document())
            .and_then(|d| d.get_element_by_id("code-memory-query"))
            .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
            .map(|cb| cb.value())
            .unwrap_or_default();
        let effective_query = if query.trim().is_empty() {
            "compose hypervectors".to_string()
        } else {
            query.clone()
        };

        let mut last = last_query.borrow_mut();
        let needs_recompute = *last != effective_query;
        if needs_recompute {
            *last = effective_query.clone();
            drop(last);

            let t0 = now_ns();
            let q_report = encode_text(&effective_query);
            let mut scores: Vec<(usize, f32)> = encoded
                .iter()
                .enumerate()
                .map(|(i, (_, _, hv, _))| (i, q_report.hv.cosine(hv)))
                .collect();
            scores.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
            let wall_ns = now_ns() - t0;

            let top_k = 5usize;
            let top_idx: Vec<usize> = scores.iter().take(top_k).map(|(i, _)| *i).collect();
            let top_scores: Vec<f32> = scores.iter().take(top_k).map(|(_, s)| *s).collect();

            let mut bytes: Vec<u8> = Vec::with_capacity(top_k * 4);
            for &i in &top_idx {
                bytes.extend_from_slice(&(i as u32).to_le_bytes());
            }
            let mut h: u64 = 0xcbf2_9ce4_8422_2325;
            for &b in &bytes {
                h ^= b as u64;
                h = h.wrapping_mul(0x100_0000_01b3);
            }

            let total_bit_ops = q_report.bit_ops + (n_items as u64) * (crate::hdc::HDC_DIM as u64);

            *last_ranking.borrow_mut() =
                Some((top_idx, top_scores, wall_ns, total_bit_ops, h));
        } else {
            drop(last);
        }

        let w = canvas.width() as f64;
        let h_c = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, w, h_c);

        ctx.set_fill_style_str("rgba(220, 240, 255, 0.95)");
        ctx.set_font("13px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            &format!("query · \"{}\"", effective_query),
            16.0,
            24.0,
        );
        ctx.set_fill_style_str("rgba(140, 200, 255, 0.7)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        let _ = ctx.fill_text(
            "substrate · 30 hypervectors · cosine query · top-5 retrieved",
            16.0,
            42.0,
        );

        let ranking = last_ranking.borrow();
        if let Some((top_idx, top_scores, wall_ns, total_bit_ops, h_hash)) = ranking.as_ref() {
            ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
            let row_h = 52.0;
            let pad_x = 16.0;
            let bar_x = 240.0;
            let bar_max = w - bar_x - 70.0;
            let y0 = 70.0;

            for (rank, (&idx, &score)) in top_idx.iter().zip(top_scores.iter()).enumerate() {
                let (id, text, _, _) = &encoded[idx];
                let row_y = y0 + rank as f64 * row_h;

                ctx.set_fill_style_str(if rank == 0 {
                    "rgba(34, 197, 94, 0.95)"
                } else {
                    "rgba(160, 170, 200, 0.45)"
                });
                ctx.fill_rect(pad_x, row_y + 4.0, 24.0, 24.0);
                ctx.set_fill_style_str("rgba(10, 14, 24, 1)");
                ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
                let _ = ctx.fill_text(&format!("{}", rank + 1), pad_x + 8.0, row_y + 20.0);

                ctx.set_fill_style_str("rgba(220, 240, 255, 0.95)");
                let _ = ctx.fill_text(id, pad_x + 36.0, row_y + 16.0);
                ctx.set_fill_style_str("rgba(160, 180, 210, 0.85)");
                ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
                let body = if text.len() > 36 { &text[..36] } else { text.as_str() };
                let _ = ctx.fill_text(body, pad_x + 36.0, row_y + 32.0);

                ctx.set_fill_style_str("rgba(40, 50, 70, 0.8)");
                ctx.fill_rect(bar_x, row_y + 14.0, bar_max, 12.0);
                let pct = score.clamp(0.0, 1.0) as f64;
                ctx.set_fill_style_str(if rank == 0 {
                    "rgba(34, 197, 94, 0.95)"
                } else {
                    "rgba(125, 211, 252, 0.85)"
                });
                ctx.fill_rect(bar_x, row_y + 14.0, bar_max * pct, 12.0);
                ctx.set_fill_style_str("rgba(220, 240, 255, 0.95)");
                ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
                let _ = ctx.fill_text(
                    &format!("cos {:+.3}", score),
                    bar_x + bar_max + 8.0,
                    row_y + 24.0,
                );
            }

            let top_score = top_scores[0];
            let resolved_tier = if top_score > 0.15 { "L0-retrieved" } else { "L1 (defer)" };
            ctx.set_fill_style_str("rgba(160, 180, 210, 0.85)");
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            let _ = ctx.fill_text(
                &format!(
                    "cascade · resolved at {}   ·   hash 0x{:016x}",
                    resolved_tier, h_hash
                ),
                16.0,
                h_c - 16.0,
            );

            let v_class = if top_score > 0.15 { "L0Retrieved" } else { "L1" };
            emit_receipt_with_hash(
                &sink,
                "code_memory_cosine_topk",
                v_class,
                1,
                *wall_ns,
                *total_bit_ops,
                *h_hash,
            );
        }

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Active-inference exhibit ─────────────────────────────────────────
//
// A closed-loop Friston/FEP demo: a 2-state, 2-obs, 2-action discrete
// agent drops into a deterministic environment, observes, updates its
// posterior via variational inference, scores both single-step
// policies by expected free energy, picks the lower-EFE action, and
// the environment transitions accordingly. Every frame is one full
// observe → plan → act → step round trip; the per-step VFE + EFE bar
// chart + posterior + action are drawn on the canvas, and a receipt
// is emitted to the page-side sink.

/// Mount the active-inference exhibit on `canvas_id`. Receipts emit
/// with `v_class = "L0Closed"` because the math is fully closed-form
/// inside the substrate — no model fires, no retrieval happens. Per
/// frame ≈ a few hundred FMAs over the 2-state space.
#[wasm_bindgen]
pub fn mount_active_inference(
    canvas_id: &str,
    sink: &js_sys::Function,
) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();

    // Build the canonical two-state model (same shape as the test
    // fixture in mgai_active_inference): obs 0 ↔ state 0, obs 1 ↔
    // state 1; action 0 = stay, action 1 = swap; the agent prefers
    // obs 1 (C[1] = 2.0).
    let model = AiModel::new(
        vec![vec![0.9, 0.1], vec![0.1, 0.9]],
        vec![
            vec![vec![1.0, 0.0], vec![0.0, 1.0]],
            vec![vec![0.0, 1.0], vec![1.0, 0.0]],
        ],
        vec![0.0, 2.0],
        vec![0.5, 0.5],
    )
    .map_err(|e| JsValue::from_str(&format!("model: {e}")))?;

    let agent = Rc::new(RefCell::new(AiAgent::new(model)));
    let env_state = Rc::new(RefCell::new(0usize));
    let frame_counter = Rc::new(RefCell::new(0u32));
    let cumulative_uj = Rc::new(RefCell::new(0u64));

    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let ag = agent.clone();
    let es = env_state.clone();
    let fc = frame_counter.clone();
    let cu = cumulative_uj.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        let mut a = ag.borrow_mut();
        let mut es_b = es.borrow_mut();
        let obs = *es_b;
        let step = match a.step(obs, 1) {
            Ok(s) => s,
            Err(_) => {
                schedule(f.borrow().as_ref().unwrap());
                return;
            }
        };
        let action = step.action;
        if action == 1 {
            *es_b = 1 - *es_b;
        }
        drop(es_b);

        // Title.
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("16px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("active inference · 2-state world", 28.0, 32.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            "observe → posterior update (vfe) → score policies (efe) → act",
            28.0,
            52.0,
        )
        .ok();

        ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(&format!("obs = {obs}"), 28.0, 90.0).ok();
        ctx.set_fill_style_str(if action == 0 {
            "rgba(200, 200, 200, 1)"
        } else {
            "rgba(255, 200, 120, 1)"
        });
        let action_label = if action == 0 { "stay" } else { "swap" };
        ctx.fill_text(&format!("action = {action} ({action_label})"), 140.0, 90.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.fill_text(
            &format!("vfe = {:.3} nats", step.observe.vfe_nats),
            300.0,
            90.0,
        )
        .ok();

        // Posterior q(s) bars.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("posterior q(s) — variational FE minimum", 28.0, 130.0).ok();
        for (i, &p) in step.observe.posterior.iter().enumerate() {
            let y = 145.0 + (i as f64) * 28.0;
            ctx.set_fill_style_str("rgba(40, 50, 70, 1)");
            ctx.fill_rect(120.0, y, 260.0, 18.0);
            ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
            ctx.fill_rect(120.0, y, 260.0 * (p as f64).max(0.0).min(1.0), 18.0);
            ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
            ctx.fill_text(&format!("s={i}"), 28.0, y + 13.0).ok();
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(&format!("{p:.3}"), 390.0, y + 13.0).ok();
        }

        // EFE per policy bars.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.fill_text("EFE per policy — lower is better", 28.0, 230.0).ok();
        let efe = &step.plan.efe_nats;
        let efe_min = efe.iter().cloned().fold(f32::INFINITY, f32::min);
        let efe_max = efe.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
        let span = (efe_max - efe_min).max(1e-6);
        for (i, (policy, &g_pi)) in step.plan.policies.iter().zip(efe.iter()).enumerate() {
            let y = 245.0 + (i as f64) * 28.0;
            let label = if policy[0] == 0 { "[stay]" } else { "[swap]" };
            let norm = (g_pi - efe_min) / span;
            let w = 260.0 * (1.0 - norm as f64).max(0.05);
            ctx.set_fill_style_str("rgba(40, 50, 70, 1)");
            ctx.fill_rect(120.0, y, 260.0, 18.0);
            let bar_color = if i == step.plan.best {
                "rgba(150, 255, 180, 1)"
            } else {
                "rgba(120, 140, 160, 1)"
            };
            ctx.set_fill_style_str(bar_color);
            ctx.fill_rect(120.0, y, w, 18.0);
            ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
            ctx.fill_text(label, 28.0, y + 13.0).ok();
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(&format!("{g_pi:.3}"), 390.0, y + 13.0).ok();
        }

        // Cumulative footer.
        let frame = {
            let mut fr = fc.borrow_mut();
            *fr += 1;
            *fr
        };
        let total_uj = {
            let mut t = cu.borrow_mut();
            *t += step.energy_uj;
            *t
        };
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            &format!("step {frame} · bit-ops {} · {} µJ", step.bit_ops, step.energy_uj),
            28.0,
            ch - 36.0,
        )
        .ok();
        ctx.fill_text(
            &format!("cumulative: {total_uj} µJ over {frame} steps"),
            28.0,
            ch - 18.0,
        )
        .ok();

        // L0-closed receipt: pure math, no model fires.
        let wall_ns_per_op = 0.5;
        emit_receipt(
            &sink,
            "active_inference_step",
            "L0Closed",
            1,
            (step.bit_ops as f64) * wall_ns_per_op,
            step.bit_ops,
        );

        // Cycle every ~120 steps so the page never lingers on a steady
        // state.
        if frame % 120 == 0 {
            *fc.borrow_mut() = 0;
            *cu.borrow_mut() = 0;
            a.reset();
            *es.borrow_mut() = 0;
        }
        drop(a);

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Constrained-design exhibit (mgai-cea + mgai-voxel) ────────────
//
// A continuously-searching parametric design problem rendered on the
// canvas: a sphere whose centre/radius are the parameter vector, three
// constraints (max-mass, build-volume fit, min wall thickness), and a
// minimize-volume objective. Each animation frame runs a few search
// steps and redraws the cross-section, the constraint check marks,
// and the objective trajectory. Substrate's direct-to-artifact
// discipline is visible: there's no text generation in the loop —
// just numbers descending under deterministic search until the design
// is feasible.

// Thread-local handle on the latest constrained-design geometry, so
// JS can pull a printable STL after the search has settled. WASM is
// single-threaded so `thread_local!` is fine; the substrate
// discipline is "direct-to-artifact" → this is the artifact.
thread_local! {
    static LATEST_CONSTRAINED_GEOMETRY: RefCell<Option<VoxelGrid>> = const { RefCell::new(None) };
}

/// Polygonise the current constrained-design geometry (the most
/// recent frame's `VoxelGrid`) and return its binary STL bytes. JS
/// glues this into a Blob URL + click-download. Returns an empty
/// array if no frame has rendered yet.
#[wasm_bindgen]
pub fn export_constrained_design_stl() -> Vec<u8> {
    LATEST_CONSTRAINED_GEOMETRY.with(|cell| {
        let borrow = cell.borrow();
        match borrow.as_ref() {
            None => Vec::new(),
            Some(g) => {
                let (mesh, _) = mgai_voxel::mesh::polygonise(g, 0.0);
                let (welded, _) = mesh.weld(1e-4);
                welded.to_stl_binary()
            }
        }
    })
}

#[wasm_bindgen]
pub fn mount_constrained_design(
    canvas_id: &str,
    sink: &js_sys::Function,
) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();

    // Build the problem fresh inside an Rc/RefCell so the rAF closure
    // can mutate it on each frame.
    fn build_problem()
    -> ConstrainedProblem<ParametricSphere, Box<dyn Constraint<VoxelGrid>>, MinimizeVolume> {
        let bounds = VoxAabb::new(VoxVec3::new(-1.5, -1.5, -1.5), VoxVec3::new(1.5, 1.5, 1.5));
        let design = ParametricSphere::new(bounds, 0.12, VoxVec3::new(0.7, 0.7, 0.0), 0.95);
        let limits = VoxAabb::new(VoxVec3::new(-0.5, -0.5, -0.5), VoxVec3::new(0.5, 0.5, 0.5));
        let cs: Vec<Box<dyn Constraint<VoxelGrid>>> = vec![
            Box::new(MaxMass { limit: 2.0, density: 1.0, model_bias: 1.0 }),
            Box::new(mgai_cea::WithinAabb { limits }),
            Box::new(mgai_cea::MinWallThickness { min_thickness: 0.05 }),
        ];
        ConstrainedProblem::new(design, cs, MinimizeVolume).unwrap()
    }

    let problem = Rc::new(RefCell::new(build_problem()));
    let cumulative_uj = Rc::new(RefCell::new(0u64));
    let frame_counter = Rc::new(RefCell::new(0u32));

    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let pb = problem.clone();
    let cu = cumulative_uj.clone();
    let fc = frame_counter.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        // Run one search burst per frame (4 steps × 4 neighbours so
        // progress is visible without crushing the canvas thread).
        let params = SearchParams {
            neighbours_per_step: 4,
            step_size: 0.05,
            max_steps: 4,
            seed: 0xC0DE_F00D,
        };
        let report;
        {
            let mut p = pb.borrow_mut();
            let (_score, r) = match cea_search(&mut *p, params) {
                Ok(v) => v,
                Err(_) => {
                    schedule(f.borrow().as_ref().unwrap());
                    return;
                }
            };
            report = r;
        }
        let score = match pb.borrow().score() {
            Ok(s) => s,
            Err(_) => {
                schedule(f.borrow().as_ref().unwrap());
                return;
            }
        };

        // Title.
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("16px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("constrained design — parametric sphere", 28.0, 32.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            "voxel kernel + rule encoder + deterministic local search",
            28.0,
            52.0,
        )
        .ok();

        // ── Cross-section: slice through z=0 of the voxel grid. ──
        let grid = &score.geometry;
        let slice_z = grid.size[2] / 2;
        let slice_w = grid.size[0];
        let slice_h = grid.size[1];
        let cell_px = 4.0_f64;
        let slice_x0 = 28.0;
        let slice_y0 = 90.0;
        for iy in 0..slice_h {
            for ix in 0..slice_w {
                let i = ((slice_z * slice_h + iy) * slice_w + ix) as usize;
                let v = grid.data[i];
                // Map signed distance to a colour band: inside =
                // accent, outside = dim.
                if v < 0.0 {
                    let t = (-v).min(0.4) / 0.4;
                    let r_byte = (40.0 + 80.0 * t as f64) as u8;
                    let g_byte = (160.0 + 60.0 * t as f64) as u8;
                    let b_byte = (255.0 - 30.0 * t as f64) as u8;
                    let style = format!("rgba({r_byte}, {g_byte}, {b_byte}, 1)");
                    ctx.set_fill_style_str(&style);
                } else {
                    let t = v.min(0.6) / 0.6;
                    let alpha = 0.18 - 0.12 * t as f64;
                    let style = format!("rgba(80, 100, 130, {alpha:.3})");
                    ctx.set_fill_style_str(&style);
                }
                let x = slice_x0 + (ix as f64) * cell_px;
                let y = slice_y0 + (iy as f64) * cell_px;
                ctx.fill_rect(x, y, cell_px, cell_px);
            }
        }

        // Cross-section legend.
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            "slice z = 0 · cyan = solid, faint = outside",
            slice_x0,
            slice_y0 + (slice_h as f64) * cell_px + 16.0,
        )
        .ok();

        // ── Constraints column. ──
        let cx0 = slice_x0 + (slice_w as f64) * cell_px + 36.0;
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("constraints", cx0, 110.0).ok();
        let mut cy = 130.0;
        for cr in &score.constraints {
            ctx.set_fill_style_str(if cr.satisfied {
                "rgba(150, 255, 180, 1)"
            } else {
                "rgba(255, 150, 150, 1)"
            });
            ctx.set_font("13px ui-monospace, SFMono-Regular, Menlo, monospace");
            let mark = if cr.satisfied { "✓" } else { "✗" };
            ctx.fill_text(&format!("{mark} {}", cr.name), cx0, cy).ok();
            ctx.set_fill_style_str("rgba(140, 150, 170, 1)");
            ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text(
                &format!("violation {:.3}", cr.violation),
                cx0 + 16.0,
                cy + 14.0,
            )
            .ok();
            cy += 36.0;
        }

        // ── Parameters + objective + footer. ──
        let pcx0 = cx0;
        let mut py = cy + 12.0;
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("parameters", pcx0, py).ok();
        py += 16.0;
        let p_vals = pb.borrow().design.parameters();
        let names = ["cx", "cy", "cz", "r "];
        for (n, v) in names.iter().zip(p_vals.iter()) {
            ctx.set_fill_style_str("rgba(220, 220, 240, 1)");
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text(&format!("{n} = {v:.3}"), pcx0, py).ok();
            py += 16.0;
        }

        py += 8.0;
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.fill_text("objective", pcx0, py).ok();
        py += 16.0;
        ctx.set_fill_style_str("rgba(220, 220, 240, 1)");
        ctx.fill_text(
            &format!("{} = {:.4}", score.objective.name, score.objective.value),
            pcx0,
            py,
        )
        .ok();
        py += 16.0;
        ctx.fill_text(
            &format!("total_score = {:.4}", score.total_score),
            pcx0,
            py,
        )
        .ok();

        // Cumulative + feasibility footer.
        let frame = {
            let mut fr = fc.borrow_mut();
            *fr += 1;
            *fr
        };
        let total_uj = {
            let mut t = cu.borrow_mut();
            *t += report.energy_uj;
            *t
        };
        ctx.set_fill_style_str(if score.is_feasible() {
            "rgba(150, 255, 180, 1)"
        } else {
            "rgba(255, 200, 120, 1)"
        });
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        let status = if score.is_feasible() {
            "FEASIBLE — search converged"
        } else {
            "searching — penalty active"
        };
        ctx.fill_text(status, 28.0, ch - 36.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.fill_text(
            &format!(
                "burst {} · this-burst {} bit-ops · cumulative {} µJ",
                frame, report.bit_ops, total_uj
            ),
            28.0,
            ch - 18.0,
        )
        .ok();

        // Emit a receipt — L0-closed math, no model fires.
        let wall_ns_per_op = 0.5;
        emit_receipt(
            &sink,
            "constrained_design_burst",
            "L0Closed",
            1,
            (report.bit_ops as f64) * wall_ns_per_op,
            report.bit_ops,
        );

        // Publish the current geometry so JS can pull a printable STL
        // any time the user clicks "download .stl". Cloning a 30³ grid
        // is ~108K f32 = ~432 KB — cheap once per frame; no realloc
        // because the thread-local holds a single VoxelGrid slot.
        LATEST_CONSTRAINED_GEOMETRY.with(|cell| {
            *cell.borrow_mut() = Some(score.geometry.clone());
        });

        // Cycle every ~240 bursts so the page never sits on a single
        // converged result.
        if frame > 240 {
            *fc.borrow_mut() = 0;
            *cu.borrow_mut() = 0;
            *pb.borrow_mut() = build_problem();
        }

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── PCs/NPCs spine exhibit ────────────────────────────────────────
//
// Visualises the substrate's architectural primitive: a deterministic
// spine handles the common case in picojoules; the leaf only fires
// when the spine returns Unresolved; the verifier gates every leaf
// proposal. The exhibit cycles through six representative inputs that
// hit each branch (spine hit, leaf accepted, leaf rejected, no leaf)
// so visitors can see the full state machine + receipt accounting.

use mgai_spine::gate::PredictiveGate;
use mgai_spine::{
    AlwaysProposes, Compartment, EscalationPolicy, Leaf, Outcome, PcsNpcsRunner,
    SpineResult, SpineVerifier, VerificationResult,
};

// Toy compartment: cached squares. The exhibit's spine knows 0² … 4².
// Inputs outside that range force escalation.
struct SquaresSpine;
impl Compartment for SquaresSpine {
    type Input = u32;
    type Output = u32;
    fn name(&self) -> &str { "squares_cache" }
    fn evaluate(&self, input: &u32) -> (SpineResult<u32>, u64) {
        if *input <= 4 {
            (SpineResult::Resolved(input * input), 64)
        } else {
            (
                SpineResult::Unresolved {
                    reason: format!("not in cache: {input}"),
                },
                64,
            )
        }
    }
}

// Toy verifier: accept iff proposed == input * input.
struct SquaresVerifier;
impl SpineVerifier<SquaresSpine> for SquaresVerifier {
    fn name(&self) -> &str { "squares_check" }
    fn verify(&self, input: &u32, proposed: &u32) -> (VerificationResult, u64) {
        if *proposed == input * input {
            (VerificationResult::Accept, 32)
        } else {
            (
                VerificationResult::Reject {
                    reason: format!("proposed {proposed}, expected {}", input * input),
                },
                32,
            )
        }
    }
}

#[wasm_bindgen]
pub fn mount_spine(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();

    // Six inputs that exercise every Outcome:
    //   0, 1 → spine hits (cached)
    //   5    → spine miss, leaf proposes 25 (correct) → LeafVerified
    //   6    → spine miss, leaf proposes 36 (correct) → LeafVerified
    //   7    → spine miss, "wrong-leaf" path → LeafRejected
    //   8    → spine miss, no-leaf path → NoLeaf
    const INPUTS: &[(u32, &str)] = &[
        (0, "spine"),
        (1, "spine"),
        (5, "leaf"),
        (6, "leaf"),
        (7, "wrong"),
        (8, "none"),
    ];

    let cursor = Rc::new(RefCell::new(0usize));
    let cumulative_uj = Rc::new(RefCell::new(0u64));
    let frame_counter = Rc::new(RefCell::new(0u32));
    // Pro2Guard-style predictive gate: learns a DTMC over the spine's
    // own outcomes; pre-flight blocks the leaf when (risk − ε) clears
    // the threshold with enough evidence behind it.
    let gate = Rc::new(RefCell::new(PredictiveGate::new(0.5).with_min_evidence(40)));
    let skipped_total = Rc::new(RefCell::new(0u64));

    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let cur = cursor.clone();
    let cu = cumulative_uj.clone();
    let fc = frame_counter.clone();
    let gt = gate.clone();
    let sk = skipped_total.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        // Advance one input per ~30 frames so each is readable.
        let frame = {
            let mut fr = fc.borrow_mut();
            *fr += 1;
            *fr
        };
        if frame % 30 == 0 {
            let mut c = cur.borrow_mut();
            *c = (*c + 1) % INPUTS.len();
        }
        let (input, mode) = INPUTS[*cur.borrow()];

        // Build the appropriate runner for this input's mode.
        let (outcome_label, outcome_color, spine_ops, leaf_ops, verifier_ops, energy_uj, output) =
            match mode {
                "spine" => {
                    let runner: PcsNpcsRunner<_, AlwaysProposes<SquaresSpine>, _> =
                        PcsNpcsRunner::new(
                            SquaresSpine,
                            Some(AlwaysProposes { value: input * input, bit_ops: 800 }),
                            SquaresVerifier,
                        );
                    match runner.run(&input) {
                        Ok((o, r)) => (
                            outcome_text(r.outcome),
                            outcome_rgba(r.outcome),
                            r.spine_bit_ops,
                            r.leaf_bit_ops,
                            r.verifier_bit_ops,
                            r.energy_uj,
                            Some(o),
                        ),
                        Err(_) => unreachable!(),
                    }
                }
                "leaf" => {
                    let runner = PcsNpcsRunner::new(
                        SquaresSpine,
                        Some(AlwaysProposes::<SquaresSpine> {
                            value: input * input,
                            bit_ops: 800,
                        }),
                        SquaresVerifier,
                    );
                    match runner.run(&input) {
                        Ok((o, r)) => (
                            outcome_text(r.outcome),
                            outcome_rgba(r.outcome),
                            r.spine_bit_ops,
                            r.leaf_bit_ops,
                            r.verifier_bit_ops,
                            r.energy_uj,
                            Some(o),
                        ),
                        Err(_) => unreachable!(),
                    }
                }
                "wrong" => {
                    let runner = PcsNpcsRunner::new(
                        SquaresSpine,
                        Some(AlwaysProposes::<SquaresSpine> { value: 999, bit_ops: 800 }),
                        SquaresVerifier,
                    );
                    match runner.run(&input) {
                        Err((_, r)) => (
                            outcome_text(r.outcome),
                            outcome_rgba(r.outcome),
                            r.spine_bit_ops,
                            r.leaf_bit_ops,
                            r.verifier_bit_ops,
                            r.energy_uj,
                            None,
                        ),
                        Ok(_) => unreachable!(),
                    }
                }
                _ => {
                    // "none": no leaf configured.
                    let runner: PcsNpcsRunner<_, AlwaysProposes<SquaresSpine>, _> =
                        PcsNpcsRunner::new(SquaresSpine, None, SquaresVerifier)
                            .with_escalation(EscalationPolicy::OnlyOnUnresolved);
                    match runner.run(&input) {
                        Err((_, r)) => (
                            outcome_text(r.outcome),
                            outcome_rgba(r.outcome),
                            r.spine_bit_ops,
                            r.leaf_bit_ops,
                            r.verifier_bit_ops,
                            r.energy_uj,
                            None,
                        ),
                        Ok(_) => unreachable!(),
                    }
                }
            };

        // Predictive gate: preflight BEFORE this call would escalate,
        // then record the actual outcome into the DTMC. When the gate
        // says "don't escalate" and this input would have called the
        // leaf, that leaf cost is counted as saved.
        let decision = gt.borrow().preflight();
        let would_escalate = leaf_ops > 0;
        if !decision.escalate && would_escalate {
            *sk.borrow_mut() += leaf_ops;
        }
        {
            let outcome_enum = match outcome_label {
                "Spine" => Outcome::Spine,
                "LeafVerified" => Outcome::LeafVerified,
                "LeafRejected" => Outcome::LeafRejected,
                _ => Outcome::NoLeaf,
            };
            gt.borrow_mut().record(outcome_enum);
        }

        // ── Draw ──
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("16px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("PCs/NPCs spine — Compartment + Leaf + Verifier", 28.0, 32.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            "spine resolves cheap · leaf only fires on miss · verifier gates every proposal",
            28.0,
            52.0,
        )
        .ok();

        // Input + outcome
        ctx.set_fill_style_str("rgba(220, 220, 240, 1)");
        ctx.set_font("14px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(&format!("input = {input}"), 28.0, 100.0).ok();
        ctx.set_fill_style_str(outcome_color);
        ctx.fill_text(&format!("outcome: {outcome_label}"), 200.0, 100.0).ok();
        if let Some(v) = output {
            ctx.set_fill_style_str("rgba(220, 220, 240, 1)");
            ctx.fill_text(&format!("output = {v}"), 460.0, 100.0).ok();
        }

        // Three-bar receipt: spine, leaf, verifier
        let bars = [
            ("spine", spine_ops, "rgba(120, 200, 255, 1)"),
            ("leaf", leaf_ops, "rgba(255, 200, 120, 1)"),
            ("verifier", verifier_ops, "rgba(180, 255, 180, 1)"),
        ];
        let max_ops = bars.iter().map(|b| b.1).max().unwrap_or(1).max(1) as f64;
        let chart_x = 80.0;
        let chart_w = 360.0;
        let mut chart_y = 150.0;
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("bit-ops breakdown per call", 28.0, chart_y - 10.0).ok();
        for (label, ops, color) in bars.iter() {
            ctx.set_fill_style_str("rgba(40, 50, 70, 1)");
            ctx.fill_rect(chart_x, chart_y, chart_w, 18.0);
            let frac = (*ops as f64) / max_ops;
            ctx.set_fill_style_str(color);
            ctx.fill_rect(chart_x, chart_y, chart_w * frac, 18.0);
            ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
            ctx.fill_text(label, 28.0, chart_y + 13.0).ok();
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(&format!("{ops}"), chart_x + chart_w + 12.0, chart_y + 13.0).ok();
            chart_y += 28.0;
        }

        // Gate panel — the predictive pre-flight layer.
        let gate_y = ch - 92.0;
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("predictive gate (DTMC over outcomes · Hoeffding-bounded)", 28.0, gate_y).ok();
        let risk_txt = match decision.predicted_rejection_risk {
            Some(r) => format!(
                "P(reject) = {:.2} ± {:.2} · evidence {} · {}",
                r,
                decision.confidence_half_width,
                decision.evidence,
                if decision.escalate { "escalation allowed" } else { "LEAF CALL BLOCKED" }
            ),
            None => "cold start — always escalates".to_string(),
        };
        ctx.set_fill_style_str(if decision.escalate {
            "rgba(150, 255, 180, 1)"
        } else {
            "rgba(255, 200, 120, 1)"
        });
        ctx.fill_text(&risk_txt, 28.0, gate_y + 16.0).ok();
        let saved = *sk.borrow();
        if saved > 0 {
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(
                &format!("leaf bit-ops saved by pre-flight blocks so far: {saved}"),
                28.0,
                gate_y + 32.0,
            )
            .ok();
        }

        // Footer
        let total_uj = {
            let mut t = cu.borrow_mut();
            *t += energy_uj;
            *t
        };
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            &format!("this call: {} bit-ops · {} µJ", spine_ops + leaf_ops + verifier_ops, energy_uj),
            28.0,
            ch - 36.0,
        )
        .ok();
        ctx.fill_text(
            &format!("cumulative: {total_uj} µJ over {frame} frames"),
            28.0,
            ch - 18.0,
        )
        .ok();

        // Emit a receipt. v_class reflects the outcome.
        let v_class = if outcome_label == "Spine" { "L0Closed" } else { "L1" };
        let total_ops = spine_ops + leaf_ops + verifier_ops;
        emit_receipt(
            &sink,
            "spine_runner_call",
            v_class,
            1,
            (total_ops as f64) * 0.5,
            total_ops,
        );

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

fn outcome_text(o: Outcome) -> &'static str {
    match o {
        Outcome::Spine => "Spine",
        Outcome::LeafVerified => "LeafVerified",
        Outcome::LeafRejected => "LeafRejected",
        Outcome::NoLeaf => "NoLeaf",
    }
}

fn outcome_rgba(o: Outcome) -> &'static str {
    match o {
        Outcome::Spine => "rgba(120, 200, 255, 1)",
        Outcome::LeafVerified => "rgba(150, 255, 180, 1)",
        Outcome::LeafRejected => "rgba(255, 150, 150, 1)",
        Outcome::NoLeaf => "rgba(255, 200, 120, 1)",
    }
}

// ── Structure-learning exhibit (AXIOM ingestion live) ─────────────
//
// A StructureLearner drops into a regime-shifting observation stream
// with ONE hidden state and grows its own state space as regimes
// appear: Dirichlet conjugate count updates, threshold expansion,
// log-Beta Bayesian-model-reduction merges. Zero gradients, zero
// sampling — every event on the canvas is closed-form and replays
// bit-identically from the same stream.

#[wasm_bindgen]
pub fn mount_structure_learning(
    canvas_id: &str,
    sink: &js_sys::Function,
) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();

    const N_OBS: usize = 3;
    // Regime schedule: blocks of a dominant observation with a pinch
    // of off-regime noise (deterministic: every 11th step is off by
    // one). Three regimes cycle.
    const BLOCK: u32 = 90;

    let learner = Rc::new(RefCell::new(
        StructureLearner::new(N_OBS, 1, 1.0)
            .with_expansion_threshold(0.45)
            .with_merge_every(150),
    ));
    let frame_counter = Rc::new(RefCell::new(0u32));
    let cumulative_uj = Rc::new(RefCell::new(0u64));
    // Rolling event log: (frame, label). Holds the last 6 events.
    let events = Rc::new(RefCell::new(Vec::<(u32, String)>::new()));
    // State-count history for the sparkline (one sample per frame).
    let history = Rc::new(RefCell::new(Vec::<u32>::new()));

    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let lr = learner.clone();
    let fc = frame_counter.clone();
    let cu = cumulative_uj.clone();
    let ev = events.clone();
    let hi = history.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        let frame = {
            let mut fr = fc.borrow_mut();
            *fr += 1;
            *fr
        };

        // Deterministic regime-shifting observation.
        let regime = ((frame / BLOCK) as usize) % N_OBS;
        let obs = if frame % 11 == 0 { (regime + 1) % N_OBS } else { regime };

        let receipt_data = {
            let mut l = lr.borrow_mut();
            match l.step(0, obs) {
                Ok(r) => r,
                Err(_) => {
                    schedule(f.borrow().as_ref().unwrap());
                    return;
                }
            }
        };
        if let Some(idx) = receipt_data.grew {
            ev.borrow_mut().push((frame, format!("grew state {idx}")));
        }
        if let Some((kept, gone)) = receipt_data.merged {
            ev.borrow_mut()
                .push((frame, format!("BMR merged {gone} → {kept}")));
        }
        {
            let mut e = ev.borrow_mut();
            let drop_n = e.len().saturating_sub(6);
            if drop_n > 0 {
                e.drain(0..drop_n);
            }
        }
        hi.borrow_mut().push(receipt_data.n_states as u32);

        // ── Draw ──
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("16px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("structure learning — the model grows its own state space", 28.0, 32.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            "Dirichlet counts + threshold expansion + log-Beta BMR merge · no gradients, no sampling",
            28.0,
            52.0,
        )
        .ok();

        // Regime + observation banner.
        ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(&format!("regime {regime} · obs {obs}"), 28.0, 86.0).ok();
        ctx.set_fill_style_str("rgba(150, 255, 180, 1)");
        ctx.fill_text(&format!("hidden states: {}", receipt_data.n_states), 220.0, 86.0).ok();

        // State-count sparkline.
        let hist = hi.borrow();
        let spark_x = 28.0;
        let spark_y = 104.0;
        let spark_w = cw - 56.0;
        let spark_h = 40.0;
        ctx.set_fill_style_str("rgba(40, 50, 70, 0.6)");
        ctx.fill_rect(spark_x, spark_y, spark_w, spark_h);
        let max_states = hist.iter().copied().max().unwrap_or(1).max(1) as f64;
        let n_hist = hist.len().max(1) as f64;
        ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
        for (i, &s) in hist.iter().enumerate() {
            let x = spark_x + (i as f64 / n_hist) * spark_w;
            let h = (s as f64 / max_states) * (spark_h - 4.0);
            ctx.fill_rect(x, spark_y + spark_h - h, (spark_w / n_hist).max(1.0), h);
        }
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("state count over time", spark_x, spark_y + spark_h + 14.0).ok();
        drop(hist);

        // Likelihood heatmap A[o][s] from the learned counts.
        let l = lr.borrow();
        let n_states = l.model.n_states;
        let cell = 34.0;
        let hm_x = 28.0;
        let hm_y = 192.0;
        // Clamp displayed columns so the heatmap never collides with
        // the event log at cw-280 (defensive; growth is bounded by the
        // data-point seeding fix, but the canvas must not rely on it).
        let max_cols = (((cw - 280.0 - 40.0 - hm_x) / (cell + 3.0)).floor() as usize).max(1);
        let shown_states = n_states.min(max_cols);
        for o in 0..N_OBS {
            for s in 0..shown_states {
                let col_sum: f64 = (0..N_OBS).map(|oo| l.model.a_counts[oo][s]).sum();
                let p = (l.model.a_counts[o][s] / col_sum.max(1e-12)) as f64;
                let g_byte = (60.0 + 180.0 * p) as u8;
                ctx.set_fill_style_str(&format!("rgba(40, {g_byte}, 220, {:.2})", 0.25 + 0.75 * p));
                ctx.fill_rect(hm_x + s as f64 * (cell + 3.0), hm_y + o as f64 * (cell + 3.0), cell, cell);
                ctx.set_fill_style_str("rgba(255,255,255,0.9)");
                ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
                ctx.fill_text(
                    &format!("{:.2}", p),
                    hm_x + s as f64 * (cell + 3.0) + 5.0,
                    hm_y + o as f64 * (cell + 3.0) + 20.0,
                )
                .ok();
            }
        }
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        let hm_label = if shown_states < n_states {
            format!("learned P(obs | state) — showing {shown_states} of {n_states} states")
        } else {
            "learned P(obs | state) — rows obs, cols states".to_string()
        };
        ctx.fill_text(&hm_label, hm_x, hm_y + 3.0 * (cell + 3.0) + 14.0).ok();

        // Posterior bars.
        let post_x = hm_x;
        let post_y = hm_y + 3.0 * (cell + 3.0) + 34.0;
        let max_bars = 8usize;
        for (s, &p) in receipt_data.posterior.iter().take(max_bars).enumerate() {
            let y = post_y + s as f64 * 20.0;
            ctx.set_fill_style_str("rgba(40, 50, 70, 1)");
            ctx.fill_rect(post_x + 40.0, y, 180.0, 14.0);
            ctx.set_fill_style_str("rgba(150, 255, 180, 1)");
            ctx.fill_rect(post_x + 40.0, y, 180.0 * (p as f64).clamp(0.0, 1.0), 14.0);
            ctx.set_fill_style_str("rgba(220, 220, 240, 1)");
            ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text(&format!("q(s{s})"), post_x, y + 11.0).ok();
        }
        drop(l);

        // Event log (right column).
        let ev_x = cw - 280.0;
        let mut ev_y = 192.0;
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("structure events", ev_x, ev_y - 12.0).ok();
        for (fr, label) in ev.borrow().iter().rev() {
            ctx.set_fill_style_str(if label.starts_with("grew") {
                "rgba(255, 200, 120, 1)"
            } else {
                "rgba(150, 255, 180, 1)"
            });
            ctx.fill_text(&format!("t={fr} · {label}"), ev_x, ev_y).ok();
            ev_y += 18.0;
        }

        // Footer + receipt.
        let total_uj = {
            let mut t = cu.borrow_mut();
            *t += receipt_data.energy_uj;
            *t
        };
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.fill_text(
            &format!(
                "step {frame} · {} bit-ops · cumulative {total_uj} µJ · replaying this stream reproduces this model bit-for-bit",
                receipt_data.bit_ops
            ),
            28.0,
            ch - 18.0,
        )
        .ok();

        emit_receipt(
            &sink,
            "structure_learning_step",
            "L0Closed",
            1,
            (receipt_data.bit_ops as f64) * 0.5,
            receipt_data.bit_ops,
        );

        // Reset cycle so the growth story replays.
        if frame >= BLOCK * 9 {
            *fc.borrow_mut() = 0;
            *cu.borrow_mut() = 0;
            ev.borrow_mut().clear();
            hi.borrow_mut().clear();
            *lr.borrow_mut() = StructureLearner::new(N_OBS, 1, 1.0)
                .with_expansion_threshold(0.45)
                .with_merge_every(150);
        }

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Factor-graph exhibit (reactive sum-product live) ──────────────
//
// One sweep of flooding-schedule belief propagation per animation
// frame, on the canonical one-step active-inference graph:
//
//   Prior — s_prev — B[action] — s_next — A — obs (observed)
//
// The anytime property is the exhibit: marginals are proper
// distributions after EVERY sweep — watch them settle from uniform
// to the exact posterior as max_delta falls. The scenario (action +
// observation) rotates so convergence replays continuously.

use mgai_factor_graph::{Factor as FgFactor, Graph as FgGraph};

#[wasm_bindgen]
pub fn mount_factor_graph(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();

    // Scenarios: (action label, transition table, observed obs).
    // Tables are [s_prev][s_next]; likelihood is shared.
    fn build(scenario: usize) -> (FgGraph, usize, &'static str, usize) {
        let (label, table, obs_val): (&'static str, Vec<Vec<f64>>, usize) = match scenario % 4 {
            0 => ("stay · obs 0", vec![vec![0.9, 0.1], vec![0.1, 0.9]], 0),
            1 => ("stay · obs 1", vec![vec![0.9, 0.1], vec![0.1, 0.9]], 1),
            2 => ("swap · obs 0", vec![vec![0.1, 0.9], vec![0.9, 0.1]], 0),
            _ => ("swap · obs 1", vec![vec![0.1, 0.9], vec![0.9, 0.1]], 1),
        };
        let mut g = FgGraph::new();
        let s_prev = g.add_variable("s_prev", 2);
        let s_next = g.add_variable("s_next", 2);
        let obs = g.add_variable("obs", 2);
        g.add_factor(FgFactor::Prior { var: s_prev, probs: vec![0.7, 0.3] }).unwrap();
        g.add_factor(FgFactor::Pairwise { a: s_prev, b: s_next, table }).unwrap();
        g.add_factor(FgFactor::Pairwise {
            a: s_next,
            b: obs,
            table: vec![vec![0.8, 0.2], vec![0.3, 0.7]],
        })
        .unwrap();
        g.observe(obs, obs_val).unwrap();
        (g, s_next, label, obs_val)
    }

    let scenario = Rc::new(RefCell::new(0usize));
    let state = Rc::new(RefCell::new(build(0)));
    let sweep_count = Rc::new(RefCell::new(0u32));
    let converged_hold = Rc::new(RefCell::new(0u32));
    let delta_history = Rc::new(RefCell::new(Vec::<f64>::new()));
    let cumulative_uj = Rc::new(RefCell::new(0u64));

    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();
    let sc = scenario.clone();
    let st = state.clone();
    let sw = sweep_count.clone();
    let chold = converged_hold.clone();
    let dh = delta_history.clone();
    let cu = cumulative_uj.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        // One sweep per frame (slowed: every 12th frame so the
        // settling is visible at 60 fps).
        let frame_gate = {
            let mut s = sw.borrow_mut();
            *s += 1;
            *s % 12 == 0
        };
        let receipt_opt = if frame_gate && *chold.borrow() == 0 {
            let mut s = st.borrow_mut();
            let r = s.0.sweep();
            dh.borrow_mut().push(r.max_delta);
            if r.max_delta < 1e-9 {
                *chold.borrow_mut() = 90; // hold converged ~1.5 s
            }
            Some(r)
        } else {
            None
        };
        // Converged-hold countdown → next scenario.
        {
            let mut h = chold.borrow_mut();
            if *h > 0 {
                *h -= 1;
                if *h == 0 {
                    let mut scv = sc.borrow_mut();
                    *scv += 1;
                    *st.borrow_mut() = build(*scv);
                    dh.borrow_mut().clear();
                }
            }
        }

        let s = st.borrow();
        let (ref graph, s_next, label, obs_val) = *s;

        // Header.
        ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
        ctx.set_font("16px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text("reactive message passing — sum-product on a factor graph", 28.0, 32.0).ok();
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(
            "one sweep at a time · marginals stay proper mid-inference · exact on trees",
            28.0,
            52.0,
        )
        .ok();
        ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
        ctx.set_font("12px ui-monospace, SFMono-Regular, Menlo, monospace");
        ctx.fill_text(&format!("scenario: {label}"), 28.0, 84.0).ok();

        // Graph layout: 3 variable nodes + 3 factor squares on a line.
        let node_y = 150.0;
        let xs = [120.0, 400.0, 680.0];
        let names = ["s_prev", "s_next", "obs"];
        // Edges (factor squares between nodes + prior square left).
        ctx.set_stroke_style_str("rgba(100, 120, 150, 1)");
        ctx.begin_path();
        ctx.move_to(60.0, node_y);
        ctx.line_to(xs[2], node_y);
        ctx.stroke();
        // Prior factor square.
        ctx.set_fill_style_str("rgba(255, 200, 120, 1)");
        ctx.fill_rect(52.0, node_y - 8.0, 16.0, 16.0);
        // Pairwise factor squares at midpoints.
        for mid in [(xs[0] + xs[1]) / 2.0, (xs[1] + xs[2]) / 2.0] {
            ctx.fill_rect(mid - 8.0, node_y - 8.0, 16.0, 16.0);
        }
        // Variable nodes + marginal bars.
        for (i, (&x, name)) in xs.iter().zip(names.iter()).enumerate() {
            let observed = i == 2;
            ctx.set_fill_style_str(if observed {
                "rgba(150, 255, 180, 1)"
            } else {
                "rgba(120, 200, 255, 1)"
            });
            ctx.begin_path();
            ctx.arc(x, node_y, 14.0, 0.0, std::f64::consts::TAU).ok();
            ctx.fill();
            ctx.set_fill_style_str("rgba(220, 220, 240, 1)");
            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text(name, x - 20.0, node_y - 24.0).ok();
            if observed {
                ctx.fill_text(&format!("= {obs_val}"), x - 12.0, node_y + 36.0).ok();
            }
            // Marginal bars under each unobserved node.
            if let Ok(m) = graph.marginal(i) {
                for (k, &p) in m.iter().enumerate() {
                    let by = node_y + 52.0 + k as f64 * 20.0;
                    ctx.set_fill_style_str("rgba(40, 50, 70, 1)");
                    ctx.fill_rect(x - 60.0, by, 120.0, 14.0);
                    ctx.set_fill_style_str(if i == s_next {
                        "rgba(150, 255, 180, 1)"
                    } else {
                        "rgba(120, 200, 255, 1)"
                    });
                    ctx.fill_rect(x - 60.0, by, 120.0 * p, 14.0);
                    ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
                    ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
                    ctx.fill_text(&format!("{p:.3}"), x + 66.0, by + 11.0).ok();
                }
            }
        }

        // Convergence trace: log10(max_delta) per sweep.
        let hist = dh.borrow();
        let tr_x = 28.0;
        let tr_y = ch - 130.0;
        let tr_w = cw - 56.0;
        let tr_h = 60.0;
        ctx.set_fill_style_str("rgba(40, 50, 70, 0.6)");
        ctx.fill_rect(tr_x, tr_y, tr_w, tr_h);
        ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
        let n = hist.len().max(1) as f64;
        for (i, &d) in hist.iter().enumerate() {
            // map log10 d ∈ [-12, 0] to bar height
            let l = (d.max(1e-12)).log10(); // [-12, 0]
            let hfrac = ((l + 12.0) / 12.0).clamp(0.0, 1.0);
            let bh = hfrac * (tr_h - 4.0);
            let x = tr_x + (i as f64 / n) * tr_w;
            ctx.fill_rect(x, tr_y + tr_h - bh, (tr_w / n).max(2.0), bh);
        }
        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
        ctx.set_font("10px ui-monospace, SFMono-Regular, Menlo, monospace");
        let conv_label = if *chold.borrow() > 0 {
            format!("CONVERGED in {} sweeps — exact posterior; next scenario shortly", hist.len())
        } else {
            format!("log₁₀(max message Δ) per sweep — {} so far", hist.len())
        };
        ctx.fill_text(&conv_label, tr_x, tr_y + tr_h + 14.0).ok();
        drop(hist);
        drop(s);

        // Receipt per executed sweep.
        if let Some(r) = receipt_opt {
            let total_uj = {
                let mut t = cu.borrow_mut();
                *t += r.energy_uj;
                *t
            };
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(
                &format!(
                    "sweep: {} messages · {} bit-ops · cumulative {} µJ",
                    r.messages_updated, r.bit_ops, total_uj
                ),
                28.0,
                ch - 18.0,
            )
            .ok();
            emit_receipt(
                &sink,
                "factor_graph_sweep",
                "L0Closed",
                1,
                (r.bit_ops as f64) * 0.5,
                r.bit_ops,
            );
        } else {
            let total_uj = *cu.borrow();
            ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
            ctx.fill_text(&format!("cumulative {total_uj} µJ"), 28.0, ch - 18.0).ok();
        }

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

// ── Twin sim surface (twin program Phase 2) ───────────────────────
//
// The deterministic kernel (mgai-sim-core) running live in the tab,
// with the program's headline interaction: a replay scrubber where
// the past is RECOMPUTED from the transcript — not stored — and the
// recomputed state hash is checked against the recorded one in front
// of the visitor. Spawns and kicks are typed SimEvents recorded into
// the same transcript, so user input is replayable too.

use mgai_quad_env::{HoverPolicy, QuadObs};
use mgai_sim_core::{replay, replay_to, SimConfig, SimEvent as TwinEvent, SimWorld as TwinWorld};
use mgai_world_model::conservation::{ConservationGate, Law as TwinLaw};

/// The live PD pilot. Its SetMotors decisions are recorded into the
/// kernel transcript like any other event, so a flight scrubs
/// bit-exact with NO controller running at replay time.
struct TwinPilot {
    policy: HoverPolicy,
    body: usize,
    mass: f32,
}

struct TwinState {
    live: TwinWorld,
    recorded_hashes: Vec<u64>,
    /// Some = scrub view: (step, frozen replayed world, hash matched).
    view: Option<(u64, TwinWorld, bool)>,
    cumulative_uj: u64,
    spawn_counter: u32,
    pilot: Option<TwinPilot>,
    wind_on: bool,
    wind_x: f32,
    /// A counterfactual timeline: same transcript prefix (hash-verified
    /// at fork time), exactly one divergent event, then independent
    /// deterministic evolution. fork = (world, fork_step, verified).
    fork: Option<(TwinWorld, u64, bool)>,
    /// Vehicle electrical energy (J) accumulated since the fork, per
    /// timeline — the counterfactual priced in joules.
    elec_live: f64,
    elec_fork: f64,
}

thread_local! {
    static TWIN: RefCell<Option<TwinState>> = const { RefCell::new(None) };
}

fn twin_canonical() -> TwinState {
    let mut live = TwinWorld::new(SimConfig::default())
        .unwrap()
        .with_gate(ConservationGate::new(vec![TwinLaw::EnergyNonIncreasing { tol: 0.08 }]));
    let ball = |pos: [f32; 3], vel: [f32; 3]| TwinEvent::SpawnSphere {
        pos,
        vel,
        radius: 0.2,
        density: 1000.0,
        restitution: 0.6,
        friction: 0.5,
    };
    live.apply(ball([0.0, 3.0, 0.0], [0.5, 0.0, 0.0])).unwrap();
    live.apply(ball([0.6, 5.0, 0.0], [-0.3, 0.0, 0.0])).unwrap();
    live.apply(ball([-0.8, 6.5, 0.0], [0.2, 0.0, 0.0])).unwrap();
    TwinState {
        live,
        recorded_hashes: Vec::new(),
        view: None,
        cumulative_uj: 0,
        spawn_counter: 0,
        pilot: None,
        wind_on: false,
        wind_x: 0.0,
        fork: None,
        elec_live: 0.0,
        elec_fork: 0.0,
    }
}

/// Spawn a ball. Position derives from the spawn counter — varied but
/// deterministic, so the transcript stays the only source of truth.
#[wasm_bindgen]
pub fn twin_spawn() {
    TWIN.with(|t| {
        if let Some(state) = t.borrow_mut().as_mut() {
            state.spawn_counter += 1;
            let k = state.spawn_counter as f32;
            let x = ((k * 0.737).fract() - 0.5) * 3.0;
            let z = 0.0;
            let _ = state.live.apply(TwinEvent::SpawnSphere {
                pos: [x, 6.0 + (k * 0.39).fract() * 2.0, z],
                vel: [((k * 0.531).fract() - 0.5) * 2.0, 0.0, 0.0],
                radius: 0.15 + (k * 0.211).fract() * 0.15,
                density: 1000.0,
                restitution: 0.55 + (k * 0.173).fract() * 0.3,
                friction: 0.5,
            });
            state.view = None;
        }
    });
}

/// Kick a body (round-robin) with an upward-lateral impulse.
#[wasm_bindgen]
pub fn twin_kick() {
    TWIN.with(|t| {
        if let Some(state) = t.borrow_mut().as_mut() {
            let n = state.live.n_bodies();
            if n == 0 {
                return;
            }
            let body = (state.live.step_index() as usize) % n;
            let s = ((state.live.step_index() as f32) * 0.317).fract() - 0.5;
            let _ = state.live.apply(TwinEvent::ApplyImpulse {
                body,
                impulse: [s * 12.0, 18.0, 0.0],
            });
            state.view = None;
        }
    });
}

/// Train the hover pilot's gains IN THE TAB (called from a worker —
/// it blocks for a few seconds). Deterministic seeded search on a
/// compact curriculum; returns the TrainingReport as JSON. Same seed,
/// same browser, same gains — the training run is a replayable
/// artifact, which is the twin program's headline sentence.
#[wasm_bindgen]
pub fn twin_train(seed: u32, iterations: u32, neighbours: u32) -> String {
    use mgai_quad_env::train::{train_gains, Task, TrainConfig};
    use mgai_quad_env::QuadConfig;
    let curriculum = vec![
        Task::Hover { target: [0.5, 2.5, -0.3], steps: 600, tol_m: 0.18 },
        Task::Waypoints {
            targets: vec![[0.0, 2.0, 0.3], [0.8, 3.0, -0.4]],
            steps_per_leg: 600,
            tol_m: 0.2,
        },
    ];
    match train_gains(
        &QuadConfig::default(),
        &curriculum,
        TrainConfig { seed: seed as u64, iterations, neighbours, step_frac: 0.25, entropy_budget_bits: None },
    ) {
        Ok(r) => serde_json::to_string(&r).unwrap_or_default(),
        Err(e) => format!("{{\"error\":\"{e}\"}}"),
    }
}

/// Apply trained gains (a HoverPolicy JSON, target ignored) to the
/// live pilot. The pilot's future SetMotors decisions — now flown
/// with the trained gains — remain typed transcript events, so the
/// trained flight replays bit-exact like everything else.
#[wasm_bindgen]
pub fn twin_apply_gains(policy_json: &str) -> bool {
    let Ok(trained) = serde_json::from_str::<HoverPolicy>(policy_json) else {
        return false;
    };
    TWIN.with(|t| {
        if let Some(state) = t.borrow_mut().as_mut() {
            if let Some(pilot) = state.pilot.as_mut() {
                let target = pilot.policy.target;
                pilot.policy = HoverPolicy { target, ..trained };
                return true;
            }
        }
        false
    })
}

/// Fork the timeline: rebuild a second world from the live
/// transcript (the shared past is verified by hash equality, not
/// assumed), then apply ONE divergent event — an opposite crosswind.
/// Both timelines then evolve deterministically side by side. This is
/// the event-keyed counterfactual made visible: the divergence you
/// see is DERIVED from one changed event, not hallucinated.
/// Returns true if the shared past verified bit-exact.
#[wasm_bindgen]
pub fn twin_fork() -> bool {
    TWIN.with(|t| {
        let mut borrow = t.borrow_mut();
        let Some(state) = borrow.as_mut() else { return false };
        if state.fork.is_some() {
            state.fork = None; // toggle off
            return true;
        }
        // Fork point: the scrubbed step if the visitor rewound the
        // timeline, otherwise the present. Rewinding + forking makes
        // the scrubbed moment the new shared present: both futures
        // grow from it, and the old future is released.
        let step = match &state.view {
            Some((scrub_step, _, _)) => *scrub_step,
            None => state.live.step_index(),
        }
        .max(1);
        let Ok((live_at, hashes)) = replay_to(state.live.transcript(), step) else {
            return false;
        };
        let verified = hashes.last().copied()
            == state.recorded_hashes.get(step as usize - 1).copied();
        let Ok((mut fork_world, _)) = replay_to(state.live.transcript(), step) else {
            return false;
        };
        // The one divergent event: opposite wind.
        let wx = if state.wind_on { -state.wind_x } else { 3.0 };
        let _ = fork_world.apply(TwinEvent::SetWind { force: [wx, 0.0, 0.0] });
        // Rewind the live timeline to the fork point (its transcript is
        // now the shared prefix) and restart the energy accounts —
        // the comparison is over the two futures.
        state.live = live_at;
        state.recorded_hashes.truncate(step as usize);
        state.elec_live = 0.0;
        state.elec_fork = 0.0;
        state.fork = Some((fork_world, step, verified));
        state.view = None;
        verified
    })
}

/// Verify an EXTERNAL continuity snapshot — the live-host rejoin.
/// Takes the host's atomic snapshot JSON `{step, state_hash,
/// transcript}`, replays the transcript in this tab on the same
/// deterministic kernel, and confirms the recomputed final hash
/// equals the host's claimed hash. True = you have independently
/// reproduced a world that ran on the server without you. Returns
/// JSON {ok, step, bytes, hash}.
#[wasm_bindgen]
pub fn twin_verify_external(snapshot_json: &str) -> String {
    let v: serde_json::Value = match serde_json::from_str(snapshot_json) {
        Ok(v) => v,
        Err(e) => return format!("{{\"ok\":false,\"reason\":\"{e}\"}}"),
    };
    let claimed = v.get("state_hash").and_then(|h| h.as_u64()).unwrap_or(0);
    let step = v.get("step").and_then(|s| s.as_u64()).unwrap_or(0);
    let Some(tr) = v.get("transcript") else {
        return "{\"ok\":false,\"reason\":\"no transcript\"}".into();
    };
    let parsed: mgai_sim_core::Transcript = match serde_json::from_value(tr.clone()) {
        Ok(p) => p,
        Err(e) => return format!("{{\"ok\":false,\"reason\":\"{e}\"}}"),
    };
    let bytes = snapshot_json.len();
    let rebuilt = match replay(&parsed) {
        Ok(r) => r,
        Err(e) => return format!("{{\"ok\":false,\"reason\":\"{e}\"}}"),
    };
    let ok = rebuilt.final_hash == claimed;
    format!(
        "{{\"ok\":{ok},\"step\":{step},\"bytes\":{bytes},\"hash\":\"{:#018x}\"}}",
        rebuilt.final_hash
    )
}

/// Rejoin: prove provable continuity in-tab. Serialize the live
/// transcript to a portable string (the artifact you'd carry to any
/// device), rebuild a fresh world from it, and verify the recomputed
/// final hash matches the run you watched. Ona's persistence is
/// trapped in a cloud session you must trust; this is a string you
/// hold and check. Returns JSON {ok, steps, bytes, hash}.
#[wasm_bindgen]
pub fn twin_rejoin() -> String {
    TWIN.with(|t| {
        let borrow = t.borrow();
        let Some(state) = borrow.as_ref() else { return "{}".into() };
        let Some(&recorded) = state.recorded_hashes.last() else {
            return "{\"ok\":false,\"reason\":\"no run yet\"}".into();
        };
        let json = match serde_json::to_string(state.live.transcript()) {
            Ok(j) => j,
            Err(e) => return format!("{{\"ok\":false,\"reason\":\"{e}\"}}"),
        };
        let bytes = json.len();
        let parsed: mgai_sim_core::Transcript = match serde_json::from_str(&json) {
            Ok(p) => p,
            Err(e) => return format!("{{\"ok\":false,\"reason\":\"{e}\"}}"),
        };
        let rebuilt = match replay(&parsed) {
            Ok(r) => r,
            Err(e) => return format!("{{\"ok\":false,\"reason\":\"{e}\"}}"),
        };
        let ok = rebuilt.final_hash == recorded;
        format!(
            "{{\"ok\":{ok},\"steps\":{},\"bytes\":{bytes},\"hash\":\"{:#018x}\"}}",
            state.recorded_hashes.len(),
            rebuilt.final_hash
        )
    })
}

/// Toggle a crosswind gust: a typed SetWind event (deterministic
/// force derived from the step index), so the disturbed flight
/// replays bit-exact. Returns the new wind x-force in newtons.
#[wasm_bindgen]
pub fn twin_gust() -> f32 {
    TWIN.with(|t| {
        let mut borrow = t.borrow_mut();
        let Some(state) = borrow.as_mut() else { return 0.0 };
        let on = state.wind_on;
        let force = if on {
            [0.0, 0.0, 0.0]
        } else {
            // Deterministic strength from the step index — varied but
            // replayable (the transcript records the event, not RNG).
            let k = (state.live.step_index() as f32 * 0.317).fract();
            [2.0 + k * 2.5, 0.0, 0.0]
        };
        let _ = state.live.apply(TwinEvent::SetWind { force });
        state.wind_on = !on;
        state.wind_x = force[0];
        state.view = None;
        force[0]
    })
}

/// Stack-hunt probe: like twin_episode but with a step count.
#[wasm_bindgen]
pub fn twin_episode_n(seed: u32, steps: u32) -> String {
    use mgai_quad_env::{run_hover, QuadConfig, QuadEnv};
    let k = seed as f32;
    let target = [
        ((k * 0.737).fract() - 0.5) * 2.0,
        2.0 + (k * 0.39).fract() * 2.0,
        ((k * 0.531).fract() - 0.5) * 1.5,
    ];
    let mut env = match QuadEnv::new(QuadConfig::default()) {
        Ok(e) => e,
        Err(e) => return format!("{{\"error\":\"{e}\"}}"),
    };
    match run_hover(&mut env, &HoverPolicy::new(target), steps as u64, 0.15) {
        Ok(r) => serde_json::to_string(&r).unwrap_or_default(),
        Err(e) => format!("{{\"error\":\"{e}\"}}"),
    }
}

/// Run one complete quad hover episode (for Worker-batched training:
/// each Web Worker loads this module and calls this with its own
/// seed). Returns the EpisodeReport as JSON — final_hash included, so
/// the page can verify that identical seeds produce identical bits
/// across workers running in parallel.
#[wasm_bindgen]
pub fn twin_episode(seed: u32) -> String {
    use mgai_quad_env::{run_hover, QuadConfig, QuadEnv};
    let k = seed as f32;
    let target = [
        ((k * 0.737).fract() - 0.5) * 2.0,
        2.0 + (k * 0.39).fract() * 2.0,
        ((k * 0.531).fract() - 0.5) * 1.5,
    ];
    let mut env = match QuadEnv::new(QuadConfig::default()) {
        Ok(e) => e,
        Err(e) => return format!("{{\"error\":\"{e}\"}}"),
    };
    match run_hover(&mut env, &HoverPolicy::new(target), 900, 0.15) {
        Ok(r) => serde_json::to_string(&r).unwrap_or_default(),
        Err(e) => format!("{{\"error\":\"{e}\"}}"),
    }
}

/// Launch the quadrotor (once) with the PD pilot targeting (0, 3, 0).
/// The spawn is a typed event; every subsequent SetMotors the pilot
/// issues is too.
#[wasm_bindgen]
pub fn twin_launch_quad() {
    TWIN.with(|t| {
        if let Some(state) = t.borrow_mut().as_mut() {
            if state.pilot.is_some() {
                return;
            }
            let mass_before = state.live.snapshot().mass;
            let body = state.live.n_bodies();
            if state
                .live
                .apply(TwinEvent::SpawnQuad {
                    pos: [-2.5, 0.5, 0.0],
                    vel: [0.0; 3],
                    half_extents: [0.12, 0.03, 0.12],
                    density: 580.0,
                    arm: 0.16,
                })
                .is_err()
            {
                return;
            }
            let mass = state.live.snapshot().mass - mass_before;
            state.pilot = Some(TwinPilot {
                policy: HoverPolicy::new([0.0, 3.0, 0.0]),
                body,
                mass,
            });
            state.view = None;
        }
    });
}

/// Retarget the pilot from a canvas click (pixel coords + canvas
/// size, inverted through the same projection the renderer uses).
#[wasm_bindgen]
pub fn twin_set_target(px: f64, py: f64, cw: f64, ch: f64) {
    TWIN.with(|t| {
        if let Some(state) = t.borrow_mut().as_mut() {
            if let Some(pilot) = state.pilot.as_mut() {
                let x = ((px - 40.0) / (cw - 80.0)) * 8.0 - 4.0;
                let y = (ch - 60.0 - py) / (ch - 160.0) * 8.0;
                pilot.policy.target =
                    [x.clamp(-3.8, 3.8) as f32, y.clamp(0.5, 7.5) as f32, 0.0];
                state.view = None;
            }
        }
    });
}

/// Scrub to a recorded step: recompute the past from the transcript
/// and report whether the recomputed hash matches the recorded one.
/// Returns the match flag (true = bit-exact).
#[wasm_bindgen]
pub fn twin_scrub(step: u32) -> bool {
    TWIN.with(|t| {
        let mut borrow = t.borrow_mut();
        let Some(state) = borrow.as_mut() else { return false };
        state.fork = None;
        let target = (step as u64).clamp(1, state.recorded_hashes.len() as u64);
        match replay_to(state.live.transcript(), target) {
            Ok((world, hashes)) => {
                let matched = hashes.last().copied()
                    == state.recorded_hashes.get(target as usize - 1).copied();
                state.view = Some((target, world, matched));
                matched
            }
            Err(_) => false,
        }
    })
}

/// Leave scrub view; the live world resumes stepping.
#[wasm_bindgen]
pub fn twin_resume() {
    TWIN.with(|t| {
        if let Some(state) = t.borrow_mut().as_mut() {
            state.view = None;
        }
    });
}

/// Total recorded steps (the scrubber's max).
#[wasm_bindgen]
pub fn twin_total_steps() -> u32 {
    TWIN.with(|t| {
        t.borrow()
            .as_ref()
            .map(|s| s.recorded_hashes.len() as u32)
            .unwrap_or(0)
    })
}

fn twin_draw_world(
    ctx: &CanvasRenderingContext2d,
    world: &TwinWorld,
    cw: f64,
    ch: f64,
    dim: bool,
    quad: Option<usize>,
    target: Option<[f32; 3]>,
) {
    // Side elevation: world x ∈ [−4, 4] → canvas width; y ∈ [0, 8] →
    // canvas height (inverted).
    let map_x = |x: f32| ((x as f64 + 4.0) / 8.0) * (cw - 80.0) + 40.0;
    let map_y = |y: f32| ch - 60.0 - (y as f64 / 8.0) * (ch - 160.0);
    let scale = (cw - 80.0) / 8.0;
    // Ground.
    ctx.set_stroke_style_str("rgba(100, 120, 150, 1)");
    ctx.begin_path();
    ctx.move_to(40.0, map_y(0.0));
    ctx.line_to(cw - 40.0, map_y(0.0));
    ctx.stroke();
    let alpha = if dim { 0.55 } else { 1.0 };
    if let Some(tg) = target {
        let tx = map_x(tg[0]);
        let ty = map_y(tg[1]);
        ctx.set_stroke_style_str(&format!("rgba(255, 200, 100, {})", alpha * 0.9));
        ctx.begin_path();
        ctx.move_to(tx - 10.0, ty);
        ctx.line_to(tx + 10.0, ty);
        ctx.move_to(tx, ty - 10.0);
        ctx.line_to(tx, ty + 10.0);
        ctx.stroke();
        ctx.begin_path();
        ctx.arc(tx, ty, 14.0, 0.0, std::f64::consts::TAU).ok();
        ctx.stroke();
    }
    for (i, (pos, vel, radius)) in world.body_states().iter().enumerate() {
        let x = map_x(pos[0]);
        let y = map_y(pos[1]);
        let r = (*radius as f64) * scale;
        if quad == Some(i) {
            // Quadrotor: rotor bar perpendicular to the body up-axis,
            // in the elevation plane.
            let up = world.body_up(i).unwrap_or([0.0, 1.0, 0.0]);
            let (bx, by) = (up[1] as f64, up[0] as f64); // ⟂ to up, canvas-y flipped
            let arm = r.max(8.0) * 1.6;
            ctx.set_stroke_style_str(&format!("rgba(120, 230, 255, {alpha})"));
            ctx.set_line_width(3.0);
            ctx.begin_path();
            ctx.move_to(x - bx * arm, y + by * arm);
            ctx.line_to(x + bx * arm, y - by * arm);
            ctx.stroke();
            ctx.set_line_width(1.0);
            ctx.set_fill_style_str(&format!("rgba(120, 230, 255, {alpha})"));
            for s in [-1.0f64, 1.0] {
                ctx.begin_path();
                ctx.arc(x + s * bx * arm, y - s * by * arm, 4.5, 0.0, std::f64::consts::TAU).ok();
                ctx.fill();
            }
            ctx.begin_path();
            ctx.arc(x, y, 3.5, 0.0, std::f64::consts::TAU).ok();
            ctx.fill();
            continue;
        }
        let hue_shift = (i * 47) % 120;
        ctx.set_fill_style_str(&format!(
            "rgba({}, {}, 255, {alpha})",
            100 + hue_shift,
            180 + (i * 23) % 60
        ));
        ctx.begin_path();
        ctx.arc(x, y, r.max(3.0), 0.0, std::f64::consts::TAU).ok();
        ctx.fill();
        // Velocity vector.
        ctx.set_stroke_style_str(&format!("rgba(255, 220, 150, {alpha})"));
        ctx.begin_path();
        ctx.move_to(x, y);
        ctx.line_to(x + (vel[0] as f64) * 10.0, y - (vel[1] as f64) * 10.0);
        ctx.stroke();
    }
}

#[wasm_bindgen]
pub fn mount_twin(canvas_id: &str, sink: &js_sys::Function) -> Result<(), JsValue> {
    let (canvas, ctx) =
        ctx_for(canvas_id).ok_or_else(|| JsValue::from_str("canvas not found"))?;
    let sink = sink.clone();
    TWIN.with(|t| {
        let mut b = t.borrow_mut();
        if b.is_none() {
            *b = Some(twin_canonical());
        }
    });

    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
    let g = f.clone();

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let cw = canvas.width() as f64;
        let ch = canvas.height() as f64;
        ctx.set_fill_style_str("rgba(8, 12, 20, 1)");
        ctx.fill_rect(0.0, 0.0, cw, ch);

        TWIN.with(|t| {
            let mut borrow = t.borrow_mut();
            let Some(state) = borrow.as_mut() else { return };

            // Header.
            ctx.set_fill_style_str("rgba(255, 255, 255, 1)");
            ctx.set_font("16px ui-monospace, SFMono-Regular, Menlo, monospace");
            ctx.fill_text("twin kernel — deterministic, transcripted, joule-metered", 28.0, 32.0).ok();

            match &state.view {
                Some((step, world, matched)) => {
                    // Frozen scrub view: the recomputed past.
                    let quad = state.pilot.as_ref().map(|p| p.body);
                    twin_draw_world(&ctx, world, cw, ch, true, quad, None);
                    ctx.set_font("13px ui-monospace, SFMono-Regular, Menlo, monospace");
                    ctx.set_fill_style_str(if *matched {
                        "rgba(150, 255, 180, 1)"
                    } else {
                        "rgba(255, 120, 120, 1)"
                    });
                    let verdict = if *matched {
                        format!("REPLAY @ step {step} — recomputed from the transcript · hash MATCHES the recorded run ≡ bit-exact")
                    } else {
                        format!("REPLAY @ step {step} — HASH MISMATCH (this would be a determinism bug)")
                    };
                    ctx.fill_text(&verdict, 28.0, 58.0).ok();
                    ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
                    ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
                    ctx.fill_text(
                        "the past is not stored — it is recomputed on every scrub · resume to continue the live run",
                        28.0,
                        ch - 18.0,
                    )
                    .ok();
                }
                None => {
                    // The counterfactual timeline: the same pilot flies
                    // the fork world from ITS state — same policy, one
                    // changed event, derived divergence.
                    if state.fork.is_some() {
                        if let Some(pilot) = &state.pilot {
                            let (fw, _, _) = state.fork.as_mut().unwrap();
                            let states = fw.body_states();
                            if let Some((pos, vel, _)) = states.get(pilot.body) {
                                let obs = QuadObs {
                                    pos: *pos,
                                    vel: *vel,
                                    up: fw.body_up(pilot.body).unwrap_or([0.0, 1.0, 0.0]),
                                    angvel: fw.body_angvel(pilot.body).unwrap_or([0.0; 3]),
                                    step: fw.step_index(),
                                };
                                let thrusts =
                                    pilot.policy.act(&obs, pilot.mass).map(|t| t.clamp(0.0, 6.0));
                                let dt = 1.0 / 120.0f64;
                                state.elec_fork += thrusts
                                    .iter()
                                    .map(|&t| (t as f64).powf(1.5) / 0.35 * dt)
                                    .sum::<f64>();
                                let _ = fw.apply(TwinEvent::SetMotors { body: pilot.body, thrusts });
                            }
                        }
                        let (fw, _, _) = state.fork.as_mut().unwrap();
                        let _ = fw.step();
                    }
                    // The pilot flies: PD action from the current
                    // observation, recorded as a typed SetMotors event.
                    if let Some(pilot) = &state.pilot {
                        let states = state.live.body_states();
                        if let Some((pos, vel, _)) = states.get(pilot.body) {
                            let obs = QuadObs {
                                pos: *pos,
                                vel: *vel,
                                up: state.live.body_up(pilot.body).unwrap_or([0.0, 1.0, 0.0]),
                                angvel: state
                                    .live
                                    .body_angvel(pilot.body)
                                    .unwrap_or([0.0; 3]),
                                step: state.live.step_index(),
                            };
                            let thrusts = pilot.policy.act(&obs, pilot.mass)
                                .map(|t| t.clamp(0.0, 6.0));
                            if state.fork.is_some() {
                                let dt = 1.0 / 120.0f64;
                                state.elec_live += thrusts
                                    .iter()
                                    .map(|&t| (t as f64).powf(1.5) / 0.35 * dt)
                                    .sum::<f64>();
                            }
                            let _ = state.live.apply(TwinEvent::SetMotors {
                                body: pilot.body,
                                thrusts,
                            });
                        }
                    }
                    // Live stepping.
                    if let Ok(receipt) = state.live.step() {
                        state.recorded_hashes.push(receipt.state_hash);
                        state.cumulative_uj += receipt.energy_uj;
                        let quad = state.pilot.as_ref().map(|p| p.body);
                        let target = state.pilot.as_ref().map(|p| p.policy.target);
                        twin_draw_world(&ctx, &state.live, cw, ch, false, quad, target);
                        if let Some((fw, fstep, verified)) = &state.fork {
                            let quad = state.pilot.as_ref().map(|p| p.body);
                            twin_draw_world(&ctx, fw, cw, ch, true, quad, None);
                            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
                            ctx.set_fill_style_str(if *verified {
                                "rgba(150, 255, 180, 1)"
                            } else {
                                "rgba(255, 120, 120, 1)"
                            });
                            ctx.fill_text(
                                &format!(
                                    "FORK @ step {fstep} — shared past {} · one changed event (opposite wind) · dim = counterfactual, derived not dreamed",
                                    if *verified { "verified ≡ bit-exact" } else { "UNVERIFIED" }
                                ),
                                28.0,
                                112.0,
                            )
                            .ok();
                            ctx.set_fill_style_str("rgba(255, 210, 130, 1)");
                            ctx.fill_text(
                                &format!(
                                    "energy since fork — yours {:.1} J · counterfactual {:.1} J · the other choice costs {:+.1} J",
                                    state.elec_live,
                                    state.elec_fork,
                                    state.elec_fork - state.elec_live
                                ),
                                28.0,
                                130.0,
                            )
                            .ok();
                        }
                        if state.wind_on {
                            ctx.set_stroke_style_str("rgba(140, 200, 255, 0.7)");
                            ctx.set_fill_style_str("rgba(140, 200, 255, 0.9)");
                            let phase = (receipt.step % 40) as f64 / 40.0;
                            for lane in 0..3 {
                                let y = 100.0 + lane as f64 * 26.0;
                                let x = 60.0 + ((phase + lane as f64 * 0.33) % 1.0) * (cw - 160.0);
                                ctx.begin_path();
                                ctx.move_to(x, y);
                                ctx.line_to(x + 26.0, y);
                                ctx.line_to(x + 19.0, y - 4.0);
                                ctx.move_to(x + 26.0, y);
                                ctx.line_to(x + 19.0, y + 4.0);
                                ctx.stroke();
                            }
                            ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
                            ctx.fill_text(
                                &format!("crosswind {:.1} N — a typed event; the pilot leans into it; the books still balance", state.wind_x),
                                28.0,
                                94.0,
                            )
                            .ok();
                        }

                        ctx.set_font("11px ui-monospace, SFMono-Regular, Menlo, monospace");
                        ctx.set_fill_style_str("rgba(120, 200, 255, 1)");
                        ctx.fill_text(
                            &format!(
                                "step {} · bodies {} · state hash {:#018x}",
                                receipt.step, receipt.n_bodies, receipt.state_hash
                            ),
                            28.0,
                            58.0,
                        )
                        .ok();
                        if let Some(gate) = &receipt.gate {
                            ctx.set_fill_style_str(if gate.passed {
                                "rgba(150, 255, 180, 1)"
                            } else {
                                "rgba(255, 120, 120, 1)"
                            });
                            let lab = if gate.passed {
                                "conservation gate: energy books balanced".to_string()
                            } else {
                                format!("conservation gate VIOLATION: {:?}", gate.laws)
                            };
                            ctx.fill_text(&lab, 28.0, 76.0).ok();
                        }
                        ctx.set_fill_style_str("rgba(160, 170, 190, 1)");
                        ctx.fill_text(
                            &format!(
                                "cumulative {} µJ · every spawn + kick is a typed event in the transcript",
                                state.cumulative_uj
                            ),
                            28.0,
                            ch - 18.0,
                        )
                        .ok();

                        emit_receipt(
                            &sink,
                            "twin_sim_step",
                            "L0Closed",
                            1,
                            (receipt.bit_ops as f64) * 0.5,
                            receipt.bit_ops,
                        );
                    }
                }
            }
        });

        schedule(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    schedule(g.borrow().as_ref().unwrap());
    std::mem::forget(g);
    Ok(())
}

This is the exact Rust file compiled into the WASM module the page just loaded. Every line that runs on your device is here. The receipt is a function of this code, not a bespoke benchmark.

What the ≡ / ≠ indicator means

  • ≡ byte-reproducible: the kernel produced the same bytes on every one of the 1000 runs. The receipt grammar can carry this as a first-class field (output_hash: Option<u64>); any consumer can detect drift without trusting the kernel's authors.
  • ≠ diverged: the substrate is confessing, not lying. Joule receipts that don't ship a hash can be honest about energy and still hide non-determinism behind the decimal point. This exhibit forces that property into the open.
  • ∎ first observation: the substrate hasn't seen this primitive before; no commitment made yet. The next run flips it to ≡ or ≠.

Lineage

Thinking Machines Lab opened their Connectionism blog with Defeating Nondeterminism in LLM Inference (Horace He, 2025-09): batch-size variance, not concurrent atomic adds, is the real source of LLM nondeterminism. They open-sourced batch-invariant-ops — fixed reductions, fixed tile sizes, fixed split sizes — and reproduced 1000 / 1000 identical completions on Qwen 3-235B at a ~1.6× cost.

Mathground takes a parallel position: rather than fix non-determinism at the kernel author's layer, publish a 64-bit fingerprint of what the kernel produced on every receipt. Consumers detect drift themselves. The two stances are complementary — TM commits to the kernel being batch-invariant; mathground commits the receipt to being honest about whether it was.

See /receipts · 05 — the reproducibility receipt for the grammar.