memlnaut-nisps/manifold/tests/pipeline-golden-lib.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

217 lines
8.5 KiB
TypeScript

/**
* Fixture-support library for the pipeline golden tests.
*
* Since the one-core-engine P4 migration the input/output PROCESSING lives in
* the C++/WASM core; the golden test (tests/pipeline-golden.test.ts) drives the
* WASM chains against the committed fixtures. This module no longer runs any TS
* pipeline maths — it only provides the PURE, deterministic fixture DATA
* (gesture trace, raw output sequence) + the representative config lists + the
* curve catalog metadata. The gesture/sequence/config data is embedded in the
* committed *.json fixtures, so the test re-derives nothing hidden.
*
* The gesture/output goldens are a FROZEN pre-migration capture (2026-07-13):
* the test proves the WASM chains reproduce them within an f32 tolerance. The
* curve goldens were partly re-baselined on 2026-07-18 (see README + the
* curves-golden.json provenance field).
*/
import {
CURVE_DEFAULT_PARAMS,
CURVE_NAMES,
type CurveName,
} from '../src/engine/curve-catalog';
import {
defaultInputConfig,
type InputConfig,
} from '../src/engine/pipeline-types';
export { CURVE_DEFAULT_PARAMS, CURVE_NAMES };
export type { CurveName, InputConfig };
// ---------------------------------------------------------------------------
// Shared timebases
// ---------------------------------------------------------------------------
/** Input trace step: 120 Hz. */
export const INPUT_DT_MS = 1000 / 120; // 8.3333… ms
/** Output sequence step: 60 Hz. */
export const OUTPUT_DT_MS = 1000 / 60; // 16.6666… ms
/** Output vector width used by the synthetic raw sequence. */
export const OUTPUT_DIMS = 8;
// ---------------------------------------------------------------------------
// Fixture value types
// ---------------------------------------------------------------------------
export interface GestureEvent {
t_ms: number;
x: number;
y: number;
}
export interface InputRunOutput {
x: number;
y: number;
frozen: boolean;
}
/** JSON-serialisable input run: an id + the full InputConfig. */
export interface InputRunSpec {
id: string;
config: InputConfig;
}
/**
* JSON-serialisable output run spec. `slewRate: null` means `Infinity`
* (JSON has no Infinity). `freezeMaskIndices` freezes those output indices for
* the whole run. `freezeSteps: [start, end)` toggles the GLOBAL freeze gate on
* for that half-open step range.
*/
export interface OutputRunSpec {
id: string;
globalCurve: number;
smoothing: number;
slewRate: number | null;
freezeMaskIndices: number[] | null;
freezeSteps: [number, number] | null;
reuseBuffer: boolean;
}
// ---------------------------------------------------------------------------
// 1. Gesture trace generator (pure formula — no Math.random / Date.now)
// ---------------------------------------------------------------------------
const TAU = Math.PI * 2;
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
/**
* One canonical synthetic pointer trace over the pipeline's native [0,1]^2
* input domain. 288 events at a fixed 120 Hz dt. Six segments (48 events each)
* exercise: horizontal sweep, vertical sweep, diagonal corner-to-corner,
* growing spiral, a Lissajous figure-eight, and dwell periods punctuated by
* abrupt corner jumps. Endpoints (0 and 1) are visited so the full range is
* covered.
*/
export function buildGestureTrace(): GestureEvent[] {
const seg = 48;
const events: GestureEvent[] = [];
const push = (x: number, y: number) => {
const i = events.length;
events.push({ t_ms: i * INPUT_DT_MS, x: clamp01(x), y: clamp01(y) });
};
// Segment 1 — horizontal sweep left→right at mid height.
for (let i = 0; i < seg; i++) push(i / (seg - 1), 0.5);
// Segment 2 — vertical sweep bottom→top at mid width.
for (let i = 0; i < seg; i++) push(0.5, i / (seg - 1));
// Segment 3 — diagonal, corner (0,0) → (1,1).
for (let i = 0; i < seg; i++) {
const t = i / (seg - 1);
push(t, t);
}
// Segment 4 — outward spiral around centre (radius 0 → 0.5).
for (let i = 0; i < seg; i++) {
const t = i / (seg - 1);
const r = 0.5 * t;
const ang = TAU * 3 * t;
push(0.5 + r * Math.cos(ang), 0.5 + r * Math.sin(ang));
}
// Segment 5 — Lissajous figure-eight (1:2), amplitude 0.48.
for (let i = 0; i < seg; i++) {
const t = i / (seg - 1);
push(0.5 + 0.48 * Math.sin(TAU * t), 0.5 + 0.48 * Math.sin(TAU * 2 * t));
}
// Segment 6 — dwell + abrupt jumps. Hold a point for 8 frames, jump, repeat.
const stops: Array<[number, number]> = [
[0.5, 0.5],
[0.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
[1.0, 0.0],
[0.5, 0.5],
];
for (let s = 0; s < stops.length; s++) {
const [x, y] = stops[s]!;
for (let h = 0; h < seg / stops.length; h++) push(x, y);
}
return events;
}
// ---------------------------------------------------------------------------
// 2. Curve sampling metadata
// ---------------------------------------------------------------------------
export const CURVE_SAMPLE_COUNT = 129; // 0..1 inclusive, step 1/128
// ---------------------------------------------------------------------------
// 3. Input pipeline configs
// ---------------------------------------------------------------------------
function cfg(overrides: Partial<InputConfig>): InputConfig {
return { ...defaultInputConfig(), ...overrides };
}
/** Representative input configs. Exercises every branch of the input chain. */
export function inputRunSpecs(): InputRunSpec[] {
return [
{ id: 'default', config: cfg({}) },
{ id: 'deadzone', config: cfg({ deadzone: 0.3 }) },
{ id: 'zoom-narrow', config: cfg({ zoom: 0.4 }) },
{ id: 'zoom-sticky-anchor', config: cfg({ zoom: 0.5, anchorMode: 'sticky', anchorX: 0.3, anchorY: 0.7 }) },
{ id: 'curve-pull-center', config: cfg({ inputCurve: 3.0 }) },
{ id: 'curve-push-extremes', config: cfg({ inputCurve: 0.4 }) },
{ id: 'smoothing', config: cfg({ smoothing: 0.8 }) },
{ id: 'invert-both', config: cfg({ invertX: true, invertY: true }) },
{ id: 'per-axis', config: cfg({ zoomX: 0.6, zoomY: 1.0, inputCurveX: 2.0, inputCurveY: 0.5 }) },
{ id: 'momentum-gentle', config: cfg({ momentumZoom: 'gentle' }) },
{ id: 'momentum-strong', config: cfg({ momentumZoom: 'strong', smoothing: 0.5 }) },
{ id: 'mixed-frozen-axis', config: cfg({ zoomX: 0.005, zoomY: 1.0 }) },
{ id: 'fully-frozen', config: cfg({ zoom: 0.005 }) },
{
id: 'combined',
config: cfg({ deadzone: 0.2, zoom: 0.7, inputCurve: 1.6, smoothing: 0.6, momentumZoom: 'gentle' }),
},
];
}
// ---------------------------------------------------------------------------
// 4. Output raw sequence + configs
// ---------------------------------------------------------------------------
/**
* A deterministic raw output sequence: 120 vectors of width OUTPUT_DIMS, each
* channel an offset sine, quantised to f32 (Math.fround) so it matches exactly
* what the Float32Array pipeline input holds.
*/
export function buildOutputSequence(steps = 120, dims = OUTPUT_DIMS): number[][] {
const seq: number[][] = [];
for (let s = 0; s < steps; s++) {
const row: number[] = [];
for (let j = 0; j < dims; j++) {
const freq = (j + 1) * 0.5;
const phase = j / dims;
const v = 0.5 + 0.5 * Math.sin(TAU * (freq * (s / steps) + phase));
row.push(Math.fround(v));
}
seq.push(row);
}
return seq;
}
/** Representative output configs. */
export function outputRunSpecs(): OutputRunSpec[] {
return [
{ id: 'default', globalCurve: 1.0, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'curve-pull', globalCurve: 2.5, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'curve-push', globalCurve: 0.4, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'smoothing', globalCurve: 1.0, smoothing: 0.85, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'slew-limited', globalCurve: 1.0, smoothing: 0, slewRate: 0.5, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'freeze-toggled', globalCurve: 1.0, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: [40, 80], reuseBuffer: false },
{ id: 'freeze-mask', globalCurve: 1.0, smoothing: 0, slewRate: null, freezeMaskIndices: [0, 2, 4], freezeSteps: null, reuseBuffer: false },
{ id: 'combined', globalCurve: 1.8, smoothing: 0.7, slewRate: 1.0, freezeMaskIndices: null, freezeSteps: [90, 110], reuseBuffer: false },
];
}