feat(manifold): runtime-shaped net reshape with confirm modal (P2.3)
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.
This commit is contained in:
parent
b6819fd26f
commit
3af92b625a
12 changed files with 449 additions and 28 deletions
|
|
@ -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/`).
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
// 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 && (
|
||||
<ReshapeModal
|
||||
target={reshapeTarget}
|
||||
current={inputs.engineInputSize}
|
||||
onConfirm={confirmReshape}
|
||||
onCancel={() => setReshapeTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Badge tone="info">{modeLabel}</Badge>
|
||||
{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 && (
|
||||
<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) ---- */}
|
||||
{reshaping && depth === 'expanded' && (
|
||||
{/* ---- Dedicated-dimensions note (only with >2 active axes) ---- */}
|
||||
{inp.axisCount > 2 && depth === 'expanded' && (
|
||||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
{/* 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).`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
86
manifold/src/console/ReshapeModal.tsx
Normal file
86
manifold/src/console/ReshapeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number>;
|
||||
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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
79
manifold/tests/e2e/reshape.spec.ts
Normal file
79
manifold/tests/e2e/reshape.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue