2026-03-23 22:23:07 +01:00
|
|
|
// WASM-backed IML — drop-in replacement for the JS IML class.
|
|
|
|
|
// Uses nisps-core compiled to WASM for inference, training, and weight ops.
|
|
|
|
|
// Training runs in a Web Worker for non-blocking operation.
|
|
|
|
|
|
feat(tests): add Playwright e2e suite + debug probe for a-immersive
- Add window.__nisps debug probe (gated on ?debug=1) exposing iml state,
getOutputs/getLoss/getWeights/getExampleCount, and action triggers
(thumbsUp/thumbsDown/train/randomise/clearExamples/saveState)
- Fix WasmIML bug: this.dataset was a plain object; import Dataset and
use new Dataset(100) so computeWeights() is available for training
- Fix WasmIML.addExample/clearDataset to use Dataset API methods
- 44 Playwright e2e tests across 4 spec files:
- ml-engine.spec.js: WASM inference bounds, training loss, thumbs
up/down behavior, async training, example capture semantics
- ui-interactions.spec.js: drawer open/close, mode switching,
heatmap bar counts, preset chips, keyboard shortcuts (1/2/Z)
- input-pipeline.spec.js: input→output variation, clamping, joystick
drag, post-training output bounds across the full input space
- persistence.spec.js: URL params (?preset, ?spread), localStorage
round-trip, saveState probe
2026-04-03 17:38:04 +02:00
|
|
|
import { Dataset } from './dataset.js';
|
|
|
|
|
|
2026-03-23 22:23:07 +01:00
|
|
|
// Activation function IDs matching C++ nisps::ACTIVATION_FUNCTIONS enum
|
|
|
|
|
const ACTIVATION = { SIGMOID: 0, TANH: 1, LINEAR: 2, RELU: 3 };
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load the Emscripten module. Returns the initialized module.
|
|
|
|
|
*/
|
|
|
|
|
async function loadNispsModule() {
|
|
|
|
|
const { default: NispsModule } = await import('../../wasm/nisps.js');
|
|
|
|
|
const mod = await NispsModule();
|
|
|
|
|
return mod;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wrap raw Emscripten module with typed JS helpers.
|
|
|
|
|
*/
|
|
|
|
|
function wrapModule(mod) {
|
|
|
|
|
return {
|
|
|
|
|
mod,
|
|
|
|
|
create: mod.cwrap('nisps_mlp_create', 'number', ['number', 'number', 'number', 'number']),
|
|
|
|
|
destroy: mod.cwrap('nisps_mlp_destroy', null, ['number']),
|
|
|
|
|
weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']),
|
|
|
|
|
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
|
|
|
|
|
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
|
|
|
|
|
inference: mod.cwrap('nisps_mlp_inference', null, ['number', 'number', 'number', 'number', 'number']),
|
2026-04-02 21:35:31 +02:00
|
|
|
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
2026-03-23 22:23:07 +01:00
|
|
|
drawWeightsSpread: mod.cwrap('nisps_mlp_draw_weights_spread', null, ['number', 'number']),
|
|
|
|
|
moveWeightsSpread: mod.cwrap('nisps_mlp_move_weights_spread', null, ['number', 'number', 'number']),
|
|
|
|
|
alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
|
|
|
|
|
free: mod.cwrap('nisps_free', null, ['number']),
|
|
|
|
|
allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']),
|
|
|
|
|
freeInt: mod.cwrap('nisps_free_int', null, ['number']),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Write a JS array into WASM heap, returning the pointer.
|
|
|
|
|
* Caller must free with w.free(ptr).
|
|
|
|
|
*/
|
|
|
|
|
function toHeapF32(w, arr) {
|
|
|
|
|
const ptr = w.alloc(arr.length);
|
|
|
|
|
w.mod.HEAPF32.set(arr, ptr >> 2);
|
|
|
|
|
return ptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toHeapI32(w, arr) {
|
|
|
|
|
const ptr = w.allocInt(arr.length);
|
|
|
|
|
w.mod.HEAP32.set(arr, ptr >> 2);
|
|
|
|
|
return ptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fromHeapF32(w, ptr, length) {
|
|
|
|
|
const offset = ptr >> 2;
|
|
|
|
|
return Array.from(w.mod.HEAPF32.subarray(offset, offset + length));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* WASM-backed IML class — API-compatible with the JS IML.
|
|
|
|
|
*
|
|
|
|
|
* Construction is async: use `await WasmIML.create(...)` instead of `new IML(...)`.
|
|
|
|
|
*/
|
|
|
|
|
export class WasmIML {
|
|
|
|
|
/**
|
|
|
|
|
* Async factory — loads WASM and constructs the IML.
|
|
|
|
|
*/
|
|
|
|
|
static async create(
|
|
|
|
|
nInputs,
|
|
|
|
|
nOutputs,
|
|
|
|
|
hiddenLayers = [10, 10, 14],
|
|
|
|
|
maxIterations = 1000,
|
|
|
|
|
learningRate = 1.0,
|
|
|
|
|
convergenceThreshold = 0.00001
|
|
|
|
|
) {
|
|
|
|
|
const mod = await loadNispsModule();
|
|
|
|
|
const iml = new WasmIML(mod, nInputs, nOutputs, hiddenLayers,
|
|
|
|
|
maxIterations, learningRate, convergenceThreshold);
|
|
|
|
|
return iml;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
constructor(mod, nInputs, nOutputs, hiddenLayers, maxIterations, learningRate, convergenceThreshold) {
|
|
|
|
|
this.nInputs = nInputs;
|
|
|
|
|
this.nOutputs = nOutputs;
|
|
|
|
|
this.maxIterations = maxIterations;
|
|
|
|
|
this.learningRate = learningRate;
|
|
|
|
|
this.convergenceThreshold = convergenceThreshold;
|
2026-04-02 21:35:31 +02:00
|
|
|
this.recencyBias = 0.6; // 0 = uniform, 1 = strong recency
|
|
|
|
|
this.weightingMode = 'global'; // 'global' | 'local' | 'combined'
|
|
|
|
|
this.localRadius = 0.15; // input-space radius for local weighting
|
2026-03-23 22:23:07 +01:00
|
|
|
|
|
|
|
|
// Layer sizes: input+bias, hidden..., output
|
|
|
|
|
const BIAS = 1;
|
|
|
|
|
this.layerSizes = [nInputs + BIAS, ...hiddenLayers, nOutputs];
|
|
|
|
|
// Activations: RELU for hidden, SIGMOID for output
|
|
|
|
|
this.activationIds = [
|
|
|
|
|
...hiddenLayers.map(() => ACTIVATION.RELU),
|
|
|
|
|
ACTIVATION.SIGMOID,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// WASM module + helpers
|
|
|
|
|
this._w = wrapModule(mod);
|
|
|
|
|
this._createMLP();
|
|
|
|
|
|
|
|
|
|
// State (JS-side, same as original IML)
|
|
|
|
|
this.inputState = new Array(nInputs).fill(0.5);
|
|
|
|
|
this.outputState = new Array(nOutputs).fill(0);
|
|
|
|
|
this.performInference = true;
|
|
|
|
|
this.inputUpdated = true;
|
|
|
|
|
this.storedWeights = null;
|
|
|
|
|
this.weightsRandomised = false;
|
|
|
|
|
this.lastLoss = null;
|
|
|
|
|
this.bestLoss = null;
|
|
|
|
|
this.lossHistory = [];
|
|
|
|
|
this.totalTrainingIterations = 0;
|
|
|
|
|
this.logFn = null;
|
|
|
|
|
|
feat(tests): add Playwright e2e suite + debug probe for a-immersive
- Add window.__nisps debug probe (gated on ?debug=1) exposing iml state,
getOutputs/getLoss/getWeights/getExampleCount, and action triggers
(thumbsUp/thumbsDown/train/randomise/clearExamples/saveState)
- Fix WasmIML bug: this.dataset was a plain object; import Dataset and
use new Dataset(100) so computeWeights() is available for training
- Fix WasmIML.addExample/clearDataset to use Dataset API methods
- 44 Playwright e2e tests across 4 spec files:
- ml-engine.spec.js: WASM inference bounds, training loss, thumbs
up/down behavior, async training, example capture semantics
- ui-interactions.spec.js: drawer open/close, mode switching,
heatmap bar counts, preset chips, keyboard shortcuts (1/2/Z)
- input-pipeline.spec.js: input→output variation, clamping, joystick
drag, post-training output bounds across the full input space
- persistence.spec.js: URL params (?preset, ?spread), localStorage
round-trip, saveState probe
2026-04-03 17:38:04 +02:00
|
|
|
// Dataset (JS-side for persistence/visualization access and sample weighting)
|
|
|
|
|
this.dataset = new Dataset(100);
|
2026-03-23 22:23:07 +01:00
|
|
|
|
|
|
|
|
// Persistent WASM buffers for inference (avoid alloc/free per frame)
|
|
|
|
|
const inputDim = nInputs + BIAS;
|
|
|
|
|
this._inputPtr = this._w.alloc(inputDim);
|
|
|
|
|
this._outputPtr = this._w.alloc(nOutputs);
|
|
|
|
|
this._inputDim = inputDim;
|
|
|
|
|
|
|
|
|
|
// Worker for async training
|
|
|
|
|
this._worker = null;
|
|
|
|
|
this._training = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_createMLP() {
|
|
|
|
|
const w = this._w;
|
|
|
|
|
const layerPtr = toHeapI32(w, new Int32Array(this.layerSizes));
|
|
|
|
|
const actPtr = toHeapI32(w, new Int32Array(this.activationIds));
|
|
|
|
|
this._mlp = w.create(layerPtr, this.layerSizes.length, actPtr, this.activationIds.length);
|
|
|
|
|
w.freeInt(layerPtr);
|
|
|
|
|
w.freeInt(actPtr);
|
|
|
|
|
this._weightCount = w.weightCount(this._mlp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Logging ----
|
|
|
|
|
setLogger(fn) { this.logFn = fn; }
|
|
|
|
|
log(msg) { if (this.logFn) this.logFn(msg); }
|
|
|
|
|
|
|
|
|
|
// ---- Input / Output ----
|
|
|
|
|
setInput(index, value) {
|
|
|
|
|
if (index >= this.nInputs) return;
|
|
|
|
|
this.inputState[index] = Math.max(0, Math.min(1, value));
|
|
|
|
|
this.inputUpdated = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setInputs(values) {
|
|
|
|
|
for (let i = 0; i < values.length && i < this.nInputs; i++) {
|
|
|
|
|
this.inputState[i] = Math.max(0, Math.min(1, values[i]));
|
|
|
|
|
}
|
|
|
|
|
this.inputUpdated = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getOutputs() { return this.outputState; }
|
|
|
|
|
|
|
|
|
|
setOutput(index, value) {
|
|
|
|
|
if (index >= this.nOutputs) return;
|
|
|
|
|
this.outputState[index] = Math.max(0, Math.min(1, value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOutputs(values) {
|
|
|
|
|
for (let i = 0; i < values.length && i < this.nOutputs; i++) {
|
|
|
|
|
this.outputState[i] = Math.max(0, Math.min(1, values[i]));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Inference (WASM, synchronous — fast) ----
|
|
|
|
|
process() {
|
|
|
|
|
if (!this.performInference || !this.inputUpdated) return;
|
|
|
|
|
|
|
|
|
|
// Write input + bias into persistent WASM buffer
|
|
|
|
|
const heap = this._w.mod.HEAPF32;
|
|
|
|
|
const inOff = this._inputPtr >> 2;
|
|
|
|
|
for (let i = 0; i < this.nInputs; i++) {
|
|
|
|
|
heap[inOff + i] = this.inputState[i];
|
|
|
|
|
}
|
|
|
|
|
heap[inOff + this.nInputs] = 1.0; // bias
|
|
|
|
|
|
|
|
|
|
this._w.inference(this._mlp, this._inputPtr, this._inputDim, this._outputPtr, this.nOutputs);
|
|
|
|
|
|
|
|
|
|
// Read output
|
|
|
|
|
const outOff = this._outputPtr >> 2;
|
|
|
|
|
for (let i = 0; i < this.nOutputs; i++) {
|
|
|
|
|
this.outputState[i] = heap[outOff + i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.inputUpdated = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Dataset ----
|
|
|
|
|
addExample(inputs, outputs) {
|
|
|
|
|
const inVec = inputs.slice(0, this.nInputs);
|
|
|
|
|
while (inVec.length < this.nInputs) inVec.push(0);
|
|
|
|
|
const outVec = outputs.slice(0, this.nOutputs);
|
|
|
|
|
while (outVec.length < this.nOutputs) outVec.push(0);
|
feat(tests): add Playwright e2e suite + debug probe for a-immersive
- Add window.__nisps debug probe (gated on ?debug=1) exposing iml state,
getOutputs/getLoss/getWeights/getExampleCount, and action triggers
(thumbsUp/thumbsDown/train/randomise/clearExamples/saveState)
- Fix WasmIML bug: this.dataset was a plain object; import Dataset and
use new Dataset(100) so computeWeights() is available for training
- Fix WasmIML.addExample/clearDataset to use Dataset API methods
- 44 Playwright e2e tests across 4 spec files:
- ml-engine.spec.js: WASM inference bounds, training loss, thumbs
up/down behavior, async training, example capture semantics
- ui-interactions.spec.js: drawer open/close, mode switching,
heatmap bar counts, preset chips, keyboard shortcuts (1/2/Z)
- input-pipeline.spec.js: input→output variation, clamping, joystick
drag, post-training output bounds across the full input space
- persistence.spec.js: URL params (?preset, ?spread), localStorage
round-trip, saveState probe
2026-04-03 17:38:04 +02:00
|
|
|
this.dataset.add(inVec, outVec);
|
2026-03-23 22:23:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearDataset() {
|
feat(tests): add Playwright e2e suite + debug probe for a-immersive
- Add window.__nisps debug probe (gated on ?debug=1) exposing iml state,
getOutputs/getLoss/getWeights/getExampleCount, and action triggers
(thumbsUp/thumbsDown/train/randomise/clearExamples/saveState)
- Fix WasmIML bug: this.dataset was a plain object; import Dataset and
use new Dataset(100) so computeWeights() is available for training
- Fix WasmIML.addExample/clearDataset to use Dataset API methods
- 44 Playwright e2e tests across 4 spec files:
- ml-engine.spec.js: WASM inference bounds, training loss, thumbs
up/down behavior, async training, example capture semantics
- ui-interactions.spec.js: drawer open/close, mode switching,
heatmap bar counts, preset chips, keyboard shortcuts (1/2/Z)
- input-pipeline.spec.js: input→output variation, clamping, joystick
drag, post-training output bounds across the full input space
- persistence.spec.js: URL params (?preset, ?spread), localStorage
round-trip, saveState probe
2026-04-03 17:38:04 +02:00
|
|
|
this.dataset.clear();
|
2026-03-23 22:23:07 +01:00
|
|
|
this.log('Dataset cleared.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get exampleCount() { return this.dataset.features.length; }
|
|
|
|
|
|
|
|
|
|
// ---- Training (WASM, synchronous) ----
|
|
|
|
|
train(options = {}) {
|
|
|
|
|
if (this.weightsRandomised && this.storedWeights) {
|
|
|
|
|
this._setFlatWeights(this.storedWeights);
|
|
|
|
|
this.weightsRandomised = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 21:35:31 +02:00
|
|
|
if (this.dataset.features.length === 0) {
|
2026-03-23 22:23:07 +01:00
|
|
|
this.log('Empty dataset, skipping training.');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.log('Training...');
|
|
|
|
|
|
2026-04-02 21:35:31 +02:00
|
|
|
const features = this.dataset.features;
|
|
|
|
|
const labels = this.dataset.labels;
|
|
|
|
|
|
|
|
|
|
// Compute per-sample weights
|
|
|
|
|
const sampleWeights = this.dataset.computeWeights(this.weightingMode, {
|
|
|
|
|
recencyBias: this.recencyBias,
|
|
|
|
|
queryInput: this.inputState,
|
|
|
|
|
radius: this.localRadius,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-23 22:23:07 +01:00
|
|
|
// Build flat arrays with bias appended to features
|
|
|
|
|
const featureDim = this.nInputs + 1; // +bias
|
|
|
|
|
const nSamples = features.length;
|
|
|
|
|
const featFlat = new Float32Array(nSamples * featureDim);
|
|
|
|
|
const labFlat = new Float32Array(nSamples * this.nOutputs);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < nSamples; i++) {
|
|
|
|
|
for (let j = 0; j < this.nInputs; j++) {
|
|
|
|
|
featFlat[i * featureDim + j] = features[i][j];
|
|
|
|
|
}
|
|
|
|
|
featFlat[i * featureDim + this.nInputs] = 1.0; // bias
|
|
|
|
|
for (let j = 0; j < this.nOutputs; j++) {
|
|
|
|
|
labFlat[i * this.nOutputs + j] = labels[i][j] || 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const featPtr = toHeapF32(this._w, featFlat);
|
|
|
|
|
const labPtr = toHeapF32(this._w, labFlat);
|
2026-04-02 21:35:31 +02:00
|
|
|
const weightPtr = toHeapF32(this._w, sampleWeights);
|
2026-03-23 22:23:07 +01:00
|
|
|
|
|
|
|
|
const loss = this._w.train(
|
|
|
|
|
this._mlp, featPtr, nSamples, featureDim,
|
|
|
|
|
labPtr, this.nOutputs,
|
2026-04-02 21:35:31 +02:00
|
|
|
weightPtr,
|
2026-03-23 22:23:07 +01:00
|
|
|
this.learningRate, this.maxIterations, this.convergenceThreshold
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this._w.free(featPtr);
|
|
|
|
|
this._w.free(labPtr);
|
2026-04-02 21:35:31 +02:00
|
|
|
this._w.free(weightPtr);
|
2026-03-23 22:23:07 +01:00
|
|
|
|
|
|
|
|
this.lastLoss = loss;
|
|
|
|
|
// We don't have per-iteration history from WASM (single return value),
|
|
|
|
|
// so record just the final loss
|
|
|
|
|
this.lossHistory.push(loss);
|
|
|
|
|
this.totalTrainingIterations += 1;
|
|
|
|
|
if (this.lossHistory.length > 1200) {
|
|
|
|
|
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
|
|
|
|
|
}
|
|
|
|
|
this.bestLoss = this.bestLoss === null ? loss : Math.min(this.bestLoss, loss);
|
|
|
|
|
|
|
|
|
|
// Run inference after training
|
|
|
|
|
this.inputUpdated = true;
|
|
|
|
|
this.process();
|
|
|
|
|
|
|
|
|
|
this.log(`Training complete. Loss: ${loss.toFixed(6)}`);
|
|
|
|
|
return loss;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Weight manipulation ----
|
|
|
|
|
randomiseWeights(spread = 0) {
|
|
|
|
|
this.storedWeights = this._getFlatWeights();
|
|
|
|
|
this._w.drawWeightsSpread(this._mlp, spread);
|
|
|
|
|
this.weightsRandomised = true;
|
|
|
|
|
|
|
|
|
|
this.inputUpdated = true;
|
|
|
|
|
this.process();
|
|
|
|
|
this.log('Weights randomised.');
|
|
|
|
|
}
|
|
|
|
|
|
feat(playground): implement Phases 2-4 of control surface spec
Phase 2 — Pinning + History:
- snapshot-stack.js: ring buffer (20 max) with auto-snapshot on
train/randomize/thumbs-down, multi-level undo, tagged entries
- ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert
- region-pin.js: pin rectangular input-space regions (Approach A: example
pinning), pinned examples always included in training
- param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights
- phase2-ui.js: undo button with history popup, A/B toggle, long-press region
pin, double-tap param pin
- Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights
Phase 3 — Input Refinement + Exploration:
- pressure-feedback.js: touch force + hold duration → intensity multiplier
- auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled
- input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/
divergence), zoom-aware resampling, offscreen canvas rendering
- phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon,
pressure indicators, settings drawer section
- joy-map-enhanced.js: added setHeatmap() for background layer rendering
Phase 4 — Output Pipeline + Visualization + Polish:
- output-pipeline.js: global curve → smoothing → slew rate → freeze gate
- weight-health.js: weight magnitude histogram, dead/saturating/healthy status
- gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection
- session-presets.js: save/load full state, URL sharing via compact params
- phase4-ui.js: freeze button, network health panel, session preset UI
All phases merged into a-app.js with proper integration: auto-snapshots,
pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs,
gradient capture around training, persistence for all new state.
2026-03-26 09:48:12 +01:00
|
|
|
// outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node.
|
|
|
|
|
// Since WASM moveWeights doesn't support pin masks, we save pinned nodes'
|
|
|
|
|
// weights before the call and restore them after.
|
|
|
|
|
moveWeights(speed, spread = 0, outputPinMask = null) {
|
|
|
|
|
let savedSlices = null;
|
|
|
|
|
|
|
|
|
|
if (outputPinMask && outputPinMask.some(v => v)) {
|
|
|
|
|
// Compute the flat-array offset of the last layer's nodes.
|
|
|
|
|
// Flat format: for each layer, for each node: [w0..wN, bias]
|
|
|
|
|
const allWeights = this._getFlatWeights();
|
|
|
|
|
const lastLayerInputSize = this.layerSizes[this.layerSizes.length - 2];
|
|
|
|
|
const numOutputNodes = this.layerSizes[this.layerSizes.length - 1];
|
|
|
|
|
const weightsPerOutputNode = lastLayerInputSize + 1; // weights + bias
|
|
|
|
|
|
|
|
|
|
// Offset of the last layer in the flat array
|
|
|
|
|
const lastLayerOffset = this._weightCount - (numOutputNodes * weightsPerOutputNode);
|
|
|
|
|
|
|
|
|
|
// Save pinned nodes' weight slices
|
|
|
|
|
savedSlices = [];
|
|
|
|
|
for (let i = 0; i < numOutputNodes; i++) {
|
|
|
|
|
if (outputPinMask[i]) {
|
|
|
|
|
const start = lastLayerOffset + i * weightsPerOutputNode;
|
|
|
|
|
const end = start + weightsPerOutputNode;
|
|
|
|
|
savedSlices.push({ start, end, data: allWeights.slice(start, end) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 22:23:07 +01:00
|
|
|
this._w.moveWeightsSpread(this._mlp, speed, spread);
|
feat(playground): implement Phases 2-4 of control surface spec
Phase 2 — Pinning + History:
- snapshot-stack.js: ring buffer (20 max) with auto-snapshot on
train/randomize/thumbs-down, multi-level undo, tagged entries
- ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert
- region-pin.js: pin rectangular input-space regions (Approach A: example
pinning), pinned examples always included in training
- param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights
- phase2-ui.js: undo button with history popup, A/B toggle, long-press region
pin, double-tap param pin
- Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights
Phase 3 — Input Refinement + Exploration:
- pressure-feedback.js: touch force + hold duration → intensity multiplier
- auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled
- input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/
divergence), zoom-aware resampling, offscreen canvas rendering
- phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon,
pressure indicators, settings drawer section
- joy-map-enhanced.js: added setHeatmap() for background layer rendering
Phase 4 — Output Pipeline + Visualization + Polish:
- output-pipeline.js: global curve → smoothing → slew rate → freeze gate
- weight-health.js: weight magnitude histogram, dead/saturating/healthy status
- gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection
- session-presets.js: save/load full state, URL sharing via compact params
- phase4-ui.js: freeze button, network health panel, session preset UI
All phases merged into a-app.js with proper integration: auto-snapshots,
pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs,
gradient capture around training, persistence for all new state.
2026-03-26 09:48:12 +01:00
|
|
|
|
|
|
|
|
// Restore pinned nodes' weights
|
|
|
|
|
if (savedSlices && savedSlices.length > 0) {
|
|
|
|
|
const allWeights = this._getFlatWeights();
|
|
|
|
|
for (const slice of savedSlices) {
|
|
|
|
|
for (let j = 0; j < slice.data.length; j++) {
|
|
|
|
|
allWeights[slice.start + j] = slice.data[j];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this._setFlatWeights(allWeights);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 22:23:07 +01:00
|
|
|
this.inputUpdated = true;
|
|
|
|
|
this.process();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Flat weight get/set (for storedWeights save/restore) ----
|
|
|
|
|
_getFlatWeights() {
|
|
|
|
|
const ptr = this._w.alloc(this._weightCount);
|
|
|
|
|
this._w.getWeights(this._mlp, ptr);
|
|
|
|
|
const weights = fromHeapF32(this._w, ptr, this._weightCount);
|
|
|
|
|
this._w.free(ptr);
|
|
|
|
|
return weights;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_setFlatWeights(flatWeights) {
|
|
|
|
|
const ptr = toHeapF32(this._w, new Float32Array(flatWeights));
|
|
|
|
|
this._w.setWeights(this._mlp, ptr);
|
|
|
|
|
this._w.free(ptr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Async training via Web Worker ----
|
|
|
|
|
get isTraining() { return this._training; }
|
|
|
|
|
|
|
|
|
|
trainAsync(onComplete) {
|
|
|
|
|
if (this._training) {
|
|
|
|
|
this.log('Training already in progress, skipping.');
|
|
|
|
|
return Promise.resolve(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore weights if randomised
|
|
|
|
|
if (this.weightsRandomised && this.storedWeights) {
|
|
|
|
|
this._setFlatWeights(this.storedWeights);
|
|
|
|
|
this.weightsRandomised = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.dataset.features.length === 0) {
|
|
|
|
|
this.log('Empty dataset, skipping training.');
|
|
|
|
|
return Promise.resolve(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._training = true;
|
|
|
|
|
this.log('Training (async)...');
|
|
|
|
|
|
|
|
|
|
// Snapshot current weights + dataset for the worker
|
|
|
|
|
const flatWeights = this._getFlatWeights();
|
|
|
|
|
const features = this.dataset.features;
|
|
|
|
|
const labels = this.dataset.labels;
|
2026-04-02 21:35:31 +02:00
|
|
|
const sampleWeights = Array.from(this.dataset.computeWeights(this.weightingMode, {
|
|
|
|
|
recencyBias: this.recencyBias,
|
|
|
|
|
queryInput: this.inputState,
|
|
|
|
|
radius: this.localRadius,
|
|
|
|
|
}));
|
2026-03-23 22:23:07 +01:00
|
|
|
|
|
|
|
|
// Lazy-init worker
|
|
|
|
|
if (!this._worker) {
|
|
|
|
|
const workerUrl = new URL('./nisps-wasm-worker.js', import.meta.url);
|
|
|
|
|
this._worker = new Worker(workerUrl, { type: 'module' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
if (e.data.type === 'trained') {
|
|
|
|
|
this._worker.removeEventListener('message', handler);
|
|
|
|
|
this._training = false;
|
|
|
|
|
|
|
|
|
|
const { weights, loss } = e.data.payload;
|
|
|
|
|
|
|
|
|
|
// Swap in trained weights
|
|
|
|
|
this._setFlatWeights(weights);
|
|
|
|
|
this.lastLoss = loss;
|
|
|
|
|
this.lossHistory.push(loss);
|
|
|
|
|
this.totalTrainingIterations += 1;
|
|
|
|
|
if (this.lossHistory.length > 1200) {
|
|
|
|
|
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
|
|
|
|
|
}
|
|
|
|
|
this.bestLoss = this.bestLoss === null ? loss : Math.min(this.bestLoss, loss);
|
|
|
|
|
|
|
|
|
|
// Run inference with new weights
|
|
|
|
|
this.inputUpdated = true;
|
|
|
|
|
this.process();
|
|
|
|
|
|
|
|
|
|
this.log(`Training complete. Loss: ${loss.toFixed(6)}`);
|
|
|
|
|
if (onComplete) onComplete({ loss, outputs: [...this.outputState] });
|
|
|
|
|
resolve(loss);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this._worker.addEventListener('message', handler);
|
|
|
|
|
this._worker.postMessage({
|
|
|
|
|
type: 'train',
|
|
|
|
|
payload: {
|
|
|
|
|
layerSizes: this.layerSizes,
|
|
|
|
|
activationIds: this.activationIds,
|
|
|
|
|
weights: flatWeights,
|
|
|
|
|
features,
|
|
|
|
|
labels,
|
2026-04-02 21:35:31 +02:00
|
|
|
sampleWeights,
|
2026-03-23 22:23:07 +01:00
|
|
|
nInputs: this.nInputs,
|
|
|
|
|
nOutputs: this.nOutputs,
|
|
|
|
|
learningRate: this.learningRate,
|
|
|
|
|
maxIterations: this.maxIterations,
|
|
|
|
|
convergenceThreshold: this.convergenceThreshold,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Cleanup ----
|
|
|
|
|
destroy() {
|
|
|
|
|
if (this._mlp) {
|
|
|
|
|
this._w.free(this._inputPtr);
|
|
|
|
|
this._w.free(this._outputPtr);
|
|
|
|
|
this._w.destroy(this._mlp);
|
|
|
|
|
this._mlp = null;
|
|
|
|
|
}
|
|
|
|
|
if (this._worker) {
|
|
|
|
|
this._worker.terminate();
|
|
|
|
|
this._worker = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|