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,