feat(dashboard): live Ghost Murmur WASM demo + ADR-093 gap analysis
## ADR-093 — dashboard gap analysis (new)
Deep review of the deployed dashboard against ADR-092 §4.2 inventory,
the original mockup at assets/NVsim Dashboard.zip, and live behavior.
Catalogues 21 gaps in 3 priority tiers:
- P0 (10 items): broken/missing functional surface — including the
rail buttons fixed in 4483a88b2 and the Ghost Murmur view.
- P1 (13 items): visible mockup features missing — sim-controls
overlay, scene toolbar, density/motion polish, modal contents.
- P2 (8 items): a11y + polish.
§5 ships a 9-iteration plan (A-I), one P0/P1 item per iteration, with
each iteration ending in build → deploy → agent-browser validation.
## Iteration A: Functional Ghost Murmur demo (P0.4)
The Ghost Murmur view was a static document. Now it ships a "Try it
yourself" section that drives the *real* nvsim Rust pipeline via WASM
when the user moves either slider:
- New `runTransient` export on nvsim WASM — accepts scene_json +
config_json + seed + n_samples, returns recovered |B|, per-axis
sigma, noise floor, frame count, and a SHA-256 witness.
- Threaded through worker.ts → WasmClient → NvsimClient interface.
- Demo UI: distance slider (10 cm → 100 km log scale), heart-dipole
moment slider (10⁻¹⁰ → 10⁻⁶ A·m²), live readout of predicted
|B| (closed-form 1/r³) vs recovered |B| (full pipeline) vs noise
floor, per-tier detectability bars (NV-ensemble lab, COTS DNV-B1,
SQUID, 60 GHz mmWave, WiFi CSI) with verdict pills, and an overall
press-physics-vs-real verdict.
- Transient witness shown so users can see byte-equivalent
determinism per (scene, config, seed) selection.
Validated end-to-end:
- agent-browser drove the slider and ran the demo on localhost
- predicted=501 fT, recovered=2.07 nT (ADC quant-floor at 10 cm with
COTS sensor, exactly the physics the spec teaches), 64 frames,
witness 1834ff374b839ec8…
- per-tier bars correctly show "NV-DNV-B1 6.0e+2× too weak" at 10 cm
with cardiac-strength dipole — vindicates the spec's central thesis
Live at https://ruvnet.github.io/RuView/nvsim/ → Ghost Murmur tab.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
4483a88b22
commit
1c922ed4ab
|
|
@ -10,10 +10,33 @@
|
|||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { getClient, pushLog } from '../store/appStore';
|
||||
import type { TransientRunResult } from '../transport/NvsimClient';
|
||||
|
||||
// Tier detection thresholds — order-of-magnitude floor each transport
|
||||
// can resolve cardiac signal at, in Tesla. Source: Ghost Murmur spec
|
||||
// §4.7, Wolf 2015, Barry 2020. These are deliberately optimistic for the
|
||||
// "available" path; the shoot-the-moon press claim sits 6+ orders below.
|
||||
const TIERS = [
|
||||
{ id: 'nvBest', label: 'NV-ensemble (best lab)', floorT: 1e-12, color: 'oklch(0.78 0.14 70)' },
|
||||
{ id: 'nvCots', label: 'NV-DNV-B1 (COTS)', floorT: 3e-10, color: 'oklch(0.72 0.18 50)' },
|
||||
{ id: 'squid', label: 'SQUID (shielded room)', floorT: 1e-15, color: 'oklch(0.78 0.12 195)' },
|
||||
{ id: 'mmw', label: '60 GHz mmWave (μ-Doppler)', floorT: 0, color: 'oklch(0.78 0.14 145)' },
|
||||
{ id: 'csi', label: 'WiFi CSI (presence)', floorT: 0, color: 'oklch(0.72 0.18 330)' },
|
||||
];
|
||||
|
||||
// Cardiac dipole moment (A·m²) — order-of-magnitude estimate from
|
||||
// Wikswo / Bison cardiac MCG modelling.
|
||||
const HEART_DIPOLE_AM2 = 5e-9;
|
||||
|
||||
@customElement('nv-ghost-murmur')
|
||||
export class NvGhostMurmur extends LitElement {
|
||||
@state() private distanceM = 0.1;
|
||||
@state() private momentLog10 = -8.3; // log10(5e-9)
|
||||
@state() private result: TransientRunResult | null = null;
|
||||
@state() private running = false;
|
||||
@state() private err: string | null = null;
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
|
|
@ -141,8 +164,309 @@ export class NvGhostMurmur extends LitElement {
|
|||
.ethics h3 { color: var(--bad); margin-top: 0; }
|
||||
.ethics ul { padding-left: 18px; margin: 8px 0; }
|
||||
.ethics li { font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; }
|
||||
|
||||
/* Demo */
|
||||
.demo {
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
|
||||
border: 1px solid oklch(0.78 0.14 70 / 0.3);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@media (max-width: 720px) { .demo-grid { grid-template-columns: 1fr; } }
|
||||
.control { margin-bottom: 14px; }
|
||||
.control .top {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 12px; margin-bottom: 6px;
|
||||
}
|
||||
.control .top .lbl { color: var(--ink-3); }
|
||||
.control .top .val {
|
||||
font-family: var(--mono); color: var(--ink);
|
||||
}
|
||||
.control input[type="range"] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 4px;
|
||||
background: var(--bg-3); border-radius: 2px; outline: none;
|
||||
}
|
||||
.control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer;
|
||||
border: 2px solid var(--bg-2);
|
||||
}
|
||||
.demo-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
border-radius: 8px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.demo-btn:hover { filter: brightness(1.08); }
|
||||
.demo-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
.readout {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.readout-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
}
|
||||
.readout-row .l { color: var(--ink-3); }
|
||||
.readout-row .v { color: var(--ink); }
|
||||
.readout-row .v.amber { color: var(--accent); }
|
||||
.tier-bar {
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 22px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tier-bar .fill {
|
||||
position: absolute; top: 0; bottom: 0; left: 0;
|
||||
transition: width 0.2s ease-out;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
.tier-bar .lbl {
|
||||
position: relative; z-index: 1;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
color: var(--ink);
|
||||
display: flex; justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
.verdict {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500;
|
||||
border: 1px solid;
|
||||
}
|
||||
.verdict.ok { background: oklch(0.78 0.14 145 / 0.08); border-color: oklch(0.78 0.14 145 / 0.4); color: var(--ok); }
|
||||
.verdict.warn { background: oklch(0.7 0.18 35 / 0.08); border-color: oklch(0.7 0.18 35 / 0.4); color: var(--warn); }
|
||||
.verdict.bad { background: oklch(0.65 0.22 25 / 0.08); border-color: oklch(0.65 0.22 25 / 0.4); color: var(--bad); }
|
||||
.demo-notes {
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
margin-top: 10px; line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Predicted MCG dipole field (Tesla) at distance r in metres.
|
||||
* Far-field approximation: |B| ≈ μ₀ · m / (4π · r³). Source: Jackson 3e §5.
|
||||
*/
|
||||
private predictedDipoleFieldT(r: number, m: number): number {
|
||||
const MU_0 = 4 * Math.PI * 1e-7;
|
||||
return (MU_0 * m) / (4 * Math.PI * Math.pow(Math.max(r, 1e-6), 3));
|
||||
}
|
||||
|
||||
private async runDemo(): Promise<void> {
|
||||
const c = getClient();
|
||||
if (!c) { this.err = 'WASM client not ready'; return; }
|
||||
this.err = null;
|
||||
this.running = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const r = this.distanceM;
|
||||
const m = Math.pow(10, this.momentLog10);
|
||||
// Heart proxy at +z = r, dipole moment along z = m A·m².
|
||||
const scene = {
|
||||
dipoles: [{ position: [0, 0, r] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
|
||||
loops: [],
|
||||
ferrous: [],
|
||||
eddy: [],
|
||||
sensors: [[0, 0, 0] as [number, number, number]],
|
||||
ambient_field: [0, 0, 0] as [number, number, number],
|
||||
};
|
||||
const config = {
|
||||
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,
|
||||
};
|
||||
this.result = await c.runTransient(scene, config, 42n, 64);
|
||||
pushLog('ok', `ghost-demo · r=${r.toFixed(3)} m · |B| recovered = ${(this.result.bMagT * 1e12).toExponential(2)} pT`);
|
||||
} catch (e) {
|
||||
this.err = (e as Error).message;
|
||||
pushLog('err', `ghost-demo failed: ${this.err}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private formatField(t: number): string {
|
||||
if (t === 0) return '0 T';
|
||||
const abs = Math.abs(t);
|
||||
if (abs >= 1e-3) return `${(t * 1e3).toFixed(2)} mT`;
|
||||
if (abs >= 1e-6) return `${(t * 1e6).toFixed(2)} µT`;
|
||||
if (abs >= 1e-9) return `${(t * 1e9).toFixed(3)} nT`;
|
||||
if (abs >= 1e-12) return `${(t * 1e12).toFixed(2)} pT`;
|
||||
if (abs >= 1e-15) return `${(t * 1e15).toFixed(2)} fT`;
|
||||
if (abs >= 1e-18) return `${(t * 1e18).toFixed(2)} aT`;
|
||||
return `${t.toExponential(2)} T`;
|
||||
}
|
||||
|
||||
private formatDistance(r: number): string {
|
||||
if (r < 1) return `${(r * 100).toFixed(1)} cm`;
|
||||
if (r < 1000) return `${r.toFixed(2)} m`;
|
||||
if (r < 1e5) return `${(r / 1000).toFixed(2)} km`;
|
||||
return `${(r / 1609).toFixed(0)} mi`;
|
||||
}
|
||||
|
||||
private renderDemo() {
|
||||
const m = Math.pow(10, this.momentLog10);
|
||||
const predicted = this.predictedDipoleFieldT(this.distanceM, m);
|
||||
const recovered = this.result?.bMagT ?? 0;
|
||||
const noiseFloor = (this.result?.noiseFloorPtSqrtHz ?? 0) * 1e-12; // pT/√Hz → T/√Hz
|
||||
|
||||
const verdictPills = TIERS.map((t) => {
|
||||
let detect: 'ok' | 'warn' | 'bad' = 'bad';
|
||||
let label = 'below floor';
|
||||
if (t.id === 'mmw') {
|
||||
if (this.distanceM <= 5) { detect = 'ok'; label = 'µ-Doppler @ chest'; }
|
||||
else if (this.distanceM <= 15) { detect = 'warn'; label = 'edge of range'; }
|
||||
else { detect = 'bad'; label = 'out of range'; }
|
||||
} else if (t.id === 'csi') {
|
||||
if (this.distanceM <= 30) { detect = this.distanceM <= 10 ? 'ok' : 'warn'; label = 'presence/breathing'; }
|
||||
else { detect = 'bad'; label = 'out of range'; }
|
||||
} else if (t.floorT > 0) {
|
||||
const ratio = predicted / t.floorT;
|
||||
if (ratio > 100) { detect = 'ok'; label = `${ratio.toExponential(1)}× floor`; }
|
||||
else if (ratio > 1) { detect = 'warn'; label = `${ratio.toFixed(1)}× floor`; }
|
||||
else { detect = 'bad'; label = `${(1 / ratio).toExponential(1)}× too weak`; }
|
||||
}
|
||||
const fillPct = t.floorT > 0
|
||||
? Math.max(2, Math.min(100, 100 + 12 * Math.log10(predicted / t.floorT)))
|
||||
: (t.id === 'mmw' ? Math.max(2, 100 - this.distanceM * 7) : Math.max(2, 100 - this.distanceM * 2));
|
||||
return html`
|
||||
<div class="tier-bar" data-tier=${t.id}>
|
||||
<div class="fill" style=${`width:${fillPct}%; background:${t.color}; border-color:${t.color}`}></div>
|
||||
<div class="lbl">
|
||||
<span>${t.label}</span>
|
||||
<span class="verdict-${detect}" style=${`color:${detect === 'ok' ? 'var(--ok)' : detect === 'warn' ? 'var(--warn)' : 'var(--bad)'}`}>${label}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
const overallDetect: 'ok' | 'warn' | 'bad' =
|
||||
predicted > 1e-12 ? 'ok' : predicted > 1e-15 ? 'warn' : 'bad';
|
||||
const overallText =
|
||||
overallDetect === 'ok'
|
||||
? `Above NV-ensemble lab floor — close-range MCG plausible at ${this.formatDistance(this.distanceM)}.`
|
||||
: overallDetect === 'warn'
|
||||
? `Below NV ensemble best, above SQUID — research-grade only at ${this.formatDistance(this.distanceM)}.`
|
||||
: `Below every published instrument's noise floor at ${this.formatDistance(this.distanceM)}. Press-release physics.`;
|
||||
|
||||
return html`
|
||||
<div class="demo">
|
||||
<h3 style="margin: 0 0 6px;">Try it yourself</h3>
|
||||
<div style="font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; line-height: 1.5;">
|
||||
Place a cardiac dipole at variable distance from the NV sensor. The
|
||||
dashboard runs the <i>real</i> nvsim Rust pipeline (compiled to WASM)
|
||||
end-to-end and reports what each tier would actually detect. Same
|
||||
determinism contract as the rest of the dashboard.
|
||||
</div>
|
||||
<div class="demo-grid">
|
||||
<div>
|
||||
<div class="control">
|
||||
<div class="top">
|
||||
<span class="lbl">Distance from sensor</span>
|
||||
<span class="val" id="demo-dist-val">${this.formatDistance(this.distanceM)}</span>
|
||||
</div>
|
||||
<input type="range" id="demo-distance"
|
||||
min="-2" max="5" step="0.05"
|
||||
.value=${String(Math.log10(this.distanceM))}
|
||||
@input=${(e: Event) => { this.distanceM = Math.pow(10, +(e.target as HTMLInputElement).value); }} />
|
||||
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
|
||||
10 cm → 100 km log scale
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="top">
|
||||
<span class="lbl">Heart dipole moment</span>
|
||||
<span class="val" id="demo-moment-val">${m.toExponential(2)} A·m²</span>
|
||||
</div>
|
||||
<input type="range" id="demo-moment"
|
||||
min="-10" max="-6" step="0.05"
|
||||
.value=${String(this.momentLog10)}
|
||||
@input=${(e: Event) => { this.momentLog10 = +(e.target as HTMLInputElement).value; }} />
|
||||
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
|
||||
published cardiac MCG ≈ 5×10⁻⁹ A·m²
|
||||
</div>
|
||||
</div>
|
||||
<button class="demo-btn" id="demo-run-btn" ?disabled=${this.running}
|
||||
@click=${() => this.runDemo()}>
|
||||
${this.running ? 'Running nvsim…' : '▶ Run nvsim at this distance'}
|
||||
</button>
|
||||
${this.err ? html`<div class="verdict bad" style="margin-top: 10px;">Error: ${this.err}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="readout">
|
||||
<div class="readout-row">
|
||||
<span class="l">Predicted |B| (1/r³)</span>
|
||||
<span class="v amber" id="demo-predicted">${this.formatField(predicted)}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Recovered |B| (nvsim)</span>
|
||||
<span class="v" id="demo-recovered">${this.result ? this.formatField(recovered) : '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Sensor noise floor</span>
|
||||
<span class="v" id="demo-floor">${this.result ? this.formatField(noiseFloor) + '/√Hz' : '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Frames run</span>
|
||||
<span class="v" id="demo-frames">${this.result?.nFrames ?? '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Witness (this run)</span>
|
||||
<span class="v" style="font-size: 10px;" id="demo-witness">${this.result?.witnessHex.slice(0, 16) ?? '—'}…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px;">
|
||||
<div style="font-size: 11.5px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;">
|
||||
Per-tier detectability
|
||||
</div>
|
||||
${verdictPills}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verdict ${overallDetect}" id="demo-verdict">${overallText}</div>
|
||||
<div class="demo-notes">
|
||||
The <code>predicted</code> value uses the closed-form magnetic-dipole
|
||||
far field <code>|B| = μ₀·m / (4π·r³)</code>. The <code>recovered</code>
|
||||
value comes from the same Rust pipeline that drives the Witness panel —
|
||||
scene → Biot-Savart → NV ensemble → ADC → MagFrame. Use the moment
|
||||
slider to ask "what if the heart were stronger?". Use the distance
|
||||
slider to walk through 10 cm (clinical MCG), 1 m (close approach),
|
||||
10 m (room-scale), 1 km (skeptic's range), and 65 km (the press claim).
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<h1>Ghost Murmur — open-source reality check</h1>
|
||||
|
|
@ -186,6 +510,9 @@ export class NvGhostMurmur extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Live demo — nvsim WASM</h2>
|
||||
${this.renderDemo()}
|
||||
|
||||
<h2>Physics reality check</h2>
|
||||
<div class="card" style="padding: 6px 14px;">
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@ export interface PipelineConfigJson {
|
|||
digitiser?: {
|
||||
f_s_hz: number;
|
||||
f_mod_hz: number;
|
||||
lp_cutoff_hz: number;
|
||||
lp_cutoff_hz?: number;
|
||||
};
|
||||
sensor?: {
|
||||
n_centers: number;
|
||||
contrast: number;
|
||||
t2_star_s: number;
|
||||
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;
|
||||
|
|
@ -63,6 +67,17 @@ export type NvsimEvent =
|
|||
|
||||
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>;
|
||||
|
|
@ -78,6 +93,7 @@ export interface NvsimClient {
|
|||
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>;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
|
|
@ -153,6 +154,36 @@ export class WasmClient implements NvsimClient {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@ type WasmPipelineStatic = WasmPipelineCtor & {
|
|||
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;
|
||||
|
|
@ -30,6 +39,7 @@ interface NvsimPkg {
|
|||
expectedReferenceWitnessHex: () => string;
|
||||
hexWitness: (b: Uint8Array) => string;
|
||||
referenceWitness: () => Uint8Array;
|
||||
runTransient: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
}
|
||||
|
||||
let _WasmPipeline!: WasmPipelineStatic;
|
||||
|
|
@ -37,6 +47,7 @@ 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
|
||||
|
|
@ -51,6 +62,7 @@ async function loadPkg(base: string): Promise<void> {
|
|||
expectedReferenceWitnessHex = pkg.expectedReferenceWitnessHex;
|
||||
hexWitness = pkg.hexWitness;
|
||||
referenceWitness = pkg.referenceWitness;
|
||||
runTransient = pkg.runTransient;
|
||||
}
|
||||
|
||||
let pipeline: WasmPipelineApi | null = null;
|
||||
|
|
@ -235,6 +247,24 @@ ws.addEventListener('message', async (ev: MessageEvent): Promise<void> => {
|
|||
);
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
# ADR-093: nvsim Dashboard Gap Analysis (post-deploy review)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Status** | Proposed — implementation in progress on `feat/nvsim-pipeline-simulator`. |
|
||||
| **Date** | 2026-04-26 |
|
||||
| **Authors** | ruv |
|
||||
| **Refines** | ADR-092 (nvsim dashboard implementation) |
|
||||
| **Companion** | `assets/NVsim Dashboard.zip` (mockup, ~4200 LOC), live deploy https://ruvnet.github.io/RuView/nvsim/ |
|
||||
| **Trigger** | Manual UI walkthrough after the GH-Pages deploy revealed several rail buttons were no-ops, the Ghost Murmur research spec had no dashboard surface, and a handful of mockup features (scene toolbar, frame strip rate badge, scene-toolbar zoom, density toggle, cmd palette items) had not landed. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Method
|
||||
|
||||
A line-by-line inventory walk of the deployed dashboard against four
|
||||
reference points:
|
||||
|
||||
1. **The mockup**: `assets/NVsim Dashboard.zip` → `NVSim Dashboard.html`.
|
||||
Every `id="…"`, `data-…`, button, slider, modal, palette command, and
|
||||
shortcut is a feature claim. We diff it against the live SPA.
|
||||
2. **ADR-092 §4.2** — the canonical inventory table of 12 zones and ~50
|
||||
components. We mark each row as ✅ shipped / ⚠ partial / ❌ missing.
|
||||
3. **ADR-092 §4.3** — REPL command set (10 commands).
|
||||
4. **ADR-092 §4.4** — keyboard shortcuts (11 chords).
|
||||
|
||||
Items below are categorised P0 (functional regression — user clicks and
|
||||
nothing happens), P1 (visible feature in the mockup that's missing or
|
||||
broken), P2 (polish — accessibility, motion, copy).
|
||||
|
||||
The closing §5 is the iteration plan.
|
||||
|
||||
---
|
||||
|
||||
## 2. P0 — broken/missing functional surface
|
||||
|
||||
| # | Gap | Location | Root cause | Fix |
|
||||
|---|---|---|---|---|
|
||||
| **P0.1** | ~~Inspector rail button no-op~~ | `nv-rail.ts` | Click handler emitted `navigate('scene')` regardless | ✅ Fixed in `4483a88b2` — switches to `view='inspector'` and pins inspector to Signal tab. |
|
||||
| **P0.2** | ~~Witness rail button no-op~~ | `nv-rail.ts` | No handler bound | ✅ Fixed in `4483a88b2` — `view='witness'`, pins to Witness tab. |
|
||||
| **P0.3** | ~~No Ghost Murmur view despite shipping research spec~~ | rail / app | Research spec at `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md` had no dashboard surface | ✅ Fixed in `4483a88b2` — new `<nv-ghost-murmur>` component, dedicated rail icon. |
|
||||
| **P0.4** | Ghost Murmur view is **read-only** | `nv-ghost-murmur.ts` | Currently a static document. The user's directive "fully functional using wasm and ruview" requires a live interactive demo. | ⏳ §5 below — interactive distance/moment sliders that actually drive `nvsim::Pipeline` via WASM and report per-tier detectability. |
|
||||
| **P0.5** | Topbar `seed` pill is decorative | `nv-topbar.ts` | Pill renders but click does nothing — should open the seed-set modal | ⏳ Wire to `openModal({ title: 'Set seed', … })`. |
|
||||
| **P0.6** | Sim controls overlay (`step ⏮ play ⏯ step ⏭ + speed`) absent in scene | `nv-scene.ts` | Mockup ships `.sim-controls` floating widget; not ported | ⏳ Add as a corner overlay in `<nv-scene>`. |
|
||||
| **P0.7** | Scene toolbar (zoom / fit / layers) missing | `nv-scene.ts` | Mockup ships `.scene-toolbar` top-left; not ported | ⏳ Implement zoom (SVG viewBox scale), fit-to-view, layer-toggle for each source class. |
|
||||
| **P0.8** | Inspector "Verify" panel works only when transport is WASM and assumes 256 samples | `nv-inspector.ts`, `WasmClient.ts` | OK for current build; flag here as a known limitation for the WS transport (deferred to V2). | Document — not a fix. |
|
||||
| **P0.9** | REPL `proof.export` command not implemented | `nv-console.ts` | Mockup has `proof.export` returning a downloadable bundle | ⏳ Wire to `client.exportProofBundle()` and trigger blob download. |
|
||||
| **P0.10** | REPL command history is per-component, lost on view switch | `nv-console.ts` | `history` is instance-private | ⏳ Move to `appStore` so it survives view changes (low impact but expected). |
|
||||
|
||||
## 3. P1 — visible mockup features missing
|
||||
|
||||
| # | Gap | Location | Notes |
|
||||
|---|---|---|---|
|
||||
| **P1.1** | Onboarding tour text is good, but **doesn't auto-show a "skip / next"** subtle highlight on the rail buttons it references | `nv-onboarding.ts` | Mockup uses spotlight cutouts. Ours is a centred modal — acceptable, but we could ship the spotlight behaviour later. |
|
||||
| **P1.2** | Density toggle in Settings drawer doesn't visibly change anything | `app.css` + `nv-settings-drawer` | CSS has `body.density-comfy/default/compact` rules but the application code only modifies `body.style.fontSize`. Wire the body class through `appStore.density` properly. |
|
||||
| **P1.3** | `motion-toggle` only flips `body.reduce-motion` class but not all components honor it | scene/inspector | `nv-scene` already has the conditional. Verify B-trace and frame-strip animations stop too. |
|
||||
| **P1.4** | Scene "stat-card" SNR readout is always `—` | `nv-scene.ts` | We never compute SNR from frames. Compute as |b| / max(σ_per_axis) and surface. |
|
||||
| **P1.5** | Inspector `frame-strip-2` from the Frame tab not in our impl | `nv-inspector.ts` | Mockup has a second sparkline strip in the Frame tab; we only ship one. Replicate. |
|
||||
| **P1.6** | Modals: New Scene + Export Proof + About defined in palette but body content is short | `nv-palette.ts` | Mockup's New-Scene dialog ships a full form (sources count, ferrous toggle, etc.). Ours is a placeholder. Implement form. |
|
||||
| **P1.7** | Scene drag persistence | `nv-scene.ts` | Mockup persists drag positions via IndexedDB. Add. |
|
||||
| **P1.8** | Sidebar Tunables sliders don't actually update the running pipeline | `nv-sidebar.ts` + `WasmClient.ts` | Slider changes the signal but worker isn't told to rebuild the pipeline with new fs/fmod. Wire `setConfig()` debounced. |
|
||||
| **P1.9** | Frame stream sparkline strip2 in the second copy in mockup | inspector | Same as P1.5 — verify. |
|
||||
| **P1.10** | "WASM" pill in topbar should show actual transport (`wasm` / `ws`); clicking should let user toggle | `nv-topbar.ts` | Pill is read-only. Make it a toggle that opens the Settings drawer at the Transport section. |
|
||||
| **P1.11** | `prefers-reduced-motion` system preference not auto-detected | `main.ts` | Read once at boot, default `motionReduced` to `true` when set. |
|
||||
| **P1.12** | Scene 3D-tilt on pointer move not ported | `nv-scene.ts` | Mockup has `.tilt-stage` perspective transform. Optional polish. |
|
||||
| **P1.13** | View-overlay "expand panel" not ported | global | Mockup has a `.view-overlay` that expands any inspector panel to full-screen. Defer V2. |
|
||||
|
||||
## 4. P2 — accessibility / polish
|
||||
|
||||
| # | Gap | Notes |
|
||||
|---|---|---|
|
||||
| **P2.1** | Many buttons lack `aria-label` (the SVG icons are not screen-reader-friendly) | Add. |
|
||||
| **P2.2** | Console log lines are text-only; `<div role="log" aria-live="polite">` recommended. | Add. |
|
||||
| **P2.3** | Modal focus trap not implemented — Tab leaks to background | Add a small focus-trap to `nv-modal` and `nv-onboarding`. |
|
||||
| **P2.4** | Color contrast on `.ink-3` light theme borderline for AA | Tweak palette. |
|
||||
| **P2.5** | No skip-to-main-content link | Add. |
|
||||
| **P2.6** | Keyboard navigation through scene draggable sources via arrow keys | Add. |
|
||||
| **P2.7** | Service worker doesn't have `clients.claim()` | Confirm. Ensures new SW activates on next nav. |
|
||||
| **P2.8** | PWA install prompt is silent | Add an install button (visible only when `beforeinstallprompt` fires). |
|
||||
|
||||
## 5. Iteration plan
|
||||
|
||||
The dynamic /loop continues with one P0/P1 item per iteration:
|
||||
|
||||
| Iter | Focus | Deliverable |
|
||||
|---|---|---|
|
||||
| **A** *(this turn)* | Functional Ghost Murmur demo (P0.4) | `WasmClient.runTransient(scene, n)` + interactive distance slider + per-tier detectability |
|
||||
| **B** | Scene sim-controls + toolbar (P0.6, P0.7) | Floating sim-controls bottom-right of scene, zoom/fit/layer toolbar top-left |
|
||||
| **C** | Topbar seed pill + WASM pill clicks (P0.5, P1.10) | Seed modal + transport toggle |
|
||||
| **D** | Sidebar tunables wire-through (P1.8) | Debounced `setConfig` RPC propagates to pipeline |
|
||||
| **E** | REPL `proof.export` + history persistence (P0.9, P0.10) | Blob download + appStore history |
|
||||
| **F** | SNR computation + reduce-motion audit (P1.4, P1.11, P1.3) | Live SNR, system-pref auto-detect |
|
||||
| **G** | Modal contents (P1.6) | New-Scene form with real Scene JSON output |
|
||||
| **H** | A11y pass (P2.1–P2.6) | aria-labels, focus traps, skip link |
|
||||
| **I** | Density toggle visual (P1.2), drag persistence (P1.7) | Polish |
|
||||
|
||||
Each iteration ends with: `npx tsc --noEmit` clean → production
|
||||
build with `NVSIM_BASE=/RuView/nvsim/` → push to `gh-pages/nvsim/`
|
||||
preserving siblings → `agent-browser` validation including console
|
||||
errors → commit on `feat/nvsim-pipeline-simulator`.
|
||||
|
||||
The acceptance criteria from ADR-092 §11 still apply unchanged. This
|
||||
ADR augments §11 rather than replacing it — every P0 item is a
|
||||
prerequisite for declaring §11.1 (faithful UI) green.
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-092 §4.2 — full UI inventory table (the contract).
|
||||
- ADR-092 §11 — 12 acceptance gates.
|
||||
- `assets/NVsim Dashboard.zip` — canonical mockup (committed).
|
||||
- `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md` — Ghost Murmur source material.
|
||||
- Live deploy — https://ruvnet.github.io/RuView/nvsim/ (verified: rail buttons functional, witness verifies, App Store catalog renders, onboarding tour works).
|
||||
|
|
@ -158,3 +158,78 @@ pub fn reference_witness() -> Result<js_sys::Uint8Array, JsValue> {
|
|||
arr.copy_from(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// One-shot pipeline run that doesn't disturb the dashboard's main
|
||||
/// pipeline. Used by the Ghost Murmur interactive demo (and any other
|
||||
/// "run-against-this-scene-please" flow) to ask: given a scene + config,
|
||||
/// what does the NV sensor recover at the origin?
|
||||
///
|
||||
/// Returns a JS object:
|
||||
/// ```js
|
||||
/// {
|
||||
/// bRecoveredT: [number, number, number], // recovered B (Tesla)
|
||||
/// bMagT: number, // |B| (Tesla)
|
||||
/// noiseFloorPtSqrtHz: number, // δB pT/√Hz from this config
|
||||
/// sigmaPt: [number, number, number], // per-axis 1σ noise estimate (pT)
|
||||
/// nFrames: number, // samples actually run
|
||||
/// witnessHex: string // SHA-256 witness for this run
|
||||
/// }
|
||||
/// ```
|
||||
#[wasm_bindgen(js_name = runTransient)]
|
||||
pub fn run_transient(
|
||||
scene_json: &str,
|
||||
config_json: &str,
|
||||
seed: f64,
|
||||
n_samples: usize,
|
||||
) -> Result<JsValue, JsValue> {
|
||||
let scene: crate::scene::Scene =
|
||||
serde_json::from_str(scene_json).map_err(|e| js_err(format!("scene parse: {e}")))?;
|
||||
let config: crate::pipeline::PipelineConfig = serde_json::from_str(config_json)
|
||||
.map_err(|e| js_err(format!("config parse: {e}")))?;
|
||||
let pipeline = crate::pipeline::Pipeline::new(scene, config, seed as u64);
|
||||
let (frames, witness) = pipeline.run_with_witness(n_samples);
|
||||
|
||||
// Average the recovered b_pt / sigma over the run for a stable point estimate.
|
||||
let mut sum_b = [0.0_f64; 3];
|
||||
let mut sum_s = [0.0_f64; 3];
|
||||
let mut sum_nf = 0.0_f64;
|
||||
let n = frames.len().max(1) as f64;
|
||||
for f in &frames {
|
||||
for k in 0..3 {
|
||||
sum_b[k] += f.b_pt[k] as f64;
|
||||
sum_s[k] += f.sigma_pt[k] as f64;
|
||||
}
|
||||
sum_nf += f.noise_floor_pt_sqrt_hz as f64;
|
||||
}
|
||||
let avg_b_pt = [sum_b[0] / n, sum_b[1] / n, sum_b[2] / n];
|
||||
let avg_s_pt = [sum_s[0] / n, sum_s[1] / n, sum_s[2] / n];
|
||||
let avg_nf = sum_nf / n;
|
||||
let b_t = [
|
||||
avg_b_pt[0] * 1.0e-12,
|
||||
avg_b_pt[1] * 1.0e-12,
|
||||
avg_b_pt[2] * 1.0e-12,
|
||||
];
|
||||
let bmag_t = (b_t[0] * b_t[0] + b_t[1] * b_t[1] + b_t[2] * b_t[2]).sqrt();
|
||||
|
||||
let obj = js_sys::Object::new();
|
||||
let b_arr = js_sys::Float64Array::new_with_length(3);
|
||||
b_arr.copy_from(&b_t);
|
||||
let s_arr = js_sys::Float64Array::new_with_length(3);
|
||||
s_arr.copy_from(&avg_s_pt);
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("bRecoveredT"), &b_arr)?;
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("bMagT"), &JsValue::from_f64(bmag_t))?;
|
||||
js_sys::Reflect::set(
|
||||
&obj,
|
||||
&JsValue::from_str("noiseFloorPtSqrtHz"),
|
||||
&JsValue::from_f64(avg_nf),
|
||||
)?;
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("sigmaPt"), &s_arr)?;
|
||||
js_sys::Reflect::set(
|
||||
&obj,
|
||||
&JsValue::from_str("nFrames"),
|
||||
&JsValue::from_f64(frames.len() as f64),
|
||||
)?;
|
||||
let witness_hex = crate::proof::Proof::hex(&witness);
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("witnessHex"), &JsValue::from_str(&witness_hex))?;
|
||||
Ok(obj.into())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue