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' },
|
||||
];
|
||||
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
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).
|
||||
*
|
||||
* 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue