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
9.6 KiB
ADR-368: RuView compute kernel as a WASM + napi-rs npm package
- Status: proposed
- Date: 2026-09-30
- Deciders: RuView maintainers; acceptance pending
- Tags: npm, wasm, napi-rs, metaharness, vitals, supply-chain
- Related: ADR-021 (vitals), ADR-263/265 (npm harness review and distribution), ADR-283 (community metaharness), ADR-285 (WASM-first Homecore metaharness)
Context
@ruvnet/ruview (ADR-283) is a dependency-free operator/contributor harness:
it guides, verifies, and delegates, but it cannot run RuView signal processing.
JavaScript consumers (MCP hosts, dashboards, browser demos, agents) that want
real RuView computation must today start the Rust sensing server or re-implement
DSP in JS, which drifts from the Rust source of truth.
We want one Rust implementation reachable from npm with:
- a portable default that works on every Node platform and in browsers;
- an optional native path for hosts that want it;
- the metaharness trust posture: least authority by default, the backend that actually ran is always reported, fail-closed on missing or tampered artifacts, and no accuracy claim without an evidence tag.
Decision
One core, one ABI, two transports
v2/crates/ruview-kernel(workspace member) wraps the ADR-021 pipeline fromwifi-densepose-vitals(EMA preprocessing, breathing 0.1–0.5 Hz, heart 0.8–2.0 Hz, anomaly z-score) in a streamingSession, a deterministic SplitMix64 synthetic-CSI generator, and a bounded session registry.- The entire ABI is one function:
call(op, request_json) -> envelope_json, returning{"ok":true,"result":…}or{"ok":false,"error":{"code","message"}}. Operations:info,validate_config,analyze,synthesize,session_open|push|summary|close. Keeping the ABI to a single string function makes backend parity a property of the transport rather than of duplicated bindings. - WASM transport (
src/wasm_abi.rs,wasm32-unknown-unknownonly):rvk_alloc/rvk_free/rvk_call/rvk_abi_version. The module has zero imports — no filesystem, network, clock, or randomness. The JS loader refuses any module that imports host functions. - napi-rs transport (
v2/crates/ruview-kernel-napi): exports onlyabiVersion()andcall(op, json). It is a standalone Cargo workspace (listed in the v2exclude) so napi never enterscargo test --workspace, and it builds withpanic = "unwind"pluscatch_unwindso a defect becomes an error envelope, not a Node crash.
Input is untrusted at the ABI
Requests are bounded before processing: 16 MiB per request, 512 subcarriers,
100 000 frames per call or synthesis, 64 concurrent sessions. Unknown JSON
fields are rejected (deny_unknown_fields); frame widths must match the
configured subcarrier count; values must be finite with magnitude ≤ 1e9; all
frames of a batch are validated before any is processed. The core returns an
error envelope instead of panicking on caller data (release builds of the WASM
module use panic = "abort", which the loader surfaces as a trap and
recovers from with a fresh instance).
npm package @ruvnet/ruview-kernel (harness/ruview-kernel)
- Zero runtime dependencies.
loadKernel({backend})withbackend ∈ {wasm (default), napi, auto}orRUVIEW_KERNEL_BACKEND. WASM is the default because it carries no host authority.napinever silently falls back;autoprefers napi and recordsfallbackReason. - Artifacts are built from source by
npm run build(scripts/build.mjs, no shell) intowasm/andnative/ruview_kernel.<triple>.node, withSHA256SUMS. The loader verifies checksums; a mismatch is always fatal and never anautofallback trigger;requireIntegrityalso refuses unchecksummed artifacts. Built artifacts are git-ignored and never committed. src/wasm.jsis filesystem-free, so browsers can useloadWasmKernelFromBytes(bytes).- CLI
ruview-kernel:doctor,info,selftest,parity,synth,analyze --file,bench.synth --outrefuses to overwrite files;analyzereads only a bounded regular file. selfTest()runs synthesize → analyze → chunked streaming session → hostile input, and asserts known-tone recovery.parity()diffs every output of both backends with a 1e-9 relative tolerance, becausesin/expcome from different libm implementations on wasm32 and native targets; exact JSON equality is reported separately and is not required.
Metaharness integration
@ruvnet/ruview gains the read-only tool ruview_kernel_selftest
(CLI: ruview kernel [--backend …]). It dynamically imports the fixed
specifier @ruvnet/ruview-kernel, so @ruvnet/ruview keeps zero runtime
dependencies, and it returns kernel_not_installed when the package is absent.
MCP arguments are limited to backend and seconds; they cannot choose a
module or artifact path. The capability appears in ruview_guidance as
portable-compute-kernel.
Evidence
- SYNTHETIC (
cd harness/ruview-kernel && node bin/cli.js selftest): 60 s at 20 Hz, 56 subcarriers, 15 bpm breathing and 72 bpm heart tones → recovered 15.0 bpm respiratory (valid) and 70.6 bpm heart (degraded confidence 0.44). Tolerances asserted: ±3 bpm respiratory, ±8 bpm heart. - SYNTHETIC parity (
node bin/cli.js parity): 900 frames / 45 readings, no difference above 1e-9 relative between wasm32 and linux-x64-gnu napi; outputs are not byte-identical. - MEASURED on one Linux x64 development container
(
node bin/cli.js bench --backend wasm|napi --seconds 120): about 78 ms (wasm) and 63 ms (napi) per 2 400-frameanalyze, including JSON transfer. Single-host timing, not a cross-platform claim. - The WASM release artifact is about 230 KB and imports nothing
(
node scripts/verify-artifacts.mjs --require-wasm).
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.stringifyof the request alone took 52–78 ms against 107–124 ms for a wholeanalyze. - Rust then re-parsed about 270 000 numbers.
Decision. Add a binary fast path without changing the ABI version.
- New operations
analyze_flatandsession_push_flat, carried bycall_f64(op, json, &[f64]):- WASM:
rvk_call_f64, which reads bytes so alignment does not matter; - napi:
callF64(op, json, Float64Array).
- WASM:
analyze()andsession.push()pack uniform frames into oneFloat64Array(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::stepis shared by both paths.
Evidence.
-
Byte-identical outputs. On identical
f64inputs 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 inwifi-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 browser, with one Rust source of truth.
- A second native build matrix exists. Until per-platform packages
(
@ruvnet/ruview-kernel-<triple>) are published by CI, only WASM is portable; the loader already resolves those package names. - JSON transfer dominates the cost of small calls; a typed-array fast path can be added later behind a new ABI version.
Rollout and rollback
- CI (
ruview-kernel-npm.yml) builds both transports from source, runs the Rust tests, the package tests withRUVIEW_KERNEL_REQUIRE_NAPI=1, parity, artifact verification, andnpm pack --dry-run. - Publication stays CI-only with provenance (ADR-265) and requires explicit maintainer approval; this ADR does not add a publish job.
- Rollback: remove the
ruview_kernel_selftesttool (the harness has no dependency on the package) and deprecate the npm version; the Rust crates are additive.