mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
CI fixes (both failures on PR #2062 were in this branch): - harness/ruview: remove `2.11.0` literals from training remedies; the ADR-265 D3 version-literal gate rejects them. - ruview-kernel workflow: install wasm32 for the toolchain pinned by v2/rust-toolchain (the action only added it to `stable`); build.mjs now fails fast with the exact `rustup target add` remedy. Kernel optimization (ADR-368 amendment): - New `analyze_flat` / `session_push_flat` ops via `call_f64`: WASM `rvk_call_f64` (alignment-agnostic bytes) and napi `callF64(Float64Array)`. `analyze()`/`session.push()` pack uniform frames automatically; irregular input keeps the JSON path and its per-frame errors. `analyzeFlat()` is the zero-copy entry. Shared `Session::step` makes both paths byte-identical on identical f64 inputs (Rust test), and the binary path avoids serde_json's non-correctly-rounded float parsing. - MEASURED (`ruview-kernel bench --seconds 120`, one Linux x64 host): 2.82x (wasm) and 2.45x (napi) faster than the JSON transport. - Profiled remaining cost (examples/profile_stages.rs): heart-rate ACF in wifi-densepose-vitals dominates; left for a separate, output-reviewed change. Rejected WASM +simd128 and opt-level=s after three measured runs (bench/wasm-variants.mjs). Device e2e (ADR-373 amendment): - test/e2e/devices.e2e.mjs + `npm run test:e2e:devices` + new workflow ruview-device-e2e.yml: pty-emulated MR60BHA2 and RPLIDAR through real pyserial, a UDP ESP32 node, and the real iPhone relay, via the CLI. - mmWave auto-detect keeps probe frames and reads only the remainder; iPhone LiDAR gains max_frames and returns immediately when refused. Suite wall time 19.6 s -> 5.6 s (MEASURED, dev container). Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
181 lines
7.7 KiB
JavaScript
Executable File
181 lines
7.7 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// SPDX-License-Identifier: MIT
|
|
// `npx @ruvnet/ruview-kernel` — run the RuView compute kernel from the shell
|
|
// (ADR-368). Zero runtime dependencies; WASM is the default backend.
|
|
|
|
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
import { argv } from 'node:process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { MAX_INPUT_BYTES } from '../src/abi.js';
|
|
import { BACKENDS, kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
|
|
|
|
const pjson = (value) => console.log(JSON.stringify(value, null, 2));
|
|
|
|
function parseFlags(rest) {
|
|
const flags = {};
|
|
for (let i = 0; i < rest.length; i++) {
|
|
const a = rest[i];
|
|
if (!a.startsWith('--')) continue;
|
|
const eq = a.indexOf('=');
|
|
if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) flags[a.slice(2)] = rest[++i];
|
|
else flags[a.slice(2)] = true;
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
function number(flags, key, fallback) {
|
|
if (flags[key] === undefined) return fallback;
|
|
const n = Number(flags[key]);
|
|
if (!Number.isFinite(n)) throw new Error(`--${key} must be a number`);
|
|
return n;
|
|
}
|
|
|
|
/** Bounded read of a caller-named regular file (JSON array/object or JSONL frames). */
|
|
function readFrames(path) {
|
|
const stat = statSync(path);
|
|
if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
|
|
if (stat.size > MAX_INPUT_BYTES) throw new Error(`${path} exceeds ${MAX_INPUT_BYTES} bytes`);
|
|
const fd = openSync(path, 'r');
|
|
let text;
|
|
try {
|
|
const buf = Buffer.alloc(stat.size);
|
|
readSync(fd, buf, 0, stat.size, 0);
|
|
text = buf.toString('utf8');
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
const trimmed = text.trim();
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
// Not a single JSON document: treat as JSONL, one frame per line.
|
|
const frames = trimmed.split('\n').filter((l) => l.trim()).map((l, i) => {
|
|
try { return JSON.parse(l); } catch { throw new Error(`line ${i + 1} is not valid JSON`); }
|
|
});
|
|
return { frames };
|
|
}
|
|
if (Array.isArray(parsed)) return { frames: parsed };
|
|
if (Array.isArray(parsed?.frames)) return { frames: parsed.frames, config: parsed.config };
|
|
if (parsed && Array.isArray(parsed.amplitudes)) return { frames: [parsed] };
|
|
throw new Error('input must be a JSON array of frames, {frames, config}, or JSONL');
|
|
}
|
|
|
|
function help() {
|
|
console.log(`Usage: ruview-kernel <command> [--backend wasm|napi|auto]
|
|
|
|
doctor [--strict-napi] report both backends, ABI, and artifact integrity
|
|
info kernel build info and limits
|
|
selftest [--seconds 60] SYNTHETIC end-to-end pipeline check
|
|
parity diff wasm vs napi outputs on the same SYNTHETIC input
|
|
synth [--seconds 60] [--breathing 15] [--heart 72] [--subcarriers 56] [--seed 7] [--out file.json]
|
|
analyze --file frames.json|frames.jsonl [--config '{"n_subcarriers":56}'] [--readings]
|
|
bench [--seconds 120] time analyze on SYNTHETIC input (MEASURED on this host)
|
|
|
|
Default backend: RUVIEW_KERNEL_BACKEND or wasm (zero-import, least authority).
|
|
Outputs are signal-processing estimates, not clinical or camera-grade measurements.`);
|
|
return 0;
|
|
}
|
|
|
|
export async function run(args) {
|
|
const cmd = args[0] ?? '--help';
|
|
const flags = parseFlags(args.slice(1));
|
|
if (flags.backend !== undefined && !BACKENDS.includes(flags.backend)) {
|
|
console.error(`--backend must be one of ${BACKENDS.join(', ')}`);
|
|
return 2;
|
|
}
|
|
const load = () => loadKernel({ backend: flags.backend });
|
|
switch (cmd) {
|
|
case 'doctor': {
|
|
const status = kernelStatus();
|
|
const ok = status.wasm.available && (!flags['strict-napi'] || status.napi.available);
|
|
pjson({ ok, ...status, note: status.napi.available ? 'both backends available' : 'napi optional; wasm backend active by default' });
|
|
return ok ? 0 : 1;
|
|
}
|
|
case 'info': {
|
|
const k = load();
|
|
pjson({ backend: k.backend, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason, integrity: k.integrity, ...k.info() });
|
|
return 0;
|
|
}
|
|
case 'selftest': {
|
|
const k = load();
|
|
const result = selfTest(k, { seconds: number(flags, 'seconds', 60) });
|
|
pjson({ ...result, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason });
|
|
return result.ok ? 0 : 1;
|
|
}
|
|
case 'parity': {
|
|
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
|
|
pjson(result);
|
|
return result.ok ? 0 : 1;
|
|
}
|
|
case 'synth': {
|
|
const out = load().synthesize({
|
|
seconds: number(flags, 'seconds', 60),
|
|
breathing_bpm: number(flags, 'breathing', 15),
|
|
heart_bpm: number(flags, 'heart', 72),
|
|
n_subcarriers: number(flags, 'subcarriers', 56),
|
|
sample_rate_hz: number(flags, 'rate', 20),
|
|
seed: number(flags, 'seed', 7),
|
|
});
|
|
const body = JSON.stringify({ evidence: out.evidence, config: { n_subcarriers: out.request.n_subcarriers, sample_rate_hz: out.request.sample_rate_hz }, frames: out.frames });
|
|
if (typeof flags.out === 'string') {
|
|
writeFileSync(flags.out, `${body}\n`, { flag: 'wx' }); // never overwrite an existing file
|
|
pjson({ ok: true, evidence: out.evidence, frames: out.frames.length, out: flags.out });
|
|
} else console.log(body);
|
|
return 0;
|
|
}
|
|
case 'analyze': {
|
|
if (typeof flags.file !== 'string') { console.error('analyze: --file <frames.json|frames.jsonl> is required'); return 2; }
|
|
const input = readFrames(flags.file);
|
|
const config = typeof flags.config === 'string' ? JSON.parse(flags.config) : (input.config || {});
|
|
const k = load();
|
|
pjson({ ok: true, backend: k.backend, ...k.analyze(input.frames, config, { includeReadings: flags.readings === true }) });
|
|
return 0;
|
|
}
|
|
case 'bench': {
|
|
const k = load();
|
|
const seconds = number(flags, 'seconds', 120);
|
|
const { frames } = k.synthesize({ seconds });
|
|
k.analyze(frames.slice(0, 200)); // warm-up
|
|
const iterations = 5;
|
|
const time = (fn) => { const t0 = performance.now(); for (let i = 0; i < iterations; i++) fn(); return (performance.now() - t0) / iterations; };
|
|
const ms = time(() => k.analyze(frames));
|
|
const jsonMs = time(() => k.call('analyze', { config: {}, frames, include_readings: false }));
|
|
pjson({
|
|
evidence: 'MEASURED',
|
|
reproducer: `npx @ruvnet/ruview-kernel bench --backend ${k.backend} --seconds ${seconds}`,
|
|
host: { node: process.version, platform: process.platform, arch: process.arch },
|
|
backend: k.backend, frames: frames.length, transport: k.binary ? 'binary' : 'json',
|
|
msPerAnalyze: Number(ms.toFixed(2)), framesPerSecond: Math.round(frames.length / (ms / 1000)),
|
|
jsonTransportMs: Number(jsonMs.toFixed(2)), speedupVsJson: Number((jsonMs / ms).toFixed(2)),
|
|
note: 'Includes transfer across the ABI; single-host timing, not a cross-host claim.',
|
|
});
|
|
return 0;
|
|
}
|
|
case '--version': case '-v': {
|
|
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
return 0;
|
|
}
|
|
case '--help': case '-h': case 'help': return help();
|
|
default:
|
|
console.error(`Unknown command: ${cmd}. Try \`ruview-kernel --help\`.`);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
const invokedDirectly = (() => {
|
|
if (!argv[1]) return false;
|
|
try {
|
|
const a = realpathSync(argv[1]);
|
|
const b = realpathSync(fileURLToPath(import.meta.url));
|
|
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
} catch { return false; }
|
|
})();
|
|
if (invokedDirectly) {
|
|
run(argv.slice(2)).then((code) => process.exit(code)).catch((error) => {
|
|
pjson({ ok: false, code: error.code || 'error', error: error.message });
|
|
process.exit(1);
|
|
});
|
|
}
|