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