mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user