memlnaut-nisps/manifold/tests/e2e/reshape.spec.ts
monkey-w1n5t0n 6c499e6826 feat(manifold): P5.2/P5.3 — derive MF_MODES from schema truth + per-mode engine dims
Schema-backed modes in console/model.ts are now DERIVED from the codegen
schemas in src/modes/generated/ (source of truth): real param names/groups/
count, plus each mode's ml net shape (MFMode.ml) and schema engine_id. A thin
manifold OVERLAY supplies only label/glyph/ModeClass/input/ordering. New
browser-viable modes xiasri + slp_workshop get derived entries; schema-less
visualizer + c15 stay hand-written on DEFAULT_MODE_ML. Schema min/max/default/
label/curve surface as engine-unit metadata (schemaMin/... on MFParam) without
touching the 0..1 routing semantics.

Switching instrument mode reshapes the runtime-shaped WASM net to the mode's
schema ml config (ConsoleApp effect keyed on [engine, modeId]; no confirm
modal). Boot lands paf_synth dims (4->[10,10,14]->33) once WASM is ready. The
P2.3 axis-count reshape offer still reads the engine's live inputSize and does
not spuriously prompt on a mode switch.

Adds schema-modes.spec.ts (P5 gate): drives switches via a new window.__mf
debug seam and asserts describe() dims, getWeights count, output length/bounds,
and UI param count FROM the imported schemas; spot-checks trainAsync after a
switch. Updates reshape/probe-api/geo-dislike specs to assert from the boot
mode schema instead of the retired fixed 32/126 shape.

All gates green: typecheck, unit (9), build, e2e (33).
2026-07-18 12:45:06 +02:00

87 lines
3.8 KiB
TypeScript

/**
* Runtime-shaped net reshape (one-core-engine P2.3 + P5.3).
*
* The WASM MLP is runtime-shaped. Since P5.3 the app reshapes the net to the
* BOOT MODE's schema `ml` config once WASM is ready, so the debug harness boots
* at the boot mode's dims (NOT the over-provisioned 32→126 default). This spec
* asserts FROM the imported schema — never hard-coded dim numbers — that:
*
* 1. the net boots at the boot mode's schema dims + weight count;
* 2. `engine.reshape(n)` to a DIFFERENT arity succeeds and `describe()` reports
* the new input size while keeping the schema output size;
* 3. getWeights length tracks the new arity;
* 4. post-ML outputs stay bounded in [0,1] and the spine still propagates.
*/
import { test, expect } from '@playwright/test';
import { loadProbe, getOutputs, settleInputs, countChanged, allWithin, weightCountFromMl } from './helpers';
import { PafSynthSchema } from '../../src/modes/generated';
// The boot mode (ConsoleApp `modeId` initial state) is paf_synth.
const BOOT = PafSynthSchema.ml;
const BOOT_INPUT = BOOT.input_size; // 4
const BOOT_OUTPUT = BOOT.output_size; // 33
const BOOT_WEIGHTS = weightCountFromMl(BOOT); // 4→[10,10,14]→33 = 809
// A reshape target arity guaranteed to differ from the boot arity.
const OTHER_INPUT = BOOT_INPUT + 6; // 10
// Reshaping only the input arity shifts the first layer: (Δin)*hidden0.
const OTHER_WEIGHTS = BOOT_WEIGHTS + (OTHER_INPUT - BOOT_INPUT) * BOOT.hidden_layers[0]!;
test.beforeEach(async ({ page }) => {
await loadProbe(page);
});
test.describe('reshape — runtime-shaped MLP', () => {
test('boots at the boot mode schema shape', async ({ page }) => {
const arch = await page.evaluate(() => window.__nisps!.describe());
expect(arch.inputSize).toBe(BOOT_INPUT);
expect(arch.outputSize).toBe(BOOT_OUTPUT);
expect(arch.hidden).toEqual([...BOOT.hidden_layers]);
const len = await page.evaluate(() => window.__nisps!.getWeights().length);
expect(len).toBe(BOOT_WEIGHTS);
});
test('reshape to a new arity succeeds and describe reports it', async ({ page }) => {
const result = await page.evaluate((n) => window.__nisps!.reshape(n), OTHER_INPUT);
expect(result).not.toBeNull();
expect(result!.inputSize).toBe(OTHER_INPUT);
expect(result!.outputSize).toBe(BOOT_OUTPUT);
const arch = await page.evaluate(() => window.__nisps!.describe());
expect(arch.inputSize).toBe(OTHER_INPUT);
expect(arch.outputSize).toBe(BOOT_OUTPUT);
});
test('getWeights length changes with the new arity', async ({ page }) => {
const before = await page.evaluate(() => window.__nisps!.getWeights().length);
expect(before).toBe(BOOT_WEIGHTS);
await page.evaluate((n) => window.__nisps!.reshape(n), OTHER_INPUT);
const after = await page.evaluate(() => window.__nisps!.getWeights().length);
expect(after).toBe(OTHER_WEIGHTS);
expect(after).not.toBe(before);
});
test('outputs stay bounded after reshape', async ({ page }) => {
await page.evaluate((n) => window.__nisps!.reshape(n), OTHER_INPUT);
await page.evaluate(() => window.__nisps!.setInputs(0.3, 0.7));
const outs = await getOutputs(page);
expect(outs.length).toBe(BOOT_OUTPUT);
expect(allWithin(outs, 0, 1)).toBe(true);
});
test('spine still propagates after reshape', async ({ page }) => {
await page.evaluate((n) => window.__nisps!.reshape(n), OTHER_INPUT);
const a = await settleInputs(page, 0.2, 0.8);
const b = await settleInputs(page, 0.9, 0.1);
expect(a.length).toBe(BOOT_OUTPUT);
expect(b.length).toBe(BOOT_OUTPUT);
expect(allWithin(a, 0, 1)).toBe(true);
expect(allWithin(b, 0, 1)).toBe(true);
// Distinct inputs must still move the mapping through the reshaped net.
expect(countChanged(a, b, 1e-3)).toBeGreaterThan(0);
});
});