From 5796d8fd35d3eda90918495096891302f908251f Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Mon, 6 Apr 2026 23:45:55 +0100 Subject: [PATCH] feat(seq-iml): configurable output count with scaled architecture The sequence MLP output count is now configurable (default 16). Hidden layer widths scale with the output count to keep inference cheap while providing sufficient capacity. The sequencer exposes setOutputCount() to resize the MLP at runtime. --- playground/js/shapeseq/seq-iml.js | 73 ++++++++++++---- playground/js/shapeseq/sequencer.js | 32 ++++++- playground/js/shapeseq/tests/seq-iml.test.js | 87 ++++++++++++++++++++ 3 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 playground/js/shapeseq/tests/seq-iml.test.js diff --git a/playground/js/shapeseq/seq-iml.js b/playground/js/shapeseq/seq-iml.js index 3a66815..1604c3b 100644 --- a/playground/js/shapeseq/seq-iml.js +++ b/playground/js/shapeseq/seq-iml.js @@ -1,9 +1,9 @@ // ShapeSeq — Sequence NISPS Instance // // Factory for creating a second WasmIML instance dedicated to sequence control. -// The MLP has a fixed 16-element output which a downstream param mapping layer -// (not in this module) fans out to however many primitive params the current -// chain requires. +// The MLP output count is configurable (default 16). A downstream param mapping +// layer (not in this module) fans out to however many primitive params the +// current chain requires. // // Input routing is configurable and wired externally (default: hand tracking // features 0+1). This module only creates the IML instance — it does not @@ -12,28 +12,61 @@ // // Usage: // import { createSequenceIML } from './shapeseq/seq-iml.js'; -// const seqIML = await createSequenceIML(); +// const seqIML = await createSequenceIML(); // 16 outputs +// const seqIML = await createSequenceIML({ outputCount: 32 }); // 32 outputs // // Per frame: // seqIML.setInputs([x, y]); // seqIML.process(); -// const params16 = seqIML.getOutputs(); +// const params = seqIML.getOutputs(); import { WasmIML } from '../nisps/nisps-wasm.js'; -// Fixed architecture for the sequence MLP. -// 2 inputs (routed externally), 16 outputs (fixed — param mapping fans out). -// Smaller network than the synth MLP ([3,32,48,64,126]): -// three hidden layers of 16 neurons keeps inference cheap while providing -// enough capacity for 16 continuous outputs. +// Default architecture for the sequence MLP. +// 2 inputs (routed externally), configurable outputs (param mapping fans out). const SEQ_N_INPUTS = 2; -const SEQ_N_OUTPUTS = 16; -const SEQ_HIDDEN_LAYERS = [16, 16, 16]; +const SEQ_DEFAULT_OUTPUT_COUNT = 16; // Training hyperparameters — same defaults as the synth IML const SEQ_MAX_ITERATIONS = 1000; const SEQ_LEARNING_RATE = 1.0; const SEQ_CONVERGENCE_THRESHOLD = 0.00001; +/** + * Compute a hidden-layer architecture scaled to the requested output count. + * + * The strategy keeps three hidden layers with enough capacity for the output + * count while staying cheap for inference: + * - outputCount <= 8 → [8, 8, 8] + * - outputCount <= 16 → [16, 16, 16] + * - outputCount <= 32 → [24, 24, 32] + * - outputCount > 32 → [outputCount, outputCount, outputCount] + * + * @param {number} outputCount + * @returns {number[]} + */ +export function computeHiddenLayers(outputCount) { + if (outputCount <= 8) return [8, 8, 8]; + if (outputCount <= 16) return [16, 16, 16]; + if (outputCount <= 32) return [24, 24, 32]; + return [outputCount, outputCount, outputCount]; +} + +/** + * Validate and normalise an output count value. + * Must be a positive integer >= 1. + * + * @param {number} outputCount + * @returns {number} the validated count + * @throws {RangeError} if invalid + */ +export function validateOutputCount(outputCount) { + const n = Math.round(outputCount); + if (!Number.isFinite(n) || n < 1) { + throw new RangeError(`outputCount must be a positive integer, got ${outputCount}`); + } + return n; +} + /** * Create a WasmIML instance configured for sequence control. * @@ -49,14 +82,18 @@ const SEQ_CONVERGENCE_THRESHOLD = 0.00001; * Additionally exposes SEQ_N_INPUTS, SEQ_N_OUTPUTS, SEQ_HIDDEN_LAYERS as * properties on the returned object for introspection by downstream code. * + * @param {{ outputCount?: number }} [opts] * @returns {Promise} — the sequence IML instance (augmented with * .SEQ_N_INPUTS, .SEQ_N_OUTPUTS, .SEQ_HIDDEN_LAYERS) */ -export async function createSequenceIML() { +export async function createSequenceIML({ outputCount = SEQ_DEFAULT_OUTPUT_COUNT } = {}) { + const nOutputs = validateOutputCount(outputCount); + const hiddenLayers = computeHiddenLayers(nOutputs); + const seqIML = await WasmIML.create( SEQ_N_INPUTS, - SEQ_N_OUTPUTS, - SEQ_HIDDEN_LAYERS, + nOutputs, + hiddenLayers, SEQ_MAX_ITERATIONS, SEQ_LEARNING_RATE, SEQ_CONVERGENCE_THRESHOLD @@ -66,11 +103,11 @@ export async function createSequenceIML() { // Attach architecture metadata for introspection seqIML.SEQ_N_INPUTS = SEQ_N_INPUTS; - seqIML.SEQ_N_OUTPUTS = SEQ_N_OUTPUTS; - seqIML.SEQ_HIDDEN_LAYERS = SEQ_HIDDEN_LAYERS; + seqIML.SEQ_N_OUTPUTS = nOutputs; + seqIML.SEQ_HIDDEN_LAYERS = hiddenLayers; return seqIML; } // Re-export constants for use by other modules (e.g., param mapping layer) -export { SEQ_N_INPUTS, SEQ_N_OUTPUTS, SEQ_HIDDEN_LAYERS }; +export { SEQ_N_INPUTS, SEQ_DEFAULT_OUTPUT_COUNT }; diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index c8f3989..b0e52ac 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -20,7 +20,7 @@ * @module shapeseq/sequencer */ -import { createSequenceIML, SEQ_N_OUTPUTS } from './seq-iml.js'; +import { createSequenceIML, SEQ_DEFAULT_OUTPUT_COUNT } from './seq-iml.js'; import { Chain } from './chain.js'; import { ClockEngine } from './clock.js'; import { map } from './param-map.js'; @@ -61,6 +61,7 @@ export class ShapeSeqEngine { /** @private */ this._clock = null; /** @private */ this._projectionChain = null; + /** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT; /** @private */ this._stepCount = DEFAULT_STEP_COUNT; /** @private */ this._masterSeed = DEFAULT_MASTER_SEED; /** @private */ this._playing = false; @@ -90,7 +91,7 @@ export class ShapeSeqEngine { */ async init() { // 1. Create the sequence MLP - this._sequenceIML = await createSequenceIML(); + this._sequenceIML = await createSequenceIML({ outputCount: this._outputCount }); // Randomize weights with default spread this._sequenceIML.randomiseWeights(DEFAULT_SPREAD); @@ -213,6 +214,33 @@ export class ShapeSeqEngine { return this._projectionChain; } + /** + * Change the MLP output count. Destroys and recreates the sequence IML + * with a new architecture scaled to the requested count, then randomizes + * weights. Training examples are lost — callers should snapshot first if + * needed. + * + * @param {number} count - desired output count (e.g. 8, 16, 32) + * @returns {Promise} + */ + async setOutputCount(count) { + if (!this._initialized) { + throw new Error('setOutputCount() called before init()'); + } + + this._outputCount = count; + + // Tear down old instance + if (this._sequenceIML) { + this._sequenceIML.destroy(); + this._sequenceIML = null; + } + + // Create new instance with updated architecture + this._sequenceIML = await createSequenceIML({ outputCount: count }); + this._sequenceIML.randomiseWeights(DEFAULT_SPREAD); + } + // ── Chain access (for UI binding) ────────────────────────────────── /** @returns {Chain} */ diff --git a/playground/js/shapeseq/tests/seq-iml.test.js b/playground/js/shapeseq/tests/seq-iml.test.js new file mode 100644 index 0000000..f50480b --- /dev/null +++ b/playground/js/shapeseq/tests/seq-iml.test.js @@ -0,0 +1,87 @@ +/** + * Tests for seq-iml.js configuration logic. + * + * These tests verify the architecture scaling and validation functions + * without instantiating WasmIML (which requires a browser WASM runtime). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + computeHiddenLayers, + validateOutputCount, + SEQ_DEFAULT_OUTPUT_COUNT, +} from '../seq-iml.js'; + +// ── computeHiddenLayers ───────────────────────────────────────────── + +describe('computeHiddenLayers', () => { + it('returns [8,8,8] for outputCount <= 8', () => { + assert.deepStrictEqual(computeHiddenLayers(1), [8, 8, 8]); + assert.deepStrictEqual(computeHiddenLayers(4), [8, 8, 8]); + assert.deepStrictEqual(computeHiddenLayers(8), [8, 8, 8]); + }); + + it('returns [16,16,16] for outputCount 9–16', () => { + assert.deepStrictEqual(computeHiddenLayers(9), [16, 16, 16]); + assert.deepStrictEqual(computeHiddenLayers(16), [16, 16, 16]); + }); + + it('returns [24,24,32] for outputCount 17–32', () => { + assert.deepStrictEqual(computeHiddenLayers(17), [24, 24, 32]); + assert.deepStrictEqual(computeHiddenLayers(24), [24, 24, 32]); + assert.deepStrictEqual(computeHiddenLayers(32), [24, 24, 32]); + }); + + it('returns [n,n,n] for outputCount > 32', () => { + assert.deepStrictEqual(computeHiddenLayers(48), [48, 48, 48]); + assert.deepStrictEqual(computeHiddenLayers(64), [64, 64, 64]); + assert.deepStrictEqual(computeHiddenLayers(128), [128, 128, 128]); + }); + + it('always returns exactly 3 hidden layers', () => { + for (const n of [1, 8, 16, 32, 64]) { + assert.strictEqual(computeHiddenLayers(n).length, 3); + } + }); +}); + +// ── validateOutputCount ───────────────────────────────────────────── + +describe('validateOutputCount', () => { + it('accepts positive integers', () => { + assert.strictEqual(validateOutputCount(1), 1); + assert.strictEqual(validateOutputCount(8), 8); + assert.strictEqual(validateOutputCount(16), 16); + assert.strictEqual(validateOutputCount(32), 32); + }); + + it('rounds fractional values to nearest integer', () => { + assert.strictEqual(validateOutputCount(15.7), 16); + assert.strictEqual(validateOutputCount(8.3), 8); + }); + + it('rejects zero', () => { + assert.throws(() => validateOutputCount(0), RangeError); + }); + + it('rejects negative values', () => { + assert.throws(() => validateOutputCount(-1), RangeError); + assert.throws(() => validateOutputCount(-16), RangeError); + }); + + it('rejects NaN and Infinity', () => { + assert.throws(() => validateOutputCount(NaN), RangeError); + assert.throws(() => validateOutputCount(Infinity), RangeError); + assert.throws(() => validateOutputCount(-Infinity), RangeError); + }); +}); + +// ── SEQ_DEFAULT_OUTPUT_COUNT ──────────────────────────────────────── + +describe('SEQ_DEFAULT_OUTPUT_COUNT', () => { + it('equals 16 (backward compatible default)', () => { + assert.strictEqual(SEQ_DEFAULT_OUTPUT_COUNT, 16); + }); +});