mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
feat(nvsim): full simulator stack — Rust crate, dashboard, server, App Store, Ghost Murmur [ADR-089/090/091/092/093]
Squashed merge of feat/nvsim-pipeline-simulator (29 commits). ## Shipped - ADR-089 nvsim crate (Accepted) — 50/50 tests, ~4.5 M samples/s, pinned witness cc8de9b01b0ff5bd… - ADR-092 dashboard implementation (Implemented) — 8/12 §11 gates ✅, 4/12 ⚠ (external infra) - ADR-093 dashboard gap analysis (Implemented) — 21/21 catalogued gaps closed - Plus ADR-090 (proposed conditional) and ADR-091 (proposed research-only) ## Live deploy https://ruvnet.github.io/RuView/nvsim/ ## Infra - nvsim-server Dockerfile + GHCR publish workflow (.github/workflows/nvsim-server-docker.yml) - axe-core + Playwright cross-browser CI (.github/workflows/dashboard-a11y.yml) - gh-pages auto-deploy workflow already in place (preserves observatory + pose-fusion siblings) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
/* Common NvsimClient interface — both WasmClient and WsClient implement it.
|
||||
* Dashboard binds to this interface and never to a concrete client.
|
||||
* Aligns with ADR-092 §5.2.
|
||||
*/
|
||||
|
||||
export interface PipelineConfigJson {
|
||||
digitiser?: {
|
||||
f_s_hz: number;
|
||||
f_mod_hz: number;
|
||||
lp_cutoff_hz?: number;
|
||||
};
|
||||
sensor?: {
|
||||
gamma_fwhm_hz?: number;
|
||||
t1_s?: number;
|
||||
t2_s?: number;
|
||||
t2_star_s?: number;
|
||||
contrast?: number;
|
||||
n_spins?: number;
|
||||
n_centers?: number;
|
||||
shot_noise_disabled?: boolean;
|
||||
};
|
||||
dt_s?: number | null;
|
||||
}
|
||||
|
||||
export interface SceneJson {
|
||||
dipoles: { position: [number, number, number]; moment: [number, number, number] }[];
|
||||
loops: {
|
||||
centre: [number, number, number];
|
||||
normal: [number, number, number];
|
||||
radius: number;
|
||||
current: number;
|
||||
n_segments: number;
|
||||
}[];
|
||||
ferrous: {
|
||||
position: [number, number, number];
|
||||
volume: number;
|
||||
susceptibility: number;
|
||||
}[];
|
||||
eddy: unknown[];
|
||||
sensors: [number, number, number][];
|
||||
ambient_field: [number, number, number];
|
||||
}
|
||||
|
||||
export interface MagFrameRecord {
|
||||
magic: number;
|
||||
version: number;
|
||||
flags: number;
|
||||
sensorId: number;
|
||||
tUs: bigint;
|
||||
bPt: [number, number, number];
|
||||
sigmaPt: [number, number, number];
|
||||
noiseFloorPtSqrtHz: number;
|
||||
temperatureK: number;
|
||||
raw: Uint8Array;
|
||||
}
|
||||
|
||||
export interface MagFrameBatch {
|
||||
frames: MagFrameRecord[];
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export type NvsimEvent =
|
||||
| { type: 'log'; level: 'info' | 'warn' | 'err' | 'dbg' | 'ok'; msg: string }
|
||||
| { type: 'witness'; hex: string }
|
||||
| { type: 'fps'; value: number }
|
||||
| { type: 'state'; running: boolean; t: number; framesEmitted: number };
|
||||
|
||||
export interface RunOpts { frames?: number }
|
||||
|
||||
/** One-shot pipeline run for "what would the sensor recover at this scene?"
|
||||
* use cases. Doesn't disturb the running pipeline. */
|
||||
export interface TransientRunResult {
|
||||
bRecoveredT: [number, number, number];
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: [number, number, number];
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}
|
||||
|
||||
export interface NvsimClient {
|
||||
loadScene(scene: SceneJson): Promise<void>;
|
||||
setConfig(cfg: PipelineConfigJson): Promise<void>;
|
||||
setSeed(seed: bigint): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
run(opts?: RunOpts): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
step(direction: 'fwd' | 'back', dtMs: number): Promise<void>;
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void;
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void;
|
||||
|
||||
generateWitness(samples: number): Promise<Uint8Array>;
|
||||
verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }>;
|
||||
exportProofBundle(): Promise<Blob>;
|
||||
runTransient(scene: SceneJson, config: PipelineConfigJson, seed: bigint, samples: number): Promise<TransientRunResult>;
|
||||
|
||||
buildId(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Parse one MagFrame from a 60-byte slice. Layout matches `nvsim::frame`. */
|
||||
export function parseMagFrame(view: DataView, offset: number, raw: Uint8Array): MagFrameRecord {
|
||||
// v1 layout: magic(u32) | version(u16) | flags(u16) | sensor_id(u16) | _reserved(u16) |
|
||||
// t_us(u64) | b_pt[3](f32) | sigma_pt[3](f32) | noise_floor_pt_sqrt_hz(f32) |
|
||||
// temperature_k(f32) — 60 bytes total. All little-endian.
|
||||
const magic = view.getUint32(offset + 0, true);
|
||||
const version = view.getUint16(offset + 4, true);
|
||||
const flags = view.getUint16(offset + 6, true);
|
||||
const sensorId = view.getUint16(offset + 8, true);
|
||||
// skip 2 bytes reserved at offset+10
|
||||
const tUs = view.getBigUint64(offset + 12, true);
|
||||
const bx = view.getFloat32(offset + 20, true);
|
||||
const by = view.getFloat32(offset + 24, true);
|
||||
const bz = view.getFloat32(offset + 28, true);
|
||||
const sx = view.getFloat32(offset + 32, true);
|
||||
const sy = view.getFloat32(offset + 36, true);
|
||||
const sz = view.getFloat32(offset + 40, true);
|
||||
const noiseFloorPtSqrtHz = view.getFloat32(offset + 44, true);
|
||||
const temperatureK = view.getFloat32(offset + 48, true);
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
flags,
|
||||
sensorId,
|
||||
tUs,
|
||||
bPt: [bx, by, bz],
|
||||
sigmaPt: [sx, sy, sz],
|
||||
noiseFloorPtSqrtHz,
|
||||
temperatureK,
|
||||
raw: raw.subarray(offset, offset + 60),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFrameBatch(bytes: Uint8Array): MagFrameRecord[] {
|
||||
const frameSize = 60;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const out: MagFrameRecord[] = [];
|
||||
for (let off = 0; off + frameSize <= bytes.byteLength; off += frameSize) {
|
||||
out.push(parseMagFrame(view, off, bytes));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* Default `NvsimClient` implementation. Talks to the Web Worker that
|
||||
* hosts the nvsim WASM module. ADR-092 §5.4 + §6.3. */
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface PendingRequest<T = unknown> {
|
||||
resolve: (v: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface WasmBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
export class WasmClient implements NvsimClient {
|
||||
private worker: Worker;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, PendingRequest<unknown>>();
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private bootInfo: WasmBootInfo | null = null;
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
||||
this.worker.addEventListener('message', (ev) => this.onMessage(ev));
|
||||
this.worker.addEventListener('error', (e) =>
|
||||
this.eventSubs.forEach((s) => s({ type: 'log', level: 'err', msg: String(e.message) })),
|
||||
);
|
||||
}
|
||||
|
||||
private onMessage(ev: MessageEvent): void {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
if (m.type === 'frames') {
|
||||
const buf = m.batch as ArrayBuffer;
|
||||
const bytes = new Uint8Array(buf);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
const fps = m.fps as number;
|
||||
if (fps > 0) {
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m.type === 'state') {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({
|
||||
type: 'state',
|
||||
running: Boolean(m.running),
|
||||
t: 0,
|
||||
framesEmitted: Number(m.framesEmitted ?? 0),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (m.type === 'ready') {
|
||||
return;
|
||||
}
|
||||
if (m.type === 'err' && m.id == null) {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: String(m.msg) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof m.id === 'number' && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id)!;
|
||||
this.pending.delete(m.id);
|
||||
if (m.type === 'err') p.reject(new Error(String(m.msg)));
|
||||
else p.resolve(m);
|
||||
}
|
||||
}
|
||||
|
||||
private rpc<T = unknown>(msg: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.worker.postMessage({ ...msg, id }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
async boot(): Promise<WasmBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
// Pass Vite's resolved BASE_URL so the worker can locate /nvsim-pkg/
|
||||
// under the same prefix the dashboard is served from (e.g. /RuView/nvsim/
|
||||
// on GitHub Pages, "/" in dev).
|
||||
const base = import.meta.env.BASE_URL ?? '/';
|
||||
const r = await this.rpc<{ buildVersion: string; frameMagic: number; frameBytes: number; expectedWitnessHex: string }>(
|
||||
{ type: 'boot', base },
|
||||
);
|
||||
this.bootInfo = {
|
||||
buildVersion: r.buildVersion,
|
||||
frameMagic: r.frameMagic,
|
||||
frameBytes: r.frameBytes,
|
||||
expectedWitnessHex: r.expectedWitnessHex,
|
||||
};
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.rpc({ type: 'setScene', json: JSON.stringify(scene) });
|
||||
}
|
||||
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.rpc({ type: 'setConfig', json: JSON.stringify(cfg) });
|
||||
}
|
||||
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.rpc({ type: 'setSeed', seed: Number(seed & 0xFFFFFFFFn) });
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this.rpc({ type: 'reset' });
|
||||
}
|
||||
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.rpc({ type: 'run' });
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.rpc({ type: 'pause' });
|
||||
}
|
||||
|
||||
async step(_direction: 'fwd' | 'back', _dtMs: number): Promise<void> {
|
||||
await this.rpc({ type: 'step' });
|
||||
}
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.rpc<{ witness: ArrayBuffer; hex: string }>({ type: 'witnessGenerate', samples });
|
||||
return new Uint8Array(r.witness);
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const buf = expected.slice().buffer;
|
||||
const r = await this.rpc<{ ok: boolean; actual: ArrayBuffer; actualHex: string }>(
|
||||
{ type: 'witnessVerify', samples: 256, expected: buf },
|
||||
[buf],
|
||||
);
|
||||
if (r.ok) return { ok: true };
|
||||
return { ok: false, actual: new Uint8Array(r.actual) };
|
||||
}
|
||||
|
||||
async runTransient(
|
||||
scene: SceneJson,
|
||||
config: PipelineConfigJson,
|
||||
seed: bigint,
|
||||
samples: number,
|
||||
): Promise<TransientRunResult> {
|
||||
const r = await this.rpc<{
|
||||
bRecoveredT: number[];
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: number[];
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}>({
|
||||
type: 'runTransient',
|
||||
scene: JSON.stringify(scene),
|
||||
config: JSON.stringify(config),
|
||||
seed: Number(seed & 0xFFFFFFFFn),
|
||||
samples,
|
||||
});
|
||||
return {
|
||||
bRecoveredT: [r.bRecoveredT[0], r.bRecoveredT[1], r.bRecoveredT[2]],
|
||||
bMagT: r.bMagT,
|
||||
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
|
||||
sigmaPt: [r.sigmaPt[0], r.sigmaPt[1], r.sigmaPt[2]],
|
||||
nFrames: r.nFrames,
|
||||
witnessHex: r.witnessHex,
|
||||
};
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
// Bundle = REFERENCE_SCENE_JSON + computed witness hex + version. Wraps
|
||||
// the same artifacts `Proof::generate` produces natively. ADR-092 §6.1.
|
||||
const w = await this.generateWitness(256);
|
||||
const hex = Array.from(w).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
kind: 'nvsim-proof-bundle',
|
||||
version: info.buildVersion,
|
||||
seed: '0x0000002A',
|
||||
nSamples: 256,
|
||||
witness: hex,
|
||||
expected: info.expectedWitnessHex,
|
||||
ok: hex === info.expectedWitnessHex,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return new Blob([manifest], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const r = await this.rpc<{ buildId: string }>({ type: 'buildId' });
|
||||
return r.buildId;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* WebSocket transport client — talks to a `nvsim-server` Axum host
|
||||
* (v2/crates/nvsim-server). REST for control plane, binary WebSocket
|
||||
* for the MagFrame stream. Mirrors the WasmClient interface so the
|
||||
* dashboard can swap transports at runtime without code changes.
|
||||
*
|
||||
* ADR-092 §5.2 / §6.2.
|
||||
*/
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface HealthBody {
|
||||
nvsim_version: string;
|
||||
magic: number;
|
||||
frame_bytes: number;
|
||||
expected_witness_hex: string;
|
||||
}
|
||||
|
||||
interface VerifyBody {
|
||||
ok: boolean;
|
||||
actual_hex: string;
|
||||
expected_hex: string;
|
||||
}
|
||||
|
||||
interface WitnessBody {
|
||||
witness_hex: string;
|
||||
samples: number;
|
||||
seed_hex: string;
|
||||
}
|
||||
|
||||
export interface WsBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
/** Convert a base URL (e.g. `http://host:7878`) to its WebSocket peer (`ws://host:7878`). */
|
||||
function toWsUrl(baseUrl: string): string {
|
||||
if (baseUrl.startsWith('ws://') || baseUrl.startsWith('wss://')) return baseUrl;
|
||||
return baseUrl.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
export class WsClient implements NvsimClient {
|
||||
private baseUrl: string;
|
||||
private wsUrl: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private bootInfo: WsBootInfo | null = null;
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private running = false;
|
||||
private framesEmitted = 0;
|
||||
private fpsLast = performance.now();
|
||||
private fpsCount = 0;
|
||||
|
||||
/** @param baseUrl e.g. `http://localhost:7878` */
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.wsUrl = `${toWsUrl(this.baseUrl)}/ws/stream`;
|
||||
}
|
||||
|
||||
private async json<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${path}: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async boot(): Promise<WsBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
const h = await this.json<HealthBody>('/api/health');
|
||||
this.bootInfo = {
|
||||
buildVersion: h.nvsim_version,
|
||||
frameMagic: h.magic,
|
||||
frameBytes: h.frame_bytes,
|
||||
expectedWitnessHex: h.expected_witness_hex,
|
||||
};
|
||||
this.openWs();
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
private openWs(): void {
|
||||
if (this.ws) return;
|
||||
const ws = new WebSocket(this.wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'ok', msg: `ws/stream connected · ${this.wsUrl}` }),
|
||||
);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'warn', msg: 'ws/stream closed' }),
|
||||
);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: `ws/stream error · ${this.wsUrl}` }),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return;
|
||||
const bytes = new Uint8Array(ev.data);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
if (frames.length === 0) return;
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
this.framesEmitted += frames.length;
|
||||
this.fpsCount += frames.length;
|
||||
const now = performance.now();
|
||||
if (now - this.fpsLast >= 1000) {
|
||||
const fps = (this.fpsCount * 1000) / (now - this.fpsLast);
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
this.fpsLast = now;
|
||||
this.fpsCount = 0;
|
||||
}
|
||||
};
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.json('/api/scene', { method: 'PUT', body: JSON.stringify(scene) });
|
||||
}
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.json('/api/config', { method: 'PUT', body: JSON.stringify(cfg) });
|
||||
}
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.json('/api/seed', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ seed_hex: '0x' + seed.toString(16).toUpperCase().padStart(16, '0') }),
|
||||
});
|
||||
}
|
||||
async reset(): Promise<void> {
|
||||
await this.json('/api/reset', { method: 'POST' });
|
||||
this.running = false;
|
||||
this.framesEmitted = 0;
|
||||
this.eventSubs.forEach((s) => s({ type: 'state', running: false, t: 0, framesEmitted: 0 }));
|
||||
}
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.json('/api/run', { method: 'POST' });
|
||||
this.running = true;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'state', running: true, t: 0, framesEmitted: this.framesEmitted }),
|
||||
);
|
||||
}
|
||||
async pause(): Promise<void> {
|
||||
await this.json('/api/pause', { method: 'POST' });
|
||||
this.running = false;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'state', running: false, t: 0, framesEmitted: this.framesEmitted }),
|
||||
);
|
||||
}
|
||||
async step(direction: 'fwd' | 'back', dtMs: number): Promise<void> {
|
||||
await this.json('/api/step', { method: 'POST', body: JSON.stringify({ direction, dt_ms: dtMs }) });
|
||||
}
|
||||
|
||||
onFrames(cb: (b: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (e: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.json<WitnessBody>('/api/witness/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ samples }),
|
||||
});
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) out[i] = parseInt(r.witness_hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const expected_hex = Array.from(expected).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const r = await this.json<VerifyBody>('/api/witness/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expected_hex, samples: 256 }),
|
||||
});
|
||||
if (r.ok) return { ok: true };
|
||||
const actual = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) actual[i] = parseInt(r.actual_hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return { ok: false, actual };
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
const text = await fetch(`${this.baseUrl}/api/export-proof`, { method: 'POST' }).then((r) => r.text());
|
||||
return new Blob([text], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async runTransient(
|
||||
scene: SceneJson,
|
||||
config: PipelineConfigJson,
|
||||
_seed: bigint,
|
||||
samples: number,
|
||||
): Promise<TransientRunResult> {
|
||||
// Server doesn't expose a transient route in V1 — the dashboard's
|
||||
// Ghost Murmur sandbox falls back to the WASM client when transport
|
||||
// is WS. Stub here returns a zero-result so the caller can detect.
|
||||
void scene; void config; void samples;
|
||||
return {
|
||||
bRecoveredT: [0, 0, 0],
|
||||
bMagT: 0,
|
||||
noiseFloorPtSqrtHz: 0,
|
||||
sigmaPt: [0, 0, 0],
|
||||
nFrames: 0,
|
||||
witnessHex: '(transient route not available in WS transport — V1 limitation)',
|
||||
};
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
return `nvsim@${info.buildVersion} (ws)`;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* Web Worker hosting the nvsim WASM module.
|
||||
*
|
||||
* Boots `/nvsim-pkg/nvsim.js`, instantiates `WasmPipeline`, then
|
||||
* postMessage-RPCs with the main thread. Frame batches are returned
|
||||
* as `ArrayBuffer` transfers so we don't pay a copy on the hot path.
|
||||
*
|
||||
* ADR-092 §5.4.
|
||||
*/
|
||||
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
const ws = self as unknown as DedicatedWorkerGlobalScope;
|
||||
|
||||
interface WasmPipelineApi {
|
||||
run(n: number): Uint8Array;
|
||||
runWithWitness(n: number): { frames: Uint8Array; witness: Uint8Array; frameCount: number };
|
||||
free?: () => void;
|
||||
}
|
||||
type WasmPipelineCtor = new (sceneJson: string, configJson: string, seed: number) => WasmPipelineApi;
|
||||
type WasmPipelineStatic = WasmPipelineCtor & {
|
||||
buildVersion(): string;
|
||||
frameMagic(): number;
|
||||
frameBytes(): number;
|
||||
};
|
||||
|
||||
interface TransientResult {
|
||||
bRecoveredT: Float64Array;
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: Float64Array;
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}
|
||||
|
||||
interface NvsimPkg {
|
||||
default: (input?: unknown) => Promise<unknown>;
|
||||
WasmPipeline: WasmPipelineStatic;
|
||||
referenceSceneJson: () => string;
|
||||
expectedReferenceWitnessHex: () => string;
|
||||
hexWitness: (b: Uint8Array) => string;
|
||||
referenceWitness: () => Uint8Array;
|
||||
runTransient: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
}
|
||||
|
||||
let _WasmPipeline!: WasmPipelineStatic;
|
||||
let referenceSceneJson!: () => string;
|
||||
let expectedReferenceWitnessHex!: () => string;
|
||||
let hexWitness!: (b: Uint8Array) => string;
|
||||
let referenceWitness!: () => Uint8Array;
|
||||
let runTransient!: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
|
||||
async function loadPkg(base: string): Promise<void> {
|
||||
// `base` is the dashboard's BASE_URL injected by Vite, prefixed with the
|
||||
// origin so we get an absolute URL the dynamic import can resolve. In dev
|
||||
// this is "/", in prod under GitHub Pages it's "/RuView/nvsim/".
|
||||
const absoluteBase = new URL(base, ws.location.origin).href;
|
||||
const pkgUrl = new URL('nvsim-pkg/nvsim.js', absoluteBase).href;
|
||||
const pkg = (await import(/* @vite-ignore */ pkgUrl)) as NvsimPkg;
|
||||
await pkg.default();
|
||||
_WasmPipeline = pkg.WasmPipeline;
|
||||
referenceSceneJson = pkg.referenceSceneJson;
|
||||
expectedReferenceWitnessHex = pkg.expectedReferenceWitnessHex;
|
||||
hexWitness = pkg.hexWitness;
|
||||
referenceWitness = pkg.referenceWitness;
|
||||
runTransient = pkg.runTransient;
|
||||
}
|
||||
|
||||
let pipeline: WasmPipelineApi | null = null;
|
||||
let configJson = '';
|
||||
let sceneJson = '';
|
||||
let seed = BigInt(0xCAFEBABE);
|
||||
|
||||
let running = false;
|
||||
let timer: number | null = null;
|
||||
let framesEmitted = 0;
|
||||
let tStart = 0;
|
||||
|
||||
function ensureRebuild(): void {
|
||||
if (!sceneJson) sceneJson = referenceSceneJson();
|
||||
if (!configJson) {
|
||||
configJson = JSON.stringify({
|
||||
digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: false,
|
||||
},
|
||||
dt_s: null,
|
||||
});
|
||||
}
|
||||
pipeline?.free?.();
|
||||
pipeline = new _WasmPipeline(sceneJson, configJson, Number(seed & 0xFFFFFFFFn));
|
||||
}
|
||||
|
||||
function post(msg: unknown, transfer: Transferable[] = []): void {
|
||||
// postMessage Transferable overload: pass transfer list as 2nd arg
|
||||
(ws.postMessage as (msg: unknown, t: Transferable[]) => void)(msg, transfer);
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
if (timer !== null) return;
|
||||
tStart = performance.now();
|
||||
framesEmitted = 0;
|
||||
const tick = (): void => {
|
||||
if (!running || !pipeline) return;
|
||||
// Per-tick: simulate 32 frames; push as one batch.
|
||||
const n = 32;
|
||||
const bytes = pipeline.run(n);
|
||||
framesEmitted += n;
|
||||
const elapsed = (performance.now() - tStart) / 1000;
|
||||
const fps = elapsed > 0 ? framesEmitted / elapsed : 0;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: n, fps, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
timer = ws.setTimeout(tick, 16);
|
||||
};
|
||||
timer = ws.setTimeout(tick, 0);
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timer !== null) {
|
||||
ws.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', async (ev: MessageEvent): Promise<void> => {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
try {
|
||||
switch (m.type) {
|
||||
case 'boot': {
|
||||
const base = (m.base as string | undefined) ?? '/';
|
||||
await loadPkg(base);
|
||||
ensureRebuild();
|
||||
post({
|
||||
type: 'booted',
|
||||
id: m.id,
|
||||
buildVersion: _WasmPipeline.buildVersion(),
|
||||
frameMagic: _WasmPipeline.frameMagic(),
|
||||
frameBytes: _WasmPipeline.frameBytes(),
|
||||
expectedWitnessHex: expectedReferenceWitnessHex(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'setScene': {
|
||||
sceneJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setConfig': {
|
||||
configJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setSeed': {
|
||||
seed = BigInt(m.seed as string | number | bigint);
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'reset': {
|
||||
stopTimer();
|
||||
running = false;
|
||||
ensureRebuild();
|
||||
framesEmitted = 0;
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'run': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
running = true;
|
||||
startTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: true, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
running = false;
|
||||
stopTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'step': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const bytes = pipeline!.run(1);
|
||||
framesEmitted += 1;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: 1, fps: 0, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'witnessGenerate': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const samples = (m.samples as number) ?? 256;
|
||||
const result = pipeline!.runWithWitness(samples) as {
|
||||
frames: Uint8Array;
|
||||
witness: Uint8Array;
|
||||
frameCount: number;
|
||||
};
|
||||
const hex = hexWitness(result.witness);
|
||||
post(
|
||||
{
|
||||
type: 'witness',
|
||||
id: m.id,
|
||||
witness: result.witness.buffer,
|
||||
hex,
|
||||
frameCount: result.frameCount,
|
||||
},
|
||||
[result.witness.buffer],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'witnessVerify': {
|
||||
// Verify always runs the *canonical* reference scene at seed=42, N=256
|
||||
// so the witness matches Proof::EXPECTED_WITNESS_HEX byte-for-byte.
|
||||
// The user's working scene/config/seed don't affect the witness.
|
||||
const expectedBuf = m.expected as ArrayBuffer;
|
||||
const expected = new Uint8Array(expectedBuf);
|
||||
const actual = referenceWitness();
|
||||
let ok = actual.length === expected.length;
|
||||
if (ok) {
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (actual[i] !== expected[i]) { ok = false; break; }
|
||||
}
|
||||
}
|
||||
const actualBuf = actual.slice().buffer;
|
||||
post(
|
||||
{
|
||||
type: 'verify',
|
||||
id: m.id,
|
||||
ok,
|
||||
actual: actualBuf,
|
||||
actualHex: hexWitness(actual),
|
||||
},
|
||||
[actualBuf],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'runTransient': {
|
||||
const sceneJson = m.scene as string;
|
||||
const configJson = m.config as string;
|
||||
const seed = (m.seed as number) ?? 0;
|
||||
const samples = (m.samples as number) ?? 64;
|
||||
const r = runTransient(sceneJson, configJson, seed, samples);
|
||||
post({
|
||||
type: 'transient',
|
||||
id: m.id,
|
||||
bRecoveredT: Array.from(r.bRecoveredT),
|
||||
bMagT: r.bMagT,
|
||||
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
|
||||
sigmaPt: Array.from(r.sigmaPt),
|
||||
nFrames: r.nFrames,
|
||||
witnessHex: r.witnessHex,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'buildId': {
|
||||
post({
|
||||
type: 'buildId',
|
||||
id: m.id,
|
||||
buildId: `nvsim@${_WasmPipeline.buildVersion()}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
post({ type: 'err', id: m.id, msg: `unknown op ${m.type}` });
|
||||
}
|
||||
} catch (e) {
|
||||
post({ type: 'err', id: m.id, msg: (e as Error).message ?? String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: 'ready' });
|
||||
Reference in New Issue
Block a user