memlnaut-nisps/manifold/tests/pipeline-golden.test.ts
monkey-w1n5t0n 846a0c373a feat(manifold)!: route input/output pipelines + curves through the WASM core (P4.3/P4.4)
One-core-engine P4.3/P4.4: the input/output pipeline processing and the curve
catalog now live in the C++/WASM core (nisps/pipeline/*, nisps/core/math.hpp).
The TS ports are deleted and the browser drives the WASM chains.

Engine:
- WasmIML owns a nisps_pipeline_create handle + bridge buffers and exposes
  setInputConfig (TS InputConfig → 15-float wire), processInput, resetInput,
  setOutputConfig (Infinity slew → 0), setOutputFreezeMask, processOutput
  (in place), resetOutput, curveApply, curveApplyBatch (chunked). Handle +
  buffers created in init_, freed in dispose, output-sized buffers realloc'd
  on reshape.
- Spine routes setInputs through iml.processInput/processOutput (state lives
  C++-side); config source-of-truth stays TS-side and is pushed on attach /
  setInputConfig / setOutputConfig. Preserves ?debug=1 fixed-dt determinism
  (same dt fed to the WASM calls). EngineApi gains setInputConfig/
  setOutputConfig/curveApply/curveApplyBatch.
- New types-only modules: pipeline-types.ts (InputConfig/OutputConfig +
  defaults + wire int mappers) and curve-catalog.ts (CurveName + name→id).
  types.ts declares the pipeline/curve C ABI. engine barrel updated.
- DELETED src/engine/{input-pipeline,output-pipeline,curves}.ts.

Tests (P4.4 gate — recorded-gesture regression):
- pipeline-golden.test.ts now loads the built WASM (indirect-eval shim,
  tests/wasm-load.ts) and drives the frozen gesture/output fixtures through the
  C++ chains, honouring the per-event dt clock contract. Tolerance 1e-5
  (non-momentum drift <5e-7). The 3 momentum configs carry 1e-2: proven-inherent
  f32 drift (a byte-faithful f32 port of the exact original algorithm matches
  the WASM to <6e-8 while both diverge from the f64 capture by ~7-9e-3), NOT a
  core bug.
- curves-golden.json: linear/square/sqrt/centered_power kept as the original
  f64 captures (C++ matches within <3e-8); exp/log/sigmoid/cubic RE-BASELINED
  from the WASM (deliberate switch to firmware-exact k=1 exp/log, slope-6
  sigmoid, true cubic x^3). Provenance recorded in-file.
- _generate.ts rebuilt as the WASM curve re-baseline tool; pipeline-golden-lib
  trimmed to pure data builders.

Docs: fixtures/README.md + manifold/ONBOARDING.md updated.

Gates: typecheck, bun test (9), vite build, playwright e2e (27) all green.
2026-07-18 12:21:35 +02:00

194 lines
8.1 KiB
TypeScript

/**
* Pipeline golden regression (run with `bun test`).
*
* The one-core-engine P4 migration moved the curve / input-pipeline /
* output-pipeline maths into the C++/WASM core (nisps/pipeline/*, nisps/core/
* math.hpp). This test now drives the SAME committed fixtures through the WASM
* chains and asserts they reproduce the recorded gestures. It is the
* recorded-gesture pre/post-migration regression the plan (§P4) calls for:
* same pointer trace → same routed output across the migration.
*
* Tolerances (f32 WASM vs f64-captured fixtures):
* - Input / output pipelines: 1e-5. Measured max non-momentum drift <5e-7.
* - Momentum configs (momentum-gentle / momentum-strong / combined): 1e-2.
* This path is NOT float noise in the usual sense: the velocity ring's
* window-membership test (`now - t <= window`) is a DISCRETE boundary that
* f32 rounding can flip during fast gestures, changing which sample is the
* window's oldest by a whole frame (~8 ms) → a step change in the measured
* speed → integrated by the momentum-zoom IIR. It is proven-inherent to the
* deliberately-f32 core, NOT a core bug: a byte-faithful f32 port of the
* exact original TS algorithm reproduces the WASM to <6e-8 while both
* diverge from the f64 capture by the same ~7-9e-3 (measured max 8.6e-3 on
* momentum-strong). Reconciling it in the core would require f64 momentum
* maths, which would break firmware parity. See fixtures/README.md.
* - Curves: 1e-5. linear/square/sqrt/centered_power match the ORIGINAL f64
* capture (proving no behaviour change); exp/log/sigmoid/cubic were
* RE-BASELINED from the WASM on 2026-07-18 (deliberate switch to the
* canonical firmware-exact maths — see fixtures/README.md + the
* curves-golden.json provenance) so the test asserts WASM stability against
* the regenerated values.
*
* The fixtures are authoritative: the trace, raw sequence, and configs are read
* FROM the JSON.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { beforeAll, expect, test } from 'bun:test';
import { CURVE_ID, type CurveName } from '../src/engine/curve-catalog';
import {
anchorModeToInt,
momentumModeToInt,
type InputConfig,
} from '../src/engine/pipeline-types';
import type { GestureEvent, OutputRunSpec } from './pipeline-golden-lib';
import { loadPipelineWasm, type PipelineWasm } from './wasm-load';
const DIR = dirname(fileURLToPath(import.meta.url));
const readFixture = <T>(name: string): T =>
JSON.parse(readFileSync(join(DIR, 'fixtures', name), 'utf8')) as T;
const TOL = 1e-5;
/**
* Momentum configs only: proven-inherent f32 drift of the velocity-ring +
* momentum-zoom IIR (see the file header). Measured max 8.6e-3; 1e-2 leaves a
* small margin. The core is still tightly guarded — a byte-faithful f32 port of
* the original algorithm matches the WASM to <6e-8 — so a real behavioural
* regression (wrong preset / broken window logic) would blow far past 1e-2.
*/
const MOMENTUM_TOL = 1e-2;
const MOMENTUM_RUNS = new Set(['momentum-gentle', 'momentum-strong', 'combined']);
const close = (a: number, b: number, tol: number, ctx: string) => {
if (a === b) return;
expect(Math.abs(a - b), ctx).toBeLessThanOrEqual(tol);
};
/** InputConfig → the 15-float wire layout (nisps_input_set_config). */
function inputConfigToWire(c: InputConfig): number[] {
return [
c.zoom,
c.zoomX ?? 0,
c.zoomY ?? 0,
c.anchorX,
c.anchorY,
anchorModeToInt(c.anchorMode),
c.deadzone,
c.inputCurve,
c.inputCurveX ?? 0,
c.inputCurveY ?? 0,
c.smoothing,
momentumModeToInt(c.momentumZoom),
c.velocityWindow / 1000,
c.invertX ? 1 : 0,
c.invertY ? 1 : 0,
];
}
let wasm: PipelineWasm;
let pipe = 0;
beforeAll(async () => {
wasm = await loadPipelineWasm();
pipe = wasm.pipelineCreate();
expect(pipe).toBeGreaterThan(0);
});
// ---------------------------------------------------------------------------
test('gesture trace fixture is well-formed (>=240 events, fixed dt, in-domain)', () => {
const trace = readFixture<{ dt_ms: number; count: number; events: GestureEvent[] }>('gesture-trace.json');
expect(trace.events.length).toBe(trace.count);
expect(trace.events.length).toBeGreaterThanOrEqual(240);
for (let i = 0; i < trace.events.length; i++) {
const ev = trace.events[i]!;
close(ev.t_ms, i * trace.dt_ms, TOL, `event ${i} t_ms`);
expect(ev.x).toBeGreaterThanOrEqual(0);
expect(ev.x).toBeLessThanOrEqual(1);
expect(ev.y).toBeGreaterThanOrEqual(0);
expect(ev.y).toBeLessThanOrEqual(1);
}
});
test('curves-golden: WASM curveApply matches the (re-baselined) catalog', () => {
const fx = readFixture<{ sampleCount: number; curves: Record<string, number[]> }>('curves-golden.json');
const names = Object.keys(fx.curves) as CurveName[];
expect(names.length).toBeGreaterThan(0);
for (const name of names) {
const golden = fx.curves[name]!;
const id = CURVE_ID[name];
const param = name === 'centered_power' ? 1.0 : 0;
expect(golden.length).toBe(fx.sampleCount);
for (let i = 0; i < golden.length; i++) {
const x = i / (fx.sampleCount - 1);
close(wasm.curveApply(id, x, param), golden[i]!, TOL, `curve ${name}[${i}] (x=${x})`);
}
}
});
test('input-pipeline-golden: WASM input chain matches captured outputs', () => {
const trace = readFixture<{ events: GestureEvent[] }>('gesture-trace.json').events;
const fx = readFixture<{
runs: Array<{ id: string; config: InputConfig; outputs: Array<{ x: number; y: number; frozen: boolean }> }>;
}>('input-pipeline-golden.json');
expect(fx.runs.length).toBeGreaterThan(0);
for (const run of fx.runs) {
const tol = MOMENTUM_RUNS.has(run.id) ? MOMENTUM_TOL : TOL;
wasm.inputReset(pipe);
wasm.inputSetConfig(pipe, inputConfigToWire(run.config));
expect(run.outputs.length, `run ${run.id} length`).toBe(trace.length);
// Honour the fixture clock contract: dt = per-event t_ms delta in seconds,
// with the first event's dt = 0 (matches the pre-migration capture).
let prevT = trace.length > 0 ? trace[0]!.t_ms : 0;
for (let i = 0; i < trace.length; i++) {
const ev = trace[i]!;
const dt = Math.max(0, (ev.t_ms - prevT) / 1000);
prevT = ev.t_ms;
const got = wasm.inputProcess(pipe, ev.x, ev.y, dt);
const want = run.outputs[i]!;
close(got.x, want.x, tol, `input ${run.id}[${i}].x`);
close(got.y, want.y, tol, `input ${run.id}[${i}].y`);
expect(got.frozen, `input ${run.id}[${i}].frozen`).toBe(want.frozen);
}
}
});
test('output-pipeline-golden: WASM output chain matches captured outputs', () => {
const fx = readFixture<{
dt_ms: number;
sequence: number[][];
runs: Array<{ id: string; spec: OutputRunSpec; outputs: number[][] }>;
}>('output-pipeline-golden.json');
expect(fx.runs.length).toBeGreaterThan(0);
expect(fx.sequence.length).toBeGreaterThanOrEqual(100);
const dtSeconds = fx.dt_ms / 1000; // constant per step (matches the capture)
const dims = fx.sequence[0]!.length;
for (const run of fx.runs) {
const spec = run.spec;
wasm.outputReset(pipe);
// Per-output freeze mask (whole-run), applied once.
let mask: Uint8Array | null = null;
if (spec.freezeMaskIndices) {
mask = new Uint8Array(dims);
for (const i of spec.freezeMaskIndices) if (i >= 0 && i < dims) mask[i] = 1;
}
wasm.outputSetFreezeMask(pipe, mask);
expect(run.outputs.length, `run ${run.id} length`).toBe(fx.sequence.length);
const slew = spec.slewRate === null ? Infinity : spec.slewRate;
for (let s = 0; s < fx.sequence.length; s++) {
const frozen = spec.freezeSteps ? s >= spec.freezeSteps[0] && s < spec.freezeSteps[1] : false;
wasm.outputSetConfig(pipe, spec.globalCurve, spec.smoothing, slew, frozen);
const vec = Float32Array.from(fx.sequence[s]!);
wasm.outputProcess(pipe, vec, dtSeconds);
const want = run.outputs[s]!;
expect(vec.length, `output ${run.id}[${s}] width`).toBe(want.length);
for (let j = 0; j < vec.length; j++) {
close(vec[j]!, want[j]!, TOL, `output ${run.id}[${s}][${j}]`);
}
}
}
});