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.
This commit is contained in:
parent
4cea170795
commit
5796d8fd35
3 changed files with 172 additions and 20 deletions
|
|
@ -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<WasmIML>} — 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 };
|
||||
|
|
|
|||
|
|
@ -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<void>}
|
||||
*/
|
||||
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} */
|
||||
|
|
|
|||
87
playground/js/shapeseq/tests/seq-iml.test.js
Normal file
87
playground/js/shapeseq/tests/seq-iml.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue