From 13013077d4ff8afdba22b89b944331654b637b9d Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sat, 25 Jul 2026 17:12:26 +0200 Subject: [PATCH] fix(manifold): complete input sizing and feedback coverage --- manifold/src/console/Drawers.tsx | 19 +++++++++ manifold/src/console/Manifold.tsx | 4 ++ manifold/src/console/types.ts | 6 +++ manifold/src/inputs/useInputLayer.ts | 2 +- .../tests/e2e/quick-dislike-repro.spec.ts | 40 +++++++++++++++++++ manifold/tests/e2e/reshape.spec.ts | 11 ++--- manifold/tests/e2e/schema-modes.spec.ts | 30 +++++++++++--- 7 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 manifold/tests/e2e/quick-dislike-repro.spec.ts diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx index 7ab4685..48e9d7e 100644 --- a/manifold/src/console/Drawers.tsx +++ b/manifold/src/console/Drawers.tsx @@ -424,6 +424,11 @@ const INPUT_MODE_OPTS: { value: InputMode; label: string }[] = [ { value: 'midi', label: 'MIDI' }, ]; +const MODEL_INPUT_OPTS: { value: '2' | '4'; label: string }[] = [ + { value: '2', label: '2 inputs' }, + { value: '4', label: '4 inputs' }, +]; + /** Standard-mapping gamepad button → verdict legend (mirrors ConsoleApp). */ const GAMEPAD_LEGEND: { btn: string; action: string }[] = [ { btn: 'RB', action: 'Up · positive feedback' }, @@ -530,6 +535,20 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { Input source + Model inputs + {ctx.mode.input === 'audio_in' ? ( + + {inp.engineInputSize} analysis inputs (fixed by this mode) + + ) : ( +
+ ctx.setModelInputSize(Number(value) as 2 | 4)} + options={MODEL_INPUT_OPTS} + /> +
+ )} {active && depth === 'expanded' && ( {active.status.message} diff --git a/manifold/src/console/Manifold.tsx b/manifold/src/console/Manifold.tsx index 00f487a..d222ac0 100644 --- a/manifold/src/console/Manifold.tsx +++ b/manifold/src/console/Manifold.tsx @@ -21,6 +21,8 @@ export interface ManifoldProps { variant?: 'rectangular' | 'circular'; frozen?: boolean; follow?: boolean; + /** Disable the built-in double-click follow mode when embedded in another gesture surface. */ + followMouseEnabled?: boolean; onLongPress?: (pos: [number, number]) => void; /** * PICK-LOCATION (Explore & place, rl-feedback §2.2 §3). While true, the next @@ -41,6 +43,7 @@ export function Manifold({ variant = 'rectangular', frozen = false, follow = false, + followMouseEnabled = true, onLongPress, picking = false, onPickLocation, @@ -152,6 +155,7 @@ export function Manifold({ * mark is now under the cursor — exits. */ const onDoubleClick = (e: ReactMouseEvent) => { + if (!followMouseEnabled) return; if (stateRef.current.frozen || stateRef.current.picking) return; if (followMouseRef.current) { setFollowMouse(false); diff --git a/manifold/src/console/types.ts b/manifold/src/console/types.ts index 73ed15e..3c72154 100644 --- a/manifold/src/console/types.ts +++ b/manifold/src/console/types.ts @@ -38,6 +38,9 @@ export interface FeedbackMarker { export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help'; export type DrawerDepth = 'condensed' | 'expanded'; +/** Manifold's supported normal-mode input arities. */ +export type ManifoldInputSize = 2 | 4; + /** * The flat context the Dock + drawers read. Pruned 2026-07 (simplification * audit S19) to the fields Dock/Drawers/OutputsBackendConfig actually consume. @@ -118,6 +121,9 @@ export interface ConsoleCtx { // ---- Modular input layer (workstream F; inputs-spec) ---- /** The composed input layer: source enable/config/status + channel layout. */ inputs: UseInputLayer; + /** UI-selected model input arity for normal Manifold modes. */ + modelInputSize: ManifoldInputSize; + setModelInputSize: (size: ManifoldInputSize) => void; spread: boolean; setSpread: (v: boolean) => void; diff --git a/manifold/src/inputs/useInputLayer.ts b/manifold/src/inputs/useInputLayer.ts index 476e4c7..9d889bc 100644 --- a/manifold/src/inputs/useInputLayer.ts +++ b/manifold/src/inputs/useInputLayer.ts @@ -62,7 +62,7 @@ export interface UseInputLayer { channelLayout: { source: string; label: string }[]; /** Total composed axis count. */ axisCount: number; - /** Engine input arity (the fixed WASM head = 2). */ + /** Current runtime-shaped WASM model input arity. */ engineInputSize: number; // ---- gamepad config ---- diff --git a/manifold/tests/e2e/quick-dislike-repro.spec.ts b/manifold/tests/e2e/quick-dislike-repro.spec.ts new file mode 100644 index 0000000..efa4942 --- /dev/null +++ b/manifold/tests/e2e/quick-dislike-repro.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { loadProbe } from './helpers'; + +test('Push away press starts a rejection immediately and keeps replaying after release', async ({ + page, +}) => { + await loadProbe(page); + const button = page.getByTitle(/Dislike — push the sound away/); + + // A quick click should store a rejection, and release must not cancel it. + await button.click(); + expect(await page.evaluate(() => window.__nisps!.feedbackCounts().negative)).toBe(1); + await page.waitForTimeout(100); + expect(await page.evaluate(() => window.__nisps!.feedbackCounts().negative)).toBe(1); + + // Fresh state: holding the same negative-feedback button must start the same + // rejection promptly; it must not silently route to a different gesture. + await loadProbe(page); + const held = page.getByTitle(/Dislike — push the sound away/); + const weightsBefore = await page.evaluate(() => Array.from(window.__nisps!.getWeights())); + const box = await held.boundingBox(); + if (!box) throw new Error('negative-feedback button has no bounds'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(700); + const heldResult = await page.evaluate((before) => { + const after = Array.from(window.__nisps!.getWeights()); + return { + negatives: window.__nisps!.feedbackCounts().negative, + weightsChanged: after.reduce( + (n, value, index) => n + (value !== before[index] ? 1 : 0), + 0, + ), + weightCount: after.length, + }; + }, weightsBefore); + console.log('held negative result', heldResult); + expect(heldResult.negatives).toBe(1); + await page.mouse.up(); +}); diff --git a/manifold/tests/e2e/reshape.spec.ts b/manifold/tests/e2e/reshape.spec.ts index 3cd0f61..d3e8691 100644 --- a/manifold/tests/e2e/reshape.spec.ts +++ b/manifold/tests/e2e/reshape.spec.ts @@ -2,8 +2,9 @@ * 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 + * BOOT MODE's schema `ml` config once WASM is ready, with normal modes using + * Manifold's default 2-input working shape rather than the schema's 4-input + * capacity. 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; @@ -18,9 +19,9 @@ 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_INPUT = 2; const BOOT_OUTPUT = BOOT.output_size; // 33 -const BOOT_WEIGHTS = weightCountFromMl(BOOT); // 4→[10,10,14]→33 = 809 +const BOOT_WEIGHTS = weightCountFromMl({ ...BOOT, input_size: BOOT_INPUT }); // 2→[10,10,14]→33 = 787 // A reshape target arity guaranteed to differ from the boot arity. const OTHER_INPUT = BOOT_INPUT + 6; // 10 @@ -32,7 +33,7 @@ test.beforeEach(async ({ page }) => { }); test.describe('reshape — runtime-shaped MLP', () => { - test('boots at the boot mode schema shape', async ({ page }) => { + test('boots at the Manifold default input shape', async ({ page }) => { const arch = await page.evaluate(() => window.__nisps!.describe()); expect(arch.inputSize).toBe(BOOT_INPUT); expect(arch.outputSize).toBe(BOOT_OUTPUT); diff --git a/manifold/tests/e2e/schema-modes.spec.ts b/manifold/tests/e2e/schema-modes.spec.ts index 7ca7fdf..456fbfe 100644 --- a/manifold/tests/e2e/schema-modes.spec.ts +++ b/manifold/tests/e2e/schema-modes.spec.ts @@ -8,7 +8,8 @@ * debug seam (`window.__mf`), then asserts against the schema — never hard-coded * numbers — that: * - * - `describe()` reports the schema's exact input/hidden/output dims; + * - `describe()` reports the schema's hidden/output dims and Manifold's + * default 2-input working shape; * - `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`; @@ -49,6 +50,7 @@ const CASES: ReadonlyArray = [ * must all reflect the target schema before we assert. */ async function switchToMode(page: Page, schema: ModeSchema): Promise { + const expectedInput = schema.ui.primary_input === 'audio_in' ? schema.ml.input_size : 2; await page.evaluate((id) => window.__mf!.setMode(id), schema.mode_id); await page.waitForFunction( (s) => @@ -56,7 +58,7 @@ async function switchToMode(page: Page, schema: ModeSchema): Promise { 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 }, + { id: schema.mode_id, params: schema.params.length, out: schema.ml.output_size, in: expectedInput }, { timeout: 10_000 }, ); } @@ -75,15 +77,21 @@ test.describe('schema-driven per-mode dims (P5 gate)', () => { test(`${schema.mode_id}: engine + UI match the schema`, async ({ page }) => { await switchToMode(page, schema); - // describe() reports the schema's exact dims. + // describe() reports schema hidden/output dims and the UI's effective + // input arity (2 for normal modes, schema-fixed for audio analysis). const arch = await page.evaluate(() => window.__nisps!.describe()); - expect(arch.inputSize).toBe(schema.ml.input_size); + expect(arch.inputSize).toBe(schema.ui.primary_input === 'audio_in' ? schema.ml.input_size : 2); 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)); + expect(weights).toBe( + weightCountFromMl({ + ...schema.ml, + input_size: schema.ui.primary_input === 'audio_in' ? schema.ml.input_size : 2, + }), + ); // Outputs have length == output_size and stay bounded. await page.evaluate(() => window.__nisps!.setInputs(0.35, 0.65)); @@ -97,6 +105,18 @@ test.describe('schema-driven per-mode dims (P5 gate)', () => { }); } + test('normal modes can opt into four model inputs from the Inputs dock seam', async ({ page }) => { + await switchToMode(page, PafSynthSchema); + await page.getByTitle('Inputs').click(); + await expect(page.getByTestId('model-input-size')).toBeVisible(); + await page.getByTestId('model-input-size').getByRole('button', { name: '4 inputs' }).click(); + await page.waitForFunction(() => window.__nisps?.describe().inputSize === 4); + expect(await page.evaluate(() => window.__mf!.getModelInputSize())).toBe(4); + + await page.evaluate(() => window.__mf!.setModelInputSize(2)); + await page.waitForFunction(() => window.__nisps?.describe().inputSize === 2); + }); + 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,