feat(dashboard): App Store runtime — 6 simulated apps emit real events live
Closes the "do the App Store toggles actually do anything?" question:
they now do, for the subset of apps whose algorithms map onto nvsim's
magnetic frame stream as a proxy for their native CSI input.
## New: AppManifest.runtime field
Three values:
- `running` — algorithm genuinely runs in browser (just nvsim today)
- `simulated` — pared-down version against nvsim's B-field stream
- `mesh-only` — needs ESP32-S3 + WS transport (deferred to V2)
Visible in the App Store as a colored badge on every card with hover
tooltip explaining what activation actually does.
## New: appRuntimes.ts — 6 in-browser simulated runtimes
- `vital_trend` — peak-detect on B_z oscillation → 1 Hz HR/BR
events 100/101/102/103/104 + bradycardia/tachypnea
- `occupancy` — variance threshold on |B| → 300/302
- `intrusion` — |B| > 1.5× ambient + 0.5 s dwell → 200
- `coherence` — recent vs baseline z-score → 2
- `adversarial` — log-jump anomaly in |B| → 3
- `exo_ghost_hunter`— impulsive/drift/random anomaly classification → 651
Each receives an AppRuntimeContext (frame, |B|, history, elapsed-time,
per-app scratch state) and emits real i32 event IDs matching the
event_types mod in wifi-densepose-wasm-edge.
## Runtime dispatcher in main.ts
On every MagFrameBatch from the worker, iterate over activeAppIds.
For each id with a registered runtime, call the runtime fn with the
context, push any returned events into appEvents + the console feed.
mesh-only apps no-op silently (their toggle still persists for the
WS transport).
## App Store UI
- Per-card runtime badge (running / simulated / mesh-only) with tooltip
- "Live runtime feed" panel above the grid: shows last 12 emitted
events with timestamp, app id, event name + i32 id, detail
- Active simulated-app counter: "5 simulated apps active"
- Per-card event counter "⚡ N ev" once events arrive
- Toggle log line includes runtime mode: "live runtime engaged" /
"queued (needs ESP32 mesh)"
Validated end-to-end on https://ruvnet.github.io/RuView/nvsim/ — toggled
{vital_trend, occupancy, intrusion, coherence}, pressed Run, the feed
filled with real events: COHERENCE_SCORE z=0.87 stable, VITAL_TREND
HR=40 BPM BR=10, BRADYCARDIA, BRADYPNEA. Console log mirrors with
[appId] prefix. Zero browser errors.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
6fe405a5f7
commit
cedb28db83
|
|
@ -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<AppActivation[]>(defaultActivations());
|
||||
const query = signal<string>('');
|
||||
|
|
@ -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<string>();
|
||||
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 <span class="k">${app.id}</span> 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 <span class="k">${app.id}</span> activated${note}`);
|
||||
} else {
|
||||
pushLog('info', `app <span class="k">${app.id}</span> deactivated`);
|
||||
}
|
||||
}
|
||||
|
||||
private filtered(): AppManifest[] {
|
||||
|
|
@ -247,15 +312,64 @@ export class NvAppStore extends LitElement {
|
|||
<span class="chip ${statusFilter.value === 'research' ? 'on' : ''}" @click=${() => statusFilter.value = 'research'}>research</span>
|
||||
</div>
|
||||
|
||||
${this.renderEventsFeed()}
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="empty">No apps match the current filters.</div>`
|
||||
: html`<div class="grid">${list.map((app) => this.card(app))}</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEventsFeed() {
|
||||
const evs = appEvents.value.slice(-12).reverse();
|
||||
const activeSimCount = activations.value.filter((a) => a.active && hasRuntime(a.id)).length;
|
||||
return html`
|
||||
<div class="events-feed">
|
||||
<h3>Live runtime feed
|
||||
${activeSimCount > 0
|
||||
? html`<span class="card-events-count" style="margin-left: 8px;">${activeSimCount} simulated app${activeSimCount === 1 ? '' : 's'} active</span>`
|
||||
: ''}
|
||||
</h3>
|
||||
<p class="lead">
|
||||
Apps with the <span class="badge rt-simulated" style="font-size:9.5px; padding:0 4px;">simulated</span>
|
||||
runtime emit real i32 event IDs against nvsim's live frame stream below.
|
||||
Apps with <span class="badge rt-mesh-only" style="font-size:9.5px; padding:0 4px;">mesh-only</span>
|
||||
need an ESP32-S3 + WS transport (deferred to V2). The
|
||||
<span class="badge rt-running" style="font-size:9.5px; padding:0 4px;">running</span>
|
||||
badge marks <code>nvsim</code> itself, which is always running.
|
||||
</p>
|
||||
${evs.length === 0
|
||||
? html`<div class="ev-empty">No events yet. Toggle a card with the <i>simulated</i> badge and press <b>▶ Run</b>.</div>`
|
||||
: html`<div class="lines">${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`
|
||||
<div class="ev-line">
|
||||
<span class="ts">${ts}</span>
|
||||
<span class="id">${ev.appId}</span>
|
||||
<span class="body"><b style="color:var(--accent-2);">${ev.eventName}</b><span style="color:var(--ink-3);"> · ${ev.eventId}</span> ${ev.detail ? `· ${ev.detail}` : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
})}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
'running': 'running',
|
||||
'simulated': 'simulated',
|
||||
'mesh-only': 'needs mesh',
|
||||
};
|
||||
const runtimeTip: Record<string, string> = {
|
||||
'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`
|
||||
<div class="card ${active ? 'active' : ''}" data-app-id=${app.id}>
|
||||
<div class="card-h">
|
||||
|
|
@ -266,12 +380,14 @@ export class NvAppStore extends LitElement {
|
|||
<div class="meta">
|
||||
<span class="badge cat">${cat.label}</span>
|
||||
<span class="badge status-${app.status}">${app.status}</span>
|
||||
<span class="badge rt-${runtime}" title=${runtimeTip[runtime]}>${runtimeLabel[runtime]}</span>
|
||||
${app.budget ? html`<span class="badge budget">budget ${app.budget}</span>` : ''}
|
||||
${app.adr ? html`<span class="badge">${app.adr}</span>` : ''}
|
||||
${app.events?.length ? html`<span class="badge">events ${app.events.join('·')}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="events">${app.crate}</span>
|
||||
${evCount > 0 ? html`<span class="card-events-count">⚡ ${evCount} ev</span>` : ''}
|
||||
<span class="toggle ${active ? 'on' : ''}" role="switch"
|
||||
aria-checked=${active}
|
||||
data-app-toggle=${app.id}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
|
||||
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
|
||||
replHistory, scenePositions, type SceneItemPos,
|
||||
activeAppIds, pushAppEvent,
|
||||
} from './store/appStore';
|
||||
import { APP_RUNTIMES, type AppRuntimeContext } from './store/appRuntimes';
|
||||
import { kvGet, kvSet } from './store/persistence';
|
||||
|
||||
function applyTheme(t: string): void {
|
||||
|
|
@ -59,6 +61,11 @@ function applyMotion(reduced: boolean): void {
|
|||
}
|
||||
});
|
||||
|
||||
// Per-app runtime scratch state + history buffer.
|
||||
const appState: Record<string, Record<string, number>> = {};
|
||||
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',
|
||||
`<span class="k">[${ev.appId}]</span> <span class="s">${ev.eventName}</span> <span class="n">(${ev.eventId})</span>${ev.detail ? ' · ' + ev.detail : ''}`);
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `[${id}] runtime error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
||||
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<string, AppRuntimeFn> = {
|
||||
vital_trend: vitalTrend,
|
||||
occupancy,
|
||||
intrusion,
|
||||
coherence,
|
||||
adversarial,
|
||||
exo_ghost_hunter: exoGhostHunter,
|
||||
};
|
||||
|
||||
export function hasRuntime(appId: string): boolean {
|
||||
return appId in APP_RUNTIMES;
|
||||
}
|
||||
|
|
@ -68,6 +68,27 @@ export function pushReplHistory(cmd: string): void {
|
|||
export interface SceneItemPos { id: string; x: number; y: number }
|
||||
export const scenePositions = signal<SceneItemPos[]>([]);
|
||||
|
||||
/** App-runtime emitted events. See appRuntimes.ts. */
|
||||
import type { AppEvent } from './appRuntimes';
|
||||
export const appEvents = signal<AppEvent[]>([]);
|
||||
export const appEventCounts = signal<Record<string, number>>({});
|
||||
|
||||
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<Set<string>>(new Set());
|
||||
|
||||
export const transportLabel = computed<string>(() =>
|
||||
transport.value === 'wasm' ? 'wasm' : 'ws',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
Loading…
Reference in New Issue