feat(dashboard): nvsim Vite+Lit dashboard with WASM transport + App Store [ADR-092]

End-to-end implementation of the operator dashboard for the nvsim
NV-diamond magnetometer simulator. Vite 5 + TypeScript strict + Lit 3,
~93 KB gzipped JS budget, runs the *real* nvsim Rust crate compiled to
wasm32-unknown-unknown inside a dedicated Web Worker.

Validated end-to-end with `npx agent-browser`:
- WASM module boots, build version + magic 0xC51A_6E70 reported
- Reference witness verifies byte-identical to Proof::EXPECTED_WITNESS_HEX
  cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4
- Pipeline runs at ~1.88 kHz on x86_64 dev hardware (4500x over Cortex-A53)
- Zero browser console errors; only Lit dev-mode warning (expected)

## nvsim crate (additive)
- New `wasm` feature flag with wasm-bindgen 0.2 / serde-wasm-bindgen 0.6
- src/wasm.rs: WasmPipeline wrapper + referenceSceneJson +
  expectedReferenceWitnessHex + referenceWitness + hexWitness exports
- crate-type = ["cdylib", "rlib"] so native + wasm both build
- rand = { default-features = false } drops getrandom OS-entropy path,
  preserving the crate's WASM-ready posture
- Native: 50/50 tests still pass, witness unchanged

## dashboard/ (new package)
- Vite 5 + TypeScript strict, Lit 3 elements, signals-based store
- 12 Lit components mirroring the mockup zones (rail, topbar, sidebar,
  scene SVG with draggable sources + NV crystal, inspector tabs
  Signal/Frame/Witness, console with REPL + filter tabs, settings
  drawer, modals, ⌘K command palette, debug HUD, toast, app-store)
- IndexedDB persistence (theme, density, motion, app activations)
- WasmClient → Web Worker → wasm-pack-built nvsim WASM module
- NvsimClient TS interface — same shape covers future WsClient transport
- MagFrame parser (60-byte LE layout matching nvsim::frame)

## App Store (ADR-092 §14a — added during impl)
- Catalog of all 65 wifi-densepose-wasm-edge modules + nvsim
- 13 categories with event-ID-range labels
- Per-app metadata: id/name/category/crate/summary/events/budget/
  status/adr/tags
- Fuzzy search, category + status filters, IndexedDB-backed activation
- ADR-092 §14a documents the registry contract and per-app schema

## Build pipeline
- wasm-pack build crates/nvsim --target web outputs to
  dashboard/public/nvsim-pkg/ (60 KB pkg, 162 KB unoptimized .wasm)
- npm run build → 93 KB gzip JS, well under 300 KB budget
- ts.config strict, npx tsc --noEmit clean
- Vite worker correctly loads WASM via dynamic import resolving
  against worker origin

## E2E validation
- agent-browser open → 4-zone grid renders correctly in dark theme
- Run button → live B-vector trace, |B| readout updates, FPS counter
- App Store → all 66 apps listed with toggles, fuzzy search filters
  to "Ghost hunter" on "ghost" query
- Witness verify → green check, console logs "determinism gate ✓"
- Console errors: zero (only expected Lit dev-mode warning)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-04-26 19:22:04 -04:00
parent db1ccbff49
commit 39ec05edcb
31 changed files with 10107 additions and 3 deletions
+25 -3
View File
@@ -10,6 +10,16 @@ keywords = ["nv-diamond", "magnetometer", "simulator", "physics", "biot-savart"]
categories = ["science", "simulation"]
readme = "README.md"
[package.metadata.wasm-pack.profile.release]
# Skip wasm-opt locally — older wasm-opt versions reject bulk-memory ops
# rustc emits at 1.92. CI runs wasm-opt with a current binaryen.
wasm-opt = false
[lib]
# `cdylib` for wasm-bindgen's wasm32 build, `rlib` so other workspace
# crates and benchmarks can keep linking against nvsim natively.
crate-type = ["cdylib", "rlib"]
# `nvsim` is a standalone leaf crate. It deliberately has NO internal RuView
# dependencies — see `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`
# §1.1 for the rationale. RuView integration (frame format alignment with
@@ -24,15 +34,27 @@ tracing = { workspace = true }
# Pass 4: deterministic ChaCha20 PRNG for shot-noise sampling. Same
# `(scene, seed)` produces byte-identical outputs across runs and machines —
# the determinism commitment in plan §5.
rand = "0.8"
rand_chacha = "0.3"
# the determinism commitment in plan §5. Default features off to drop the
# `getrandom` OS-entropy path; nvsim seeds from a caller-supplied u64 so
# OS entropy is never needed (this is also what makes nvsim WASM-ready).
rand = { version = "0.8", default-features = false }
rand_chacha = { version = "0.3", default-features = false }
# Pass 5: SHA-256 over concatenated MagFrame bytes is the simulator's
# content-addressable witness. Same scene + seed → same digest, the
# foundation of Pass 6's proof bundle.
sha2 = { workspace = true }
# ADR-092: optional wasm-bindgen surface for in-browser dashboard.
# Enable with `--features wasm` and target wasm32-unknown-unknown.
wasm-bindgen = { version = "0.2", optional = true }
serde-wasm-bindgen = { version = "0.6", optional = true }
js-sys = { version = "0.3", optional = true }
[features]
default = []
wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:js-sys"]
[dev-dependencies]
approx = "0.5"
criterion = { workspace = true }
+3
View File
@@ -48,6 +48,9 @@ pub mod scene;
pub mod sensor;
pub mod source;
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub mod wasm;
pub use proof::Proof;
pub use digitiser::{
+160
View File
@@ -0,0 +1,160 @@
//! WASM bindings for `nvsim` — ADR-092 dashboard transport.
//!
//! Exposes the deterministic pipeline through a small `wasm-bindgen`
//! surface so the Vite + Lit dashboard can run the *real* Rust simulator
//! in a Web Worker. Same `(scene, config, seed)` → byte-identical
//! `MagFrame` stream and SHA-256 witness as native — that's the
//! determinism contract the dashboard's Witness panel asserts.
//!
//! Only compiled when the `wasm` feature is on; gated to `target = wasm32`
//! so the rest of the workspace stays unaffected.
#![cfg(all(feature = "wasm", target_arch = "wasm32"))]
use wasm_bindgen::prelude::*;
use crate::pipeline::{Pipeline, PipelineConfig};
use crate::scene::Scene;
/// Build identifier surfaced to the dashboard so it can pin a specific
/// nvsim version + the SHA-256 of the `.wasm` artifact (the latter is
/// computed by the dashboard, not here, but this string is part of what
/// the dashboard logs at boot).
pub const NVSIM_BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Convert a `JsValue` error from `serde_wasm_bindgen` into a JS-side
/// `Error` with a useful message.
fn js_err(msg: impl AsRef<str>) -> JsValue {
JsValue::from_str(msg.as_ref())
}
/// In-browser pipeline. Wraps [`Pipeline`] with JS-friendly construction
/// (JSON for `Scene` and `PipelineConfig`) and `Vec<u8>` outputs (raw
/// concatenated [`MagFrame`] bytes — 60 bytes/frame, magic `0xC51A_6E70`).
#[wasm_bindgen]
pub struct WasmPipeline {
inner: Pipeline,
}
#[wasm_bindgen]
impl WasmPipeline {
/// Construct from JSON strings + a `seed` (BigInt-friendly; passed in
/// as `f64` since wasm-bindgen does not yet ergonomically pass `u64`,
/// then bit-cast through `as u64`). The dashboard sends seeds as
/// `Number(seed_hex)` from a 32-bit value to fit cleanly.
#[wasm_bindgen(constructor)]
pub fn new(scene_json: &str, config_json: &str, seed: f64) -> Result<WasmPipeline, JsValue> {
let scene: Scene =
serde_json::from_str(scene_json).map_err(|e| js_err(format!("scene parse: {e}")))?;
let config: PipelineConfig = serde_json::from_str(config_json)
.map_err(|e| js_err(format!("config parse: {e}")))?;
let seed_u64 = seed as u64;
Ok(WasmPipeline {
inner: Pipeline::new(scene, config, seed_u64),
})
}
/// Run `n_samples` of the pipeline and return the concatenated raw
/// `MagFrame` bytes (`n_samples * sensors * 60` bytes). The dashboard
/// parses this into typed records on the main thread.
#[wasm_bindgen]
pub fn run(&self, n_samples: usize) -> Vec<u8> {
let frames = self.inner.run(n_samples);
let mut out = Vec::with_capacity(frames.len() * 60);
for f in &frames {
out.extend_from_slice(&f.to_bytes());
}
out
}
/// Run + SHA-256 witness in one call. Returns a JS object
/// `{ frames: Uint8Array, witness: Uint8Array }`. Same
/// `(scene, config, seed)` produces byte-identical `witness` across
/// runs, machines, and transports — the regression dashboard pins.
#[wasm_bindgen(js_name = runWithWitness)]
pub fn run_with_witness(&self, n_samples: usize) -> Result<JsValue, JsValue> {
let (frames, witness) = self.inner.run_with_witness(n_samples);
let mut bytes = Vec::with_capacity(frames.len() * 60);
for f in &frames {
bytes.extend_from_slice(&f.to_bytes());
}
// Use js_sys::Object directly — keeps the call cheap and avoids
// pulling serde_wasm_bindgen on the hot path.
let obj = js_sys::Object::new();
let frames_arr = js_sys::Uint8Array::new_with_length(bytes.len() as u32);
frames_arr.copy_from(&bytes);
let witness_arr = js_sys::Uint8Array::new_with_length(32);
witness_arr.copy_from(&witness);
js_sys::Reflect::set(&obj, &JsValue::from_str("frames"), &frames_arr)?;
js_sys::Reflect::set(&obj, &JsValue::from_str("witness"), &witness_arr)?;
js_sys::Reflect::set(
&obj,
&JsValue::from_str("frameCount"),
&JsValue::from_f64(frames.len() as f64),
)?;
Ok(obj.into())
}
/// nvsim build version (semver from Cargo.toml).
#[wasm_bindgen(js_name = buildVersion)]
pub fn build_version() -> String {
NVSIM_BUILD_VERSION.to_string()
}
/// Magic constant for the `MagFrame` v1 binary record. The dashboard's
/// hex-dump panel highlights these four bytes (`0xC51A_6E70` → `701A6EC5`
/// little-endian) as a sanity check.
#[wasm_bindgen(js_name = frameMagic)]
pub fn frame_magic() -> u32 {
crate::frame::MAG_FRAME_MAGIC
}
/// Bytes-per-frame for v1 — `60` today; surfaced so the dashboard
/// can advance its parse cursor without re-deriving the layout.
#[wasm_bindgen(js_name = frameBytes)]
pub fn frame_bytes() -> u32 {
crate::frame::MAG_FRAME_BYTES as u32
}
}
/// Convenience: parse the bundled reference scene to JSON. Lets the
/// dashboard's "load reference scene" flow round-trip through the Rust
/// type system instead of duplicating the JSON literal in the JS code.
#[wasm_bindgen(js_name = referenceSceneJson)]
pub fn reference_scene_json() -> String {
crate::proof::Proof::REFERENCE_SCENE_JSON.to_string()
}
/// Hex-encode a 32-byte witness for display.
#[wasm_bindgen(js_name = hexWitness)]
pub fn hex_witness(witness: &[u8]) -> Result<String, JsValue> {
if witness.len() != 32 {
return Err(js_err(format!(
"witness must be 32 bytes, got {}",
witness.len()
)));
}
let mut a = [0u8; 32];
a.copy_from_slice(witness);
Ok(crate::proof::Proof::hex(&a))
}
/// Expected reference witness for `Proof::REFERENCE_SCENE_JSON @ seed=42,
/// N=256` — the bytes the dashboard's Verify panel compares against.
#[wasm_bindgen(js_name = expectedReferenceWitnessHex)]
pub fn expected_reference_witness_hex() -> String {
"cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4".to_string()
}
/// Run the canonical reference pipeline (`Proof::generate`) end-to-end and
/// return the SHA-256 witness as a 32-byte `Uint8Array`. This is the
/// dashboard's source of truth for the Verify-witness panel.
#[wasm_bindgen(js_name = referenceWitness)]
pub fn reference_witness() -> Result<js_sys::Uint8Array, JsValue> {
let bytes = crate::proof::Proof::generate().map_err(|e| js_err(format!("{e}")))?;
let arr = js_sys::Uint8Array::new_with_length(32);
arr.copy_from(&bytes);
Ok(arr)
}