test(manifold): merge P4 golden parity fixtures (pre-P4 capture)
This commit is contained in:
commit
21b223abd3
8 changed files with 34549 additions and 0 deletions
71
manifold/tests/fixtures/README.md
vendored
Normal file
71
manifold/tests/fixtures/README.md
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Pipeline golden fixtures
|
||||||
|
|
||||||
|
**Captured 2026-07-13** from the current **TypeScript** engine implementations,
|
||||||
|
**before** the P4 "one core engine" migration
|
||||||
|
(`docs/specs/plans/one-core-engine-refactor.md` §P4) replaces the TS
|
||||||
|
curve/input/output code with calls into the C++/WASM core.
|
||||||
|
|
||||||
|
P4's own gate reads: *"recorded-gesture regression: same pointer trace → same
|
||||||
|
routed output pre/post migration (capture fixture before starting)."* These
|
||||||
|
files are that capture.
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
| File | What it pins | Source under test |
|
||||||
|
|---|---|---|
|
||||||
|
| `gesture-trace.json` | One canonical synthetic pointer trace (288 events, fixed 120 Hz dt) over the input pipeline's native `[0,1]²` domain: h/v sweeps, diagonal, spiral, figure-eight, dwell + abrupt corner jumps. Pure formula — no `Math.random`, no `Date.now`. | (input to the input pipeline) |
|
||||||
|
| `curves-golden.json` | `applyCurve(name, x)` for every curve id in `curves.ts`, 129 samples of `x ∈ [0,1]` inclusive, at each curve's default `param`. | `src/engine/curves.ts` |
|
||||||
|
| `input-pipeline-golden.json` | The gesture trace run through `processInput` under 14 representative configs (default, deadzone, zoom, sticky anchor, per-axis, curves, smoothing, invert, momentum gentle/strong, frozen axis, fully frozen, combined). Records `{x, y, frozen}` per event. Configs embedded. | `src/engine/input-pipeline.ts` |
|
||||||
|
| `output-pipeline-golden.json` | A deterministic raw-output sequence (120 vectors × 8 channels of offset sines, quantised to f32) run through `processOutput` under 8 configs (default, curves, smoothing, slew limiting, global-freeze toggled mid-sequence, per-output freeze mask, combined). Records the processed vector per step. Configs embedded. | `src/engine/output-pipeline.ts` |
|
||||||
|
|
||||||
|
## The drift guard
|
||||||
|
|
||||||
|
`../pipeline-golden.test.ts` (`bun test`) re-runs the **current** TS
|
||||||
|
implementations against these fixtures and asserts equality within **1e-9**. It
|
||||||
|
reads the trace, raw sequence, and configs **from the JSON** — the fixtures are
|
||||||
|
authoritative, so editing `pipeline-golden-lib.ts` config lists cannot mask a
|
||||||
|
regression. Any change to `curves.ts` / `input-pipeline.ts` / `output-pipeline.ts`
|
||||||
|
that alters numeric behaviour breaks this test until the goldens are
|
||||||
|
deliberately re-captured.
|
||||||
|
|
||||||
|
To re-capture (only when intended): `cd manifold && bun tests/fixtures/_generate.ts`.
|
||||||
|
|
||||||
|
## Contracts you must reproduce to consume these
|
||||||
|
|
||||||
|
### State contract (both pipelines are stateful)
|
||||||
|
- **Input:** EMA-smoothed x/y, a velocity ring, and a momentum-zoom multiplier.
|
||||||
|
- **Output:** `prev` + `smoothed` buffers driving slew/freeze.
|
||||||
|
|
||||||
|
Each config **run resets state** (`defaultInputState()` / `defaultOutputState()`)
|
||||||
|
at step 0. Runs are independent; do not carry state between them.
|
||||||
|
|
||||||
|
### Clock contract (input pipeline only)
|
||||||
|
`input-pipeline.ts`'s momentum-zoom path reads `performance.now()` (wall clock)
|
||||||
|
for its 150 ms velocity window. To make the momentum configs reproducible, the
|
||||||
|
capture pins `performance.now()` to each event's `t_ms` before processing it, so
|
||||||
|
the velocity window slides over the gesture's own timescale. `dt` passed to
|
||||||
|
`processInput` is the per-event `t_ms` delta in seconds (fixed `1000/120` ms).
|
||||||
|
A future consumer that ports this to C++ must feed the same per-event timestamps
|
||||||
|
(the trace's `t_ms`) into whatever owns the velocity ring, or the momentum runs
|
||||||
|
will not match. The output pipeline uses no wall clock; its `dtMs` is a fixed
|
||||||
|
`1000/60`.
|
||||||
|
|
||||||
|
### JSON encodings
|
||||||
|
- `slewRate: null` in an output spec means `Infinity` (JSON has no `Infinity`).
|
||||||
|
- Output raw values are pre-quantised with `Math.fround` so they equal exactly
|
||||||
|
what a `Float32Array` holds.
|
||||||
|
|
||||||
|
## How P4 should consume these
|
||||||
|
|
||||||
|
After the input/output/curve logic moves into the C++/WASM core, flip
|
||||||
|
`pipeline-golden.test.ts` to drive the **WASM** implementations (via the
|
||||||
|
main-thread `nisps` instance) instead of the TS `run*` helpers, keeping the same
|
||||||
|
fixtures as the expected values. That proves *same pointer trace → same routed
|
||||||
|
output* across the migration.
|
||||||
|
|
||||||
|
**Tolerance:** these goldens were produced in TS **f64**. The WASM core computes
|
||||||
|
in **f32** for many paths, so exact 1e-9 equality will not hold post-migration —
|
||||||
|
relax the comparison to about **1e-5** (and expect the sigmoid/exp/log curve tails
|
||||||
|
and long smoothing/slew accumulations to be the widest-drifting points). If any
|
||||||
|
value drifts materially beyond that, it is a real behavioural divergence, not
|
||||||
|
float noise, and must be reconciled in the core rather than by widening tolerance.
|
||||||
98
manifold/tests/fixtures/_generate.ts
vendored
Normal file
98
manifold/tests/fixtures/_generate.ts
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Regenerates the pipeline golden fixtures from the CURRENT TS implementations.
|
||||||
|
*
|
||||||
|
* Run once, from manifold/: `bun tests/fixtures/_generate.ts`
|
||||||
|
*
|
||||||
|
* This is the capture tool. It must only be re-run intentionally (it overwrites
|
||||||
|
* the goldens). The drift guard lives in tests/pipeline-golden.test.ts, which
|
||||||
|
* re-runs the same code against the committed fixtures without rewriting them.
|
||||||
|
*
|
||||||
|
* Captured 2026-07-13, before the P4 core migration
|
||||||
|
* (docs/specs/plans/one-core-engine-refactor.md §P4).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import {
|
||||||
|
CURVE_DEFAULT_PARAMS,
|
||||||
|
CURVE_SAMPLE_COUNT,
|
||||||
|
INPUT_DT_MS,
|
||||||
|
OUTPUT_DIMS,
|
||||||
|
OUTPUT_DT_MS,
|
||||||
|
buildGestureTrace,
|
||||||
|
buildOutputSequence,
|
||||||
|
inputRunSpecs,
|
||||||
|
outputRunSpecs,
|
||||||
|
runInputPipeline,
|
||||||
|
runOutputPipeline,
|
||||||
|
sampleAllCurves,
|
||||||
|
} from '../pipeline-golden-lib';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const write = (name: string, data: unknown) => {
|
||||||
|
const path = join(DIR, name);
|
||||||
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
||||||
|
console.log('wrote', name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAPTURED = '2026-07-13';
|
||||||
|
const SOURCE_NOTE =
|
||||||
|
'Captured from the TS engine implementations before the P4 one-core-engine migration. See tests/fixtures/README.md.';
|
||||||
|
|
||||||
|
// 1. Gesture trace ----------------------------------------------------------
|
||||||
|
const trace = buildGestureTrace();
|
||||||
|
write('gesture-trace.json', {
|
||||||
|
description: 'Canonical synthetic pointer trace over the input pipeline native [0,1]^2 domain.',
|
||||||
|
captured: CAPTURED,
|
||||||
|
note: SOURCE_NOTE,
|
||||||
|
dt_ms: INPUT_DT_MS,
|
||||||
|
count: trace.length,
|
||||||
|
domain: { x: [0, 1], y: [0, 1] },
|
||||||
|
segments: ['h-sweep', 'v-sweep', 'diagonal', 'spiral', 'figure-eight', 'dwell+jumps'],
|
||||||
|
events: trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Curves -----------------------------------------------------------------
|
||||||
|
write('curves-golden.json', {
|
||||||
|
description: 'applyCurve(name, x) sampled at 129 points x in [0,1] inclusive, using default params.',
|
||||||
|
captured: CAPTURED,
|
||||||
|
note: SOURCE_NOTE,
|
||||||
|
sampleCount: CURVE_SAMPLE_COUNT,
|
||||||
|
xStep: 1 / (CURVE_SAMPLE_COUNT - 1),
|
||||||
|
defaultParams: CURVE_DEFAULT_PARAMS,
|
||||||
|
curves: sampleAllCurves(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Input pipeline ---------------------------------------------------------
|
||||||
|
write('input-pipeline-golden.json', {
|
||||||
|
description: 'Gesture trace (gesture-trace.json) run through processInput under representative configs.',
|
||||||
|
captured: CAPTURED,
|
||||||
|
note: SOURCE_NOTE,
|
||||||
|
traceRef: 'gesture-trace.json',
|
||||||
|
dt_ms: INPUT_DT_MS,
|
||||||
|
clockContract: 'performance.now() is pinned to each event t_ms during capture so momentum is deterministic.',
|
||||||
|
stateContract: 'State reset to defaultInputState() at step 0 of every run.',
|
||||||
|
runs: inputRunSpecs().map(({ id, config }) => ({
|
||||||
|
id,
|
||||||
|
config,
|
||||||
|
outputs: runInputPipeline(trace, config),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Output pipeline --------------------------------------------------------
|
||||||
|
const sequence = buildOutputSequence();
|
||||||
|
write('output-pipeline-golden.json', {
|
||||||
|
description: 'Deterministic raw output vectors (offset sines, f32) run through processOutput under representative configs.',
|
||||||
|
captured: CAPTURED,
|
||||||
|
note: SOURCE_NOTE,
|
||||||
|
dt_ms: OUTPUT_DT_MS,
|
||||||
|
dims: OUTPUT_DIMS,
|
||||||
|
stateContract: 'State reset to defaultOutputState() at step 0 of every run. slewRate null = Infinity.',
|
||||||
|
sequence,
|
||||||
|
runs: outputRunSpecs().map((spec) => ({
|
||||||
|
id: spec.id,
|
||||||
|
spec,
|
||||||
|
outputs: runOutputPipeline(sequence, spec),
|
||||||
|
})),
|
||||||
|
});
|
||||||
1067
manifold/tests/fixtures/curves-golden.json
vendored
Normal file
1067
manifold/tests/fixtures/curves-golden.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
1467
manifold/tests/fixtures/gesture-trace.json
vendored
Normal file
1467
manifold/tests/fixtures/gesture-trace.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
20479
manifold/tests/fixtures/input-pipeline-golden.json
vendored
Normal file
20479
manifold/tests/fixtures/input-pipeline-golden.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
10934
manifold/tests/fixtures/output-pipeline-golden.json
vendored
Normal file
10934
manifold/tests/fixtures/output-pipeline-golden.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
322
manifold/tests/pipeline-golden-lib.ts
Normal file
322
manifold/tests/pipeline-golden-lib.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
/**
|
||||||
|
* Shared runner + generator library for the pipeline golden fixtures.
|
||||||
|
*
|
||||||
|
* Captured 2026-07-13, BEFORE the P4 "one core engine" migration
|
||||||
|
* (docs/specs/plans/one-core-engine-refactor.md §P4) replaces the TS
|
||||||
|
* curve/input/output implementations with C++/WASM calls.
|
||||||
|
*
|
||||||
|
* This module is imported by BOTH:
|
||||||
|
* - tests/fixtures/_generate.ts — writes the *.json fixtures once, and
|
||||||
|
* - tests/pipeline-golden.test.ts — re-runs the CURRENT TS implementations
|
||||||
|
* against the committed fixtures and asserts exact equality.
|
||||||
|
*
|
||||||
|
* The `run*` functions are the single source of truth for how a fixture was
|
||||||
|
* produced. The fixtures embed the trace / raw sequence / configs, so the test
|
||||||
|
* re-derives outputs purely from committed data — no hidden inputs.
|
||||||
|
*
|
||||||
|
* --- Determinism / clock contract -----------------------------------------
|
||||||
|
* `input-pipeline.ts`'s momentum-zoom path reads `performance.now()` (wall
|
||||||
|
* clock) for its velocity ring. To make the momentum configs reproducible,
|
||||||
|
* `runInputPipeline` overrides `performance.now` with a synthetic clock driven
|
||||||
|
* by the trace's own `t_ms`: before processing event i, the clock is pinned to
|
||||||
|
* `events[i].t_ms`. The velocity window (150 ms) therefore slides over the
|
||||||
|
* gesture's own timescale, deterministically. The original `performance.now`
|
||||||
|
* is restored afterwards. `output-pipeline.ts` uses no wall clock.
|
||||||
|
*
|
||||||
|
* --- State contract --------------------------------------------------------
|
||||||
|
* Both pipelines are STATEFUL (input: EMA smoothing + velocity ring + momentum
|
||||||
|
* multiplier; output: prev + smoothed buffers for slew/freeze). Each config run
|
||||||
|
* RESETS state to `defaultInputState()` / `defaultOutputState()` at step 0, so
|
||||||
|
* runs are independent and order-free.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CURVE_NAMES, applyCurve, type CurveName } from '../src/engine/curves';
|
||||||
|
import {
|
||||||
|
defaultInputConfig,
|
||||||
|
defaultInputState,
|
||||||
|
processInput,
|
||||||
|
type InputConfig,
|
||||||
|
} from '../src/engine/input-pipeline';
|
||||||
|
import {
|
||||||
|
defaultOutputState,
|
||||||
|
processOutput,
|
||||||
|
type OutputConfig,
|
||||||
|
} from '../src/engine/output-pipeline';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CURVE_SAMPLE_COUNT = 129; // 0..1 inclusive, step 1/128
|
||||||
|
|
||||||
|
/** Default `param` used per curve (mirrors applyCurve's `?? default`). */
|
||||||
|
export const CURVE_DEFAULT_PARAMS: Record<CurveName, number | null> = {
|
||||||
|
linear: null,
|
||||||
|
exp: 4.0,
|
||||||
|
log: 4.0,
|
||||||
|
square: null,
|
||||||
|
sqrt: null,
|
||||||
|
sigmoid: 8.0,
|
||||||
|
cubic: null,
|
||||||
|
centered_power: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sampleCurve(name: CurveName): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < CURVE_SAMPLE_COUNT; i++) {
|
||||||
|
const x = i / (CURVE_SAMPLE_COUNT - 1); // inclusive endpoints
|
||||||
|
out.push(applyCurve(name, x));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleAllCurves(): Record<string, number[]> {
|
||||||
|
const out: Record<string, number[]> = {};
|
||||||
|
for (const name of CURVE_NAMES) out[name] = sampleCurve(name);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Input pipeline configs + runner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function cfg(overrides: Partial<InputConfig>): InputConfig {
|
||||||
|
return { ...defaultInputConfig(), ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Representative input configs. Exercises every branch of processInput. */
|
||||||
|
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' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the gesture trace through the input pipeline under one config.
|
||||||
|
* Resets state at step 0. Drives a synthetic `performance.now` from the trace's
|
||||||
|
* t_ms so the momentum path is deterministic (see clock contract above).
|
||||||
|
*/
|
||||||
|
export function runInputPipeline(trace: readonly GestureEvent[], config: InputConfig): InputRunOutput[] {
|
||||||
|
const perf = globalThis.performance as { now(): number };
|
||||||
|
const realNow = perf.now;
|
||||||
|
let clock = 0;
|
||||||
|
perf.now = () => clock;
|
||||||
|
try {
|
||||||
|
let state = defaultInputState();
|
||||||
|
const outputs: InputRunOutput[] = [];
|
||||||
|
let prevT = trace.length > 0 ? trace[0]!.t_ms : 0;
|
||||||
|
for (const ev of trace) {
|
||||||
|
clock = ev.t_ms;
|
||||||
|
const dt = Math.max(0, (ev.t_ms - prevT) / 1000);
|
||||||
|
prevT = ev.t_ms;
|
||||||
|
const res = processInput([ev.x, ev.y], config, state, dt);
|
||||||
|
outputs.push({ x: res.x, y: res.y, frozen: res.frozen });
|
||||||
|
state = res.state;
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
} finally {
|
||||||
|
perf.now = realNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Output raw sequence + configs + runner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputConfigForStep(spec: OutputRunSpec, step: number, dims: number): OutputConfig {
|
||||||
|
const frozen = spec.freezeSteps ? step >= spec.freezeSteps[0] && step < spec.freezeSteps[1] : false;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
globalCurve: spec.globalCurve,
|
||||||
|
smoothing: spec.smoothing,
|
||||||
|
slewRate: spec.slewRate === null ? Infinity : spec.slewRate,
|
||||||
|
freezeOutput: frozen,
|
||||||
|
freezeMask: mask,
|
||||||
|
reuseBuffer: spec.reuseBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the raw sequence through the output pipeline under one spec. Resets state
|
||||||
|
* at step 0. The global-freeze gate follows `spec.freezeSteps`.
|
||||||
|
*/
|
||||||
|
export function runOutputPipeline(sequence: readonly number[][], spec: OutputRunSpec): number[][] {
|
||||||
|
const dims = sequence.length > 0 ? sequence[0]!.length : 0;
|
||||||
|
let state = defaultOutputState();
|
||||||
|
const outputs: number[][] = [];
|
||||||
|
for (let s = 0; s < sequence.length; s++) {
|
||||||
|
const raw = Float32Array.from(sequence[s]!);
|
||||||
|
const config = outputConfigForStep(spec, s, dims);
|
||||||
|
const res = processOutput(raw, config, state, OUTPUT_DT_MS);
|
||||||
|
outputs.push(Array.from(res.processed));
|
||||||
|
state = res.state;
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
111
manifold/tests/pipeline-golden.test.ts
Normal file
111
manifold/tests/pipeline-golden.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Pipeline golden drift guard (run with `bun test`).
|
||||||
|
*
|
||||||
|
* Re-runs the CURRENT TS curve / input-pipeline / output-pipeline
|
||||||
|
* implementations against the committed fixtures in ./fixtures and asserts
|
||||||
|
* exact equality (tolerance 1e-9). It pins:
|
||||||
|
* - the pointer trace (gesture-trace.json) → routed input output,
|
||||||
|
* - applyCurve(name, x) over the curve catalog,
|
||||||
|
* - the raw-output sequence → processed output.
|
||||||
|
*
|
||||||
|
* Purpose: guard the TS implementations against silent drift until P4
|
||||||
|
* (docs/specs/plans/one-core-engine-refactor.md §P4) flips these assertions to
|
||||||
|
* the C++/WASM implementations. See fixtures/README.md for the migration
|
||||||
|
* playbook and the f32-vs-f64 tolerance note.
|
||||||
|
*
|
||||||
|
* The fixtures are authoritative: configs, trace, and raw sequence are read
|
||||||
|
* FROM the JSON, so editing pipeline-golden-lib.ts config lists cannot mask a
|
||||||
|
* regression here — only re-running fixtures/_generate.ts updates the goldens.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import type { CurveName } from '../src/engine/curves';
|
||||||
|
import { applyCurve } from '../src/engine/curves';
|
||||||
|
import type { InputConfig } from '../src/engine/input-pipeline';
|
||||||
|
import {
|
||||||
|
runInputPipeline,
|
||||||
|
runOutputPipeline,
|
||||||
|
type GestureEvent,
|
||||||
|
type OutputRunSpec,
|
||||||
|
} from './pipeline-golden-lib';
|
||||||
|
|
||||||
|
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-9;
|
||||||
|
const close = (a: number, b: number, ctx: string) => {
|
||||||
|
if (a === b) return;
|
||||||
|
expect(Math.abs(a - b), ctx).toBeLessThanOrEqual(TOL);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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, `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: applyCurve matches captured samples', () => {
|
||||||
|
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]!;
|
||||||
|
expect(golden.length).toBe(fx.sampleCount);
|
||||||
|
for (let i = 0; i < golden.length; i++) {
|
||||||
|
const x = i / (fx.sampleCount - 1);
|
||||||
|
close(applyCurve(name, x), golden[i]!, `curve ${name}[${i}] (x=${x})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('input-pipeline-golden: processInput 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 got = runInputPipeline(trace, run.config);
|
||||||
|
expect(got.length, `run ${run.id} length`).toBe(run.outputs.length);
|
||||||
|
for (let i = 0; i < got.length; i++) {
|
||||||
|
close(got[i]!.x, run.outputs[i]!.x, `input ${run.id}[${i}].x`);
|
||||||
|
close(got[i]!.y, run.outputs[i]!.y, `input ${run.id}[${i}].y`);
|
||||||
|
expect(got[i]!.frozen, `input ${run.id}[${i}].frozen`).toBe(run.outputs[i]!.frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output-pipeline-golden: processOutput matches captured outputs', () => {
|
||||||
|
const fx = readFixture<{
|
||||||
|
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);
|
||||||
|
for (const run of fx.runs) {
|
||||||
|
const got = runOutputPipeline(fx.sequence, run.spec);
|
||||||
|
expect(got.length, `run ${run.id} length`).toBe(run.outputs.length);
|
||||||
|
for (let s = 0; s < got.length; s++) {
|
||||||
|
const gotRow = got[s]!;
|
||||||
|
const wantRow = run.outputs[s]!;
|
||||||
|
expect(gotRow.length, `output ${run.id}[${s}] width`).toBe(wantRow.length);
|
||||||
|
for (let j = 0; j < gotRow.length; j++) {
|
||||||
|
close(gotRow[j]!, wantRow[j]!, `output ${run.id}[${s}][${j}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue