fix(manifold): complete input sizing and feedback coverage

This commit is contained in:
monkey-w1n5t0n 2026-07-25 17:12:26 +02:00
parent 856642a962
commit 13013077d4
7 changed files with 101 additions and 11 deletions

View file

@ -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) {
<SectionLabel>Input source</SectionLabel>
<Segmented value={inp.inputMode} onChange={inp.setInputMode} options={INPUT_MODE_OPTS} />
<SectionLabel>Model inputs</SectionLabel>
{ctx.mode.input === 'audio_in' ? (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>
{inp.engineInputSize} analysis inputs (fixed by this mode)
</span>
) : (
<div data-testid="model-input-size">
<Segmented
value={String(ctx.modelInputSize) as '2' | '4'}
onChange={(value) => ctx.setModelInputSize(Number(value) as 2 | 4)}
options={MODEL_INPUT_OPTS}
/>
</div>
)}
{active && depth === 'expanded' && (
<span style={{ fontSize: 9, color: STATUS_TONE[active.status.state] ?? 'var(--fg-dim)' }}>
{active.status.message}

View file

@ -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<HTMLDivElement>) => {
if (!followMouseEnabled) return;
if (stateRef.current.frozen || stateRef.current.picking) return;
if (followMouseRef.current) {
setFollowMouse(false);

View file

@ -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;

View file

@ -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 ----

View file

@ -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();
});

View file

@ -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 32126 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);

View file

@ -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<ModeSchema> = [
* must all reflect the target schema before we assert.
*/
async function switchToMode(page: Page, schema: ModeSchema): Promise<void> {
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<void> {
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,