memlnaut-nisps/playground/js/nisps/nisps-wasm.js

445 lines
14 KiB
JavaScript
Raw Normal View History

// 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.
// 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']),
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
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;
// 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;
// Dataset (JS-side for persistence/visualization access)
this.dataset = { features: [], labels: [], maxExamples: 100 };
// 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);
if (this.dataset.features.length >= this.dataset.maxExamples) {
this.dataset.features.shift();
this.dataset.labels.shift();
}
this.dataset.features.push([...inVec]);
this.dataset.labels.push([...outVec]);
}
clearDataset() {
this.dataset.features = [];
this.dataset.labels = [];
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;
}
const features = this.dataset.features;
const labels = this.dataset.labels;
if (features.length === 0) {
this.log('Empty dataset, skipping training.');
return null;
}
this.log('Training...');
// 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);
const loss = this._w.train(
this._mlp, featPtr, nSamples, featureDim,
labPtr, this.nOutputs,
this.learningRate, this.maxIterations, this.convergenceThreshold
);
this._w.free(featPtr);
this._w.free(labPtr);
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) });
}
}
}
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);
}
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;
// 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,
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;
}
}
}