feat(manifold): merge P2.3 — runtime reshape wiring + reset-on-reshape modal

This commit is contained in:
monkey-w1n5t0n 2026-07-14 03:55:07 +02:00
commit 7e4dc26f6e
12 changed files with 449 additions and 28 deletions

View file

@ -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). 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 - `useInputLayer.ts` — React binding; manages exclusive input mode + gamepad stick mode + MIDI
device/learn map; exposes `pushPad`, `sources`, `channelLayout`, etc. device/learn map; exposes `pushPad`, `sources`, `channelLayout`, etc.
- **Reshape status:** the WASM head is over-provisioned to 32 but the spine's `setInputs` historically - **Reshape (P2.3, live):** the net is now **runtime-shaped**. It boots at the default
treated the head as effectively 2-D — confirm current behaviour in `spine.ts`/`input-layer.ts` over-provisioned 32-input head (zero-padding preserved), and `EngineApi.reshape({ inputSize, … })`
before relying on >2 active dims. See the `manifold-mixed-inputs` memory for the locked design `WasmIML.reshape` swaps in a new net at the requested arity, **warm-started** from the overlapping
(reshapeable net, reset-on-reshape modal, adaptive slider viz when >2 dims). 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/` ### Feedback — `src/feedback/`
- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp. - `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. `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 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`. 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, 4. **WASM MLP is runtime-shaped (since one-core-engine P2).** `nisps_ml_create(in, out, hidden[])`
fixed hidden layers, 32 input slots. Requesting other sizes in options is a logged warning, not honours its dims (non-positive/null → the default `32→[10,14,18]→126` head, so pre-P2 callers are
dynamic. Changing it means rebuilding WASM (`bash scripts/build-wasm.sh`) and updating the parity bit-identical), and `nisps_ml_reshape` swaps in a warm-started net at new dims. Weights = 3148 at
test (`tests/cpp/parity_check.cpp`) or parity CI goes red. 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). 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 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/`). dev+preview, and at nginx server scope in prod (inherited by `/next/`).

View file

@ -36,6 +36,7 @@ import { Manifold } from './Manifold';
import { ReadoutStrip } from './ReadoutStrip'; import { ReadoutStrip } from './ReadoutStrip';
import { VerdictCluster } from './VerdictCluster'; import { VerdictCluster } from './VerdictCluster';
import { Dock } from './Dock'; import { Dock } from './Dock';
import { ReshapeModal } from './ReshapeModal';
import type { import type {
Axes, Axes,
ConsoleCtx, ConsoleCtx,
@ -276,6 +277,44 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
// `pushPad`; MIDI + gamepad are pulled by the layer's own rAF loop. // `pushPad`; MIDI + gamepad are pulled by the layer's own rAF loop.
const inputs = useInputLayer(engine); 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<number | null>(null);
const prevAxisCountRef = useRef<number | null>(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, // 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 // 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 // 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); if (v) setActive(null);
}} }}
/> />
{reshapeTarget !== null && (
<ReshapeModal
target={reshapeTarget}
current={inputs.engineInputSize}
onConfirm={confirmReshape}
onCancel={() => setReshapeTarget(null)}
/>
)}
</div> </div>
); );
} }

View file

@ -374,7 +374,11 @@ function MidiInputMeter({ label, value, onClear }: { label: string; value: numbe
function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const inp = ctx.inputs; 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 active = inp.sources.find((s) => s.enabled);
const modeLabel = INPUT_MODE_OPTS.find((o) => o.value === inp.inputMode)?.label ?? 'Internal'; const modeLabel = INPUT_MODE_OPTS.find((o) => o.value === inp.inputMode)?.label ?? 'Internal';
@ -383,7 +387,13 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge tone="info">{modeLabel}</Badge> <Badge tone="info">{modeLabel}</Badge>
{inp.inputMode !== 'internal' && <Chip tone="var(--accent)">{inp.axisCount} axes</Chip>} {inp.inputMode !== 'internal' && <Chip tone="var(--accent)">{inp.axisCount} axes</Chip>}
{reshaping && <Chip tone="var(--warn)">blended {inp.engineInputSize}</Chip>} {inputMismatch && (
<Chip tone="var(--warn)">
{inp.axisCount > inp.engineInputSize
? `${inp.axisCount} axes ${inp.engineInputSize} slots`
: `net: ${inp.engineInputSize}-D`}
</Chip>
)}
{active && ( {active && (
<Chip tone={STATUS_TONE[active.status.state] ?? 'var(--fg-dim)'}>{active.status.state}</Chip> <Chip tone={STATUS_TONE[active.status.state] ?? 'var(--fg-dim)'}>{active.status.state}</Chip>
)} )}
@ -517,16 +527,15 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
</> </>
)} )}
{/* ---- Reshape note (only when >2 axes feed the fixed WASM head) ---- */} {/* ---- Dedicated-dimensions note (only with >2 active axes) ---- */}
{reshaping && depth === 'expanded' && ( {inp.axisCount > 2 && depth === 'expanded' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{/* TODO(workstream F, docs/specs/inputs-spec.md "multiple WASM modules + The net has {inp.engineInputSize} dedicated inputs each active axis drives its own
warm-start"): give every axis its own genuine input dimension by (re)loading a dimension 1:1 (no blending).
WASM module whose MLP arity matches axisCount and warm-starting from the prior {inputMismatch
net. Deferred the reduction lives in InputLayer.compose(). */} ? ` Its ${inp.engineInputSize} slots don't match the ${inp.axisCount} active axes; ` +
The browser WASM is a fixed {inp.engineInputSize}-input head (MLP&lt;2,&gt;), so the{' '} `changing the layout offers a reshape to ${inp.axisCount} inputs (warm-started).`
{inp.axisCount} axes are blended down to {inp.engineInputSize} (evenX / oddY mean). True : ''}
per-axis dimensions land with the multi-WASM reshape.
</p> </p>
)} )}
</> </>

View file

@ -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 (
<div
role="dialog"
aria-modal="true"
aria-label="Reshape the net"
onClick={onCancel}
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(2px)',
}}
>
<div
onClick={(e) => 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,
}}
>
<div
style={{
fontSize: 'var(--fs-sm)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--accent)',
}}
>
Reshape the net?
</div>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', lineHeight: 1.7, color: 'var(--fg)' }}>
Reshape the net to {target} input{target === 1 ? '' : 's'}? Weights are warm-started from
the current {current}-input net; examples and exploration state reset.
</p>
<p style={{ margin: 0, fontSize: 9, lineHeight: 1.6, color: 'var(--fg-dim)' }}>
Decline to keep the current net the extra axes stay zero-padded (inert).
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button size="sm" variant="ghost" onClick={onCancel}>
Keep current
</Button>
<Button size="sm" variant="primary" onClick={onConfirm}>
Reshape to {target}
</Button>
</div>
</div>
</div>
);
}

View file

@ -19,7 +19,7 @@
*/ */
import type { EngineApi } from '../engine/engine-api'; 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 { export interface DebugProbe {
// ---- Core engine surface (live) ---- // ---- Core engine surface (live) ----
@ -29,6 +29,14 @@ export interface DebugProbe {
getLossHistory(): ReadonlyArray<number>; getLossHistory(): ReadonlyArray<number>;
getWeights(): Float32Array; getWeights(): Float32Array;
getExampleCount(): number; 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; setInputs(x: number, y: number): void;
thumbsUp(): number; thumbsUp(): number;
thumbsDown(): number; thumbsDown(): number;
@ -96,6 +104,20 @@ function makeProbe(engine: EngineApi): DebugProbe {
return engine.getState().exampleCount; 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 { setInputs(x: number, y: number): void {
engine.setInput(x, y); engine.setInput(x, y);
}, },

View file

@ -219,6 +219,22 @@ export class EngineApi {
this.process(); 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 { clearExamples(): void {
this.iml.clearExamples(); this.iml.clearExamples();
} }

View file

@ -116,6 +116,11 @@ export class Spine implements EngineSink {
setState(patch: EngineStatePatch): void { setState(patch: EngineStatePatch): void {
let changed = false; let changed = false;
const s = this.state_; 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.inputSize !== undefined && patch.inputSize !== s.inputSize) { s.inputSize = patch.inputSize; changed = true; }
if (patch.outputSize !== undefined && patch.outputSize !== s.outputSize) { if (patch.outputSize !== undefined && patch.outputSize !== s.outputSize) {
s.outputSize = patch.outputSize; s.outputSize = patch.outputSize;

View file

@ -201,6 +201,9 @@ export type WorkerRequest =
minErr: number; minErr: number;
inputSize: number; inputSize: number;
outputSize: 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'; kind: 'dispose';

View file

@ -256,6 +256,109 @@ export class WasmIML {
return this.lastLoss_; 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 // Inference
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@ -403,6 +506,7 @@ export class WasmIML {
minErr, minErr,
inputSize: this.arch_.inputSize, inputSize: this.arch_.inputSize,
outputSize: this.arch_.outputSize, outputSize: this.arch_.outputSize,
hidden: this.arch_.hidden,
}); });
this.setWeights(result.weights); this.setWeights(result.weights);
this.lastLoss_ = result.loss; this.lastLoss_ = result.loss;

View file

@ -29,6 +29,8 @@ export interface TrainArgs {
minErr: number; minErr: number;
inputSize: number; inputSize: number;
outputSize: 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 { export interface TrainResult {
@ -95,6 +97,7 @@ export class WasmTrainer {
minErr: args.minErr, minErr: args.minErr,
inputSize: args.inputSize, inputSize: args.inputSize,
outputSize: args.outputSize, outputSize: args.outputSize,
hidden: args.hidden,
}; };
this.worker.postMessage(msg, [ this.worker.postMessage(msg, [
args.weights.buffer, args.weights.buffer,
@ -170,6 +173,14 @@ if (isWorker) {
let mlHandle = 0; let mlHandle = 0;
let weightCount = 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 weightsPtr = 0;
let weightsViewLen = 0; let weightsViewLen = 0;
let featuresPtr = 0; let featuresPtr = 0;
@ -194,13 +205,41 @@ if (isWorker) {
mod = await factory({ mod = await factory({
locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path), 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 // Default shape (0,0 → 32→[10,14,18]→126). The worker's net MUST match the
// the main thread's shape — weights are exchanged as flat vectors. When // main thread's shape — weights are exchanged as flat vectors. Each `train`
// the main thread creates/reshapes with non-default dims (one-core-engine // message carries the current dims (one-core-engine P2.3), so `ensureNet`
// P2.3+), the init/train messages must carry those dims and this call // recreates this net if the main net was reshaped.
// must pass them through. workerSeed = seed >>> 0;
mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0); mlHandle = mod._nisps_ml_create(0, 0, 0, 0, workerSeed);
weightCount = mod._nisps_ml_weight_count(mlHandle); 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 { 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' }; return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' };
} }
try { 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); ensureBuffers(req.features, req.labels, req.sampleWeights, req.weights);
mod._nisps_ml_set_weights(mlHandle, weightsPtr); mod._nisps_ml_set_weights(mlHandle, weightsPtr);

View file

@ -23,8 +23,9 @@
* behaviour) that diluted every source and biased the net toward idle * behaviour) that diluted every source and biased the net toward idle
* sources' resting values. * sources' resting values.
* *
* Changing the ACTIVE axis count is a reshape: the front-end resets the net * Changing the ACTIVE axis count offers a reshape (ConsoleApp ReshapeModal):
* (recreate-from-scratch, behind a confirm modal) since slot meanings change. * 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'; import type { InputAction, InputSource } from './types';

View file

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