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:
ruv
2026-04-26 21:21:27 -04:00
parent 4483a88b22
commit 1c922ed4ab
6 changed files with 596 additions and 5 deletions
+328 -1
View File
@@ -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>
+20 -4
View File
@@ -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>;
+31
View File
@@ -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.
+30
View File
@@ -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',