fix(manifold): complete input sizing and feedback coverage
This commit is contained in:
parent
856642a962
commit
13013077d4
7 changed files with 101 additions and 11 deletions
|
|
@ -424,6 +424,11 @@ const INPUT_MODE_OPTS: { value: InputMode; label: string }[] = [
|
||||||
{ value: 'midi', label: 'MIDI' },
|
{ 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). */
|
/** Standard-mapping gamepad button → verdict legend (mirrors ConsoleApp). */
|
||||||
const GAMEPAD_LEGEND: { btn: string; action: string }[] = [
|
const GAMEPAD_LEGEND: { btn: string; action: string }[] = [
|
||||||
{ btn: 'RB', action: 'Up · positive feedback' },
|
{ btn: 'RB', action: 'Up · positive feedback' },
|
||||||
|
|
@ -530,6 +535,20 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
|
|
||||||
<SectionLabel>Input source</SectionLabel>
|
<SectionLabel>Input source</SectionLabel>
|
||||||
<Segmented value={inp.inputMode} onChange={inp.setInputMode} options={INPUT_MODE_OPTS} />
|
<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' && (
|
{active && depth === 'expanded' && (
|
||||||
<span style={{ fontSize: 9, color: STATUS_TONE[active.status.state] ?? 'var(--fg-dim)' }}>
|
<span style={{ fontSize: 9, color: STATUS_TONE[active.status.state] ?? 'var(--fg-dim)' }}>
|
||||||
{active.status.message}
|
{active.status.message}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export interface ManifoldProps {
|
||||||
variant?: 'rectangular' | 'circular';
|
variant?: 'rectangular' | 'circular';
|
||||||
frozen?: boolean;
|
frozen?: boolean;
|
||||||
follow?: boolean;
|
follow?: boolean;
|
||||||
|
/** Disable the built-in double-click follow mode when embedded in another gesture surface. */
|
||||||
|
followMouseEnabled?: boolean;
|
||||||
onLongPress?: (pos: [number, number]) => void;
|
onLongPress?: (pos: [number, number]) => void;
|
||||||
/**
|
/**
|
||||||
* PICK-LOCATION (Explore & place, rl-feedback §2.2 §3). While true, the next
|
* PICK-LOCATION (Explore & place, rl-feedback §2.2 §3). While true, the next
|
||||||
|
|
@ -41,6 +43,7 @@ export function Manifold({
|
||||||
variant = 'rectangular',
|
variant = 'rectangular',
|
||||||
frozen = false,
|
frozen = false,
|
||||||
follow = false,
|
follow = false,
|
||||||
|
followMouseEnabled = true,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
picking = false,
|
picking = false,
|
||||||
onPickLocation,
|
onPickLocation,
|
||||||
|
|
@ -152,6 +155,7 @@ export function Manifold({
|
||||||
* mark is now under the cursor — exits.
|
* mark is now under the cursor — exits.
|
||||||
*/
|
*/
|
||||||
const onDoubleClick = (e: ReactMouseEvent<HTMLDivElement>) => {
|
const onDoubleClick = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!followMouseEnabled) return;
|
||||||
if (stateRef.current.frozen || stateRef.current.picking) return;
|
if (stateRef.current.frozen || stateRef.current.picking) return;
|
||||||
if (followMouseRef.current) {
|
if (followMouseRef.current) {
|
||||||
setFollowMouse(false);
|
setFollowMouse(false);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ export interface FeedbackMarker {
|
||||||
export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help';
|
export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help';
|
||||||
export type DrawerDepth = 'condensed' | 'expanded';
|
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
|
* The flat context the Dock + drawers read. Pruned 2026-07 (simplification
|
||||||
* audit S19) to the fields Dock/Drawers/OutputsBackendConfig actually consume.
|
* audit S19) to the fields Dock/Drawers/OutputsBackendConfig actually consume.
|
||||||
|
|
@ -118,6 +121,9 @@ export interface ConsoleCtx {
|
||||||
// ---- Modular input layer (workstream F; inputs-spec) ----
|
// ---- Modular input layer (workstream F; inputs-spec) ----
|
||||||
/** The composed input layer: source enable/config/status + channel layout. */
|
/** The composed input layer: source enable/config/status + channel layout. */
|
||||||
inputs: UseInputLayer;
|
inputs: UseInputLayer;
|
||||||
|
/** UI-selected model input arity for normal Manifold modes. */
|
||||||
|
modelInputSize: ManifoldInputSize;
|
||||||
|
setModelInputSize: (size: ManifoldInputSize) => void;
|
||||||
|
|
||||||
spread: boolean;
|
spread: boolean;
|
||||||
setSpread: (v: boolean) => void;
|
setSpread: (v: boolean) => void;
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export interface UseInputLayer {
|
||||||
channelLayout: { source: string; label: string }[];
|
channelLayout: { source: string; label: string }[];
|
||||||
/** Total composed axis count. */
|
/** Total composed axis count. */
|
||||||
axisCount: number;
|
axisCount: number;
|
||||||
/** Engine input arity (the fixed WASM head = 2). */
|
/** Current runtime-shaped WASM model input arity. */
|
||||||
engineInputSize: number;
|
engineInputSize: number;
|
||||||
|
|
||||||
// ---- gamepad config ----
|
// ---- gamepad config ----
|
||||||
|
|
|
||||||
40
manifold/tests/e2e/quick-dislike-repro.spec.ts
Normal file
40
manifold/tests/e2e/quick-dislike-repro.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
* Runtime-shaped net reshape (one-core-engine P2.3 + P5.3).
|
* 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
|
* 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
|
* BOOT MODE's schema `ml` config once WASM is ready, with normal modes using
|
||||||
* at the boot mode's dims (NOT the over-provisioned 32→126 default). This spec
|
* 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:
|
* asserts FROM the imported schema — never hard-coded dim numbers — that:
|
||||||
*
|
*
|
||||||
* 1. the net boots at the boot mode's schema dims + weight count;
|
* 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.
|
// The boot mode (ConsoleApp `modeId` initial state) is paf_synth.
|
||||||
const BOOT = PafSynthSchema.ml;
|
const BOOT = PafSynthSchema.ml;
|
||||||
const BOOT_INPUT = BOOT.input_size; // 4
|
const BOOT_INPUT = 2;
|
||||||
const BOOT_OUTPUT = BOOT.output_size; // 33
|
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.
|
// A reshape target arity guaranteed to differ from the boot arity.
|
||||||
const OTHER_INPUT = BOOT_INPUT + 6; // 10
|
const OTHER_INPUT = BOOT_INPUT + 6; // 10
|
||||||
|
|
@ -32,7 +33,7 @@ test.beforeEach(async ({ page }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('reshape — runtime-shaped MLP', () => {
|
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());
|
const arch = await page.evaluate(() => window.__nisps!.describe());
|
||||||
expect(arch.inputSize).toBe(BOOT_INPUT);
|
expect(arch.inputSize).toBe(BOOT_INPUT);
|
||||||
expect(arch.outputSize).toBe(BOOT_OUTPUT);
|
expect(arch.outputSize).toBe(BOOT_OUTPUT);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
* debug seam (`window.__mf`), then asserts against the schema — never hard-coded
|
* debug seam (`window.__mf`), then asserts against the schema — never hard-coded
|
||||||
* numbers — that:
|
* 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;
|
* - `getWeights().length` equals the schema-implied weight count;
|
||||||
* - post-ML outputs have length == output_size and stay bounded in [0,1];
|
* - post-ML outputs have length == output_size and stay bounded in [0,1];
|
||||||
* - the rendered UI param count equals `schema.params.length`;
|
* - 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.
|
* must all reflect the target schema before we assert.
|
||||||
*/
|
*/
|
||||||
async function switchToMode(page: Page, schema: ModeSchema): Promise<void> {
|
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.evaluate((id) => window.__mf!.setMode(id), schema.mode_id);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(s) =>
|
(s) =>
|
||||||
|
|
@ -56,7 +58,7 @@ async function switchToMode(page: Page, schema: ModeSchema): Promise<void> {
|
||||||
window.__mf?.paramCount() === s.params &&
|
window.__mf?.paramCount() === s.params &&
|
||||||
window.__nisps?.describe().outputSize === s.out &&
|
window.__nisps?.describe().outputSize === s.out &&
|
||||||
window.__nisps?.describe().inputSize === s.in,
|
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 },
|
{ 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 }) => {
|
test(`${schema.mode_id}: engine + UI match the schema`, async ({ page }) => {
|
||||||
await switchToMode(page, schema);
|
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());
|
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.outputSize).toBe(schema.ml.output_size);
|
||||||
expect(arch.hidden).toEqual([...schema.ml.hidden_layers]);
|
expect(arch.hidden).toEqual([...schema.ml.hidden_layers]);
|
||||||
|
|
||||||
// getWeights length equals the schema-implied weight count.
|
// getWeights length equals the schema-implied weight count.
|
||||||
const weights = await page.evaluate(() => window.__nisps!.getWeights().length);
|
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.
|
// Outputs have length == output_size and stay bounded.
|
||||||
await page.evaluate(() => window.__nisps!.setInputs(0.35, 0.65));
|
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 }) => {
|
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
|
// 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,
|
// of contrasting examples at the mode's output arity and train. A finite,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue