From 3af92b625a8f0599b6d98b4441bcc184a45710a7 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Tue, 14 Jul 2026 03:54:12 +0200 Subject: [PATCH] feat(manifold): runtime-shaped net reshape with confirm modal (P2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the runtime-shaped WASM MLP (one-core-engine P2) through the manifold: - WasmIML.reshape(dims, spread): calls nisps_ml_reshape, re-describes the instance, reallocates every dim-dependent heap buffer, refreshes weightCount, clears the TS Dataset mirror (C-side resets), drops the stale training worker, and pushes the new shape through the sink. - EngineApi.reshape exposes it and re-ticks the spine so outputs/audio reflect the new net. Spine already tolerates the arity change (buffers resize, version bumps); documented. - Training worker protocol carries the current hidden dims; the worker ensureNet()s its mirror net to match after a reshape. - ConsoleApp offers the reshape behind ReshapeModal on an active-layout CHANGE (never on load; default 32-input over-provisioned head + zero-padding preserved when declined). British copy, reset-on-reshape. - Drawers: delete the stale even/odd blending note; honest dedicated-dimensions line + net-arity chip. - Probe: __nisps.reshape(nIn) / .describe(); e2e reshape.spec (default 32/126, reshape to 4, describe reports 4, bounded outputs, weight count 3148→2868, spine still propagates). All 25 e2e pass (20 existing + 5 new). - ONBOARDING: refresh the reshape status + stale hardwired-arity gotcha. --- manifold/ONBOARDING.md | 23 ++++-- manifold/src/console/ConsoleApp.tsx | 48 ++++++++++++ manifold/src/console/Drawers.tsx | 31 +++++--- manifold/src/console/ReshapeModal.tsx | 86 +++++++++++++++++++++ manifold/src/debug/probe.ts | 24 +++++- manifold/src/engine/engine-api.ts | 16 ++++ manifold/src/engine/spine.ts | 5 ++ manifold/src/engine/types.ts | 3 + manifold/src/engine/wasm-iml.ts | 104 ++++++++++++++++++++++++++ manifold/src/engine/wasm-worker.ts | 53 +++++++++++-- manifold/src/inputs/input-layer.ts | 5 +- manifold/tests/e2e/reshape.spec.ts | 79 +++++++++++++++++++ 12 files changed, 449 insertions(+), 28 deletions(-) create mode 100644 manifold/src/console/ReshapeModal.tsx create mode 100644 manifold/tests/e2e/reshape.spec.ts diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md index 658d513..664b05c 100644 --- a/manifold/ONBOARDING.md +++ b/manifold/ONBOARDING.md @@ -194,10 +194,16 @@ a setting → `--r-*` tokens. double=4 axes, deadzone 0.08), `midi-input-source.ts` (Web MIDI, batch CC-learn, multi-port). - `useInputLayer.ts` — React binding; manages exclusive input mode + gamepad stick mode + MIDI device/learn map; exposes `pushPad`, `sources`, `channelLayout`, etc. -- **Reshape status:** the WASM head is over-provisioned to 32 but the spine's `setInputs` historically - treated the head as effectively 2-D — confirm current behaviour in `spine.ts`/`input-layer.ts` - before relying on >2 active dims. See the `manifold-mixed-inputs` memory for the locked design - (reshapeable net, reset-on-reshape modal, adaptive slider viz when >2 dims). +- **Reshape (P2.3, live):** the net is now **runtime-shaped**. It boots at the default + over-provisioned 32-input head (zero-padding preserved), and `EngineApi.reshape({ inputSize, … })` + → `WasmIML.reshape` swaps in a new net at the requested arity, **warm-started** from the overlapping + weights (`nisps_ml_reshape`; C-side dataset + feedback state RESET). When the active axis layout + CHANGES to a count ≠ the net's arity, `ConsoleApp` offers the swap behind `ReshapeModal.tsx` + (reset-on-reshape confirm; declining keeps the zero-padded head). Never offered on load. The + spine tolerates the arity change (buffers resize, version bumps); the training worker + (`wasm-worker.ts`) carries the current dims in its train message and re-creates its mirror net to + match. Debug: `window.__nisps.reshape(nIn)` / `.describe()`. See the `manifold-mixed-inputs` memory + for the locked design (adaptive slider viz when >2 dims is still pending). ### Feedback — `src/feedback/` - `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp. @@ -239,10 +245,11 @@ a setting → `--r-*` tokens. `engine-host.ts`, `wasm-iml.ts`, `wasm-worker.ts`) handles this — use it. 3. **`nisps.js` is non-module Emscripten glue (no ES exports).** Workers/worklet fetch + indirect-eval to install the global `createNispsModule`; the worklet uses raw `WebAssembly.instantiate`. -4. **WASM architecture is hardwired at build time** (`nisps/wasm/bindings.cpp`): 2→126 outputs, - fixed hidden layers, 32 input slots. Requesting other sizes in options is a logged warning, not - dynamic. Changing it means rebuilding WASM (`bash scripts/build-wasm.sh`) and updating the parity - test (`tests/cpp/parity_check.cpp`) or parity CI goes red. +4. **WASM MLP is runtime-shaped (since one-core-engine P2).** `nisps_ml_create(in, out, hidden[])` + honours its dims (non-positive/null → the default `32→[10,14,18]→126` head, so pre-P2 callers are + bit-identical), and `nisps_ml_reshape` swaps in a warm-started net at new dims. Weights = 3148 at + the default shape; reshaping only the input arity shifts the first layer (e.g. →4 inputs = 2868). + The firmware MLP stays compile-time templated — only the WASM/browser build is dynamic. 5. **`curves.ts` ↔ `nisps/core/math.hpp` must stay lockstep** (golden-vector parity tests). 6. **COOP/COEP headers are mandatory** for the WASM/worklet path — set in `vite.config.ts` for dev+preview, and at nginx server scope in prod (inherited by `/next/`). diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index 6038767..c7fbddb 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -36,6 +36,7 @@ import { Manifold } from './Manifold'; import { ReadoutStrip } from './ReadoutStrip'; import { VerdictCluster } from './VerdictCluster'; import { Dock } from './Dock'; +import { ReshapeModal } from './ReshapeModal'; import type { Axes, ConsoleCtx, @@ -276,6 +277,44 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp // `pushPad`; MIDI + gamepad are pulled by the layer's own rAF loop. const inputs = useInputLayer(engine); + // ---- Reshape offer (runtime-shaped net, P2) -------------------------------- + // When the ACTIVE input layout CHANGES (source added/removed, MIDI-learn axes + // change, gamepad stick mode) to an axis count that differs from the net's + // current input arity, offer a warm-started reshape behind a confirm modal + // (locked decision: "reshapeable N-D net, reset-on-reshape modal"). We only + // offer on a genuine CHANGE — never on first load — so the default 32-input + // over-provisioned head is preserved untouched, and declining keeps the + // zero-padding path working. Once per layout change (debounced by the ref). + const [reshapeTarget, setReshapeTarget] = useState(null); + const prevAxisCountRef = useRef(null); + useEffect(() => { + if (!engine) return; + const n = inputs.axisCount; + // Ignore the boot transient (sources attach a frame after mount, so the + // count ramps 0 → 2) and any "no active axes" lull — neither is a layout the + // user chose, and treating 0 as a baseline would make the first real layout + // look like a change and prompt on load. + if (n < 1) return; + const inSize = inputs.engineInputSize; + // First established layout is the baseline (default load) — record, never + // prompt. This is the default over-provisioned case that must stay untouched. + if (prevAxisCountRef.current === null) { + prevAxisCountRef.current = n; + return; + } + if (n === prevAxisCountRef.current) return; // arity unchanged → no offer + prevAxisCountRef.current = n; + // Offer iff the new active layout no longer matches the net's arity. + if (n !== inSize) setReshapeTarget(n); + else setReshapeTarget(null); + }, [engine, inputs.axisCount, inputs.engineInputSize]); + + const confirmReshape = () => { + const n = reshapeTarget; + setReshapeTarget(null); + if (engine && n != null) engine.reshape({ inputSize: n }); + }; + // Drive a pad/joystick/manifold move through the input layer's XY-pad source, // then mirror the raw position into React state for readouts. The layer's loop // composes it with any other active sources and writes to the engine; we still @@ -1096,6 +1135,15 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp if (v) setActive(null); }} /> + + {reshapeTarget !== null && ( + setReshapeTarget(null)} + /> + )} ); } diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx index 80719da..d134e05 100644 --- a/manifold/src/console/Drawers.tsx +++ b/manifold/src/console/Drawers.tsx @@ -374,7 +374,11 @@ function MidiInputMeter({ label, value, onClear }: { label: string; value: numbe function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { const inp = ctx.inputs; - const reshaping = inp.axisCount > inp.engineInputSize; + // The net is runtime-shaped (P2): each active axis drives its OWN input slot + // 1:1. A mismatch means the net is over-provisioned (axes < slots, extra slots + // zero-padded) or over capacity (axes > slots, extras dropped) — the reshape + // offer resolves either. + const inputMismatch = inp.axisCount > 0 && inp.axisCount !== inp.engineInputSize; const active = inp.sources.find((s) => s.enabled); const modeLabel = INPUT_MODE_OPTS.find((o) => o.value === inp.inputMode)?.label ?? 'Internal'; @@ -383,7 +387,13 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
{modeLabel} {inp.inputMode !== 'internal' && {inp.axisCount} axes} - {reshaping && blended → {inp.engineInputSize}} + {inputMismatch && ( + + {inp.axisCount > inp.engineInputSize + ? `${inp.axisCount} axes › ${inp.engineInputSize} slots` + : `net: ${inp.engineInputSize}-D`} + + )} {active && ( {active.status.state} )} @@ -517,16 +527,15 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { )} - {/* ---- Reshape note (only when >2 axes feed the fixed WASM head) ---- */} - {reshaping && depth === 'expanded' && ( + {/* ---- Dedicated-dimensions note (only with >2 active axes) ---- */} + {inp.axisCount > 2 && depth === 'expanded' && (

- {/* TODO(workstream F, docs/specs/inputs-spec.md — "multiple WASM modules + - warm-start"): give every axis its own genuine input dimension by (re)loading a - WASM module whose MLP arity matches axisCount and warm-starting from the prior - net. Deferred — the reduction lives in InputLayer.compose(). */} - The browser WASM is a fixed {inp.engineInputSize}-input head (MLP<2,…>), so the{' '} - {inp.axisCount} axes are blended down to {inp.engineInputSize} (even→X / odd→Y mean). True - per-axis dimensions land with the multi-WASM reshape. + The net has {inp.engineInputSize} dedicated inputs — each active axis drives its own + dimension 1:1 (no blending). + {inputMismatch + ? ` Its ${inp.engineInputSize} slots don't match the ${inp.axisCount} active axes; ` + + `changing the layout offers a reshape to ${inp.axisCount} inputs (warm-started).` + : ''}

)} diff --git a/manifold/src/console/ReshapeModal.tsx b/manifold/src/console/ReshapeModal.tsx new file mode 100644 index 0000000..66aa0f9 --- /dev/null +++ b/manifold/src/console/ReshapeModal.tsx @@ -0,0 +1,86 @@ +/** + * ReshapeModal — confirm modal for reshaping the runtime-shaped net (P2). + * + * Offered when the composed ACTIVE input-axis count changes to something other + * than the net's current input arity (see ConsoleApp's reshape-offer effect). + * The locked decision (manifold-mixed-inputs memory) is a "reset-on-reshape" + * flow: warm-start the weights from the current net, reset examples + explore + * state. Declining keeps the current (over-provisioned, zero-padded) net — so + * the default 32-input head behaviour never changes unless the user opts in. + * + * Small inline modal in the house design language (no external dialog dep). + */ +import { Button } from '../primitives'; + +export interface ReshapeModalProps { + /** Target input arity (the current active axis count). */ + target: number; + /** The net's current input arity, for the copy. */ + current: number; + onConfirm: () => void; + onCancel: () => void; +} + +export function ReshapeModal({ target, current, onConfirm, onCancel }: ReshapeModalProps) { + return ( +
+
e.stopPropagation()} + style={{ + width: 'min(420px, 90vw)', + background: 'var(--bg-1)', + border: '1px solid var(--line)', + borderRadius: 'var(--r-2)', + boxShadow: '0 12px 40px rgba(0,0,0,0.5)', + fontFamily: 'var(--font-mono)', + color: 'var(--fg)', + padding: 'var(--sp-4, 18px)', + display: 'flex', + flexDirection: 'column', + gap: 14, + }} + > +
+ Reshape the net? +
+

+ Reshape the net to {target} input{target === 1 ? '' : 's'}? Weights are warm-started from + the current {current}-input net; examples and exploration state reset. +

+

+ Decline to keep the current net — the extra axes stay zero-padded (inert). +

+
+ + +
+
+
+ ); +} diff --git a/manifold/src/debug/probe.ts b/manifold/src/debug/probe.ts index 97d0236..221b7c2 100644 --- a/manifold/src/debug/probe.ts +++ b/manifold/src/debug/probe.ts @@ -19,7 +19,7 @@ */ import type { EngineApi } from '../engine/engine-api'; -import type { FeedbackMode, LayerStats } from '../engine/types'; +import type { FeedbackMode, LayerStats, MLArchitecture } from '../engine/types'; export interface DebugProbe { // ---- Core engine surface (live) ---- @@ -29,6 +29,14 @@ export interface DebugProbe { getLossHistory(): ReadonlyArray; getWeights(): Float32Array; getExampleCount(): number; + /** The net's current shape (runtime-shaped MLP; P2). */ + describe(): MLArchitecture; + /** + * Reshape the net (warm-started; dataset + feedback reset). Returns the new + * shape on success, or null if the reshape was rejected / no-op. Omitted dims + * keep their current value. + */ + reshape(inputSize: number, outputSize?: number, hidden?: [number, number, number]): MLArchitecture | null; setInputs(x: number, y: number): void; thumbsUp(): number; thumbsDown(): number; @@ -96,6 +104,20 @@ function makeProbe(engine: EngineApi): DebugProbe { return engine.getState().exampleCount; }, + describe(): MLArchitecture { + return engine.architecture; + }, + + reshape(inputSize, outputSize, hidden): MLArchitecture | null { + const dims: { inputSize?: number; outputSize?: number; hidden?: [number, number, number] } = { + inputSize, + }; + if (outputSize !== undefined) dims.outputSize = outputSize; + if (hidden !== undefined) dims.hidden = hidden; + const ok = engine.reshape(dims); + return ok ? engine.architecture : null; + }, + setInputs(x: number, y: number): void { engine.setInput(x, y); }, diff --git a/manifold/src/engine/engine-api.ts b/manifold/src/engine/engine-api.ts index 1b0373a..1fd492e 100644 --- a/manifold/src/engine/engine-api.ts +++ b/manifold/src/engine/engine-api.ts @@ -219,6 +219,22 @@ export class EngineApi { this.process(); } + /** + * Reshape the net to new dims (runtime-shaped MLP; one-core-engine P2). The + * new net is warm-started from the current net's overlapping weights; the + * dataset + feedback/exploration state RESET (front-end shows a confirm modal + * first). Returns true on success. On success the spine re-reads its arity and + * re-ticks the last input so outputs/audio reflect the new net. + */ + reshape( + dims: { inputSize?: number; outputSize?: number; hidden?: [number, number, number] }, + spread = this.spread_, + ): boolean { + const ok = this.iml.reshape(dims, spread); + if (ok) this.process(); + return ok; + } + clearExamples(): void { this.iml.clearExamples(); } diff --git a/manifold/src/engine/spine.ts b/manifold/src/engine/spine.ts index 47bc705..38afc64 100644 --- a/manifold/src/engine/spine.ts +++ b/manifold/src/engine/spine.ts @@ -116,6 +116,11 @@ export class Spine implements EngineSink { setState(patch: EngineStatePatch): void { let changed = false; const s = this.state_; + // A reshape (WasmIML.reshape → sink.setState) can change either dim. The + // stored inputSize drives the setInputs axis loop (and lastRawInputs resizes + // itself there); a changed outputSize resizes the hot output buffers below. + // Either bumps the version so useSyncExternalStore consumers re-read the new + // arity (e.g. inputs.engineInputSize in the dock). if (patch.inputSize !== undefined && patch.inputSize !== s.inputSize) { s.inputSize = patch.inputSize; changed = true; } if (patch.outputSize !== undefined && patch.outputSize !== s.outputSize) { s.outputSize = patch.outputSize; diff --git a/manifold/src/engine/types.ts b/manifold/src/engine/types.ts index 6055ced..4b64026 100644 --- a/manifold/src/engine/types.ts +++ b/manifold/src/engine/types.ts @@ -201,6 +201,9 @@ export type WorkerRequest = minErr: number; inputSize: number; outputSize: number; + // The main net's hidden layers. The worker (re)creates its mirror net to + // this shape so weight vectors exchange 1:1 after a reshape (P2.3). + hidden: readonly [number, number, number]; } | { kind: 'dispose'; diff --git a/manifold/src/engine/wasm-iml.ts b/manifold/src/engine/wasm-iml.ts index 9e20755..8c58160 100644 --- a/manifold/src/engine/wasm-iml.ts +++ b/manifold/src/engine/wasm-iml.ts @@ -256,6 +256,109 @@ export class WasmIML { return this.lastLoss_; } + // ------------------------------------------------------------------- + // Reshape (runtime-shaped MLP; one-core-engine P2) + // ------------------------------------------------------------------- + + /** + * Swap the net for one at new dims, warm-started from the overlapping weights + * of the current net (`nisps_ml_reshape`). Any omitted dim keeps its current + * value. Returns true on success (false = C-side rejected / no change). + * + * The C side RESETS its dataset/examples and feedback/exploration state on + * reshape, so this method also clears the TS `Dataset` mirror, reallocates + * every dim-dependent heap buffer, refreshes `weightCount`, and pushes the new + * shape + zeroed example/output state through the sink so React re-reads. + */ + reshape( + dims: { inputSize?: number; outputSize?: number; hidden?: readonly [number, number, number] }, + spread = 0.6, + ): boolean { + const wantIn = dims.inputSize ?? this.arch_.inputSize; + const wantOut = dims.outputSize ?? this.arch_.outputSize; + const wantHidden = dims.hidden ?? this.arch_.hidden; + + const hiddenPtr = this.module._malloc(wantHidden.length * 4); + new Int32Array(this.module.HEAP32.buffer, hiddenPtr, wantHidden.length).set(wantHidden); + const ok = this.module._nisps_ml_reshape( + this.mlHandle, + wantIn, + wantOut, + hiddenPtr, + wantHidden.length, + spread, + ); + this.module._free(hiddenPtr); + if (ok !== 1) return false; + + // Re-describe the (new) instance and refresh the weight count. + this.module._nisps_ml_describe(this.mlHandle, this.describePtr); + const d = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6); + this.arch_ = { + inputSize: d[0], + hidden: [d[1], d[2], d[3]], + outputSize: d[4], + numLayers: d[5], + }; + this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle); + + // Reallocate every dim-dependent heap buffer. Freeing first then reallocating + // means a later malloc may sbrk-grow the heap and detach earlier views, so we + // rebind() all of them afterwards. + this.featuresBuf.free(); + this.labelsBuf.free(); + this.weightsBuf.free(); + this.statsBuf.free(); + this.batchInBuf.free(); + this.batchOutBuf.free(); + this.pinMaskBuf.free(); + this.feedbackBuf.free(); + this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize); + this.labelsBuf = new HeapBuffer(this.module, this.arch_.outputSize); + this.weightsBuf = new HeapBuffer(this.module, this.weightCount_); + this.statsBuf = new HeapBuffer(this.module, this.arch_.numLayers * 4); + this.batchInBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.inputSize); + this.batchOutBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.outputSize); + this.pinMaskBuf = new HeapU8(this.module, this.arch_.outputSize); + this.feedbackBuf = new HeapBuffer(this.module, this.arch_.outputSize); + this.featuresBuf.rebind(); + this.labelsBuf.rebind(); + this.weightsBuf.rebind(); + this.statsBuf.rebind(); + this.batchInBuf.rebind(); + this.batchOutBuf.rebind(); + this.pinMaskBuf.rebind(); + this.feedbackBuf.rebind(); + + // C-side dataset/examples reset on reshape → clear the TS mirror to match. + this.dataset.clear(); + this.lastLoss_ = null; + + // The lazy training worker's mirror net is now stale (wrong arity). Dropping + // it makes the next trainAsync re-create it; the train protocol also carries + // the current dims so a fresh worker matches (see wasm-worker.ts). + if (this.trainer) { + this.trainer.dispose(); + this.trainer = null; + } + + this.sink.setState({ + inputSize: this.arch_.inputSize, + outputSize: this.arch_.outputSize, + exampleCount: 0, + lastLoss: null, + lossHistory: [], + }); + this.sink.setOutputs(new Float32Array(this.arch_.outputSize)); + this.publishWeights_(); + this.sink.emit('ml.reshaped', { + inputSize: this.arch_.inputSize, + outputSize: this.arch_.outputSize, + }); + this.scheduleSave_(); + return true; + } + // ------------------------------------------------------------------- // Inference // ------------------------------------------------------------------- @@ -403,6 +506,7 @@ export class WasmIML { minErr, inputSize: this.arch_.inputSize, outputSize: this.arch_.outputSize, + hidden: this.arch_.hidden, }); this.setWeights(result.weights); this.lastLoss_ = result.loss; diff --git a/manifold/src/engine/wasm-worker.ts b/manifold/src/engine/wasm-worker.ts index b8ee549..097afb3 100644 --- a/manifold/src/engine/wasm-worker.ts +++ b/manifold/src/engine/wasm-worker.ts @@ -29,6 +29,8 @@ export interface TrainArgs { minErr: number; inputSize: number; outputSize: number; + /** Main net's hidden layers, so the worker's mirror net matches after reshape. */ + hidden: readonly [number, number, number]; } export interface TrainResult { @@ -95,6 +97,7 @@ export class WasmTrainer { minErr: args.minErr, inputSize: args.inputSize, outputSize: args.outputSize, + hidden: args.hidden, }; this.worker.postMessage(msg, [ args.weights.buffer, @@ -170,6 +173,14 @@ if (isWorker) { let mlHandle = 0; let weightCount = 0; + // Current mirror-net shape. The net is (re)created to match the main thread's + // dims so flat weight vectors exchange 1:1 across a reshape (P2.3). Seed is + // irrelevant to results — the net is immediately overwritten via set_weights. + let netInputSize = 0; + let netOutputSize = 0; + let netHidden: readonly number[] = []; + let workerSeed = 0; + let weightsPtr = 0; let weightsViewLen = 0; let featuresPtr = 0; @@ -194,13 +205,41 @@ if (isWorker) { mod = await factory({ locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path), }); - // Default shape (0,0 → 32→[10,14,18]→126). The worker's net MUST match - // the main thread's shape — weights are exchanged as flat vectors. When - // the main thread creates/reshapes with non-default dims (one-core-engine - // P2.3+), the init/train messages must carry those dims and this call - // must pass them through. - mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0); + // Default shape (0,0 → 32→[10,14,18]→126). The worker's net MUST match the + // main thread's shape — weights are exchanged as flat vectors. Each `train` + // message carries the current dims (one-core-engine P2.3), so `ensureNet` + // recreates this net if the main net was reshaped. + workerSeed = seed >>> 0; + mlHandle = mod._nisps_ml_create(0, 0, 0, 0, workerSeed); weightCount = mod._nisps_ml_weight_count(mlHandle); + const dPtr = mod._malloc(6 * 4); + mod._nisps_ml_describe(mlHandle, dPtr); + const d = new Int32Array(mod.HEAP32.buffer, dPtr, 6); + netInputSize = d[0]; + netOutputSize = d[4]; + netHidden = [d[1], d[2], d[3]]; + mod._free(dPtr); + } + + /** Recreate the mirror net iff the requested shape differs from the current. */ + function ensureNet(inputSize: number, outputSize: number, hidden: readonly [number, number, number]): void { + if (!mod) throw new Error('worker module not loaded'); + const same = + netInputSize === inputSize && + netOutputSize === outputSize && + netHidden.length === hidden.length && + netHidden.every((h, i) => h === hidden[i]); + if (same) return; + + if (mlHandle) mod._nisps_ml_destroy(mlHandle); + const hPtr = mod._malloc(hidden.length * 4); + new Int32Array(mod.HEAP32.buffer, hPtr, hidden.length).set(hidden); + mlHandle = mod._nisps_ml_create(inputSize, outputSize, hPtr, hidden.length, workerSeed); + mod._free(hPtr); + weightCount = mod._nisps_ml_weight_count(mlHandle); + netInputSize = inputSize; + netOutputSize = outputSize; + netHidden = [...hidden]; } function ensureBuffers(features: Float32Array, labels: Float32Array, sampleWeights: Float32Array, weights: Float32Array): void { @@ -240,6 +279,8 @@ if (isWorker) { return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' }; } try { + // Match the main net's shape (it may have been reshaped since init). + ensureNet(req.inputSize, req.outputSize, req.hidden); ensureBuffers(req.features, req.labels, req.sampleWeights, req.weights); mod._nisps_ml_set_weights(mlHandle, weightsPtr); diff --git a/manifold/src/inputs/input-layer.ts b/manifold/src/inputs/input-layer.ts index 3cfc0f9..a5cffd1 100644 --- a/manifold/src/inputs/input-layer.ts +++ b/manifold/src/inputs/input-layer.ts @@ -23,8 +23,9 @@ * behaviour) — that diluted every source and biased the net toward idle * sources' resting values. * - * Changing the ACTIVE axis count is a reshape: the front-end resets the net - * (recreate-from-scratch, behind a confirm modal) since slot meanings change. + * Changing the ACTIVE axis count offers a reshape (ConsoleApp → ReshapeModal): + * a new net at the new arity, warm-started from the overlapping weights, with + * examples + feedback state reset. Declining keeps this over-provisioned head. */ import type { InputAction, InputSource } from './types'; diff --git a/manifold/tests/e2e/reshape.spec.ts b/manifold/tests/e2e/reshape.spec.ts new file mode 100644 index 0000000..b9bddb9 --- /dev/null +++ b/manifold/tests/e2e/reshape.spec.ts @@ -0,0 +1,79 @@ +/** + * Runtime-shaped net reshape (one-core-engine P2.3). + * + * The WASM MLP is runtime-shaped: it boots at the default over-provisioned + * 32→[10,14,18]→126 head, and `engine.reshape({ inputSize })` swaps in a new net + * at the requested arity, warm-started from the overlapping weights. This spec + * drives the reshape through the debug probe (`__nisps.reshape`) and asserts: + * + * 1. default dims are 32 inputs / 126 outputs (unchanged by P2.3); + * 2. reshape to 4 inputs succeeds and `describe()` reports 4/126; + * 3. post-ML outputs stay bounded in [0,1] after the reshape; + * 4. getWeights length shrinks by (32-4)*10 = 280 → 2868 (biases unchanged); + * 5. the spine still propagates — distinct inputs → distinct bounded outputs. + */ +import { test, expect } from '@playwright/test'; +import { loadProbe, getOutputs, settleInputs, countChanged, allWithin } from './helpers'; + +// Default: 32*10 + 10*14 + 14*18 + 18*126 weights + (10+14+18+126) biases = 3148. +const DEFAULT_WEIGHT_COUNT = 3148; +// Reshaping to 4 inputs only shrinks the first layer: 3148 - (32-4)*10 = 2868. +const RESHAPED_WEIGHT_COUNT = 2868; + +test.beforeEach(async ({ page }) => { + await loadProbe(page); +}); + +test.describe('reshape — runtime-shaped MLP', () => { + test('boots at the default 32 / 126 shape', async ({ page }) => { + const arch = await page.evaluate(() => window.__nisps!.describe()); + expect(arch.inputSize).toBe(32); + expect(arch.outputSize).toBe(126); + + const len = await page.evaluate(() => window.__nisps!.getWeights().length); + expect(len).toBe(DEFAULT_WEIGHT_COUNT); + }); + + test('reshape to 4 inputs succeeds and describe reports 4 / 126', async ({ page }) => { + const result = await page.evaluate(() => window.__nisps!.reshape(4)); + expect(result).not.toBeNull(); + expect(result!.inputSize).toBe(4); + expect(result!.outputSize).toBe(126); + + const arch = await page.evaluate(() => window.__nisps!.describe()); + expect(arch.inputSize).toBe(4); + expect(arch.outputSize).toBe(126); + }); + + test('getWeights length changes with the new arity', async ({ page }) => { + const before = await page.evaluate(() => window.__nisps!.getWeights().length); + expect(before).toBe(DEFAULT_WEIGHT_COUNT); + + await page.evaluate(() => window.__nisps!.reshape(4)); + + const after = await page.evaluate(() => window.__nisps!.getWeights().length); + expect(after).toBe(RESHAPED_WEIGHT_COUNT); + expect(after).not.toBe(before); + }); + + test('outputs stay bounded after reshape', async ({ page }) => { + await page.evaluate(() => window.__nisps!.reshape(4)); + await page.evaluate(() => window.__nisps!.setInputs(0.3, 0.7)); + const outs = await getOutputs(page); + expect(outs.length).toBe(126); + expect(allWithin(outs, 0, 1)).toBe(true); + }); + + test('spine still propagates after reshape', async ({ page }) => { + await page.evaluate(() => window.__nisps!.reshape(4)); + + const a = await settleInputs(page, 0.2, 0.8); + const b = await settleInputs(page, 0.9, 0.1); + expect(a.length).toBe(126); + expect(b.length).toBe(126); + expect(allWithin(a, 0, 1)).toBe(true); + expect(allWithin(b, 0, 1)).toBe(true); + // Distinct inputs must still move the mapping through the reshaped net. + expect(countChanged(a, b, 1e-3)).toBeGreaterThan(0); + }); +});