diff --git a/dashboard/src/components/nv-app-store.ts b/dashboard/src/components/nv-app-store.ts index 9c02dcd6..f6b90f9b 100644 --- a/dashboard/src/components/nv-app-store.ts +++ b/dashboard/src/components/nv-app-store.ts @@ -18,7 +18,8 @@ import { type AppCategory, type AppManifest, type AppActivation, } from '../store/apps'; import { kvGet, kvSet } from '../store/persistence'; -import { pushLog } from '../store/appStore'; +import { pushLog, activeAppIds, appEvents, appEventCounts } from '../store/appStore'; +import { hasRuntime } from '../store/appRuntimes'; const activations = signal(defaultActivations()); const query = signal(''); @@ -31,9 +32,13 @@ const statusFilter = signal<'all' | 'available' | 'beta' | 'research'>('all'); })(); effect(() => { - // Persist activations on change (post-load). + // Persist activations on change (post-load) AND mirror into the + // active-set signal that main.ts watches to drive runtime dispatch. const v = activations.value; if (v.length > 0) void kvSet('app-activations', v); + const set = new Set(); + for (const a of v) if (a.active) set.add(a.id); + activeAppIds.value = set; }); @customElement('nv-app-store') @@ -142,6 +147,53 @@ export class NvAppStore extends LitElement { .badge.status-beta { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); } .badge.status-research { color: var(--accent-3); border-color: oklch(0.72 0.18 330 / 0.4); } .badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); } + .badge.rt-running { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.5); background: oklch(0.78 0.14 145 / 0.08); } + .badge.rt-simulated { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.5); background: oklch(0.78 0.14 70 / 0.08); } + .badge.rt-mesh-only { color: var(--ink-3); border-color: var(--line); } + .events-feed { + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 18px; + } + .events-feed h3 { + margin: 0 0 8px; + font-size: 13px; font-weight: 600; + color: var(--ink); + } + .events-feed .lead { + font-size: 12px; color: var(--ink-3); + margin: 0 0 10px; + line-height: 1.5; + } + .events-feed .lines { + display: flex; flex-direction: column; gap: 4px; + max-height: 160px; overflow-y: auto; + } + .ev-line { + display: grid; + grid-template-columns: 60px 90px 1fr; + gap: 10px; + padding: 4px 6px; + border-radius: 4px; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-2); + } + .ev-line:hover { background: var(--bg-3); } + .ev-line .ts { color: var(--ink-4); font-size: 10.5px; } + .ev-line .id { color: var(--accent); font-size: 10.5px; } + .ev-line .body { color: var(--ink); } + .ev-empty { + font-size: 12px; color: var(--ink-3); + padding: 8px 0; + } + .card-events-count { + font-size: 10.5px; + color: var(--accent-4); + font-family: var(--mono); + } .card-foot { display: flex; align-items: center; gap: 8px; padding-top: 8px; margin-top: 4px; @@ -178,7 +230,11 @@ export class NvAppStore extends LitElement { override connectedCallback(): void { super.connectedCallback(); - effect(() => { activations.value; query.value; activeCat.value; statusFilter.value; this.renderTick++; }); + effect(() => { + activations.value; query.value; activeCat.value; statusFilter.value; + appEvents.value; appEventCounts.value; + this.renderTick++; + }); } private isActive(id: string): boolean { @@ -186,9 +242,18 @@ export class NvAppStore extends LitElement { } private toggle(app: AppManifest): void { + const wasActive = this.isActive(app.id); const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a); activations.value = next; - pushLog(this.isActive(app.id) ? 'ok' : 'info', `app ${app.id} deactivated`); + if (!wasActive) { + const r = app.runtime ?? 'mesh-only'; + const note = r === 'simulated' ? ' · live runtime engaged' + : r === 'mesh-only' ? ' · queued (needs ESP32 mesh)' + : ''; + pushLog('ok', `app ${app.id} activated${note}`); + } else { + pushLog('info', `app ${app.id} deactivated`); + } } private filtered(): AppManifest[] { @@ -247,15 +312,64 @@ export class NvAppStore extends LitElement { statusFilter.value = 'research'}>research + ${this.renderEventsFeed()} + ${list.length === 0 ? html`
No apps match the current filters.
` : html`
${list.map((app) => this.card(app))}
`} `; } + private renderEventsFeed() { + const evs = appEvents.value.slice(-12).reverse(); + const activeSimCount = activations.value.filter((a) => a.active && hasRuntime(a.id)).length; + return html` +
+

Live runtime feed + ${activeSimCount > 0 + ? html`${activeSimCount} simulated app${activeSimCount === 1 ? '' : 's'} active` + : ''} +

+

+ Apps with the simulated + runtime emit real i32 event IDs against nvsim's live frame stream below. + Apps with mesh-only + need an ESP32-S3 + WS transport (deferred to V2). The + running + badge marks nvsim itself, which is always running. +

+ ${evs.length === 0 + ? html`
No events yet. Toggle a card with the simulated badge and press ▶ Run.
` + : html`
${evs.map((ev) => { + const dt = new Date(ev.ts); + const ts = `${String(dt.getSeconds()).padStart(2, '0')}.${String(dt.getMilliseconds()).padStart(3, '0')}`; + return html` +
+ ${ts} + ${ev.appId} + ${ev.eventName} · ${ev.eventId} ${ev.detail ? `· ${ev.detail}` : ''} +
+ `; + })}
`} +
+ `; + } + private card(app: AppManifest) { const active = this.isActive(app.id); const cat = CATEGORIES[app.category]; + const runtime = app.runtime ?? 'mesh-only'; + const evCount = appEventCounts.value[app.id] ?? 0; + const runtimeLabel: Record = { + 'running': 'running', + 'simulated': 'simulated', + 'mesh-only': 'needs mesh', + }; + const runtimeTip: Record = { + 'running': 'This app is genuinely running in your browser right now.', + 'simulated': 'A pared-down version of this algorithm runs against nvsim\'s magnetic frame stream as a proxy for its native CSI input. Toggle on, then press ▶ Run to see real event IDs in the feed.', + 'mesh-only': 'This algorithm needs CSI subcarrier data from an ESP32-S3 mesh. The toggle persists; activation is pushed via WS transport (V2).', + }; return html`
@@ -266,12 +380,14 @@ export class NvAppStore extends LitElement {
${cat.label} ${app.status} + ${runtimeLabel[runtime]} ${app.budget ? html`budget ${app.budget}` : ''} ${app.adr ? html`${app.adr}` : ''} ${app.events?.length ? html`events ${app.events.join('·')}` : ''}
${app.crate} + ${evCount > 0 ? html`⚡ ${evCount} ev` : ''} > = {}; + const bMagHistory: number[] = []; + const runtimeStartTs = performance.now(); + client.onFrames((batch) => { if (batch.frames.length === 0) return; const last = batch.frames[batch.frames.length - 1]; @@ -67,11 +74,44 @@ function applyMotion(reduced: boolean): void { const by = last.bPt[1] * 1e-12; const bz = last.bPt[2] * 1e-12; lastB.value = [bx, by, bz]; - bMag.value = Math.sqrt(bx * bx + by * by + bz * bz); - // For trace display we use nT scale. + const bmagT = Math.sqrt(bx * bx + by * by + bz * bz); + bMag.value = bmagT; pushTrace([bx * 1e9, by * 1e9, bz * 1e9]); const amp = Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3); pushStripBar(amp); + + bMagHistory.push(bmagT); + while (bMagHistory.length > 256) bMagHistory.shift(); + + // Dispatch the frame to every active simulated app runtime. + const activeIds = activeAppIds.value; + if (activeIds.size === 0) return; + const elapsedS = (performance.now() - runtimeStartTs) / 1000; + for (const id of activeIds) { + const fn = APP_RUNTIMES[id]; + if (!fn) continue; // mesh-only apps — toggle persists, no in-browser runtime + if (!appState[id]) appState[id] = {}; + const ctx: AppRuntimeContext = { + frame: last, + bMagT: bmagT, + bRecoveredT: [bx, by, bz], + bHistory: bMagHistory, + elapsedS, + state: appState[id], + }; + try { + const result = fn(ctx); + if (!result) continue; + const evs = Array.isArray(result) ? result : [result]; + for (const ev of evs) { + pushAppEvent(ev); + pushLog('info', + `[${ev.appId}] ${ev.eventName} (${ev.eventId})${ev.detail ? ' · ' + ev.detail : ''}`); + } + } catch (e) { + pushLog('warn', `[${id}] runtime error: ${(e as Error).message}`); + } + } }); try { diff --git a/dashboard/src/store/appRuntimes.ts b/dashboard/src/store/appRuntimes.ts new file mode 100644 index 00000000..2ecb7444 --- /dev/null +++ b/dashboard/src/store/appRuntimes.ts @@ -0,0 +1,236 @@ +/* In-browser simulated runtimes for App Store apps. + * + * Each runtime takes the most recent nvsim MagFrame + a short rolling + * history and decides whether to emit one or more app events. Outputs are + * illustrative: nvsim produces magnetic-field samples, the wasm-edge + * algorithms expect WiFi CSI subcarriers — different physical modalities. + * The simulated runtime preserves *event-emission semantics* (the same + * i32 event IDs, the same trigger logic shape) so users can see the + * cards working without an ESP32 mesh. + * + * For engineering-grade output, deploy the real `wifi-densepose-wasm-edge` + * crate to ESP32 firmware over the WS transport — see ADR-040 / ADR-092 §6.2. + */ + +import type { MagFrameRecord } from '../transport/NvsimClient'; + +export interface AppEvent { + /** Wall-clock timestamp (ms). */ + ts: number; + /** App id that emitted. */ + appId: string; + /** i32 event id from `event_types` mod in wifi-densepose-wasm-edge. */ + eventId: number; + /** Human-readable event name (matches the constant name). */ + eventName: string; + /** Numeric value the app reports (units app-specific). */ + value: number; + /** Optional extra context for the console line. */ + detail?: string; +} + +export interface AppRuntimeContext { + frame: MagFrameRecord; + bMagT: number; + bRecoveredT: [number, number, number]; + /** Rolling history of |B| in T. Most recent last. */ + bHistory: number[]; + /** Time since the runtime was activated (s). */ + elapsedS: number; + /** Per-app scratch state — runtimes can persist counters here. */ + state: Record; +} + +export type AppRuntimeFn = (ctx: AppRuntimeContext) => AppEvent | AppEvent[] | null; + +/** Welford-style running-stat helper. */ +function rollingMean(arr: number[]): number { + if (arr.length === 0) return 0; + let s = 0; + for (const v of arr) s += v; + return s / arr.length; +} +function rollingStd(arr: number[]): number { + if (arr.length < 2) return 0; + const m = rollingMean(arr); + let s = 0; + for (const v of arr) s += (v - m) * (v - m); + return Math.sqrt(s / (arr.length - 1)); +} + +/** vital_trend — periodic 1-Hz HR/BR estimate from the B_z oscillation. */ +const vitalTrend: AppRuntimeFn = (ctx) => { + if (ctx.bHistory.length < 64) return null; + const last = ctx.state['lastEmitS'] ?? 0; + if (ctx.elapsedS - last < 1.0) return null; + ctx.state['lastEmitS'] = ctx.elapsedS; + + // Crude HR estimate: count zero-crossings of detrended B_z over the last + // 64 samples; treat each crossing pair as one cardiac cycle. + const tail = ctx.bHistory.slice(-64); + const m = rollingMean(tail); + let crossings = 0; + for (let i = 1; i < tail.length; i++) { + if ((tail[i] - m) * (tail[i - 1] - m) < 0) crossings++; + } + // 64 samples ≈ 0.65 s at the worker's 32-frame batches × 16 ms tick. + const cycles = crossings / 2; + const hr = Math.max(40, Math.min(180, Math.round((cycles / 0.65) * 60))); + const br = Math.max(8, Math.min(30, Math.round(hr / 4))); // crude proxy + + const evs: AppEvent[] = [ + { ts: Date.now(), appId: 'vital_trend', eventId: 100, eventName: 'VITAL_TREND', value: hr, detail: `HR≈${hr} BPM, BR≈${br} br/min` }, + ]; + if (hr < 60) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 103, eventName: 'BRADYCARDIA', value: hr, detail: `HR=${hr} BPM` }); + else if (hr > 100) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 104, eventName: 'TACHYCARDIA', value: hr, detail: `HR=${hr} BPM` }); + if (br < 12) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 101, eventName: 'BRADYPNEA', value: br, detail: `BR=${br} br/min` }); + else if (br > 24) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 102, eventName: 'TACHYPNEA', value: br, detail: `BR=${br} br/min` }); + return evs; +}; + +/** occupancy — variance threshold on |B| over a 5-second window. */ +const occupancy: AppRuntimeFn = (ctx) => { + if (ctx.bHistory.length < 32) return null; + const last = ctx.state['lastEmitS'] ?? 0; + if (ctx.elapsedS - last < 2.0) return null; + const std = rollingStd(ctx.bHistory.slice(-128)) * 1e9; // T → nT + const occupied = std > 0.01; // empirical threshold for the demo + const wasOccupied = (ctx.state['occ'] ?? 0) > 0.5; + if (occupied !== wasOccupied) { + ctx.state['occ'] = occupied ? 1 : 0; + ctx.state['lastEmitS'] = ctx.elapsedS; + return { + ts: Date.now(), + appId: 'occupancy', + eventId: occupied ? 300 : 302, + eventName: occupied ? 'ZONE_OCCUPIED' : 'ZONE_TRANSITION', + value: std, + detail: occupied ? `σ(|B|)=${std.toFixed(3)} nT — entered` : `σ(|B|)=${std.toFixed(3)} nT — left`, + }; + } + return null; +}; + +/** intrusion — |B| above ambient + dwell timer. */ +const intrusion: AppRuntimeFn = (ctx) => { + const ambient = ctx.state['ambient'] ?? ctx.bMagT; + ctx.state['ambient'] = 0.95 * ambient + 0.05 * ctx.bMagT; + const exceeds = ctx.bMagT > ambient * 1.5 && ctx.bMagT > 1e-12; + const dwellStart = ctx.state['dwellStart'] ?? 0; + if (exceeds && dwellStart === 0) { + ctx.state['dwellStart'] = ctx.elapsedS; + } else if (!exceeds) { + ctx.state['dwellStart'] = 0; + } + if (exceeds && dwellStart > 0 && ctx.elapsedS - dwellStart > 0.5 && (ctx.state['lastEmitS'] ?? 0) < dwellStart) { + ctx.state['lastEmitS'] = ctx.elapsedS; + return { + ts: Date.now(), + appId: 'intrusion', + eventId: 200, + eventName: 'INTRUSION_ALERT', + value: ctx.bMagT * 1e9, + detail: `|B|=${(ctx.bMagT * 1e9).toFixed(2)} nT > 1.5× ambient (${(ambient * 1e9).toFixed(2)} nT) for ${(ctx.elapsedS - dwellStart).toFixed(1)} s`, + }; + } + return null; +}; + +/** coherence — z-score of recent |B| against a longer baseline. */ +const coherence: AppRuntimeFn = (ctx) => { + if (ctx.bHistory.length < 64) return null; + const last = ctx.state['lastEmitS'] ?? 0; + if (ctx.elapsedS - last < 0.5) return null; + ctx.state['lastEmitS'] = ctx.elapsedS; + + const recent = ctx.bHistory.slice(-32); + const baseline = ctx.bHistory.slice(-128, -32); + if (baseline.length < 32) return null; + const mu = rollingMean(baseline); + const sd = rollingStd(baseline); + if (sd === 0) return null; + const recentMean = rollingMean(recent); + const z = Math.abs(recentMean - mu) / sd; + return { + ts: Date.now(), + appId: 'coherence', + eventId: 2, + eventName: 'COHERENCE_SCORE', + value: z, + detail: `z=${z.toFixed(2)} σ ${z > 3 ? '· DRIFT' : z > 1.5 ? '· marginal' : '· stable'}`, + }; +}; + +/** adversarial — detect physically-impossible 1/r³ violation. */ +const adversarial: AppRuntimeFn = (ctx) => { + if (ctx.bHistory.length < 32) return null; + const last = ctx.state['lastEmitS'] ?? 0; + if (ctx.elapsedS - last < 3.0) return null; + + // Fake "multi-link consistency": compare instantaneous |B| with the + // smoothed |B|. A sharp factor-of-N step violates dipole physics + // (real 1/r³ source moves continuously). + const tail = ctx.bHistory.slice(-32); + let maxJump = 0; + for (let i = 1; i < tail.length; i++) { + const j = Math.abs(Math.log(Math.max(tail[i], 1e-15)) - Math.log(Math.max(tail[i - 1], 1e-15))); + if (j > maxJump) maxJump = j; + } + if (maxJump > 5) { + ctx.state['lastEmitS'] = ctx.elapsedS; + return { + ts: Date.now(), + appId: 'adversarial', + eventId: 3, + eventName: 'ANOMALY_DETECTED', + value: maxJump, + detail: `log-jump ${maxJump.toFixed(1)} — physically implausible step in |B|`, + }; + } + return null; +}; + +/** exo_ghost_hunter — empty-room CSI anomaly detector adapted to the + * magnetic noise floor: flag impulsive / periodic / drift / random + * patterns and a hidden-presence sub-detector at 0.15-0.5 Hz. */ +const exoGhostHunter: AppRuntimeFn = (ctx) => { + if (ctx.bHistory.length < 128) return null; + const last = ctx.state['lastEmitS'] ?? 0; + if (ctx.elapsedS - last < 4.0) return null; + ctx.state['lastEmitS'] = ctx.elapsedS; + + const tail = ctx.bHistory.slice(-128); + const std = rollingStd(tail) * 1e9; + // Detect impulsive: max - mean > 4σ + const m = rollingMean(tail); + let maxDev = 0; + for (const v of tail) { + const d = Math.abs(v - m); + if (d > maxDev) maxDev = d; + } + const cls: 1 | 3 | 4 = maxDev > 4 * (std * 1e-9) ? 1 // impulsive + : ctx.elapsedS > 10 ? 3 // drift bias as a default after warmup + : 4; // random + const clsName = cls === 1 ? 'impulsive' : cls === 3 ? 'drift' : 'random'; + return { + ts: Date.now(), + appId: 'exo_ghost_hunter', + eventId: 651, + eventName: 'ANOMALY_CLASS', + value: cls, + detail: `class=${clsName} · σ=${std.toFixed(3)} nT`, + }; +}; + +export const APP_RUNTIMES: Record = { + vital_trend: vitalTrend, + occupancy, + intrusion, + coherence, + adversarial, + exo_ghost_hunter: exoGhostHunter, +}; + +export function hasRuntime(appId: string): boolean { + return appId in APP_RUNTIMES; +} diff --git a/dashboard/src/store/appStore.ts b/dashboard/src/store/appStore.ts index 9616add9..c5fec1e5 100644 --- a/dashboard/src/store/appStore.ts +++ b/dashboard/src/store/appStore.ts @@ -68,6 +68,27 @@ export function pushReplHistory(cmd: string): void { export interface SceneItemPos { id: string; x: number; y: number } export const scenePositions = signal([]); +/** App-runtime emitted events. See appRuntimes.ts. */ +import type { AppEvent } from './appRuntimes'; +export const appEvents = signal([]); +export const appEventCounts = signal>({}); + +export function pushAppEvent(ev: AppEvent): void { + const next = appEvents.value.slice(); + next.push(ev); + while (next.length > 200) next.shift(); + appEvents.value = next; + + const c = { ...appEventCounts.value }; + c[ev.appId] = (c[ev.appId] ?? 0) + 1; + appEventCounts.value = c; +} + +/** Active app activations — driven by the App Store toggles. Mirrored + * from `apps.ts` but exposed as a signal here so `main.ts` can dispatch + * frames to active runtimes without importing the App Store component. */ +export const activeAppIds = signal>(new Set()); + export const transportLabel = computed(() => transport.value === 'wasm' ? 'wasm' : 'ws', ); diff --git a/dashboard/src/store/apps.ts b/dashboard/src/store/apps.ts index d9bc161d..bcb14402 100644 --- a/dashboard/src/store/apps.ts +++ b/dashboard/src/store/apps.ts @@ -42,6 +42,19 @@ export type AppCategory = | 'aut' | 'exo'; +/** What actually happens when a card's toggle is on. + * - `running` — the algorithm is genuinely running in the browser right now + * (e.g. `nvsim` itself, which is the simulator the dashboard fronts). + * - `simulated` — a pared-down version of the algorithm runs against nvsim's + * live magnetic frame stream as a *proxy* for its native CSI input. + * Emits real i32 event IDs into the console feed; output is illustrative, + * not engineering-grade. Listed apps' Rust source is real, builds for + * wasm32-unknown-unknown, and passes its native unit tests. + * - `mesh-only` — algorithm needs CSI subcarrier data from a real ESP32-S3 + * mesh (or a future CSI simulator). Toggling persists the selection so + * the WS transport can push activation when connected. */ +export type AppRuntime = 'running' | 'simulated' | 'mesh-only'; + export interface AppManifest { /** Stable kebab-case id; matches the wasm-edge module name (e.g. `med_sleep_apnea`). */ id: string; @@ -67,6 +80,8 @@ export interface AppManifest { status: 'available' | 'beta' | 'research'; /** ADR back-reference. */ adr?: string; + /** What actually happens when active — see AppRuntime docs. */ + runtime?: AppRuntime; } export const APPS: AppManifest[] = [ @@ -83,6 +98,7 @@ export const APPS: AppManifest[] = [ status: 'available', tags: ['quantum', 'magnetometer', 'simulator', 'witness', 'wasm'], adr: 'ADR-089', + runtime: 'running', }, // ── Core sensing primitives (ADR-014/040 flagship modules) ─────────────── @@ -97,6 +113,7 @@ export const APPS: AppManifest[] = [ status: 'available', tags: ['hci', 'csi', 'classifier', 'dtw'], adr: 'ADR-014', + runtime: 'mesh-only', }, { id: 'coherence', @@ -109,6 +126,7 @@ export const APPS: AppManifest[] = [ status: 'available', tags: ['gate', 'csi', 'coherence', 'drift'], adr: 'ADR-029', + runtime: 'simulated', }, { id: 'adversarial', @@ -122,6 +140,7 @@ export const APPS: AppManifest[] = [ status: 'available', tags: ['security', 'csi', 'spoofing', 'mesh'], adr: 'ADR-032', + runtime: 'simulated', }, { id: 'rvf', @@ -144,6 +163,7 @@ export const APPS: AppManifest[] = [ budget: 'S', status: 'available', tags: ['csi', 'building', 'presence'], + runtime: 'simulated', }, { id: 'vital_trend', @@ -156,6 +176,7 @@ export const APPS: AppManifest[] = [ status: 'available', tags: ['medical', 'vitals', 'csi'], adr: 'ADR-021', + runtime: 'simulated', }, { id: 'intrusion', @@ -167,6 +188,7 @@ export const APPS: AppManifest[] = [ budget: 'S', status: 'available', tags: ['security', 'zone', 'csi'], + runtime: 'simulated', }, // ── Medical & Health (100-series) ──────────────────────────────────────── @@ -241,7 +263,7 @@ export const APPS: AppManifest[] = [ { id: 'aut_self_healing_mesh', name: 'Self-healing mesh', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Mesh-topology repair with per-node health gossip.', budget: 'M', status: 'beta', tags: ['mesh', 'health'] }, // ── Exotic / Research (650-series) ─────────────────────────────────────── - { id: 'exo_ghost_hunter', name: 'Ghost hunter (anomaly)', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Empty-room CSI anomaly detector — impulsive/periodic/drift/random + hidden-presence sub-detector.', events: [650, 651, 652, 653], budget: 'S', status: 'available', tags: ['anomaly', 'paranormal', 'csi'], adr: 'ADR-041' }, + { id: 'exo_ghost_hunter', name: 'Ghost hunter (anomaly)', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Empty-room CSI anomaly detector — impulsive/periodic/drift/random + hidden-presence sub-detector.', events: [650, 651, 652, 653], budget: 'S', status: 'available', tags: ['anomaly', 'paranormal', 'csi'], adr: 'ADR-041', runtime: 'simulated' }, { id: 'exo_breathing_sync', name: 'Breathing sync', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Multi-person breathing synchrony analytics.', budget: 'M', status: 'beta', tags: ['breathing', 'sync'] }, { id: 'exo_dream_stage', name: 'Dream-stage classifier', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'NREM/REM stage classification from breathing + micro-motion.', budget: 'M', status: 'research', tags: ['sleep', 'rem'] }, { id: 'exo_emotion_detect', name: 'Emotion detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Coarse arousal/valence from breathing + heart-rate variability.', budget: 'M', status: 'research', tags: ['affect'] },