memlnaut-nisps/manifold/tests/e2e/schema-modes.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

117 lines
4.8 KiB
TypeScript

/**
* Schema-driven per-mode dims — the one-core-engine P5 gate.
*
* The generated mode schemas (`src/modes/generated/`) are the SOURCE OF TRUTH:
* `MF_MODES` derives its params from them (P5.2) and the engine reshapes to each
* mode's `ml` config on switch (P5.3). This spec imports the schemas DIRECTLY
* and, for a representative set of modes, drives a mode switch through the UI
* debug seam (`window.__mf`), then asserts against the schema — never hard-coded
* numbers — that:
*
* - `describe()` reports the schema's exact input/hidden/output dims;
* - `getWeights().length` equals the schema-implied weight count;
* - post-ML outputs have length == output_size and stay bounded in [0,1];
* - the rendered UI param count equals `schema.params.length`;
* - training still works after a mode switch (per-mode dims flow through the
* async training worker — one-core-engine P2.2/P2.3 buffer sizing).
*/
import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { loadProbe, getOutputs, allWithin, weightCountFromMl } from './helpers';
import type { MfDebugHook } from '../../src/console/ConsoleApp';
import type { ModeSchema } from '../../src/modes/generated/types';
import {
PafSynthSchema,
ChannelStripSchema,
MemlceliumSchema,
XiasriSchema,
} from '../../src/modes/generated';
declare global {
interface Window {
__mf?: MfDebugHook;
}
}
// Representative modes: an xy synth (33 out), a joystick synth (24 out), a
// sequencer (56 out), and a browser-viable NEW mode (xiasri, 24 out) that never
// had a hand-written catalogue entry.
const CASES: ReadonlyArray<ModeSchema> = [
PafSynthSchema,
ChannelStripSchema,
MemlceliumSchema,
XiasriSchema,
];
/**
* Switch the instrument mode via the debug seam and wait until the switch has
* fully landed: the modeId, the rendered param count, and the reshaped net dims
* must all reflect the target schema before we assert.
*/
async function switchToMode(page: Page, schema: ModeSchema): Promise<void> {
await page.evaluate((id) => window.__mf!.setMode(id), schema.mode_id);
await page.waitForFunction(
(s) =>
window.__mf?.getModeId() === s.id &&
window.__mf?.paramCount() === s.params &&
window.__nisps?.describe().outputSize === s.out &&
window.__nisps?.describe().inputSize === s.in,
{ id: schema.mode_id, params: schema.params.length, out: schema.ml.output_size, in: schema.ml.input_size },
{ timeout: 10_000 },
);
}
test.beforeEach(async ({ page }) => {
await loadProbe(page);
});
test.describe('schema-driven per-mode dims (P5 gate)', () => {
test('the debug seam exposes every catalogue mode id', async ({ page }) => {
const ids = await page.evaluate(() => window.__mf!.modeIds());
for (const schema of CASES) expect(ids).toContain(schema.mode_id);
});
for (const schema of CASES) {
test(`${schema.mode_id}: engine + UI match the schema`, async ({ page }) => {
await switchToMode(page, schema);
// describe() reports the schema's exact dims.
const arch = await page.evaluate(() => window.__nisps!.describe());
expect(arch.inputSize).toBe(schema.ml.input_size);
expect(arch.outputSize).toBe(schema.ml.output_size);
expect(arch.hidden).toEqual([...schema.ml.hidden_layers]);
// getWeights length equals the schema-implied weight count.
const weights = await page.evaluate(() => window.__nisps!.getWeights().length);
expect(weights).toBe(weightCountFromMl(schema.ml));
// Outputs have length == output_size and stay bounded.
await page.evaluate(() => window.__nisps!.setInputs(0.35, 0.65));
const outs = await getOutputs(page);
expect(outs).toHaveLength(schema.ml.output_size);
expect(allWithin(outs, 0, 1)).toBe(true);
// The rendered UI param count equals schema.params.length.
const paramCount = await page.evaluate(() => window.__mf!.paramCount());
expect(paramCount).toBe(schema.params.length);
});
}
test('training works after a mode switch (per-mode dims flow to the worker)', async ({ page }) => {
// Switch to a mode with distinct dims from the boot mode, then add a couple
// of contrasting examples at the mode's output arity and train. A finite,
// non-negative loss proves the async worker re-created its mirror net at the
// reshaped dims (buffer sizing did not assume a fixed 126).
await switchToMode(page, MemlceliumSchema);
const outSize = MemlceliumSchema.ml.output_size;
const loss = await page.evaluate(async (n) => {
const p = window.__nisps!;
p.addExample([0.1, 0.9], new Array(n).fill(0.1));
p.addExample([0.9, 0.1], new Array(n).fill(0.9));
return p.trainAsync();
}, outSize);
expect(typeof loss).toBe('number');
expect(Number.isFinite(loss)).toBe(true);
expect(loss).toBeGreaterThanOrEqual(0);
});
});