diff --git a/.github/workflows/dashboard-pages.yml b/.github/workflows/dashboard-pages.yml
new file mode 100644
index 00000000..d484e048
--- /dev/null
+++ b/.github/workflows/dashboard-pages.yml
@@ -0,0 +1,85 @@
+name: nvsim Dashboard → GitHub Pages
+
+# Deploys the nvsim Vite/Lit dashboard to gh-pages/nvsim/ — preserving
+# the existing observatory/, pose-fusion/, and root index.html demos
+# already published from gh-pages. ADR-092 §9.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'v2/crates/nvsim/**'
+ - 'dashboard/**'
+ - '.github/workflows/dashboard-pages.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: dashboard-pages
+ cancel-in-progress: true
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@v4
+
+ - name: Install Rust + wasm32 target
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - name: Cache cargo registry
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ v2/target
+ key: ${{ runner.os }}-cargo-nvsim-${{ hashFiles('v2/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-nvsim-
+
+ - name: Install wasm-pack
+ run: cargo install wasm-pack --locked --version 0.13.x || true
+
+ - name: Build nvsim WASM
+ working-directory: v2
+ run: |
+ wasm-pack build crates/nvsim \
+ --target web \
+ --out-dir ../../dashboard/public/nvsim-pkg \
+ --release \
+ -- --no-default-features --features wasm
+
+ - name: Setup Node 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: dashboard/package-lock.json
+
+ - name: Install dashboard deps
+ working-directory: dashboard
+ run: npm ci
+
+ - name: Build dashboard
+ working-directory: dashboard
+ env:
+ NVSIM_BASE: /RuView/nvsim/
+ run: npm run build
+
+ - name: Deploy to gh-pages/nvsim/
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./dashboard/dist
+ destination_dir: nvsim
+ # CRITICAL: preserves observatory/, pose-fusion/, root index.html
+ # and any other RuView demos already on gh-pages.
+ keep_files: true
+ commit_message: 'deploy(nvsim): ${{ github.sha }}'
+ user_name: 'github-actions[bot]'
+ user_email: 'github-actions[bot]@users.noreply.github.com'
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 0426b91d..359c9b7f 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -9,12 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@preact/signals-core": "^1.8.0",
- "lit": "^3.2.1"
+ "lit": "^3.2.1",
+ "workbox-window": "^7.4.0"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10",
- "vite-plugin-pwa": "^0.21.1",
+ "vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.4"
}
},
@@ -5861,17 +5862,17 @@
}
},
"node_modules/vite-plugin-pwa": {
- "version": "0.21.2",
- "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz",
- "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
+ "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.6",
"pretty-bytes": "^6.1.1",
"tinyglobby": "^0.2.10",
- "workbox-build": "^7.3.0",
- "workbox-window": "^7.3.0"
+ "workbox-build": "^7.4.0",
+ "workbox-window": "^7.4.0"
},
"engines": {
"node": ">=16.0.0"
@@ -5880,10 +5881,10 @@
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
- "@vite-pwa/assets-generator": "^0.2.6",
- "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
- "workbox-build": "^7.3.0",
- "workbox-window": "^7.3.0"
+ "@vite-pwa/assets-generator": "^1.0.0",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
+ "workbox-build": "^7.4.0",
+ "workbox-window": "^7.4.0"
},
"peerDependenciesMeta": {
"@vite-pwa/assets-generator": {
@@ -6304,7 +6305,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
@@ -6420,7 +6420,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
"integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "^2.0.2",
diff --git a/dashboard/package.json b/dashboard/package.json
index 91c00bd2..490b3677 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -14,12 +14,13 @@
},
"dependencies": {
"@preact/signals-core": "^1.8.0",
- "lit": "^3.2.1"
+ "lit": "^3.2.1",
+ "workbox-window": "^7.4.0"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10",
- "vite-plugin-pwa": "^0.21.1",
+ "vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.4"
}
}
diff --git a/dashboard/public/icon-192.svg b/dashboard/public/icon-192.svg
new file mode 100644
index 00000000..a378624b
--- /dev/null
+++ b/dashboard/public/icon-192.svg
@@ -0,0 +1,4 @@
+
+
+ NV
+
diff --git a/dashboard/public/icon-512.svg b/dashboard/public/icon-512.svg
new file mode 100644
index 00000000..67372c10
--- /dev/null
+++ b/dashboard/public/icon-512.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ NV
+
diff --git a/dashboard/src/components/nv-app.ts b/dashboard/src/components/nv-app.ts
index 5cbba7df..8e54ba8a 100644
--- a/dashboard/src/components/nv-app.ts
+++ b/dashboard/src/components/nv-app.ts
@@ -16,6 +16,7 @@ import './nv-modal';
import './nv-palette';
import './nv-debug-hud';
import './nv-settings-drawer';
+import './nv-onboarding';
export type View = 'scene' | 'apps' | 'settings';
@@ -87,6 +88,7 @@ export class NvApp extends LitElement {
+
`;
}
}
diff --git a/dashboard/src/components/nv-onboarding.ts b/dashboard/src/components/nv-onboarding.ts
new file mode 100644
index 00000000..c219b0d6
--- /dev/null
+++ b/dashboard/src/components/nv-onboarding.ts
@@ -0,0 +1,203 @@
+/* First-run welcome tour. 5 steps walking the user through the
+ * dashboard's main concepts. Persists `seen` flag in IndexedDB so it
+ * only shows the first time. ADR-092 §10 Pass 6.
+ */
+
+import { LitElement, html, css } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { kvGet, kvSet } from '../store/persistence';
+
+interface TourStep {
+ title: string;
+ body: string;
+ cta?: string;
+}
+
+const STEPS: TourStep[] = [
+ {
+ title: 'Welcome to nvsim',
+ body: `
nvsim is an open-source, deterministic forward simulator for
+ nitrogen-vacancy diamond magnetometry — a real Rust crate compiled to
+ WASM and running in your browser, right now.
+ This 30-second tour highlights the four panels you'll use most.
`,
+ cta: 'Start tour',
+ },
+ {
+ title: '1. Scene canvas',
+ body: `The middle panel shows your magnetic scene — sources you can
+ drag (rebar, heart proxy, mains hum, ferrous door) and a single NV-diamond
+ sensor in the centre. Field lines from each source connect to the sensor
+ and animate while the pipeline runs.
+ Click 2 on your keyboard any time to jump to the Frame inspector.
`,
+ },
+ {
+ title: '2. Run the pipeline',
+ body: `Click the ▶ Run button (top-right) to start streaming
+ MagFrame records at the digitiser's sample rate. The B-vector
+ trace and Frame stream sparkline update live, and the FPS pill in the
+ topbar shows the simulator's throughput in kHz.
+ Space toggles run/pause from anywhere.
`,
+ },
+ {
+ title: '3. Witness panel',
+ body: `The Witness tab is the heart of nvsim's determinism contract.
+ Click Verify and the pipeline re-derives the SHA-256 over a 256-frame
+ reference run and asserts it matches the constant pinned in the Rust crate.
+ Same input → same hash → byte-for-byte across browsers, OSes, transports.
+ If the hash drifts, your build is non-canonical.
`,
+ },
+ {
+ title: '4. App Store',
+ body: `The grid icon on the left rail opens the App Store — every
+ hot-loadable WASM edge module RuView ships, plus the simulators. 66 apps
+ across 13 categories: medical, security, building, retail, industrial,
+ signal, learning, autonomy, and more.
+ Toggle any card to mark it active in this session; the WS transport
+ will push the activation set to a connected ESP32 mesh.
`,
+ },
+ {
+ title: 'You are ready',
+ body: `Press ⌘K (or Ctrl K ) any time for the command
+ palette, ? for the full shortcuts list, or just start clicking.
+ Source on GitHub:
+ github.com/ruvnet/RuView · ADR-089, ADR-092 · MIT/Apache-2.0.
`,
+ cta: 'Get started',
+ },
+];
+
+@customElement('nv-onboarding')
+export class NvOnboarding extends LitElement {
+ @state() private open = false;
+ @state() private step = 0;
+
+ static styles = css`
+ :host {
+ position: fixed; inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(4px);
+ z-index: 240;
+ display: grid; place-items: center;
+ opacity: 0; pointer-events: none;
+ transition: opacity 0.18s;
+ }
+ :host([open]) { opacity: 1; pointer-events: auto; }
+ .card {
+ background: var(--bg-1);
+ border: 1px solid var(--line-2);
+ border-radius: var(--radius);
+ box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
+ width: min(560px, 92vw);
+ max-height: 86vh;
+ display: flex; flex-direction: column;
+ transform: translateY(12px) scale(0.98);
+ transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
+ }
+ :host([open]) .card { transform: translateY(0) scale(1); }
+ .h {
+ padding: 20px 22px 8px;
+ display: flex; justify-content: space-between; align-items: flex-start;
+ }
+ .h h2 { margin: 0; font-size: 18px; letter-spacing: -0.01em; }
+ .body {
+ padding: 8px 22px 16px;
+ font-size: 13px; color: var(--ink-2); line-height: 1.55;
+ overflow-y: auto;
+ }
+ .body p { margin: 0 0 12px; }
+ .body code, .body kbd {
+ font-family: var(--mono); font-size: 11.5px;
+ padding: 1px 5px; background: var(--bg-3);
+ border: 1px solid var(--line); border-radius: 4px;
+ color: var(--accent);
+ }
+ .footer {
+ display: flex; align-items: center; gap: 12px;
+ padding: 12px 22px;
+ border-top: 1px solid var(--line);
+ }
+ .dots { display: flex; gap: 6px; flex: 1; }
+ .dot {
+ width: 6px; height: 6px; border-radius: 50%;
+ background: var(--bg-3); border: 1px solid var(--line-2);
+ }
+ .dot.active { background: var(--accent); border-color: var(--accent); }
+ button {
+ padding: 8px 14px;
+ border-radius: 8px;
+ font-size: 12.5px; font-weight: 500;
+ border: 1px solid var(--line);
+ background: var(--bg-2); color: var(--ink);
+ cursor: pointer;
+ font-family: inherit;
+ }
+ button.primary {
+ background: var(--accent); border-color: var(--accent);
+ color: #1a0f00;
+ }
+ button.ghost { background: transparent; }
+ .skip {
+ width: 28px; height: 28px;
+ background: transparent; border: 1px solid var(--line);
+ border-radius: 6px; color: var(--ink-2);
+ }
+ `;
+
+ override async connectedCallback(): Promise {
+ super.connectedCallback();
+ window.addEventListener('nv-show-tour', this.show as EventListener);
+ const seen = await kvGet('onboarding-seen');
+ if (!seen) {
+ this.open = true;
+ this.setAttribute('open', '');
+ }
+ }
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ window.removeEventListener('nv-show-tour', this.show as EventListener);
+ }
+
+ private show = (): void => {
+ this.step = 0;
+ this.open = true;
+ this.setAttribute('open', '');
+ };
+
+ private async dismiss(): Promise {
+ this.open = false;
+ this.removeAttribute('open');
+ await kvSet('onboarding-seen', true);
+ }
+
+ private next(): void {
+ if (this.step < STEPS.length - 1) this.step++;
+ else void this.dismiss();
+ }
+
+ private prev(): void {
+ if (this.step > 0) this.step--;
+ }
+
+ override render() {
+ const s = STEPS[this.step];
+ return html`
+
+
+
${s.title}
+ this.dismiss()} aria-label="Skip tour">×
+
+
+
+
+ `;
+ }
+}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index 84d02c01..9f9a2dff 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -1,4 +1,5 @@
import { defineConfig } from 'vite';
+import { VitePWA } from 'vite-plugin-pwa';
// Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker.
// Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable
@@ -11,6 +12,48 @@ export default defineConfig({
worker: {
format: 'es',
},
+ plugins: [
+ VitePWA({
+ registerType: 'autoUpdate',
+ includeAssets: [
+ 'nvsim-pkg/nvsim.js',
+ 'nvsim-pkg/nvsim_bg.wasm',
+ ],
+ manifest: {
+ name: 'nvsim — NV-Diamond Magnetometer Simulator',
+ short_name: 'nvsim',
+ description: 'Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs.',
+ theme_color: '#0d1117',
+ background_color: '#0d1117',
+ display: 'standalone',
+ scope: base,
+ start_url: base,
+ icons: [
+ {
+ src: 'icon-192.svg',
+ sizes: '192x192',
+ type: 'image/svg+xml',
+ purpose: 'any maskable',
+ },
+ {
+ src: 'icon-512.svg',
+ sizes: '512x512',
+ type: 'image/svg+xml',
+ purpose: 'any maskable',
+ },
+ ],
+ },
+ workbox: {
+ globPatterns: ['**/*.{js,css,html,svg,wasm,woff,woff2}'],
+ // WASM is large; bump the precache size budget so workbox doesn't
+ // skip nvsim_bg.wasm.
+ maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
+ },
+ devOptions: {
+ enabled: false,
+ },
+ }),
+ ],
build: {
target: 'es2022',
sourcemap: true,
@@ -27,13 +70,9 @@ export default defineConfig({
port: 5173,
strictPort: true,
fs: {
- // wasm-pack output sits in public/nvsim-pkg; vite already serves it,
- // but allow fs reads from the workspace root for HMR convenience.
allow: ['..', '.'],
},
headers: {
- // SAB ring buffer is opt-in; these headers are no-op without crossOriginIsolated
- // but make local dev parity with a future CORS-isolated host.
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
diff --git a/v2/Cargo.toml b/v2/Cargo.toml
index 7005fc5f..113859cd 100644
--- a/v2/Cargo.toml
+++ b/v2/Cargo.toml
@@ -20,6 +20,7 @@ members = [
"crates/wifi-densepose-pointcloud",
"crates/wifi-densepose-geo",
"crates/nvsim",
+ "crates/nvsim-server",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
diff --git a/v2/crates/nvsim-server/Cargo.toml b/v2/crates/nvsim-server/Cargo.toml
new file mode 100644
index 00000000..10e2ce3d
--- /dev/null
+++ b/v2/crates/nvsim-server/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "nvsim-server"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+description = "Axum REST + WebSocket server fronting the nvsim NV-diamond pipeline simulator (ADR-092 §6.2)."
+repository.workspace = true
+keywords = ["nvsim", "axum", "websocket", "magnetometer", "simulator"]
+categories = ["science", "web-programming", "simulation"]
+
+[[bin]]
+name = "nvsim-server"
+path = "src/main.rs"
+
+[dependencies]
+nvsim = { path = "../nvsim" }
+axum = { workspace = true }
+tokio = { workspace = true }
+tower = { workspace = true }
+tower-http = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
+futures-util = "0.3"
+clap = { version = "4.5", features = ["derive"] }
+thiserror = { workspace = true }
diff --git a/v2/crates/nvsim-server/src/main.rs b/v2/crates/nvsim-server/src/main.rs
new file mode 100644
index 00000000..abab93c3
--- /dev/null
+++ b/v2/crates/nvsim-server/src/main.rs
@@ -0,0 +1,420 @@
+//! `nvsim-server` — Axum host fronting the deterministic nvsim pipeline.
+//!
+//! ADR-092 §6.2 — REST control plane + binary WebSocket data plane.
+//! Same `(scene, config, seed)` produces byte-identical witnesses across
+//! the WASM transport (in-browser worker) and this WS transport — the
+//! determinism contract the dashboard's Verify panel asserts.
+//!
+//! ## Routes
+//!
+//! | Method | Path | Purpose |
+//! |--------|-------------------------|----------------------------------|
+//! | GET | /api/health | liveness + nvsim version + magic |
+//! | GET | /api/scene | current scene (JSON) |
+//! | PUT | /api/scene | replace scene |
+//! | GET | /api/config | current `PipelineConfig` |
+//! | PUT | /api/config | replace config |
+//! | GET | /api/seed | current seed (hex) |
+//! | PUT | /api/seed | set seed |
+//! | POST | /api/run | start a run |
+//! | POST | /api/pause | pause |
+//! | POST | /api/reset | reset to t=0 |
+//! | POST | /api/step | single step |
+//! | POST | /api/witness/generate | run N frames + return SHA-256 |
+//! | POST | /api/witness/verify | re-derive + compare expected |
+//! | POST | /api/witness/reference | run canonical Proof::generate |
+//! | POST | /api/export-proof | proof bundle as JSON |
+//! | GET | /ws/stream | binary MagFrame batch stream |
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::{
+ extract::{
+ ws::{Message, WebSocket, WebSocketUpgrade},
+ State,
+ },
+ http::StatusCode,
+ response::IntoResponse,
+ routing::{get, post},
+ Json, Router,
+};
+use clap::Parser;
+use serde::{Deserialize, Serialize};
+use tokio::sync::Mutex;
+use tower_http::{
+ cors::{Any, CorsLayer},
+ trace::TraceLayer,
+};
+use tracing::{info, warn};
+
+use nvsim::{
+ pipeline::{Pipeline, PipelineConfig},
+ proof::Proof,
+ scene::Scene,
+};
+
+#[derive(Parser, Debug)]
+#[command(name = "nvsim-server", version)]
+struct Args {
+ #[arg(long, default_value = "127.0.0.1:7878")]
+ listen: SocketAddr,
+ #[arg(long, default_value = "*")]
+ allowed_origin: String,
+}
+
+#[derive(Debug, Clone)]
+struct AppState {
+ inner: Arc>,
+}
+
+#[derive(Debug, Clone)]
+struct RunState {
+ scene: Scene,
+ config: PipelineConfig,
+ seed: u64,
+ running: bool,
+ frames_emitted: u64,
+}
+
+impl AppState {
+ fn new() -> Self {
+ let scene = Proof::reference_scene().expect("reference scene parses");
+ Self {
+ inner: Arc::new(Mutex::new(RunState {
+ scene,
+ config: PipelineConfig::default(),
+ seed: Proof::SEED,
+ running: false,
+ frames_emitted: 0,
+ })),
+ }
+ }
+}
+
+#[derive(Serialize)]
+struct HealthBody {
+ nvsim_version: &'static str,
+ magic: u32,
+ frame_bytes: usize,
+ expected_witness_hex: &'static str,
+}
+
+#[derive(Serialize)]
+struct SeedBody {
+ seed_hex: String,
+}
+
+#[derive(Deserialize)]
+struct SeedReq {
+ seed_hex: String,
+}
+
+#[derive(Deserialize, Default)]
+struct WitnessReq {
+ samples: Option,
+}
+
+#[derive(Serialize)]
+struct WitnessBody {
+ witness_hex: String,
+ samples: usize,
+ seed_hex: String,
+}
+
+#[derive(Deserialize)]
+struct VerifyReq {
+ expected_hex: String,
+ samples: Option,
+}
+
+#[derive(Serialize)]
+struct VerifyBody {
+ ok: bool,
+ actual_hex: String,
+ expected_hex: String,
+}
+
+#[derive(Deserialize)]
+struct StepReq {
+ direction: Option,
+ dt_ms: Option,
+}
+
+#[derive(Serialize)]
+struct ProofBundle {
+ kind: &'static str,
+ nvsim_version: &'static str,
+ seed_hex: String,
+ n_samples: usize,
+ witness_hex: String,
+ expected_hex: &'static str,
+ ok: bool,
+ ts: String,
+}
+
+const EXPECTED_WITNESS_HEX: &str =
+ "cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4";
+
+#[tokio::main]
+async fn main() {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| "nvsim_server=info,tower_http=info".into()),
+ )
+ .init();
+
+ let args = Args::parse();
+ let state = AppState::new();
+ let cors = CorsLayer::new()
+ .allow_origin(if args.allowed_origin == "*" {
+ tower_http::cors::AllowOrigin::any()
+ } else {
+ args.allowed_origin
+ .parse::()
+ .map(tower_http::cors::AllowOrigin::exact)
+ .unwrap_or_else(|_| tower_http::cors::AllowOrigin::any())
+ })
+ .allow_headers(Any)
+ .allow_methods(Any);
+
+ let app = Router::new()
+ .route("/api/health", get(health))
+ .route("/api/scene", get(get_scene).put(put_scene))
+ .route("/api/config", get(get_config).put(put_config))
+ .route("/api/seed", get(get_seed).put(put_seed))
+ .route("/api/run", post(run_pipe))
+ .route("/api/pause", post(pause_pipe))
+ .route("/api/reset", post(reset_pipe))
+ .route("/api/step", post(step_pipe))
+ .route("/api/witness/generate", post(witness_generate))
+ .route("/api/witness/verify", post(witness_verify))
+ .route("/api/witness/reference", post(witness_reference))
+ .route("/api/export-proof", post(export_proof))
+ .route("/ws/stream", get(ws_handler))
+ .with_state(state)
+ .layer(cors)
+ .layer(TraceLayer::new_for_http());
+
+ info!("nvsim-server listening on http://{}", args.listen);
+ let listener = tokio::net::TcpListener::bind(args.listen)
+ .await
+ .expect("bind listener");
+ axum::serve(listener, app).await.expect("axum serve");
+}
+
+async fn health() -> Json {
+ Json(HealthBody {
+ nvsim_version: env!("CARGO_PKG_VERSION"),
+ magic: nvsim::MAG_FRAME_MAGIC,
+ frame_bytes: nvsim::frame::MAG_FRAME_BYTES,
+ expected_witness_hex: EXPECTED_WITNESS_HEX,
+ })
+}
+
+async fn get_scene(State(s): State) -> Json {
+ Json(s.inner.lock().await.scene.clone())
+}
+
+async fn put_scene(
+ State(s): State,
+ Json(scene): Json,
+) -> Result<&'static str, AppError> {
+ s.inner.lock().await.scene = scene;
+ Ok("ok")
+}
+
+async fn get_config(State(s): State) -> Json {
+ Json(s.inner.lock().await.config)
+}
+
+async fn put_config(
+ State(s): State,
+ Json(cfg): Json,
+) -> Result<&'static str, AppError> {
+ s.inner.lock().await.config = cfg;
+ Ok("ok")
+}
+
+async fn get_seed(State(s): State) -> Json {
+ let seed = s.inner.lock().await.seed;
+ Json(SeedBody {
+ seed_hex: format!("0x{:016X}", seed),
+ })
+}
+
+async fn put_seed(
+ State(s): State,
+ Json(req): Json,
+) -> Result<&'static str, AppError> {
+ let raw = req.seed_hex.trim().trim_start_matches("0x");
+ let seed = u64::from_str_radix(raw, 16).map_err(|e| AppError::BadInput(e.to_string()))?;
+ s.inner.lock().await.seed = seed;
+ Ok("ok")
+}
+
+async fn run_pipe(State(s): State) -> &'static str {
+ s.inner.lock().await.running = true;
+ "running"
+}
+
+async fn pause_pipe(State(s): State) -> &'static str {
+ s.inner.lock().await.running = false;
+ "paused"
+}
+
+async fn reset_pipe(State(s): State) -> &'static str {
+ let mut g = s.inner.lock().await;
+ g.frames_emitted = 0;
+ g.running = false;
+ "reset"
+}
+
+async fn step_pipe(
+ State(s): State,
+ Json(_req): Json,
+) -> Result<&'static str, AppError> {
+ s.inner.lock().await.frames_emitted += 1;
+ Ok("ok")
+}
+
+async fn witness_generate(
+ State(s): State,
+ Json(req): Json,
+) -> Json {
+ let n = req.samples.unwrap_or(256);
+ let g = s.inner.lock().await;
+ let pipeline = Pipeline::new(g.scene.clone(), g.config, g.seed);
+ let (_, witness) = pipeline.run_with_witness(n);
+ Json(WitnessBody {
+ witness_hex: Proof::hex(&witness),
+ samples: n,
+ seed_hex: format!("0x{:016X}", g.seed),
+ })
+}
+
+async fn witness_verify(
+ State(_s): State,
+ Json(req): Json,
+) -> Result, AppError> {
+ // ADR-092 §6.3 — verify always runs the *canonical* reference scene
+ // (Proof::generate) so it matches Proof::EXPECTED_WITNESS_HEX. The
+ // user's working scene/config/seed don't enter this check.
+ let _samples = req.samples.unwrap_or(Proof::N_SAMPLES);
+ let actual = Proof::generate().map_err(|e| AppError::Internal(e.to_string()))?;
+ let actual_hex = Proof::hex(&actual);
+ let expected_hex = req.expected_hex.trim().to_lowercase();
+ let ok = actual_hex == expected_hex;
+ Ok(Json(VerifyBody {
+ ok,
+ actual_hex,
+ expected_hex,
+ }))
+}
+
+async fn witness_reference() -> Result, AppError> {
+ let actual = Proof::generate().map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(Json(WitnessBody {
+ witness_hex: Proof::hex(&actual),
+ samples: Proof::N_SAMPLES,
+ seed_hex: format!("0x{:016X}", Proof::SEED),
+ }))
+}
+
+async fn export_proof(State(_s): State) -> Result, AppError> {
+ let actual = Proof::generate().map_err(|e| AppError::Internal(e.to_string()))?;
+ let actual_hex = Proof::hex(&actual);
+ let ok = actual_hex == EXPECTED_WITNESS_HEX;
+ Ok(Json(ProofBundle {
+ kind: "nvsim-proof-bundle",
+ nvsim_version: env!("CARGO_PKG_VERSION"),
+ seed_hex: format!("0x{:016X}", Proof::SEED),
+ n_samples: Proof::N_SAMPLES,
+ witness_hex: actual_hex,
+ expected_hex: EXPECTED_WITNESS_HEX,
+ ok,
+ ts: chrono_like_now(),
+ }))
+}
+
+fn chrono_like_now() -> String {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let secs = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ format!("{secs}-unix")
+}
+
+async fn ws_handler(
+ ws: WebSocketUpgrade,
+ State(s): State,
+) -> impl IntoResponse {
+ ws.on_upgrade(move |socket| handle_ws(socket, s))
+}
+
+async fn handle_ws(mut socket: WebSocket, state: AppState) {
+ info!("ws/stream client connected");
+ // Build the pipeline on connect — single instance per client; the
+ // server doesn't multiplex pipelines because the sim is fast enough
+ // to spin one up per client without measurable latency.
+ let (scene, config, seed) = {
+ let g = state.inner.lock().await;
+ (g.scene.clone(), g.config, g.seed)
+ };
+ let pipeline = Pipeline::new(scene, config, seed);
+ let mut tick = tokio::time::interval(std::time::Duration::from_millis(16));
+ let batch_size = 32usize;
+
+ loop {
+ tokio::select! {
+ _ = tick.tick() => {
+ let running = { state.inner.lock().await.running };
+ if !running { continue; }
+
+ let frames = pipeline.run(batch_size);
+ let mut bytes = Vec::with_capacity(frames.len() * nvsim::frame::MAG_FRAME_BYTES);
+ for f in &frames { bytes.extend_from_slice(&f.to_bytes()); }
+ if socket.send(Message::Binary(bytes)).await.is_err() {
+ warn!("ws/stream client closed");
+ return;
+ }
+
+ let mut g = state.inner.lock().await;
+ g.frames_emitted = g.frames_emitted.saturating_add(frames.len() as u64);
+ }
+ msg = socket.recv() => {
+ match msg {
+ Some(Ok(Message::Close(_))) | None => {
+ info!("ws/stream client disconnected");
+ return;
+ }
+ Some(Ok(_)) => { /* ignore inbound messages in V1 */ }
+ Some(Err(e)) => {
+ warn!(?e, "ws/stream socket error");
+ return;
+ }
+ }
+ }
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+enum AppError {
+ #[error("bad input: {0}")]
+ BadInput(String),
+ #[error("internal: {0}")]
+ Internal(String),
+}
+
+impl IntoResponse for AppError {
+ fn into_response(self) -> axum::response::Response {
+ let (code, msg) = match &self {
+ AppError::BadInput(_) => (StatusCode::BAD_REQUEST, self.to_string()),
+ AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
+ };
+ (code, msg).into_response()
+ }
+}