diff --git a/.github/workflows/ruview-device-e2e.yml b/.github/workflows/ruview-device-e2e.yml new file mode 100644 index 000000000..8b78416cd --- /dev/null +++ b/.github/workflows/ruview-device-e2e.yml @@ -0,0 +1,68 @@ +# ADR-373/374 — device-access end-to-end through the real @ruvnet/ruview CLI. +# MR60BHA2 (60 GHz) and RPLIDAR are emulated on pseudo-terminals and read via +# real pyserial; an ESP32 node is emulated over UDP; the iPhone LiDAR path +# uses the repository's real relay. All results are SYNTHETIC: this proves +# the transports and parsers, not hardware behaviour. + +name: ruview device e2e + +on: + push: + branches: [main] + paths: + - 'harness/ruview/src/**' + - 'harness/ruview/bin/**' + - 'harness/ruview/test/e2e/**' + - 'integrations/iphone-lidar/web/**' + - '.github/workflows/ruview-device-e2e.yml' + pull_request: + paths: + - 'harness/ruview/src/**' + - 'harness/ruview/bin/**' + - 'harness/ruview/test/e2e/**' + - 'integrations/iphone-lidar/web/**' + - '.github/workflows/ruview-device-e2e.yml' + +permissions: + contents: read + +concurrency: + group: ruview-device-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + devices: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + + - name: Install pyserial + run: python -m pip install pyserial==3.5 + + - name: Install the iPhone LiDAR relay + working-directory: integrations/iphone-lidar/web + run: npm ci --ignore-scripts + + - name: Install harness + working-directory: harness/ruview + run: npm ci --ignore-scripts + + # Missing prerequisites fail instead of skipping (RUVIEW_E2E_REQUIRE). + # The suite links each emulator pty to /dev/ttyRUVIEWE2E with + # `sudo -n`, because the harness refuses /dev/pts/* port names. + - name: Device e2e (SYNTHETIC devices) + working-directory: harness/ruview + env: + RUVIEW_E2E_REQUIRE: '1' + run: npm run test:e2e:devices diff --git a/.github/workflows/ruview-kernel-npm.yml b/.github/workflows/ruview-kernel-npm.yml index 60320271f..8e850926e 100644 --- a/.github/workflows/ruview-kernel-npm.yml +++ b/.github/workflows/ruview-kernel-npm.yml @@ -60,6 +60,12 @@ jobs: with: targets: wasm32-unknown-unknown + # v2/rust-toolchain pins its own channel; the target must be installed + # for that toolchain, not only for the action's default `stable`. + - name: Add wasm32 target to the pinned v2 toolchain + working-directory: v2 + run: rustup target add wasm32-unknown-unknown + - name: Cache cargo uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 with: diff --git a/docs/adr/ADR-368-ruview-kernel-wasm-napi-npm.md b/docs/adr/ADR-368-ruview-kernel-wasm-napi-npm.md index 42fcf180f..a88804da8 100644 --- a/docs/adr/ADR-368-ruview-kernel-wasm-napi-npm.md +++ b/docs/adr/ADR-368-ruview-kernel-wasm-napi-npm.md @@ -110,6 +110,61 @@ module or artifact path. The capability appears in `ruview_guidance` as None of these numbers says anything about real-world vital-sign accuracy. That still requires the ADR-293 ground-truth protocol with a reference device. +## Amendment 1 (2026-10-01): binary transport and optimization evidence + +**Profile (MEASURED on one Linux x64 development container, 2 400 frames × +56 subcarriers).** JSON was the dominant cost: + +- JS `JSON.stringify` of the request alone took 52–78 ms against + 107–124 ms for a whole `analyze`. +- Rust then re-parsed about 270 000 numbers. + +**Decision.** Add a binary fast path without changing the ABI version. + +- New operations `analyze_flat` and `session_push_flat`, carried by + `call_f64(op, json, &[f64])`: + - WASM: `rvk_call_f64`, which reads bytes so alignment does not matter; + - napi: `callF64(op, json, Float64Array)`. +- `analyze()` and `session.push()` pack uniform frames into one + `Float64Array` (all amplitudes, then all phases) and use the binary path + automatically. Irregular input still takes the JSON path so its error names + the bad frame. +- `analyzeFlat(Float64Array, …)` is the zero-copy entry. +- `Session::step` is shared by both paths. + +**Evidence.** + +- **Byte-identical outputs.** On identical `f64` inputs the two paths + produce byte-identical JSON (`tests/abi.rs`). +- **A JSON precision finding.** serde_json's default float parser is not + correctly rounded, so the JSON transport perturbs inputs by ULPs (34 of 160 + amplitude values in a 2 s, 4-subcarrier SYNTHETIC sample). The binary path is bit-exact. +- **Speed.** MEASURED, `ruview-kernel bench --seconds 120`, same host: + + | Backend | JSON transport | Binary transport | Speed-up | + |---|---|---|---| + | wasm | 134.8 ms | 47.8 ms (~50 000 frames/s) | 2.82× | + | napi | 109.7 ms | 44.8 ms (~53 600 frames/s) | 2.45× | + +- **Remaining cost is DSP.** Native timing gives heart-rate extraction + 20.8–22.9 ms of 30.6–38.3 ms (about 60–68%, MEASURED; reproducer: + `cargo run --release -p ruview-kernel --example profile_stages`): the per-frame autocorrelation over the 15 s window + in `wifi-densepose-vitals`. That crate is shared with the production sensing + server, and an incremental ACF would change floating-point summation order + and therefore outputs. It is left as a follow-up that needs its own review + and regression evidence. + +**Rejected after measurement.** MEASURED, three runs on the same host; +reproducer: `node bench/wasm-variants.mjs` in `harness/ruview-kernel`. Each +run is the median of 15 `analyzeFlat` calls on 2 400 frames. All variants +produced byte-identical outputs. + +| Variant | Size | Median ms (3 runs) | Decision | +|---|---|---|---| +| baseline (`opt-level = 3`) | 244 KB | 38.3–40.6 | kept | +| `+simd128` | 242 KB | 37.4–54.7 | rejected: no consistent gain, and it needs SIMD-capable engines | +| `opt-level = "s"` | 227 KB | 40.8–70.1 | rejected: consistently slower for a 17 KB size saving | + ## Consequences - JavaScript consumers get real RuView DSP without a server, in Node or the diff --git a/docs/adr/ADR-373-host-device-access-layer.md b/docs/adr/ADR-373-host-device-access-layer.md index baea36b90..35b27ed81 100644 --- a/docs/adr/ADR-373-host-device-access-layer.md +++ b/docs/adr/ADR-373-host-device-access-layer.md @@ -104,6 +104,25 @@ Development container, Linux x64, Node 22. No physical sensors were attached. captures. Each modality needs one real capture before its parser is called hardware-validated. +## Amendment 1 (2026-10-01): e2e suite and latency + +- `harness/ruview/test/e2e/devices.e2e.mjs` (`npm run test:e2e:devices`, CI + workflow `ruview-device-e2e.yml`) automates the emulated-device runs above + through the real CLI: + - MR60BHA2 and RPLIDAR on pseudo-terminals, read via real pyserial; + - an ESP32 UDP node; + - the real iPhone relay. +- The harness still refuses `/dev/pts/*` port names. The suite links each pty + to `/dev/ttyRUVIEWE2E` with root or `sudo -n`. +- `RUVIEW_E2E_REQUIRE=1` turns missing prerequisites into failures. +- mmWave auto-detect keeps its probe frames (≤ 2 s per model) and reads only + the remainder of the window. A 5 s request previously cost about 9 s. +- `ruview_lidar_read` (iPhone) gains `max_frames` and returns immediately on + a refused connection. +- MEASURED wall time for the device e2e suite on the development container: + 19.6 s before these changes, 5.6 s after (concurrent cases, no redundant + re-reads). + ## Consequences - One command per modality works across CLI, MCP and SDK, with actionable diff --git a/harness/ruview-kernel/README.md b/harness/ruview-kernel/README.md index 3c40181e7..023b6668e 100644 --- a/harness/ruview-kernel/README.md +++ b/harness/ruview-kernel/README.md @@ -51,6 +51,23 @@ const readings = session.push(frames.slice(0, 200)); session.close(); ``` +### Binary fast path + +`analyze()` and `session.push()` automatically send uniform frames as one +`Float64Array` (all amplitudes, then all phases) instead of JSON numbers. +Irregular input still goes through JSON, so errors name the bad frame. To +skip packing entirely: + +```js +const data = new Float64Array(frames * 56 * 2); // amplitudes…, then phases… +kernel.analyzeFlat(data, { n_subcarriers: 56 }, { phases: true }); +``` + +MEASURED on one Linux x64 host (`ruview-kernel bench --seconds 120`): 2.8× +(wasm) and 2.5× (napi) faster than the JSON transport for 2 400 frames. The +binary path is also bit-exact, while JSON number parsing can move inputs by +ULPs. + In a browser, fetch the module yourself and use the filesystem-free entry: ```js diff --git a/harness/ruview-kernel/bench/wasm-variants.mjs b/harness/ruview-kernel/bench/wasm-variants.mjs new file mode 100644 index 000000000..52d3f1167 --- /dev/null +++ b/harness/ruview-kernel/bench/wasm-variants.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Reproducer for the ADR-368 amendment's rejected WASM build options. +// Builds the kernel three ways into separate target dirs and reports size, +// median analyzeFlat time over 15 runs (2 400 SYNTHETIC frames), and whether +// outputs are byte-identical to the baseline build. +// node bench/wasm-variants.mjs (from harness/ruview-kernel; needs cargo + wasm32 target) +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { flattenFrames } from '../src/abi.js'; +import { loadWasmKernelFromBytes } from '../src/wasm.js'; + +const V2 = resolve(import.meta.dirname, '..', '..', '..', 'v2'); +const variants = { + baseline: {}, + simd128: { CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: '-C target-feature=+simd128' }, + 'opt-level-s': { CARGO_PROFILE_RELEASE_OPT_LEVEL: 's' }, +}; +const built = {}; +for (const [name, env] of Object.entries(variants)) { + const targetDir = join(tmpdir(), `ruview-kernel-variant-${name}`); + const r = spawnSync('cargo', ['build', '-q', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--target-dir', targetDir], { cwd: V2, env: { ...process.env, ...env }, stdio: 'inherit' }); + if (r.status !== 0) process.exit(1); + built[name] = readFileSync(join(targetDir, 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm')); +} +const base = loadWasmKernelFromBytes(built.baseline); +const { frames } = base.synthesize({ seconds: 120 }); +const { data } = flattenFrames(frames, 56); +const reference = JSON.stringify(base.analyzeFlat(data, {}, { phases: true, includeReadings: true })); +const rows = []; +for (const [name, bytes] of Object.entries(built)) { + const k = loadWasmKernelFromBytes(bytes); + const identical = JSON.stringify(k.analyzeFlat(data, {}, { phases: true, includeReadings: true })) === reference; + for (let i = 0; i < 3; i++) k.analyzeFlat(data, {}, { phases: true }); + const runs = []; + for (let i = 0; i < 15; i++) { const t = performance.now(); k.analyzeFlat(data, {}, { phases: true }); runs.push(performance.now() - t); } + runs.sort((a, b) => a - b); + rows.push({ variant: name, bytes: bytes.length, medianMs: Number(runs[7].toFixed(1)), identicalToBaseline: identical }); +} +console.log(JSON.stringify({ evidence: 'MEASURED (this host only)', frames: frames.length, rows }, null, 2)); diff --git a/harness/ruview-kernel/bin/cli.js b/harness/ruview-kernel/bin/cli.js index fba00a27d..eb67caa2e 100755 --- a/harness/ruview-kernel/bin/cli.js +++ b/harness/ruview-kernel/bin/cli.js @@ -138,17 +138,18 @@ export async function run(args) { const seconds = number(flags, 'seconds', 120); const { frames } = k.synthesize({ seconds }); k.analyze(frames.slice(0, 200)); // warm-up - const t0 = performance.now(); const iterations = 5; - for (let i = 0; i < iterations; i++) k.analyze(frames); - const ms = (performance.now() - t0) / iterations; + 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, msPerAnalyze: Number(ms.toFixed(2)), - framesPerSecond: Math.round(frames.length / (ms / 1000)), - note: 'Includes JSON encode/decode across the ABI; single-host timing, not a cross-host claim.', + 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; } diff --git a/harness/ruview-kernel/scripts/build.mjs b/harness/ruview-kernel/scripts/build.mjs index 1607b2933..033955696 100644 --- a/harness/ruview-kernel/scripts/build.mjs +++ b/harness/ruview-kernel/scripts/build.mjs @@ -51,8 +51,19 @@ function install(src, dir, name) { return { artifact: `${dir}/${name}`, sha256: digest }; } +/** Fail fast when the toolchain v2/ pins lacks the wasm32 target. */ +function requireWasmTarget() { + const r = spawnSync('rustup', ['target', 'list', '--installed'], { cwd: V2, encoding: 'utf8' }); + if (r.status === 0 && !r.stdout.split(/\r?\n/).includes('wasm32-unknown-unknown')) { + console.error('wasm32-unknown-unknown is not installed for the toolchain pinned by v2/rust-toolchain.'); + console.error('Fix: (cd v2 && rustup target add wasm32-unknown-unknown)'); + process.exit(1); + } +} + const built = []; if (wantWasm) { + requireWasmTarget(); cargo(['build', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--locked'], V2); const src = join(V2, 'target', 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm'); built.push(install(src, 'wasm', 'ruview_kernel.wasm')); diff --git a/harness/ruview-kernel/src/abi.js b/harness/ruview-kernel/src/abi.js index f87312ed2..b2f510c72 100644 --- a/harness/ruview-kernel/src/abi.js +++ b/harness/ruview-kernel/src/abi.js @@ -6,7 +6,10 @@ export const MAX_INPUT_BYTES = 16 * 1024 * 1024; export const OPERATIONS = Object.freeze([ 'info', 'validate_config', 'analyze', 'synthesize', 'session_open', 'session_push', 'session_summary', 'session_close', + 'analyze_flat', 'session_push_flat', ]); +const DEFAULT_SUBCARRIERS = 56; +const FRAME_KEYS = new Set(['amplitudes', 'phases']); /** Error raised for a `{ok:false}` kernel envelope or a transport failure. */ export class KernelError extends Error { @@ -43,27 +46,82 @@ export function decodeResponse(text) { throw new KernelError(String(error.code || 'internal'), String(error.message || 'kernel error')); } +/** + * Pack uniform frames into one Float64Array: all amplitudes, then all phases. + * Returns null whenever the JSON path must handle the input instead, so that + * malformed frames still get the kernel's precise per-frame error messages. + */ +export function flattenFrames(frames, n) { + if (!Array.isArray(frames) || !Number.isInteger(n) || n < 1) return null; + const count = frames.length; + const withPhases = count > 0 && Array.isArray(frames[0]?.phases) && frames[0].phases.length > 0; + const data = new Float64Array(count * n * (withPhases ? 2 : 1)); + const phaseBase = count * n; + for (let k = 0; k < count; k++) { + const f = frames[k]; + if (!f || typeof f !== 'object' || !Array.isArray(f.amplitudes) || f.amplitudes.length !== n) return null; + for (const key of Object.keys(f)) if (!FRAME_KEYS.has(key)) return null; + const ph = f.phases ?? []; + if (!Array.isArray(ph) || ph.length !== (withPhases ? n : 0)) return null; + for (let i = 0; i < n; i++) { + const a = f.amplitudes[i]; + if (typeof a !== 'number') return null; + data[k * n + i] = a; + if (withPhases) { + const p = ph[i]; + if (typeof p !== 'number') return null; + data[phaseBase + k * n + i] = p; + } + } + } + return { data, withPhases }; +} + /** * Wrap a raw transport `(op, json) => json` into the high-level kernel API. * Both backends go through here, so their behaviour differs only in transport. + * `transportF64(op, json, Float64Array)` is the optional binary fast path. */ -export function createKernel(transport, meta) { +export function createKernel(transport, meta, transportF64 = null) { const call = (op, request) => decodeResponse(transport(op, encodeRequest(op, request))); + const callF64 = (op, request, data) => { + if (!(data instanceof Float64Array)) throw new KernelError('invalid_request', 'binary frame data must be a Float64Array'); + if (data.byteLength > MAX_INPUT_BYTES) throw new KernelError('limit_exceeded', `binary data exceeds ${MAX_INPUT_BYTES} bytes`); + return decodeResponse(transportF64(op, encodeRequest(op, request), data)); + }; + const flat = (frames, config) => (transportF64 ? flattenFrames(frames, config?.n_subcarriers ?? DEFAULT_SUBCARRIERS) : null); const kernel = { ...meta, + binary: Boolean(transportF64), call, info: () => call('info', {}), validateConfig: (config = {}) => call('validate_config', config), - analyze: (frames, config = {}, { includeReadings = false } = {}) => - call('analyze', { config, frames, include_readings: includeReadings }), + analyze(frames, config = {}, { includeReadings = false } = {}) { + const packed = flat(frames, config); + return packed + ? callF64('analyze_flat', { config, phases: packed.withPhases, include_readings: includeReadings }, packed.data) + : call('analyze', { config, frames, include_readings: includeReadings }); + }, + /** Zero-copy entry: amplitudes for every frame, then phases if `phases`. */ + analyzeFlat(data, config = {}, { phases = false, includeReadings = false } = {}) { + if (!transportF64) throw new KernelError('backend_unavailable', 'this kernel build has no binary transport'); + return callF64('analyze_flat', { config, phases, include_readings: includeReadings }, data); + }, synthesize: (options = {}) => call('synthesize', options), openSession(config = {}) { + const n = config?.n_subcarriers ?? DEFAULT_SUBCARRIERS; const { session } = call('session_open', { config }); let open = true; const ensureOpen = () => { if (!open) throw new KernelError('unknown_session', 'session is closed'); }; return { id: session, - push(frames) { ensureOpen(); return call('session_push', { session, frames }).readings; }, + push(frames) { + ensureOpen(); + const packed = transportF64 ? flattenFrames(frames, n) : null; + return packed + ? callF64('session_push_flat', { session, phases: packed.withPhases }, packed.data).readings + : call('session_push', { session, frames }).readings; + }, summary() { ensureOpen(); return call('session_summary', { session }).summary; }, close() { if (!open) return false; open = false; return call('session_close', { session }).closed; }, }; diff --git a/harness/ruview-kernel/src/index.js b/harness/ruview-kernel/src/index.js index 3d21a4704..61c59ba7b 100644 --- a/harness/ruview-kernel/src/index.js +++ b/harness/ruview-kernel/src/index.js @@ -91,7 +91,7 @@ function loadNapi(options) { if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `napi ABI ${abi} != loader ABI ${ABI_VERSION}`); return createKernel((op, json) => addon.call(op, json), { backend: 'napi', abi, triple, artifact: path, sha256: artifact.sha256, integrity: artifact.integrity, - }); + }, typeof addon.callF64 === 'function' ? (op, json, data) => addon.callF64(op, json, data) : null); } /** diff --git a/harness/ruview-kernel/src/wasm.js b/harness/ruview-kernel/src/wasm.js index 5490801ad..dd02693fa 100644 --- a/harness/ruview-kernel/src/wasm.js +++ b/harness/ruview-kernel/src/wasm.js @@ -66,11 +66,36 @@ export function createWasmTransport(module) { } }; transport.generation = () => generation; + transport.f64 = (op, json, data) => { + if (!exports) { exports = instantiate(module); generation += 1; } + if (typeof exports.rvk_call_f64 !== 'function') throw new KernelError('backend_unavailable', 'WASM module predates the binary transport'); + try { + const [opPtr, opLen] = write(exports, op); + const [inPtr, inLen] = write(exports, json); + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const dataPtr = exports.rvk_alloc(bytes.length); + if (dataPtr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed'); + new Uint8Array(exports.memory.buffer, dataPtr, bytes.length).set(bytes); + const packed = exports.rvk_call_f64(opPtr, opLen, inPtr, inLen, dataPtr, bytes.length); + if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed'); + const ptr = Number(packed >> 32n); + const len = Number(packed & 0xffffffffn); + const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice()); + exports.rvk_free(ptr, len); + return text; + } catch (error) { + if (error instanceof KernelError) throw error; + exports = null; + throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`); + } + }; return transport; } /** Build the high-level kernel API from WASM bytes (browser or Node). */ export function loadWasmKernelFromBytes(bytes, meta = {}) { const module = compileKernelModule(bytes); - return createKernel(createWasmTransport(module), { backend: 'wasm', abi: ABI_VERSION, ...meta }); + const transport = createWasmTransport(module); + const hasF64 = WebAssembly.Module.exports(module).some((e) => e.name === 'rvk_call_f64'); + return createKernel(transport, { backend: 'wasm', abi: ABI_VERSION, ...meta }, hasF64 ? transport.f64 : null); } diff --git a/harness/ruview-kernel/test/binary.test.mjs b/harness/ruview-kernel/test/binary.test.mjs new file mode 100644 index 000000000..b3a9b0fe3 --- /dev/null +++ b/harness/ruview-kernel/test/binary.test.mjs @@ -0,0 +1,54 @@ +// Binary (Float64Array) fast path (ADR-368 amendment). Evidence: SYNTHETIC. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { flattenFrames, MAX_INPUT_BYTES } from '../src/abi.js'; +import { deepClose, loadKernel } from '../src/index.js'; +import { napiSkip, requireWasm } from './helpers.mjs'; + +test('flattenFrames packs uniform frames and defers irregular ones to JSON', () => { + const f = (a, p) => (p ? { amplitudes: a, phases: p } : { amplitudes: a }); + const packed = flattenFrames([f([1, 2], [5, 6]), f([3, 4], [7, 8])], 2); + assert.deepEqual([...packed.data], [1, 2, 3, 4, 5, 6, 7, 8]); + assert.equal(packed.withPhases, true); + assert.deepEqual([...flattenFrames([f([1, 2]), f([3, 4])], 2).data], [1, 2, 3, 4]); + assert.equal(flattenFrames([f([1, 2])], 3), null, 'width mismatch'); + assert.equal(flattenFrames([f([1, 2], [1, 2]), f([3, 4])], 2), null, 'mixed phases'); + assert.equal(flattenFrames([{ amplitudes: [1, 2], extra: 1 }], 2), null, 'unknown key'); + assert.equal(flattenFrames([f([1, '2'])], 2), null, 'non-number'); + assert.equal(flattenFrames([null], 2), null); + assert.equal(flattenFrames([], 2).data.length, 0); +}); + +function transportsAgree(k) { + assert.equal(k.binary, true, `${k.backend} exposes the binary transport`); + const { frames } = k.synthesize({ seconds: 30, n_subcarriers: 16 }); + const viaBinary = k.analyze(frames, { n_subcarriers: 16 }, { includeReadings: true }); + const viaJson = k.call('analyze', { config: { n_subcarriers: 16 }, frames, include_readings: true }); + // JSON number parsing perturbs inputs by ULPs; outputs agree to 1e-9. + assert.deepEqual(deepClose(viaBinary, viaJson), []); + const s = k.openSession({ n_subcarriers: 16 }); + for (let i = 0; i < frames.length; i += 50) s.push(frames.slice(i, i + 50)); + assert.deepEqual(s.summary(), viaBinary.summary, 'streamed binary pushes equal batch binary analysis'); + s.close(); +} + +test('wasm binary and JSON transports agree', () => { + requireWasm(); + transportsAgree(loadKernel({ backend: 'wasm' })); +}); + +test('napi binary and JSON transports agree', { skip: napiSkip }, () => { + transportsAgree(loadKernel({ backend: 'napi' })); +}); + +test('binary entry points validate input and keep precise JSON errors for bad frames', () => { + requireWasm(); + const k = loadKernel({ backend: 'wasm' }); + assert.throws(() => k.analyzeFlat([1, 2, 3]), (e) => e.code === 'invalid_request'); + assert.throws(() => k.analyzeFlat(new Float64Array(MAX_INPUT_BYTES / 8 + 1)), (e) => e.code === 'limit_exceeded'); + assert.throws(() => k.analyzeFlat(new Float64Array(5), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request'); + assert.throws(() => k.analyzeFlat(Float64Array.of(1, NaN), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request'); + // A malformed frame falls back to JSON, whose error names the frame. + assert.throws(() => k.analyze([{ amplitudes: [1, 2] }, { amplitudes: [1] }], { n_subcarriers: 2 }), (e) => /frames\[1\]/.test(e.message)); + assert.equal(k.info().abi, 1, 'instance healthy after rejected input'); +}); diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index ad47245be..d4c29a322 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -24,7 +24,7 @@ "CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d", "LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06", "README.md": "364173d393785775ea6c85af29f20c290211dacc13ebed3ae673636622a1f3d3", - "bin/cli.js": "d0d02f8885d271e633f3454fa4f8c33f7ed3dfe473f6fa2837e4fe281e2cdd34", + "bin/cli.js": "c53d8916283da25b996655071e27710c4bfd713b21c0013cd8ab97497e1dd7e1", "brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572", "flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce", @@ -32,7 +32,7 @@ "flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214", "flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab", "flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0", - "package.json": "207479e243f1e2c646d8d5081feab4e557dcc54d4f1b71fb23c88d860abb37e1", + "package.json": "5dfe08604660f6d2b2266a69da614763db1ff071d15a62dd57afbcb4333720a5", "scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565", "scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b", "scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10", @@ -45,8 +45,8 @@ "skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76", "src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6", "src/devices/esp32.js": "2e643a20d5a23c9f47dea0d3bc3443b0f2cc97076087d9446a46e50c34494d8b", - "src/devices/lidar.js": "e4cef802278dcc5274a6f1bfac970ba565514cf13ca6986dd6f485c62270d6bc", - "src/devices/mmwave.js": "97275e787839c9a7d81d96fbc08360ff75b2b143fbdadec0c03dcec733636a4c", + "src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629", + "src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526", "src/devices/registry.js": "66b139c2322e6971641b7855a3241982039091d4ae192a7d946e0e7c51d329c0", "src/devices/serial-pump.js": "bb1351cd129fd00aa71a5d06104efcff5c0a56f3507e39e1df05395fe7fe7844", "src/doctor.js": "0fc72096d991aebfde344d67cec5accbdf429bd22e49edd0940aab36b0a36a3e", @@ -67,10 +67,10 @@ "src/sdk.d.ts": "302e4c0035040734b63fa77f5dc73d4db883410c7b146596b76977e1a4fc9ff6", "src/sdk.js": "2b44c498b423265536e251a11d1fd8fdc8a04a98161a5e2ce17ce0a5d7c4ecd2", "src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb", - "src/tools.js": "9755c5682e16bd5eac508110b761097f35a0e44b87007cd1fff5d128453df36b", - "src/training.js": "3488df675e07debe48a1d721da02c28e233ade9f2a75aa8dab33c95de456e3fe" + "src/tools.js": "eade40d776361493393a7dcafee9611e31d60d24e64aec1f7c9fd3421c9222cf", + "src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956" }, - "filesDigest": "d986410681ac7ee50749471246952830a57a12c903cffe1d2ff2bb281f89e9cd", + "filesDigest": "12f28ad45374209e96fb7386f7499eefbff17742d55dc47c0299388627f2afa2", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index 99799e1fc..992993cf6 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -56dd458cc13d6ea54f9bb225680343a0562fc7375bb5d6a053803b73f809004b manifest.json +4bef516201eb49d0e4df5819992170833b9d04a9079f2219d617b6ab892740a8 manifest.json diff --git a/harness/ruview/bin/cli.js b/harness/ruview/bin/cli.js index fdd4503b6..c791bbef7 100644 --- a/harness/ruview/bin/cli.js +++ b/harness/ruview/bin/cli.js @@ -44,7 +44,7 @@ const VERB_TO_TOOL = { // Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369). const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']); -const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets']); +const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames']); const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified']); function toSchemaArgs(flags) { diff --git a/harness/ruview/package.json b/harness/ruview/package.json index d73ac290b..8c096f8e0 100644 --- a/harness/ruview/package.json +++ b/harness/ruview/package.json @@ -48,7 +48,8 @@ "manifest:update": "node ./scripts/update-manifest.mjs", "manifest:verify": "node ./scripts/verify-manifest.mjs", "prepack": "node ./scripts/sync-skills.mjs && node ./scripts/update-manifest.mjs --quiet && node ./scripts/verify-manifest.mjs --quiet", - "prepublishOnly": "npm test && node ./scripts/verify-manifest.mjs" + "prepublishOnly": "npm test && node ./scripts/verify-manifest.mjs", + "test:e2e:devices": "node --test test/e2e/devices.e2e.mjs" }, "keywords": [ "wifi-sensing", diff --git a/harness/ruview/src/devices/lidar.js b/harness/ruview/src/devices/lidar.js index e68b203d8..0ddba2f8a 100644 --- a/harness/ruview/src/devices/lidar.js +++ b/harness/ruview/src/devices/lidar.js @@ -93,11 +93,13 @@ export function depthStats(packet) { /** * Connect to the iPhone LiDAR relay as a WebSocket client. - * args: { url: ws(s)://host:port/ws/lidar, seconds }. The access token comes + * args: { url: ws(s)://host:port/ws/lidar, seconds, max_frames }. Returns early + * once max_frames arrive or the connection is refused. The access token comes * only from RUVIEW_LIDAR_TOKEN (never from tool arguments or logs). */ export function readIphoneLidar(args, deps = {}) { const seconds = Math.min(Math.max(args.seconds ?? 5, 1), 60); + const maxFrames = Math.min(Math.max(args.max_frames ?? 10_000, 1), 10_000); let url; try { url = new URL(args.url); @@ -127,7 +129,7 @@ export function readIphoneLidar(args, deps = {}) { resolve({ ok: n > 0 && !extra.reason, source: 'iphone', relay: shown, seconds, frames: n, rejectedFrames: rejected, - fps: Number((n / seconds).toFixed(2)), + fps: Number((n / Math.max((Date.now() - started) / 1000, 0.001)).toFixed(2)), ...(n ? { last: frames.at(-1), confidentFractionMean: Number((frames.reduce((a, f) => a + f.confidentFraction, 0) / n).toFixed(3)) } : {}), ...(n === 0 && !extra.reason ? (opened ? { reason: 'no_frames', detail: lastError, remedy: 'Connected, but the iPhone is not streaming: start capture in the RuView LiDAR app pointed at this relay.' } @@ -136,6 +138,7 @@ export function readIphoneLidar(args, deps = {}) { ...extra, }); }; + const started = Date.now(); const timer = setTimeout(() => finish(), seconds * 1000); try { ws = new WS(url.href); @@ -149,12 +152,16 @@ export function readIphoneLidar(args, deps = {}) { const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8'); if (text.length > 4_000_000) throw new Error('frame too large'); frames.push(depthStats(JSON.parse(text))); - if (frames.length > 10_000) frames.shift(); + if (frames.length >= maxFrames) finish(); } catch (error) { rejected += 1; lastError = error.message; } }; - ws.onerror = () => { lastError = 'websocket error'; }; + ws.onerror = () => { + lastError = 'websocket error'; + if (!opened) finish(); // refused before opening: nothing will arrive + }; ws.onclose = (event) => { - if (!frames.length && event?.code && event.code !== 1000) finish({ ok: false, reason: 'relay_closed', detail: `close code ${event.code}${event.code === 1008 ? ' (token rejected?)' : ''}` }); + if (!opened) finish(); + else if (!frames.length && event?.code && event.code !== 1000) finish({ ok: false, reason: 'relay_closed', detail: `close code ${event.code}${event.code === 1008 ? ' (token rejected?)' : ''}` }); }; }); } diff --git a/harness/ruview/src/devices/mmwave.js b/harness/ruview/src/devices/mmwave.js index b2bcf15f2..035aa32c7 100644 --- a/harness/ruview/src/devices/mmwave.js +++ b/harness/ruview/src/devices/mmwave.js @@ -140,31 +140,51 @@ export function parseMmwave(model, bytes) { return { frames, errors: parser.errors }; } -/** Read an mmWave radar. args: { port, model: auto|mr60bha2|ld2410, seconds }. */ +/** + * Read an mmWave radar. args: { port, model: auto|mr60bha2|ld2410, seconds }. + * Auto-detect probes each model's baud for up to 2 s; the probe's frames are + * kept and only the remainder of the window is read afterwards, so a request + * for N seconds costs about N seconds rather than probe + N. + */ export async function readMmwave(args, deps) { const seconds = args.seconds ?? 10; - const models = !args.model || args.model === 'auto' ? ['mr60bha2', 'ld2410'] : [args.model]; + const auto = !args.model || args.model === 'auto'; + const models = auto ? ['mr60bha2', 'ld2410'] : [args.model]; + const probeSeconds = auto ? Math.min(seconds, 2) : seconds; const attempts = []; - for (const model of models) { - let cap; + const capture = async (model, secs) => { try { - cap = await readSerial({ port: args.port, baud: MMWAVE_MODELS[model].baud, seconds: models.length > 1 ? Math.min(seconds, 4) : seconds }, deps); + return await readSerial({ port: args.port, baud: MMWAVE_MODELS[model].baud, seconds: secs }, deps); } catch (error) { return { ok: false, reason: error.reason || 'invalid_arguments', detail: error.message }; } + }; + for (const model of models) { + const cap = await capture(model, probeSeconds); if (!cap.ok) return { ok: false, reason: cap.reason, detail: cap.detail, remedy: cap.remedy }; - const { frames, errors } = parseMmwave(model, cap.bytes); + let { frames, errors } = parseMmwave(model, cap.bytes); attempts.push({ model, bytes: cap.bytes.length, frames: frames.length, errors }); - if (frames.length >= 2) { - // Auto-detect used a short probe; re-read for the full window on the detected model. - if (models.length > 1 && seconds > 4) return { ...(await readMmwave({ ...args, model }, deps)), detected: model }; - return { - ok: true, - ...summarizeMmwave(model, frames, errors, cap.bytes.length, cap.seconds), - detected: models.length > 1 ? model : undefined, - evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)', - }; + if (frames.length < 2) continue; + let bytes = cap.bytes.length; + let elapsed = cap.seconds; + const remaining = seconds - probeSeconds; + if (auto && remaining >= 0.5) { + const more = await capture(model, remaining); + if (more.ok) { + // A fresh parser: the probe may have ended mid-frame. + const extra = parseMmwave(model, more.bytes); + frames = frames.concat(extra.frames); + errors += extra.errors; + bytes += more.bytes.length; + elapsed += more.seconds; + } } + return { + ok: true, + ...summarizeMmwave(model, frames, errors, bytes, elapsed), + ...(auto ? { detected: model } : {}), + evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)', + }; } return { ok: false, reason: 'no_valid_frames', attempts, diff --git a/harness/ruview/src/tools.js b/harness/ruview/src/tools.js index ab40f0163..6a149b992 100644 --- a/harness/ruview/src/tools.js +++ b/harness/ruview/src/tools.js @@ -522,6 +522,7 @@ export const TOOLS = { baud: { type: 'number', enum: [115200, 256000, 460800, 921600], description: 'rplidar baud. Default 115200 (A1/A2M8).' }, url: { type: 'string', minLength: 5, maxLength: 512, description: 'iphone relay: ws(s)://host:port/ws/lidar (no token in the URL).' }, seconds: { type: 'number', minimum: 1, maximum: 60, description: 'Default 5.' }, + max_frames: { type: 'number', minimum: 1, maximum: 10000, description: 'iphone: stop after this many depth frames.' }, }, }, handler(args = {}) { diff --git a/harness/ruview/src/training.js b/harness/ruview/src/training.js index bf45f6ca2..fcf551e39 100644 --- a/harness/ruview/src/training.js +++ b/harness/ruview/src/training.js @@ -150,9 +150,9 @@ export async function runTraining(args, deps) { const r = await deps.exec(plan.cmd, plan.args, { cwd: plan.cwd, timeoutMs: plan.timeoutMs }); const text = `${r.stdout}\n${r.stderr}`; const hint = r.ok ? null : /Cannot find a libtorch install/i.test(text) - ? 'libtorch not found: set LIBTORCH, or LIBTORCH_USE_PYTORCH=1 with the PyTorch version tch expects installed (tch 0.24 → torch 2.11.0).' + ? 'libtorch not found: set LIBTORCH, or LIBTORCH_USE_PYTORCH=1 with the PyTorch version tch expects installed (tch 0.24 expects torch 2.11; see the torch-sys build script).' : /torch_api(?:_generated)?\.cpp|version mismatch|LIBTORCH_BYPASS_VERSION_CHECK/i.test(text) - ? 'libtorch version does not match the tch crate (tch 0.24 expects torch 2.11.0); install that version instead of bypassing the check.' + ? 'libtorch version does not match the tch crate (tch 0.24 expects torch 2.11); install that version instead of bypassing the check.' : /libtorch|torch-sys|LIBTORCH/i.test(text) ? 'libtorch build/link failed: check LIBTORCH / LD_LIBRARY_PATH (DYLD_LIBRARY_PATH on macOS).' : /requires the `tch-backend`/i.test(text) ? 'Build with the tch-backend feature.' : null; diff --git a/harness/ruview/test/devices.test.mjs b/harness/ruview/test/devices.test.mjs index c8f874923..bb1a9c052 100644 --- a/harness/ruview/test/devices.test.mjs +++ b/harness/ruview/test/devices.test.mjs @@ -66,7 +66,7 @@ test('ESP32 packets parse exactly like the firmware structs', () => { test('ESP32 capture receives a live UDP stream on loopback and measures loss', async () => { const port = 20000 + Math.floor(Math.random() * 20000); - const pending = captureEsp32({ udp_port: port, bind: '127.0.0.1', seconds: 1.5 }); + const pending = captureEsp32({ udp_port: port, bind: '127.0.0.1', seconds: 5, max_packets: 7 }); const sender = dgram.createSocket('udp4'); await new Promise((r) => setTimeout(r, 150)); const sends = []; @@ -123,8 +123,10 @@ test('mmWave read auto-detects MR60BHA2 through the serial pump', async () => { const deps = pumpDeps(stream); const r = await readMmwave({ port: '/dev/ttyUSB0', seconds: 3 }, deps); assert.equal(r.ok, true, JSON.stringify(r)); - assert.deepEqual([r.model, r.detected, r.frames, r.breathingBpmMean, r.heartBpmMean, r.distanceCmMean, r.presentFraction], ['mr60bha2', 'mr60bha2', 4, 14.5, 71, 92.5, 1]); - assert.equal(deps.calls[0].args[3], '115200'); + // Probe (2 s) frames are kept and only the remaining 1 s is read again, + // so the fake pump's 4-frame stream is seen twice. + assert.deepEqual([r.model, r.detected, r.frames, r.breathingBpmMean, r.heartBpmMean, r.distanceCmMean, r.presentFraction], ['mr60bha2', 'mr60bha2', 8, 14.5, 71, 92.5, 1]); + assert.deepEqual(deps.calls.map((c) => [c.args[3], c.args[4]]), [['115200', '2'], ['115200', '1']]); const ld = await readMmwave({ port: 'COM5', model: 'ld2410', seconds: 2 }, pumpDeps([...ld2410Frame(3, 90, 60), ...ld2410Frame(0, 0, 0)])); assert.deepEqual([ld.ok, ld.model, ld.frames], [true, 'ld2410', 2]); const noise = await readMmwave({ port: 'COM5', seconds: 2 }, pumpDeps([1, 2, 3, 4, 5])); @@ -171,11 +173,11 @@ test('iPhone LiDAR relay client returns statistics only and keeps the token out class FakeWS { constructor(url) { opened = url; - setTimeout(() => { this.onmessage?.({ data: JSON.stringify(lidarPacket()) }); this.onmessage?.({ data: '{"type":"bogus"}' }); }, 20); + setTimeout(() => { this.onopen?.(); this.onmessage?.({ data: '{"type":"bogus"}' }); this.onmessage?.({ data: JSON.stringify(lidarPacket()) }); }, 20); } close() {} } - const r = await readIphoneLidar({ url: 'ws://10.0.0.5:8787/ws/lidar', seconds: 1 }, { WebSocket: FakeWS, env: { RUVIEW_LIDAR_TOKEN: 'tok123' } }); + const r = await readIphoneLidar({ url: 'ws://10.0.0.5:8787/ws/lidar', seconds: 5, max_frames: 1 }, { WebSocket: FakeWS, env: { RUVIEW_LIDAR_TOKEN: 'tok123' } }); assert.equal(r.ok, true, JSON.stringify(r)); assert.equal(r.frames, 1); assert.equal(r.rejectedFrames, 1); @@ -184,7 +186,7 @@ test('iPhone LiDAR relay client returns statistics only and keeps the token out assert.equal(r.relay, 'ws://10.0.0.5:8787/ws/lidar', 'token never echoed'); assert.ok(!JSON.stringify(r).includes('tok123')); class NeverOpens { constructor() { setTimeout(() => this.onerror?.({}), 10); } close() {} } - const refused = await readIphoneLidar({ url: 'ws://10.0.0.5:8787/ws/lidar', seconds: 1 }, { WebSocket: NeverOpens, env: {} }); + const refused = await readIphoneLidar({ url: 'ws://10.0.0.5:8787/ws/lidar', seconds: 30 }, { WebSocket: NeverOpens, env: {} }); assert.deepEqual([refused.ok, refused.reason], [false, 'connect_failed']); assert.equal((await readIphoneLidar({ url: 'ws://h/ws/lidar?token=x' })).reason, 'invalid_url'); assert.equal((await readIphoneLidar({ url: 'http://h/ws' })).reason, 'invalid_url'); diff --git a/harness/ruview/test/e2e/devices.e2e.mjs b/harness/ruview/test/e2e/devices.e2e.mjs new file mode 100644 index 000000000..65c0a9aa9 --- /dev/null +++ b/harness/ruview/test/e2e/devices.e2e.mjs @@ -0,0 +1,135 @@ +// Device-access end-to-end suite (ADR-373), driven through the real CLI. +// * MR60BHA2 and RPLIDAR are emulated on pseudo-terminals and read through +// real pyserial. The harness deliberately refuses /dev/pts/* port names, +// so each pty is exposed as /dev/ttyRUVIEWE2E (root or `sudo -n`). +// * The ESP32 node is a UDP sender of firmware-format packets. +// * The iPhone LiDAR path uses the repository's real relay. +// Every number is SYNTHETIC. Set RUVIEW_E2E_REQUIRE=1 (CI) to turn missing +// prerequisites into failures instead of skips. +import { describe, it as test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import dgram from 'node:dgram'; +import { existsSync, symlinkSync, unlinkSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = join(HERE, '..', '..', 'bin', 'cli.js'); +const REPO = join(HERE, '..', '..', '..', '..'); +const REQUIRE = process.env.RUVIEW_E2E_REQUIRE === '1'; +const python = ['python3', 'python'].find((p) => spawnSync(p, ['-c', 'import serial'], { stdio: 'ignore' }).status === 0); +const posix = process.platform === 'linux' || process.platform === 'darwin'; +const canLink = posix && (process.getuid?.() === 0 || spawnSync('sudo', ['-n', 'true'], { stdio: 'ignore' }).status === 0); +const skip = (cond, why) => (cond ? false : (REQUIRE ? false : why)); + +function cli(args, env = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { env: { ...process.env, ...env } }); + let out = ''; + child.stdout.on('data', (d) => { out += d; }); + child.on('close', (code) => { + try { resolve({ code, json: JSON.parse(out) }); } catch { resolve({ code, json: null, raw: out }); } + }); + }); +} + +async function withEmulator(kind, seconds, index, fn) { + assert.ok(python, 'python with pyserial is required'); + assert.ok(canLink, 'root or passwordless sudo is required to expose the pty as /dev/tty*'); + const child = spawn(python, [join(HERE, 'emulate_sensor.py'), kind, String(seconds)]); + const pts = await new Promise((resolve, reject) => { + child.stdout.on('data', (d) => { const m = /READY (\S+)/.exec(String(d)); if (m) resolve(m[1]); }); + child.on('exit', (code) => reject(new Error(`emulator exited ${code}`))); + }); + const link = `/dev/ttyRUVIEWE2E${index}`; + const root = process.getuid?.() === 0; + if (root) { if (existsSync(link)) unlinkSync(link); symlinkSync(pts, link); } + else assert.equal(spawnSync('sudo', ['-n', 'ln', '-sf', pts, link]).status, 0); + try { + return await fn(link); + } finally { + if (root) { try { unlinkSync(link); } catch { /* gone */ } } else spawnSync('sudo', ['-n', 'rm', '-f', link]); + child.kill(); + } +} + +describe('RuView device access e2e (SYNTHETIC devices)', { concurrency: true }, () => { +test('60 GHz MR60BHA2 over pyserial: auto-detect, zero checksum errors, decoded values', { skip: skip(python && canLink, 'needs pyserial + root/sudo for a /dev/tty* link') }, async () => { + await withEmulator('mr60', 12, 0, async (port) => { + const r = await cli(['mmwave', '--port', port, '--seconds', '5']); + assert.equal(r.code, 0, JSON.stringify(r.json || r.raw)); + const d = r.json; + assert.deepEqual([d.model, d.detected, d.checksumErrors, d.presentFraction], ['mr60bha2', 'mr60bha2', 0, 1]); + assert.ok(d.frames >= 50, `frames=${d.frames}`); + assert.equal(d.breathingBpmMean, 14.8); + assert.equal(d.heartBpmMean, 68.5); + assert.equal(d.distanceCmMean, 87); + }); +}); + +test('RPLIDAR over pyserial: SCAN/STOP handshake, full revolutions and coverage', { skip: skip(python && canLink, 'needs pyserial + root/sudo for a /dev/tty* link') }, async () => { + await withEmulator('rplidar', 10, 1, async (port) => { + const r = await cli(['lidar', '--source', 'rplidar', '--port', port, '--seconds', '3']); + assert.equal(r.code, 0, JSON.stringify(r.json || r.raw)); + assert.ok(r.json.revolutions >= 10, `revolutions=${r.json.revolutions}`); + assert.equal(r.json.angularCoverage, 1); + assert.equal(r.json.resyncBytes, 0); + assert.deepEqual(r.json.rangeMm, { min: 1200, median: 1500, max: 1500 }); + }); +}); + +test('ESP32 node UDP stream: per-node rate, zero loss, vitals decoded', async () => { + const port = 21000 + Math.floor(Math.random() * 20000); + const pending = cli(['esp32', '--udp-port', String(port), '--bind', '127.0.0.1', '--seconds', '3']); + await new Promise((r) => setTimeout(r, 700)); + const sock = dgram.createSocket('udp4'); + for (let seq = 0; seq < 40; seq++) { + const b = Buffer.alloc(20 + 64 * 2); + b.writeUInt32LE(0xC5110001, 0); b.writeUInt8(7, 4); b.writeUInt8(1, 5); b.writeUInt16LE(64, 6); + b.writeUInt32LE(2437, 8); b.writeUInt32LE(seq, 12); b.writeInt8(-48, 16); b.writeInt8(-94, 17); + await new Promise((r) => sock.send(b, port, '127.0.0.1', r)); + if (seq === 20) { + const v = Buffer.alloc(32); + v.writeUInt32LE(0xC5110002, 0); v.writeUInt8(7, 4); v.writeUInt8(1, 5); v.writeUInt16LE(1520, 6); v.writeUInt32LE(690000, 8); + await new Promise((r) => sock.send(v, port, '127.0.0.1', r)); + } + await new Promise((r) => setTimeout(r, 25)); + } + sock.close(); + const r = await pending; + assert.equal(r.code, 0, JSON.stringify(r.json)); + const node = r.json.nodes[0]; + assert.deepEqual([r.json.csiNodes, node.nodeId, node.packets.csi, node.csiLossFraction, node.lastVitals.breathingBpm, node.lastVitals.heartBpm], [1, 7, 40, 0, 15.2, 69]); +}); + +const relayDir = join(REPO, 'integrations', 'iphone-lidar', 'web'); +const relayReady = existsSync(join(relayDir, 'node_modules', 'ws')); +test('iPhone LiDAR through the real relay: frames accepted, token enforced and never echoed', { skip: skip(relayReady && typeof WebSocket === 'function', 'run `npm ci` in integrations/iphone-lidar/web (Node 22+)') }, async () => { + const port = 21000 + Math.floor(Math.random() * 20000); + const token = `e2e${Math.random().toString(36).slice(2)}`; + const relay = spawn(process.execPath, ['relay.mjs'], { cwd: relayDir, env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', RUVIEW_LIDAR_TOKEN: token } }); + try { + await new Promise((resolve) => relay.stdout.on('data', (d) => { if (/listening/.test(String(d))) resolve(); })); + const w = 16; const h = 12; + const mm = Buffer.alloc(w * h * 2); + for (let i = 0; i < w * h; i++) mm.writeUInt16LE(1800 + (i % 50), i * 2); + const pkt = JSON.stringify({ type: 'ruview.lidar.depth.v1', depth: { width: w, height: h, encoding: 'u16le-mm+u8-confidence', millimetersBase64: mm.toString('base64'), confidenceBase64: Buffer.alloc(w * h, 2).toString('base64') }, intrinsics: { fx: 200, fy: 200, cx: 8, cy: 6, imageWidth: w, imageHeight: h }, pose: { matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } }); + const phone = new WebSocket(`ws://127.0.0.1:${port}/ws/lidar?token=${token}`); + await new Promise((resolve, reject) => { phone.onopen = resolve; phone.onerror = reject; }); + const timer = setInterval(() => phone.send(pkt), 100); + const url = `ws://127.0.0.1:${port}/ws/lidar`; + const ok = await cli(['lidar', '--source', 'iphone', '--url', url, '--seconds', '2'], { RUVIEW_LIDAR_TOKEN: token }); + const bad = await cli(['lidar', '--source', 'iphone', '--url', url, '--seconds', '1'], { RUVIEW_LIDAR_TOKEN: 'wrong' }); + clearInterval(timer); + phone.close(); + assert.equal(ok.code, 0, JSON.stringify(ok.json)); + assert.ok(ok.json.frames >= 10, `frames=${ok.json.frames}`); + assert.equal(ok.json.last.medianDepthM, 1.824); + assert.ok(!JSON.stringify(ok.json).includes(token), 'token never echoed'); + assert.equal(bad.json.reason, 'connect_failed'); + } finally { + relay.kill(); + } +}); +}); diff --git a/harness/ruview/test/e2e/emulate_sensor.py b/harness/ruview/test/e2e/emulate_sensor.py new file mode 100644 index 000000000..006e83992 --- /dev/null +++ b/harness/ruview/test/e2e/emulate_sensor.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MIT +# Emulates a serial sensor on a pseudo-terminal for the device e2e suite +# (ADR-373). Evidence produced through it is SYNTHETIC, never hardware. +# mr60 Seeed MR60BHA2 frames (breathing, heart, presence, distance) +# rplidar answers SCAN (A5 20) with the descriptor + 5-byte nodes; STOP (A5 25) +# Usage: emulate_sensor.py -> prints "READY ". +import os, pty, struct, sys, time, tty +kind, secs = sys.argv[1], float(sys.argv[2]) +master, slave = pty.openpty() +tty.setraw(slave) +name = os.ttyname(slave) +print('READY', name, flush=True) +def ck(b): + x = 0 + for v in b: x ^= v + return (~x) & 0xff +def mr60(t, p): + h = bytes([1, 0, 1, len(p) >> 8, len(p) & 255, t >> 8, t & 255]) + return h + bytes([ck(h)]) + p + bytes([ck(p)]) +end = time.time() + secs +if kind == 'mr60': + while time.time() < end: + os.write(master, mr60(0x0A14, struct.pack('> 7, d & 255, d >> 8]) + a = (a + 10) % 360 + os.write(master, out) + time.sleep(0.02) diff --git a/v2/crates/ruview-kernel-napi/src/lib.rs b/v2/crates/ruview-kernel-napi/src/lib.rs index ca60f5c39..2b7a49da5 100644 --- a/v2/crates/ruview-kernel-napi/src/lib.rs +++ b/v2/crates/ruview-kernel-napi/src/lib.rs @@ -4,6 +4,7 @@ //! and WASM backends of `@ruvnet/ruview-kernel` stay behaviourally identical. #![deny(clippy::all)] +use napi::bindgen_prelude::Float64Array; use napi_derive::napi; /// JSON ABI version; must equal the WASM module's `rvk_abi_version()`. @@ -20,3 +21,14 @@ pub fn call(op: String, input: String) -> String { r#"{"ok":false,"error":{"code":"internal","message":"kernel panicked"}}"#.to_string() }) } + +/// Binary fast path: frame data as a `Float64Array` (`analyze_flat`, +/// `session_push_flat`), same envelope as [`call`]. +#[napi] +pub fn call_f64(op: String, input: String, data: Float64Array) -> String { + // The kernel only reads the slice, so observing it after a panic is sound. + let values = std::panic::AssertUnwindSafe(&data[..]); + std::panic::catch_unwind(move || ruview_kernel::call_f64(&op, &input, *values)).unwrap_or_else( + |_| r#"{"ok":false,"error":{"code":"internal","message":"kernel panicked"}}"#.to_string(), + ) +} diff --git a/v2/crates/ruview-kernel/examples/profile_stages.rs b/v2/crates/ruview-kernel/examples/profile_stages.rs new file mode 100644 index 000000000..c73daef6d --- /dev/null +++ b/v2/crates/ruview-kernel/examples/profile_stages.rs @@ -0,0 +1,52 @@ +//! Stage timing for the ADR-021 pipeline on SYNTHETIC frames (ADR-368 amendment). +//! +//! Reproducer: `cargo run --release -p ruview-kernel --example profile_stages` +//! Prints wall time per stage over 120 s of 20 Hz, 56-subcarrier synthetic CSI. +use std::time::Instant; +use wifi_densepose_vitals::{ + BreathingExtractor, CsiFrame, CsiVitalPreprocessor, HeartRateExtractor, +}; + +fn main() { + let frames = ruview_kernel::synthesize(&ruview_kernel::SynthRequest { + seconds: 120.0, + ..Default::default() + }) + .expect("synthetic frames"); + let n = 56; + let mut pre = CsiVitalPreprocessor::new(n, 0.05); + let mut breathing = BreathingExtractor::new(n, 20.0, 30.0); + let mut heart = HeartRateExtractor::new(n, 20.0, 15.0); + let weights = vec![1.0 / n as f64; n]; + let (mut t_pre, mut t_br, mut t_hr) = (0u128, 0u128, 0u128); + for (i, f) in frames.iter().enumerate() { + let frame = CsiFrame { + amplitudes: f.amplitudes.clone(), + phases: f.phases.clone(), + n_subcarriers: n, + sample_index: i as u64, + sample_rate_hz: 20.0, + }; + let t = Instant::now(); + let residuals = pre.process(&frame).expect("residuals"); + t_pre += t.elapsed().as_nanos(); + let t = Instant::now(); + breathing.extract(&residuals, &weights); + t_br += t.elapsed().as_nanos(); + let t = Instant::now(); + heart.extract(&residuals, &frame.phases); + t_hr += t.elapsed().as_nanos(); + } + let t = Instant::now(); + let mut session = + ruview_kernel::Session::new(ruview_kernel::SessionConfig::default()).expect("session"); + session.push(&frames).expect("push"); + println!( + "SYNTHETIC frames={} preprocess_us={} breathing_us={} heart_us={} full_session_us={}", + frames.len(), + t_pre / 1000, + t_br / 1000, + t_hr / 1000, + t.elapsed().as_micros() + ); +} diff --git a/v2/crates/ruview-kernel/src/lib.rs b/v2/crates/ruview-kernel/src/lib.rs index da800adb1..85641bbf7 100644 --- a/v2/crates/ruview-kernel/src/lib.rs +++ b/v2/crates/ruview-kernel/src/lib.rs @@ -46,6 +46,8 @@ pub const MAX_SUBCARRIERS: usize = 512; pub const MAX_FRAMES_PER_CALL: usize = 100_000; /// Maximum frames `synthesize` may generate. pub const MAX_SYNTH_FRAMES: usize = 100_000; +/// Maximum f64 values in one binary (flat) request. +pub const MAX_FLAT_VALUES: usize = MAX_INPUT_BYTES / 8; /// Maximum concurrently open sessions. pub const MAX_SESSIONS: usize = 64; @@ -59,6 +61,8 @@ pub const OPERATIONS: &[&str] = &[ "session_push", "session_summary", "session_close", + "analyze_flat", + "session_push_flat", ]; /// Structured kernel error. @@ -317,79 +321,118 @@ impl Session { for (i, f) in frames.iter().enumerate() { self.check_frame(i, f)?; } - let n = self.config.n_subcarriers; - let zeros = vec![0.0; n]; let mut out = Vec::new(); for f in frames { - let phases = if f.phases.is_empty() { - zeros.clone() - } else { - f.phases.clone() - }; - let frame = CsiFrame { - amplitudes: f.amplitudes.clone(), - phases, - n_subcarriers: n, - sample_index: self.frames, - sample_rate_hz: self.config.sample_rate_hz, - }; - self.frames += 1; - let Some(residuals) = self.pre.process(&frame) else { - continue; - }; - let motion = (residuals.iter().map(|r| r * r).sum::() / n as f64).sqrt(); - self.motion_sum += motion; - if let Some(rr) = self.breathing.extract(&residuals, &self.weights) { - self.last_rr = rr; - } - if let Some(hr) = self.heart.extract(&residuals, &frame.phases) { - self.last_hr = hr; - } - if self.frames % self.emit_every as u64 == 0 { - let t_secs = self.frames as f64 / self.config.sample_rate_hz; - let signal_quality = (self.last_rr.confidence + self.last_hr.confidence) / 2.0; - let vital = VitalReading { - respiratory_rate: self.last_rr.clone(), - heart_rate: self.last_hr.clone(), - subcarrier_count: n, - signal_quality, - timestamp_secs: t_secs, - }; - let alerts: Vec = if self.last_rr.status == VitalStatus::Unavailable - && self.last_hr.status == VitalStatus::Unavailable - { - Vec::new() // warm-up: nothing to judge yet - } else { - self.anomaly - .check(&vital) - .into_iter() - .map(|a| Alert { - vital: a.vital_type, - kind: a.alert_type, - severity: a.severity, - message: a.message, - }) - .collect() - }; - self.alerts += alerts.len() as u64; - self.store.push(vital); - self.readings += 1; - let reading = Reading { - frame: self.frames, - t_secs, - respiratory: (&self.last_rr).into(), - heart: (&self.last_hr).into(), - motion_energy: motion, - signal_quality, - alerts, - }; - self.last = Some(reading.clone()); - out.push(reading); + let phases = (!f.phases.is_empty()).then_some(f.phases.as_slice()); + if let Some(r) = self.step(&f.amplitudes, phases) { + out.push(r); } } Ok(out) } + /// Binary fast path (ADR-368): `data` holds every frame's amplitudes + /// back to back (`frames * n` values), followed by the same layout for + /// phases when `with_phases`. Validated all-or-nothing like [`push`]. + pub fn push_flat(&mut self, data: &[f64], with_phases: bool) -> KResult> { + let n = self.config.n_subcarriers; + let planes = if with_phases { 2 } else { 1 }; + if data.len() % (n * planes) != 0 { + return Err(KernelError::invalid(format!( + "flat data length {} is not a multiple of n_subcarriers * {planes} ({})", + data.len(), + n * planes + ))); + } + let count = data.len() / (n * planes); + if count > MAX_FRAMES_PER_CALL { + return Err(KernelError::new( + "limit_exceeded", + format!("at most {MAX_FRAMES_PER_CALL} frames per call"), + )); + } + if let Some(i) = data.iter().position(|v| !v.is_finite() || v.abs() > 1.0e9) { + return Err(KernelError::invalid(format!( + "frames[{}] contains a non-finite or out-of-range value", + (i % (count * n).max(1)) / n + ))); + } + let (amps, phases) = data.split_at(count * n); + let mut out = Vec::new(); + for k in 0..count { + let a = &s[k * n..(k + 1) * n]; + let p = with_phases.then(|| &phases[k * n..(k + 1) * n]); + if let Some(r) = self.step(a, p) { + out.push(r); + } + } + Ok(out) + } + + /// Process one validated frame; returns a reading at emission boundaries. + fn step(&mut self, amplitudes: &[f64], phases: Option<&[f64]>) -> Option { + let n = self.config.n_subcarriers; + let frame = CsiFrame { + amplitudes: amplitudes.to_vec(), + phases: phases.map_or_else(|| vec![0.0; n], <[f64]>::to_vec), + n_subcarriers: n, + sample_index: self.frames, + sample_rate_hz: self.config.sample_rate_hz, + }; + self.frames += 1; + let residuals = self.pre.process(&frame)?; + let motion = (residuals.iter().map(|r| r * r).sum::() / n as f64).sqrt(); + self.motion_sum += motion; + if let Some(rr) = self.breathing.extract(&residuals, &self.weights) { + self.last_rr = rr; + } + if let Some(hr) = self.heart.extract(&residuals, &frame.phases) { + self.last_hr = hr; + } + if self.frames % self.emit_every as u64 != 0 { + return None; + } + let t_secs = self.frames as f64 / self.config.sample_rate_hz; + let signal_quality = (self.last_rr.confidence + self.last_hr.confidence) / 2.0; + let vital = VitalReading { + respiratory_rate: self.last_rr.clone(), + heart_rate: self.last_hr.clone(), + subcarrier_count: n, + signal_quality, + timestamp_secs: t_secs, + }; + let alerts: Vec = if self.last_rr.status == VitalStatus::Unavailable + && self.last_hr.status == VitalStatus::Unavailable + { + Vec::new() // warm-up: nothing to judge yet + } else { + self.anomaly + .check(&vital) + .into_iter() + .map(|a| Alert { + vital: a.vital_type, + kind: a.alert_type, + severity: a.severity, + message: a.message, + }) + .collect() + }; + self.alerts += alerts.len() as u64; + self.store.push(vital); + self.readings += 1; + let reading = Reading { + frame: self.frames, + t_secs, + respiratory: (&self.last_rr).into(), + heart: (&self.last_hr).into(), + motion_energy: motion, + signal_quality, + alerts, + }; + self.last = Some(reading.clone()); + Some(reading) + } + pub fn summary(&self) -> Summary { let stats = self.store.stats(); let (rr, hr, valid) = match &stats { @@ -563,6 +606,25 @@ struct PushRequest { frames: Vec, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AnalyzeFlatRequest { + #[serde(default)] + config: SessionConfig, + #[serde(default)] + phases: bool, + #[serde(default)] + include_readings: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PushFlatRequest { + session: u32, + #[serde(default)] + phases: bool, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct HandleRequest { @@ -592,6 +654,7 @@ pub fn info() -> Value { "max_frames_per_call": MAX_FRAMES_PER_CALL, "max_synth_frames": MAX_SYNTH_FRAMES, "max_sessions": MAX_SESSIONS, + "max_flat_values": MAX_FLAT_VALUES, }, "target": if cfg!(target_arch = "wasm32") { "wasm32" } else { "native" }, "evidence_policy": "Outputs are signal-processing estimates, not clinical or camera-grade measurements; synthesize() output is SYNTHETIC.", @@ -671,6 +734,9 @@ fn dispatch(op: &str, input: &str) -> KResult { Ok(json!({ "session": req.session, "closed": closed })) }) } + "analyze_flat" | "session_push_flat" => Err(KernelError::invalid(format!( + "{op} takes binary frame data; use call_f64" + ))), _ => Err(KernelError::new( "unknown_operation", format!( @@ -681,12 +747,63 @@ fn dispatch(op: &str, input: &str) -> KResult { } } +fn dispatch_f64(op: &str, input: &str, data: &[f64]) -> KResult { + if input.len() > MAX_INPUT_BYTES { + return Err(KernelError::new( + "limit_exceeded", + format!("request exceeds {MAX_INPUT_BYTES} bytes"), + )); + } + if data.len() > MAX_FLAT_VALUES { + return Err(KernelError::new( + "limit_exceeded", + format!("flat data exceeds {MAX_FLAT_VALUES} values"), + )); + } + match op { + "analyze_flat" => { + let req: AnalyzeFlatRequest = parse(input)?; + let mut s = Session::new(req.config)?; + let readings = s.push_flat(data, req.phases)?; + let mut out = json!({ "config": s.config(), "summary": s.summary() }); + if req.include_readings { + out["readings"] = to_value(&readings)?; + } + Ok(out) + } + "session_push_flat" => { + let req: PushFlatRequest = parse(input)?; + with_registry(|r| { + let s = r + .sessions + .get_mut(&req.session) + .ok_or_else(|| KernelError::new("unknown_session", "no such session"))?; + let readings = s.push_flat(data, req.phases)?; + Ok(json!({ "session": req.session, "readings": readings, "frames": s.frames })) + }) + } + _ => Err(KernelError::invalid(format!( + "{op} does not take binary frame data" + ))), + } +} + +fn envelope(result: KResult) -> String { + match result { + Ok(result) => json!({ "ok": true, "result": result }), + Err(e) => json!({ "ok": false, "error": { "code": e.code, "message": e.message } }), + } + .to_string() +} + /// The single ABI entry point: operation name + JSON request → JSON envelope. /// Never panics on caller input. pub fn call(op: &str, input: &str) -> String { - let envelope = match dispatch(op, input) { - Ok(result) => json!({ "ok": true, "result": result }), - Err(e) => json!({ "ok": false, "error": { "code": e.code, "message": e.message } }), - }; - envelope.to_string() + envelope(dispatch(op, input)) +} + +/// Binary fast path: same envelope, with frame data as a flat `f64` slice +/// (`analyze_flat`, `session_push_flat`) instead of JSON numbers. +pub fn call_f64(op: &str, input: &str, data: &[f64]) -> String { + envelope(dispatch_f64(op, input, data)) } diff --git a/v2/crates/ruview-kernel/src/wasm_abi.rs b/v2/crates/ruview-kernel/src/wasm_abi.rs index 65a4812ca..f9aec240b 100644 --- a/v2/crates/ruview-kernel/src/wasm_abi.rs +++ b/v2/crates/ruview-kernel/src/wasm_abi.rs @@ -52,6 +52,56 @@ unsafe fn take(ptr: *mut u8, len: usize) -> Vec { v } +/// Write a response string into a fresh module buffer and pack (ptr, len). +unsafe fn respond(response: &str) -> u64 { + let bytes = response.as_bytes(); + let out = rvk_alloc(bytes.len()); + if out.is_null() { + return 0; + } + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out, bytes.len()); + ((out as u64) << 32) | bytes.len() as u64 +} + +/// Binary fast path: like `rvk_call`, plus `data` holding little-endian +/// `f64` values (`data_len` bytes, a multiple of 8). Consumes all buffers. +/// +/// # Safety +/// Each pointer must come from `rvk_alloc` with the given length. +#[no_mangle] +pub unsafe extern "C" fn rvk_call_f64( + op_ptr: *mut u8, + op_len: usize, + in_ptr: *mut u8, + in_len: usize, + data_ptr: *mut u8, + data_len: usize, +) -> u64 { + let op = take(op_ptr, op_len); + let input = take(in_ptr, in_len); + // Decode in place from the byte buffer (alignment-agnostic), then free it. + let values: Vec = if data_ptr.is_null() { + Vec::new() + } else { + let bytes = std::slice::from_raw_parts(data_ptr, data_len); + let v = bytes + .chunks_exact(8) + .map(|c| f64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]])) + .collect(); + rvk_free(data_ptr, data_len); + v + }; + let response = if data_len % 8 != 0 { + r#"{"ok":false,"error":{"code":"invalid_request","message":"binary data length must be a multiple of 8"}}"#.to_string() + } else { + match (std::str::from_utf8(&op), std::str::from_utf8(&input)) { + (Ok(op), Ok(input)) => crate::call_f64(op, input, &values), + _ => r#"{"ok":false,"error":{"code":"invalid_request","message":"operation and request must be UTF-8"}}"#.to_string(), + } + }; + respond(&response) +} + /// Run one operation. Consumes both input buffers. /// /// # Safety @@ -70,11 +120,5 @@ pub unsafe extern "C" fn rvk_call( (Ok(op), Ok(input)) => crate::call(op, input), _ => r#"{"ok":false,"error":{"code":"invalid_request","message":"operation and request must be UTF-8"}}"#.to_string(), }; - let bytes = response.as_bytes(); - let out = rvk_alloc(bytes.len()); - if out.is_null() { - return 0; - } - std::ptr::copy_nonoverlapping(bytes.as_ptr(), out, bytes.len()); - ((out as u64) << 32) | bytes.len() as u64 + respond(&response) } diff --git a/v2/crates/ruview-kernel/tests/abi.rs b/v2/crates/ruview-kernel/tests/abi.rs index c5655c6e4..31f97cf6c 100644 --- a/v2/crates/ruview-kernel/tests/abi.rs +++ b/v2/crates/ruview-kernel/tests/abi.rs @@ -172,3 +172,100 @@ fn session_count_is_bounded() { ok("session_close", json!({ "session": id })); } } + +fn flatten(frames: &[ruview_kernel::FrameInput], with_phases: bool) -> Vec { + let mut out: Vec = frames + .iter() + .flat_map(|f| f.amplitudes.iter().copied()) + .collect(); + if with_phases { + out.extend(frames.iter().flat_map(|f| f.phases.iter().copied())); + } + out +} + +#[test] +fn flat_binary_path_matches_json_path_exactly() { + let req = SynthRequest { + seconds: 40.0, + n_subcarriers: 24, + ..SynthRequest::default() + }; + let frames = synthesize(&req).unwrap(); + let config = json!({ "n_subcarriers": 24 }); + for with_phases in [true, false] { + let json_frames: Vec = frames + .iter() + .map(|f| { + if with_phases { + json!(f) + } else { + json!({ "amplitudes": f.amplitudes }) + } + }) + .collect(); + let body = json!({ "config": config, "frames": json_frames, "include_readings": true }) + .to_string(); + let via_json = call("analyze", &body); + // serde_json's default float parser is not correctly rounded, so the + // JSON transport itself perturbs inputs by ULPs. Feed the flat path the + // exact f64 values the JSON path decoded; the outputs must then match + // byte for byte because both run the same Session::step. + let decoded: Vec = + serde_json::from_value(serde_json::from_str::(&body).unwrap()["frames"].clone()) + .unwrap(); + let via_flat = ruview_kernel::call_f64( + "analyze_flat", + &json!({ "config": config, "phases": with_phases, "include_readings": true }) + .to_string(), + &flatten(&decoded, with_phases), + ); + assert_eq!( + via_json, via_flat, + "flat and JSON transports must be byte-identical (phases={with_phases})" + ); + } +} + +#[test] +fn flat_binary_path_validates_shape_and_values() { + let code = |op: &str, req: Value, data: &[f64]| -> String { + let v: Value = + serde_json::from_str(&ruview_kernel::call_f64(op, &req.to_string(), data)).unwrap(); + assert_eq!(v["ok"], false, "{v}"); + v["error"]["code"].as_str().unwrap().to_string() + }; + let cfg = json!({ "config": { "n_subcarriers": 4 } }); + assert_eq!( + code("analyze_flat", cfg.clone(), &[1.0; 6]), + "invalid_request" + ); + assert_eq!( + code("analyze_flat", cfg.clone(), &[1.0, 2.0, f64::NAN, 4.0]), + "invalid_request" + ); + assert_eq!( + code("analyze_flat", cfg.clone(), &[1.0, 2.0, 3.0, 2.0e9]), + "invalid_request" + ); + assert_eq!(code("info", json!({}), &[1.0]), "invalid_request"); + assert_eq!( + code( + "session_push_flat", + json!({ "session": 999_999 }), + &[1.0; 4] + ), + "unknown_session" + ); + // The JSON entry point refuses the binary-only operations. + let v: Value = serde_json::from_str(&call("analyze_flat", "{}")).unwrap(); + assert_eq!(v["error"]["code"], "invalid_request"); + // Empty input is a valid, empty analysis. + let v: Value = serde_json::from_str(&ruview_kernel::call_f64( + "analyze_flat", + &cfg.to_string(), + &[], + )) + .unwrap(); + assert_eq!(v["result"]["summary"]["frames"], 0); +}