memlnaut-nisps/tests/cpp/parity_wasm.mjs

496 lines
20 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* tests/cpp/parity_wasm.mjs runs the same fixed-seed sequence as
* parity_check.cpp against the WASM build of nisps and writes a binary blob
* with identical layout. The shell wrapper compares the two blobs.
*
* The WASM module is loaded from manifold/public/nisps.{js,wasm}
* scripts/build-wasm.sh must have run first.
*
fix(ml): one named example capacity; train() and trainAsync() no longer diverge Phase 2, S35. Two real defects from one root cause, both confirmed by trace rather than taken from the audit: 1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since train() reads the C++ ring and trainAsync() reads the TS mirror, past 100 examples the two trained on different datasets — silently. 2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++ side's example_count() (up to 128), but wasm-iml.ts allocates that heap buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror, the span reads past the end of the caller's allocation. Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by FixedStorage's default template arg, DynamicStorage's default ctor arg, and the MLP<> alias (which is the only real FixedStorage instantiation path and carried its own independent 128 literal — the last copy of this dual truth). Expose it through nisps_ml_describe and have the TS side read it instead of hardcoding. Dataset's constructor default is removed entirely: a default was what invited this bug class, and the sole call site now always supplies the describe() value. ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor. nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so every call site had to grow in the same change or it would overflow the WASM heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init defaults, init per-instance, reshape re-describe — the finding said there were two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity harness's expected-dims check now also pins the new max_examples slot. Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage policies to one constant, and drives MLPCore<DynamicStorage> exactly as bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at 100. Fail-before/pass-after confirmed by temporarily setting the constant to 100: 2 failures, named. Reverted: green. Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the hardcoded default was at dataset.ts:45. Gates: run-all-tests.sh ALL GREEN, parity PASS.
2026-07-21 13:22:38 +02:00
* Blob format and payload order are defined by parity_check.cpp its header
* and `---- Stage N ----` sections are the authoritative stage list (seven
* stages: ML inference, ML training, PAFSynth, ChannelStrip, feedback
* controller, geometric dislike, pipelines + curves). Format: magic 'NPRT',
* version 5, n_floats, float32 payload. Keep the two drivers in lockstep and
* bump VERSION in both (and in parity_diff.mjs) on any layout change.
*
* Exit codes:
* 0 success
* 2 wasm load failure
* 3 file write failure
*/
import { readFile, writeFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const repoRoot = resolve(__dirname, '..', '..');
const MAGIC = 0x5450524e; // 'NPRT'
const VERSION = 5; // v5 adds stage 7 (pipelines + curves)
const SEED = 42 >>> 0;
const INPUT_X = 0.25;
const INPUT_Y = 0.75;
const SAMPLE_RATE = 48000;
const SYNTH_FRAMES = 128;
const PROBE_IDX = [0, 5, 19, 31, 73, 137, 251, 491, 999, 1583, 2401, 3289];
async function loadWasm() {
const wasmGluePath = resolve(repoRoot, 'manifold', 'public', 'nisps.js');
try {
await access(wasmGluePath, fsConstants.R_OK);
} catch {
console.error(`[parity_wasm] missing ${wasmGluePath}`);
console.error(`[parity_wasm] run scripts/build-wasm.sh first`);
process.exit(2);
}
// The Emscripten glue is generated with MODULARIZE=1, which writes
// var createNispsModule = (() => ...)();
// if (typeof exports==='object' && typeof module==='object') module.exports = ...;
// It lives in manifold/public/, which is a sub-package with
// "type":"module" in its parent package.json — so neither `require()` nor
// `import()` can extract the factory cleanly. We work around this by
// reading the file as text and evaluating it inside a thin shim that
// returns `createNispsModule`.
const source = await readFile(wasmGluePath, 'utf8');
// The shim wraps the glue in a function and exposes the symbol it sets.
// Indirect-eval keeps things at module scope so `var` declarations don't
// pollute the host process.
// eslint-disable-next-line no-new-func
const factory = new Function(
'module', 'exports',
`${source}\n;return typeof createNispsModule === 'function' ? createNispsModule : null;`
)({ exports: {} }, {});
if (typeof factory !== 'function') {
console.error('[parity_wasm] could not locate createNispsModule in glue');
process.exit(2);
}
const wasmBinaryPath = resolve(repoRoot, 'manifold', 'public', 'nisps.wasm');
const wasmBinary = await readFile(wasmBinaryPath);
const Module = await factory({ wasmBinary });
return Module;
}
/**
* Wrap the C ABI as friendly JS calls.
*/
function bind(Module) {
const cwrap = Module.cwrap;
return {
create: cwrap('nisps_ml_create', 'number', ['number','number','number','number','number']),
destroy: cwrap('nisps_ml_destroy', null, ['number']),
setInput: cwrap('nisps_ml_set_input', null, ['number','number','number']),
process: cwrap('nisps_ml_process', null, ['number']),
outputsPtr: cwrap('nisps_ml_outputs','number', ['number']),
inferBatch: cwrap('nisps_ml_infer_batch', null, ['number','number','number','number']),
addExample: cwrap('nisps_ml_add_example', null, ['number','number','number']),
train: cwrap('nisps_ml_train', 'number', ['number','number','number','number','number']),
weightCount: cwrap('nisps_ml_weight_count', 'number', ['number']),
getWeights: cwrap('nisps_ml_get_weights', null, ['number','number']),
drawWeights: cwrap('nisps_ml_draw_weights', null, ['number','number']),
feedbackSetMode: cwrap('nisps_ml_feedback_set_mode', null, ['number','number']),
feedbackDown: cwrap('nisps_ml_feedback_down', 'number', ['number','number','number','number','number']),
feedbackUp: cwrap('nisps_ml_feedback_up', 'number', ['number']),
feedbackStaticOutput: cwrap('nisps_ml_feedback_static_output', 'number', ['number','number']),
feedbackEnterExplore: cwrap('nisps_ml_feedback_enter_explore', null, ['number','number']),
feedbackReroll: cwrap('nisps_ml_feedback_reroll', null, ['number','number']),
feedbackNudge: cwrap('nisps_ml_feedback_nudge', null, ['number','number']),
feedbackUndo: cwrap('nisps_ml_feedback_undo', null, ['number']),
feedbackLike: cwrap('nisps_ml_feedback_like', null, ['number']),
feedbackCommitPlace: cwrap('nisps_ml_feedback_commit_place', null, ['number']),
feedbackPlacedOutput: cwrap('nisps_ml_feedback_placed_output', 'number', ['number','number']),
feedbackAdvanceGeometric: cwrap('nisps_ml_feedback_advance_geometric', 'number', ['number','number']),
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
feedbackPositiveCount: cwrap('nisps_ml_feedback_positive_count', 'number', ['number']),
feedbackNegativeCount: cwrap('nisps_ml_feedback_negative_count', 'number', ['number']),
feat(wasm)!: P2.2 — nisps_ml_create honours dims; runtime-shaped browser MLP + reshape Operator-approved ABI change (P2 stop-point). The WASM MLP is now MLPCore<DynamicStorage>: - nisps_ml_create(input, output, hidden[3], n, seed) honours its args; non-positive/null fall back to the historical 32→[10,14,18]→126, so every pre-P2 caller (manifold, worker, parity harness) stays bit-identical. Invalid/oversized dims (>4096) → null. - NEW nisps_ml_reshape(ml, in, out, hidden, n, spread): fresh net at the new dims, warm-started via nisps/ml/warm_start.hpp (overlapping region copied; rest keeps spread init); feedback controller re-created (state resets — reset-on-reshape modal is the front-end contract). Failure leaves the old net untouched. - nisps_ml_describe(ml, out): takes the handle; null reports defaults. - FeedbackController got the same storage split: algorithms in FeedbackControllerCore<FbStorage>; FixedFeedbackStorage keeps firmware/ tests source-identical via the old alias; DynamicFeedbackStorage (one arena) sizes to the runtime net. Firmware .text unchanged (122692). - MLHandle: per-instance scratch vectors; dropped the dead 2MB batch_out_scratch. - TS: types.ts decls (+_nisps_ml_reshape), wasm-iml re-describes the created instance, worker carries a shape-contract note for P2.3. Verified: ctest 4/4 incl. new warm-start grow/shrink test; reshape ABI smoke (dims honoured, overlap survives, invalid rejected, outputs bounded); parity PASS unchanged (2.4e-7); lint clean; manifold 9 unit + 20 e2e green; firmware .text 122692 (+0.30% vs pre-P2 baseline).
2026-07-14 03:38:06 +02:00
describe: cwrap('nisps_ml_describe', null, ['number','number']),
pipelineCreate: cwrap('nisps_pipeline_create', 'number', []),
pipelineDestroy: cwrap('nisps_pipeline_destroy', null, ['number']),
inputSetConfig: cwrap('nisps_input_set_config', null, ['number','number','number']),
inputProcess: cwrap('nisps_input_process', 'number', ['number','number','number','number','number']),
outputSetConfig: cwrap('nisps_output_set_config', null, ['number','number','number','number','number']),
outputSetFreezeMask: cwrap('nisps_output_set_freeze_mask', null, ['number','number','number']),
outputProcess: cwrap('nisps_output_process', null, ['number','number','number','number']),
curveApply: cwrap('nisps_curve_apply', 'number', ['number','number','number']),
engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']),
engineDestroy: cwrap('nisps_engine_destroy', null, ['number']),
engineSetParams: cwrap('nisps_engine_set_params', null, ['number','number','number']),
engineProcessBlock: cwrap('nisps_engine_process_block', null,
['number','number','number','number','number','number']),
malloc: Module._malloc,
free: Module._free,
HEAPF32: Module.HEAPF32,
};
}
function getOutputsCopy(api, mlPtr, nOut) {
const ptr = api.outputsPtr(mlPtr);
// outputs are float32 starting at ptr, length nOut.
const start = ptr / 4;
return new Float32Array(api.HEAPF32.buffer, ptr, nOut).slice();
}
function getWeightsCopy(api, mlPtr) {
const n = api.weightCount(mlPtr);
const buf = api.malloc(n * 4);
api.getWeights(mlPtr, buf);
const out = new Float32Array(api.HEAPF32.buffer, buf, n).slice();
api.free(buf);
return out;
}
function runEngine(api, engineId, paramCount, inputAmp, frames) {
const e = api.engineCreate(engineId, SAMPLE_RATE);
if (!e) throw new Error(`engineCreate(${engineId}) returned 0`);
const paramsBuf = api.malloc(paramCount * 4);
const params = new Float32Array(api.HEAPF32.buffer, paramsBuf, paramCount);
params.fill(0.5);
api.engineSetParams(e, paramsBuf, paramCount);
// Allocate input/output buffers. We process one sample at a time to mirror
// the native test exactly (which calls process(s) per sample).
const inLBuf = api.malloc(4);
const inRBuf = api.malloc(4);
const outLBuf = api.malloc(4);
const outRBuf = api.malloc(4);
const inL = new Float32Array(api.HEAPF32.buffer, inLBuf, 1);
const inR = new Float32Array(api.HEAPF32.buffer, inRBuf, 1);
const outL = new Float32Array(api.HEAPF32.buffer, outLBuf, 1);
const outR = new Float32Array(api.HEAPF32.buffer, outRBuf, 1);
let lAcc = 0;
let rAcc = 0;
for (let i = 0; i < frames; ++i) {
inL[0] = inputAmp;
inR[0] = inputAmp;
api.engineProcessBlock(e, inLBuf, inRBuf, outLBuf, outRBuf, 1);
lAcc += outL[0];
rAcc += outR[0];
}
api.free(paramsBuf);
api.free(inLBuf);
api.free(inRBuf);
api.free(outLBuf);
api.free(outRBuf);
api.engineDestroy(e);
return [lAcc / frames, rAcc / frames];
}
async function main() {
const outPath = process.argv[2] ?? 'parity_wasm.bin';
const Module = await loadWasm();
const api = bind(Module);
feat(wasm)!: P2.2 — nisps_ml_create honours dims; runtime-shaped browser MLP + reshape Operator-approved ABI change (P2 stop-point). The WASM MLP is now MLPCore<DynamicStorage>: - nisps_ml_create(input, output, hidden[3], n, seed) honours its args; non-positive/null fall back to the historical 32→[10,14,18]→126, so every pre-P2 caller (manifold, worker, parity harness) stays bit-identical. Invalid/oversized dims (>4096) → null. - NEW nisps_ml_reshape(ml, in, out, hidden, n, spread): fresh net at the new dims, warm-started via nisps/ml/warm_start.hpp (overlapping region copied; rest keeps spread init); feedback controller re-created (state resets — reset-on-reshape modal is the front-end contract). Failure leaves the old net untouched. - nisps_ml_describe(ml, out): takes the handle; null reports defaults. - FeedbackController got the same storage split: algorithms in FeedbackControllerCore<FbStorage>; FixedFeedbackStorage keeps firmware/ tests source-identical via the old alias; DynamicFeedbackStorage (one arena) sizes to the runtime net. Firmware .text unchanged (122692). - MLHandle: per-instance scratch vectors; dropped the dead 2MB batch_out_scratch. - TS: types.ts decls (+_nisps_ml_reshape), wasm-iml re-describes the created instance, worker carries a shape-contract note for P2.3. Verified: ctest 4/4 incl. new warm-start grow/shrink test; reshape ABI smoke (dims honoured, overlap survives, invalid rejected, outputs bounded); parity PASS unchanged (2.4e-7); lint clean; manifold 9 unit + 20 e2e green; firmware .text 122692 (+0.30% vs pre-P2 baseline).
2026-07-14 03:38:06 +02:00
// Verify dimensions match the native side. A null handle reports the
fix(ml): one named example capacity; train() and trainAsync() no longer diverge Phase 2, S35. Two real defects from one root cause, both confirmed by trace rather than taken from the audit: 1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since train() reads the C++ ring and trainAsync() reads the TS mirror, past 100 examples the two trained on different datasets — silently. 2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++ side's example_count() (up to 128), but wasm-iml.ts allocates that heap buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror, the span reads past the end of the caller's allocation. Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by FixedStorage's default template arg, DynamicStorage's default ctor arg, and the MLP<> alias (which is the only real FixedStorage instantiation path and carried its own independent 128 literal — the last copy of this dual truth). Expose it through nisps_ml_describe and have the TS side read it instead of hardcoding. Dataset's constructor default is removed entirely: a default was what invited this bug class, and the sole call site now always supplies the describe() value. ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor. nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so every call site had to grow in the same change or it would overflow the WASM heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init defaults, init per-instance, reshape re-describe — the finding said there were two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity harness's expected-dims check now also pins the new max_examples slot. Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage policies to one constant, and drives MLPCore<DynamicStorage> exactly as bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at 100. Fail-before/pass-after confirmed by temporarily setting the constant to 100: 2 failures, named. Reverted: green. Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the hardcoded default was at dataset.ts:45. Gates: run-all-tests.sh ALL GREEN, parity PASS.
2026-07-21 13:22:38 +02:00
// DEFAULT shape (what create() yields for non-positive args). 7 ints:
// [in, h1, h2, h3, out, n_layers, max_examples] (S35 — the buffer size
// and view length below MUST track nisps_ml_describe's actual output or
// this silently overflows the WASM heap by 4 bytes).
const dimsBuf = api.malloc(7 * 4);
feat(wasm)!: P2.2 — nisps_ml_create honours dims; runtime-shaped browser MLP + reshape Operator-approved ABI change (P2 stop-point). The WASM MLP is now MLPCore<DynamicStorage>: - nisps_ml_create(input, output, hidden[3], n, seed) honours its args; non-positive/null fall back to the historical 32→[10,14,18]→126, so every pre-P2 caller (manifold, worker, parity harness) stays bit-identical. Invalid/oversized dims (>4096) → null. - NEW nisps_ml_reshape(ml, in, out, hidden, n, spread): fresh net at the new dims, warm-started via nisps/ml/warm_start.hpp (overlapping region copied; rest keeps spread init); feedback controller re-created (state resets — reset-on-reshape modal is the front-end contract). Failure leaves the old net untouched. - nisps_ml_describe(ml, out): takes the handle; null reports defaults. - FeedbackController got the same storage split: algorithms in FeedbackControllerCore<FbStorage>; FixedFeedbackStorage keeps firmware/ tests source-identical via the old alias; DynamicFeedbackStorage (one arena) sizes to the runtime net. Firmware .text unchanged (122692). - MLHandle: per-instance scratch vectors; dropped the dead 2MB batch_out_scratch. - TS: types.ts decls (+_nisps_ml_reshape), wasm-iml re-describes the created instance, worker carries a shape-contract note for P2.3. Verified: ctest 4/4 incl. new warm-start grow/shrink test; reshape ABI smoke (dims honoured, overlap survives, invalid rejected, outputs bounded); parity PASS unchanged (2.4e-7); lint clean; manifold 9 unit + 20 e2e green; firmware .text 122692 (+0.30% vs pre-P2 baseline).
2026-07-14 03:38:06 +02:00
api.describe(0, dimsBuf);
fix(ml): one named example capacity; train() and trainAsync() no longer diverge Phase 2, S35. Two real defects from one root cause, both confirmed by trace rather than taken from the audit: 1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since train() reads the C++ ring and trainAsync() reads the TS mirror, past 100 examples the two trained on different datasets — silently. 2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++ side's example_count() (up to 128), but wasm-iml.ts allocates that heap buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror, the span reads past the end of the caller's allocation. Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by FixedStorage's default template arg, DynamicStorage's default ctor arg, and the MLP<> alias (which is the only real FixedStorage instantiation path and carried its own independent 128 literal — the last copy of this dual truth). Expose it through nisps_ml_describe and have the TS side read it instead of hardcoding. Dataset's constructor default is removed entirely: a default was what invited this bug class, and the sole call site now always supplies the describe() value. ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor. nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so every call site had to grow in the same change or it would overflow the WASM heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init defaults, init per-instance, reshape re-describe — the finding said there were two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity harness's expected-dims check now also pins the new max_examples slot. Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage policies to one constant, and drives MLPCore<DynamicStorage> exactly as bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at 100. Fail-before/pass-after confirmed by temporarily setting the constant to 100: 2 failures, named. Reverted: green. Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the hardcoded default was at dataset.ts:45. Gates: run-all-tests.sh ALL GREEN, parity PASS.
2026-07-21 13:22:38 +02:00
const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 7).slice();
api.free(dimsBuf);
fix(ml): one named example capacity; train() and trainAsync() no longer diverge Phase 2, S35. Two real defects from one root cause, both confirmed by trace rather than taken from the audit: 1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since train() reads the C++ ring and trainAsync() reads the TS mirror, past 100 examples the two trained on different datasets — silently. 2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++ side's example_count() (up to 128), but wasm-iml.ts allocates that heap buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror, the span reads past the end of the caller's allocation. Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by FixedStorage's default template arg, DynamicStorage's default ctor arg, and the MLP<> alias (which is the only real FixedStorage instantiation path and carried its own independent 128 literal — the last copy of this dual truth). Expose it through nisps_ml_describe and have the TS side read it instead of hardcoding. Dataset's constructor default is removed entirely: a default was what invited this bug class, and the sole call site now always supplies the describe() value. ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor. nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so every call site had to grow in the same change or it would overflow the WASM heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init defaults, init per-instance, reshape re-describe — the finding said there were two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity harness's expected-dims check now also pins the new max_examples slot. Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage policies to one constant, and drives MLPCore<DynamicStorage> exactly as bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at 100. Fail-before/pass-after confirmed by temporarily setting the constant to 100: 2 failures, named. Reverted: green. Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the hardcoded default was at dataset.ts:45. Gates: run-all-tests.sh ALL GREEN, parity PASS.
2026-07-21 13:22:38 +02:00
// Expect: [32, 10, 14, 18, 126, 4, 128] (32-input max for mix-and-match;
// 128 = nisps::ml::kDefaultMaxExamples, nisps/ml/storage.hpp)
const expectedDims = [32, 10, 14, 18, 126, 4, 128];
for (let i = 0; i < expectedDims.length; ++i) {
if (dims[i] !== expectedDims[i]) {
console.error(`[parity_wasm] WASM build has dim[${i}]=${dims[i]}, native expected ${expectedDims[i]}`);
console.error(`[parity_wasm] WASM dims:`, Array.from(dims));
process.exit(2);
}
}
feat(manifold): MIDI + game controller inputs; widen ML net to N-D Wire the modular input layer into the Console and reshape the browser engine so input axes are genuine independent dimensions. Inputs (manifold/src/inputs/): - gamepad-source: emit press+release edges with standard-mapping labels (enables hold-and-move); single/double-stick already present. - midi-input-source: single-device selection + batch "MIDI Learn" (every CC swept while armed becomes an axis); notes stay discrete. - input-layer: compose() forwards each axis 1:1 (no mean-blend); add onReducedInput so the manifold tracks gamepad/MIDI position. - types: InputAction.phase, InputMode. Console (manifold/src/console/): - ConsoleApp: bind gamepad buttons to verdicts (RB up / LB down / X randomise / Y nudge / B undo / A-hold reposition); mirror composed position onto the manifold. - Drawers: rebuilt Inputs drawer (source picker, gamepad legend, MIDI device picker + batch-learn flow, learned-control meters). Engine (nisps/wasm, manifold/src/engine): - DefaultMLP widened MLP<2,..> -> MLP<32,..> (32 = MAX_AXES); each active axis gets a dedicated slot, unused slots held at 0 (inert). Rebuilt nisps.wasm (playground + manifold). - spine/engine-api: setInputs writes the full N-D vector (was dropping arr[2+]); primary pair keeps the 2-D pipeline; process() re-ticks the whole vector via spine.reprocess(). Tests: - parity_check/parity_wasm: ParityMLP -> 32 inputs, widen example bufs. - CMakeLists: build parity binary with -ffp-contract=off so native matches FMA-free WASM (training amplified the gap past 1e-5). Inputs dock is still an exclusive picker; mixing toggles, reshape modal, and the >2-D slider view (inputs-spec.md) are groundwork-laid but not yet wired. See docs/redesign/midi-gamepad-inputs-worklog.md.
2026-06-28 21:05:30 +02:00
const N_IN = dims[0];
const N_OUT = dims[4];
// --- Stage 1: ML inference ---
feat(manifold): MIDI + game controller inputs; widen ML net to N-D Wire the modular input layer into the Console and reshape the browser engine so input axes are genuine independent dimensions. Inputs (manifold/src/inputs/): - gamepad-source: emit press+release edges with standard-mapping labels (enables hold-and-move); single/double-stick already present. - midi-input-source: single-device selection + batch "MIDI Learn" (every CC swept while armed becomes an axis); notes stay discrete. - input-layer: compose() forwards each axis 1:1 (no mean-blend); add onReducedInput so the manifold tracks gamepad/MIDI position. - types: InputAction.phase, InputMode. Console (manifold/src/console/): - ConsoleApp: bind gamepad buttons to verdicts (RB up / LB down / X randomise / Y nudge / B undo / A-hold reposition); mirror composed position onto the manifold. - Drawers: rebuilt Inputs drawer (source picker, gamepad legend, MIDI device picker + batch-learn flow, learned-control meters). Engine (nisps/wasm, manifold/src/engine): - DefaultMLP widened MLP<2,..> -> MLP<32,..> (32 = MAX_AXES); each active axis gets a dedicated slot, unused slots held at 0 (inert). Rebuilt nisps.wasm (playground + manifold). - spine/engine-api: setInputs writes the full N-D vector (was dropping arr[2+]); primary pair keeps the 2-D pipeline; process() re-ticks the whole vector via spine.reprocess(). Tests: - parity_check/parity_wasm: ParityMLP -> 32 inputs, widen example bufs. - CMakeLists: build parity binary with -ffp-contract=off so native matches FMA-free WASM (training amplified the gap past 1e-5). Inputs dock is still an exclusive picker; mixing toggles, reshape modal, and the >2-D slider view (inputs-spec.md) are groundwork-laid but not yet wired. See docs/redesign/midi-gamepad-inputs-worklog.md.
2026-06-28 21:05:30 +02:00
const ml = api.create(N_IN, N_OUT, 0, 0, SEED);
api.drawWeights(ml, 0.5);
api.setInput(ml, 0, INPUT_X);
api.setInput(ml, 1, INPUT_Y);
api.process(ml);
const outsStage1 = getOutputsCopy(api, ml, N_OUT);
// Weight probe.
const weights = getWeightsCopy(api, ml);
const probeValues = PROBE_IDX.map((idx) => idx < weights.length ? weights[idx] : 0);
// --- Stage 2: training ---
const features = [
[0.1, 0.9],
[0.5, 0.5],
[0.9, 0.1],
];
const labelFor = (i) => {
const out = new Float32Array(N_OUT);
const a = i * 0.3 + 0.05;
for (let j = 0; j < N_OUT; ++j) out[j] = a + 0.005 * j;
return out;
};
feat(manifold): MIDI + game controller inputs; widen ML net to N-D Wire the modular input layer into the Console and reshape the browser engine so input axes are genuine independent dimensions. Inputs (manifold/src/inputs/): - gamepad-source: emit press+release edges with standard-mapping labels (enables hold-and-move); single/double-stick already present. - midi-input-source: single-device selection + batch "MIDI Learn" (every CC swept while armed becomes an axis); notes stay discrete. - input-layer: compose() forwards each axis 1:1 (no mean-blend); add onReducedInput so the manifold tracks gamepad/MIDI position. - types: InputAction.phase, InputMode. Console (manifold/src/console/): - ConsoleApp: bind gamepad buttons to verdicts (RB up / LB down / X randomise / Y nudge / B undo / A-hold reposition); mirror composed position onto the manifold. - Drawers: rebuilt Inputs drawer (source picker, gamepad legend, MIDI device picker + batch-learn flow, learned-control meters). Engine (nisps/wasm, manifold/src/engine): - DefaultMLP widened MLP<2,..> -> MLP<32,..> (32 = MAX_AXES); each active axis gets a dedicated slot, unused slots held at 0 (inert). Rebuilt nisps.wasm (playground + manifold). - spine/engine-api: setInputs writes the full N-D vector (was dropping arr[2+]); primary pair keeps the 2-D pipeline; process() re-ticks the whole vector via spine.reprocess(). Tests: - parity_check/parity_wasm: ParityMLP -> 32 inputs, widen example bufs. - CMakeLists: build parity binary with -ffp-contract=off so native matches FMA-free WASM (training amplified the gap past 1e-5). Inputs dock is still an exclusive picker; mixing toggles, reshape modal, and the >2-D slider view (inputs-spec.md) are groundwork-laid but not yet wired. See docs/redesign/midi-gamepad-inputs-worklog.md.
2026-06-28 21:05:30 +02:00
// Feature buffer is NIn-wide (zero-padded): two real axes + unused slots at 0,
// matching the native side and the front-end's mix-and-match input shape.
const featBuf = api.malloc(N_IN * 4);
const featF32 = new Float32Array(api.HEAPF32.buffer, featBuf, N_IN);
const labelBuf = api.malloc(N_OUT * 4);
for (let i = 0; i < features.length; ++i) {
feat(manifold): MIDI + game controller inputs; widen ML net to N-D Wire the modular input layer into the Console and reshape the browser engine so input axes are genuine independent dimensions. Inputs (manifold/src/inputs/): - gamepad-source: emit press+release edges with standard-mapping labels (enables hold-and-move); single/double-stick already present. - midi-input-source: single-device selection + batch "MIDI Learn" (every CC swept while armed becomes an axis); notes stay discrete. - input-layer: compose() forwards each axis 1:1 (no mean-blend); add onReducedInput so the manifold tracks gamepad/MIDI position. - types: InputAction.phase, InputMode. Console (manifold/src/console/): - ConsoleApp: bind gamepad buttons to verdicts (RB up / LB down / X randomise / Y nudge / B undo / A-hold reposition); mirror composed position onto the manifold. - Drawers: rebuilt Inputs drawer (source picker, gamepad legend, MIDI device picker + batch-learn flow, learned-control meters). Engine (nisps/wasm, manifold/src/engine): - DefaultMLP widened MLP<2,..> -> MLP<32,..> (32 = MAX_AXES); each active axis gets a dedicated slot, unused slots held at 0 (inert). Rebuilt nisps.wasm (playground + manifold). - spine/engine-api: setInputs writes the full N-D vector (was dropping arr[2+]); primary pair keeps the 2-D pipeline; process() re-ticks the whole vector via spine.reprocess(). Tests: - parity_check/parity_wasm: ParityMLP -> 32 inputs, widen example bufs. - CMakeLists: build parity binary with -ffp-contract=off so native matches FMA-free WASM (training amplified the gap past 1e-5). Inputs dock is still an exclusive picker; mixing toggles, reshape modal, and the >2-D slider view (inputs-spec.md) are groundwork-laid but not yet wired. See docs/redesign/midi-gamepad-inputs-worklog.md.
2026-06-28 21:05:30 +02:00
featF32.fill(0);
featF32[0] = features[i][0];
featF32[1] = features[i][1];
const label = labelFor(i);
new Float32Array(api.HEAPF32.buffer, labelBuf, N_OUT).set(label);
api.addExample(ml, featBuf, labelBuf);
}
api.free(featBuf);
api.free(labelBuf);
const finalLoss = api.train(ml, 0.3, 50, 0.0, 0 /* null sample_weights */);
api.setInput(ml, 0, INPUT_X);
api.setInput(ml, 1, INPUT_Y);
api.process(ml);
const outsStage2 = getOutputsCopy(api, ml, N_OUT);
// (ml stays alive through stage 5 below; destroyed after the feedback stage.)
// --- Stage 3: PAFSynth ---
// PAFSynth has 33 params per param_count() in nisps/engines/paf_synth.hpp.
const [pafL, pafR] = runEngine(api, 'paf_synth', 33, 0.0, SYNTH_FRAMES);
// --- Stage 4: ChannelStrip (24 params) ---
const [csL, csR] = runEngine(api, 'channel_strip', 24, 0.25, SYNTH_FRAMES);
// --- Stage 5: feedback ("Down Action": RandomiseOutputs + RandomiseMlp) ---
// Mirrors parity_check.cpp stage 5. The controller is seeded inside the WASM
// MLHandle as (seed XOR salt), matching the native side. ml is untouched by
// stages 3-4, so its RNG state here equals post-stage-2.
const FB_RANDOUT = 1;
const FB_RANDMLP = 2;
const feedbackFloats = [];
const fbBuf = api.malloc(N_OUT * 4);
api.feedbackSetMode(ml, FB_RANDOUT);
api.feedbackDown(ml, 0, 0.1, 0.5, 0); // enter
api.feedbackStaticOutput(ml, fbBuf);
for (const v of new Float32Array(api.HEAPF32.buffer, fbBuf, N_OUT)) feedbackFloats.push(v);
api.feedbackDown(ml, 0, 0.1, 0.5, 0); // re-roll
api.feedbackStaticOutput(ml, fbBuf);
for (const v of new Float32Array(api.HEAPF32.buffer, fbBuf, N_OUT)) feedbackFloats.push(v);
api.free(fbBuf);
api.feedbackUp(ml); // commit (no weight change)
api.feedbackSetMode(ml, FB_RANDMLP);
api.feedbackDown(ml, 0, 0.1, 0.5, 0); // enter → randomise temp net
{
const tempW = getWeightsCopy(api, ml);
for (const idx of PROBE_IDX) feedbackFloats.push(idx < tempW.length ? tempW[idx] : 0);
}
api.feedbackUp(ml); // commit → restore original net
{
const restoredW = getWeightsCopy(api, ml);
for (const idx of PROBE_IDX) feedbackFloats.push(idx < restoredW.length ? restoredW[idx] : 0);
}
// --- Stage 5d: ExploreAndPlace lifecycle ---
// Reuses the single MLHandle.feedback controller (mode → ExploreAndPlace) so
// its RNG state matches native `fb` (both drained identical RandomiseOutputs
// draws). enter → reroll → nudge → undo → place → commit.
const FB_EXPLORE_PLACE = 3;
api.feedbackSetMode(ml, FB_EXPLORE_PLACE);
api.feedbackEnterExplore(ml, 0.5); // snapshot + randomise scratchpad
api.feedbackReroll(ml, 0.5); // scratchpad op
api.feedbackNudge(ml, 0.05); // controller-Rng perturb
{
const scratchW = getWeightsCopy(api, ml);
for (const idx of PROBE_IDX) feedbackFloats.push(idx < scratchW.length ? scratchW[idx] : 0);
}
api.feedbackUndo(ml); // pop nudge
api.setInput(ml, 0, INPUT_X);
api.setInput(ml, 1, INPUT_Y);
api.process(ml);
api.feedbackLike(ml); // begin place: freeze scratchpad output
{
const placedBuf = api.malloc(N_OUT * 4);
api.feedbackPlacedOutput(ml, placedBuf);
for (const v of new Float32Array(api.HEAPF32.buffer, placedBuf, N_OUT)) feedbackFloats.push(v);
api.free(placedBuf);
}
api.feedbackCommitPlace(ml); // restore real net
{
const restoredW = getWeightsCopy(api, ml);
for (const idx of PROBE_IDX) feedbackFloats.push(idx < restoredW.length ? restoredW[idx] : 0);
const committedBuf = api.malloc(N_OUT * 4);
api.feedbackPlacedOutput(ml, committedBuf);
for (const v of new Float32Array(api.HEAPF32.buffer, committedBuf, N_OUT)) feedbackFloats.push(v);
api.free(committedBuf);
}
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
// --- Stage 6: geometric dislike (one-core-engine P3) ---
// Mirrors parity_check.cpp stage 6: two likes feed the replay positives via
// the Avoid+Geometric on_up path, then two dislikes (second deepens) plus
// eight 5ms replay ticks train toward the computed push-away target. f32
// arithmetic for the "heard" vector via Math.fround matches native exactly.
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
const FB_AVOID = 0;
api.feedbackSetMode(ml, FB_AVOID);
const likeAt = (x, y) => {
api.setInput(ml, 0, x);
api.setInput(ml, 1, y);
api.process(ml);
api.feedbackUp(ml); // Avoid+Geometric: store_positive + LikeStore
};
likeAt(0.2, 0.2);
likeAt(0.8, 0.8);
const POS_DELTA = Math.fround(0.15);
const dislikeAt = (x, y) => {
api.setInput(ml, 0, x);
api.setInput(ml, 1, y);
api.process(ml);
const outs = getOutputsCopy(api, ml, N_OUT);
const heard = new Float32Array(N_OUT);
for (let j = 0; j < N_OUT; j++) {
let v = Math.fround(outs[j] + ((j & 1) !== 0 ? -POS_DELTA : POS_DELTA));
if (v < 0) v = 0;
if (v > 1) v = 1;
heard[j] = v;
}
const heardBuf = api.malloc(N_OUT * 4);
new Float32Array(api.HEAPF32.buffer, heardBuf, N_OUT).set(heard);
api.feedbackDown(ml, heardBuf, 0.1, 0.5, 0);
api.free(heardBuf);
};
dislikeAt(0.25, 0.75);
dislikeAt(0.26, 0.74); // within dedup radius: deepen + push
for (let i = 0; i < 8; i++) api.feedbackAdvanceGeometric(ml, 0.005);
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
feedbackFloats.push(api.feedbackPositiveCount(ml));
feedbackFloats.push(api.feedbackNegativeCount(ml));
api.setInput(ml, 0, INPUT_X);
api.setInput(ml, 1, INPUT_Y);
api.process(ml);
{
const outs = getOutputsCopy(api, ml, N_OUT);
for (const v of outs) feedbackFloats.push(v);
const w = getWeightsCopy(api, ml);
for (const idx of PROBE_IDX) feedbackFloats.push(idx < w.length ? w[idx] : 0);
}
api.destroy(ml);
// --- Stage 7: pipelines + curves (one-core-engine P4) ---
// Mirrors parity_check.cpp stage 7: rational traces (bit-exact across the
// JS/C++ boundary) through the input chain, output chain, and curve
// catalog via the C ABI.
const pipelineFloats = [];
{
const outXY = api.malloc(8);
const cfgBuf = api.malloc(15 * 4);
// Input-config wire layout (see bindings.cpp): [zoom, zoomX, zoomY,
// anchorX, anchorY, anchorMode, deadzone, inputCurve, curveX, curveY,
// smoothing, momentumMode, velocityWindowS, invertX, invertY]
const runInput = (cfg) => {
const p = api.pipelineCreate();
new Float32Array(api.HEAPF32.buffer, cfgBuf, 15).set(cfg);
api.inputSetConfig(p, cfgBuf, 15);
const dt = Math.fround(1 / 120);
for (let i = 0; i < 120; i++) {
const x = Math.fround(((i * 37) % 97) / 96);
const y = Math.fround(((i * 53 + 11) % 89) / 88);
api.inputProcess(p, x, y, dt, outXY);
if (i % 10 === 9) {
const v = new Float32Array(api.HEAPF32.buffer, outXY, 2);
pipelineFloats.push(v[0], v[1]);
}
}
api.pipelineDestroy(p);
};
const defIn = [1, 0, 0, 0.5, 0.5, 2, 0, 1, 0, 0, 0, 0, 0.15, 0, 0];
runInput(defIn);
runInput([0.7, 0, 0, 0.5, 0.5, 2, 0.1, 1.8, 0, 0, 0.6, 2, 0.15, 1, 0]);
const N16 = 16;
const vecBuf = api.malloc(N16 * 4);
const maskBuf = api.malloc(N16);
const runOutput = (curve, smoothing, slew, withMask) => {
const p = api.pipelineCreate();
api.outputSetConfig(p, curve, smoothing, slew, 0);
if (withMask) {
const m = new Uint8Array(api.HEAPF32.buffer, maskBuf, N16);
for (let j = 0; j < N16; j++) m[j] = j % 2 === 0 ? 1 : 0;
api.outputSetFreezeMask(p, maskBuf, N16);
}
const dt = Math.fround(1 / 60);
for (let i = 0; i < 60; i++) {
const vec = new Float32Array(api.HEAPF32.buffer, vecBuf, N16);
for (let j = 0; j < N16; j++) {
vec[j] = Math.fround(((i * 13 + j * 29) % 101) / 100);
}
api.outputProcess(p, vecBuf, N16, dt);
if (i % 15 === 14) {
const out = new Float32Array(api.HEAPF32.buffer, vecBuf, N16);
for (let j = 0; j < N16; j++) pipelineFloats.push(out[j]);
}
}
api.pipelineDestroy(p);
};
runOutput(1, 0, 0, false); // defaults (slew 0 = unlimited)
runOutput(2.2, 0.5, 2.0, true);
for (let id = 0; id <= 7; id++) {
for (let i = 0; i <= 16; i++) {
pipelineFloats.push(api.curveApply(id, Math.fround(i / 16), 1.7));
}
}
api.free(outXY);
api.free(cfgBuf);
api.free(vecBuf);
api.free(maskBuf);
}
// --- Build payload, write blob ---
const payload = [];
for (const v of outsStage1) payload.push(v);
for (const v of probeValues) payload.push(v);
for (const v of outsStage2) payload.push(v);
payload.push(finalLoss);
payload.push(pafL, pafR);
payload.push(csL, csR);
for (const v of feedbackFloats) payload.push(v);
for (const v of pipelineFloats) payload.push(v);
// Sanity: all finite.
for (let i = 0; i < payload.length; ++i) {
if (!Number.isFinite(payload[i])) {
console.error(`[parity_wasm] non-finite value at offset ${i}: ${payload[i]}`);
process.exit(2);
}
}
const buf = Buffer.alloc(12 + payload.length * 4);
buf.writeUInt32LE(MAGIC, 0);
buf.writeUInt32LE(VERSION, 4);
buf.writeUInt32LE(payload.length, 8);
for (let i = 0; i < payload.length; ++i) {
buf.writeFloatLE(payload[i], 12 + i * 4);
}
await writeFile(outPath, buf);
console.log(`[parity_wasm] wrote ${payload.length} floats to ${outPath}`);
}
main().catch((err) => {
console.error('[parity_wasm] error:', err);
process.exit(3);
});