feat(playground): add spread param for sigmoid saturation control and fix randomise sync

Add ?spread=0-1 URL param that controls weight initialization scaling,
RL noise scaling per layer, noise cap, and weight decay to prevent
sigmoid output saturation. At spread=0 (original behavior) weights are
uniform [-1,1] and outputs polarise near 0/1. At spread=1 weights use
Xavier scaling (1/sqrt(fan_in)), noise is proportionally reduced, and
10% weight decay per thumbs-down prevents unbounded magnitude drift.

Also fix randomise to re-inject current joystick position and re-run
inference before routing outputs, eliminating the jump on first
joystick move after randomise.

Defaults: tame=1, spread=0.6 across all app variants.
This commit is contained in:
w1n5t0n 2026-03-22 02:10:26 +02:00
parent d9be808aaa
commit 3db90b035a
7 changed files with 94 additions and 31 deletions

View file

@ -38,6 +38,27 @@ The `playground/` directory contains a browser-based interactive demo of the NIS
Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls), `js/synth/` (C15 bridge, param map, arpeggiator), `js/app.js` (wiring).
### URL Parameters
| Param | Range | Default | Effect |
|-------|-------|---------|--------|
| `tame` | 01 | 1 | Constrains synth output ranges toward safe limits |
| `spread` | 01 | 0.6 | Controls weight initialization, RL noise scaling, and weight decay (see below) |
#### `spread` — sigmoid saturation control
The MLP uses ReLU hidden layers with a sigmoid output layer. With uniform [-1,1] weights, the sum of many weighted inputs at each layer drives sigmoid pre-activations far from zero (std dev ≈ √fan_in), causing outputs to saturate near 0 or 1. The `spread` parameter addresses this:
- **`spread=0`** (polarised): Weights drawn from uniform [-1,1]. RL noise cap = 0.3. Noise applied uniformly across layers. Outputs cluster at extremes — good for exploration of radical mappings.
- **`spread=1`** (centered): Weights scaled by 1/√fan_in per layer (Xavier initialization). RL noise cap = 0.05. Noise also scaled per-layer. Weight decay prevents magnitude drift. Outputs spread across the full [0,1] range — better for fine-grained RL shaping.
- **Intermediate values** interpolate linearly between these two regimes.
Affects four code paths:
1. **`drawWeights(spread)`** — initial randomisation weight scale
2. **`moveWeights(speed, spread)`** — RL exploration noise scale per layer
3. **Weight decay in `moveWeights`** — each call decays weights by `10% * spread` before adding noise, preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is no decay (original behavior). At spread=1, weights decay ~10% per call, creating a natural equilibrium where exploration noise and decay balance out rather than weights growing until sigmoid permanently saturates.
4. **Noise cap** in thumbs-down handler — `0.3*(1-spread) + 0.05*spread`
### C15 Parameter Map
The 126 synth parameters in `js/synth/param-map.js` were curated from the C15's 287 total parameters. Excluded categories:

View file

@ -360,9 +360,9 @@ function padPresetOutputs(outputs) {
function init() {
// Parse ?tame URL param
const urlParams = new URLSearchParams(window.location.search);
tameLevel = parseFloat(urlParams.get('tame') ?? '0');
spreadLevel = parseFloat(urlParams.get('spread') ?? '0');
if (isNaN(spreadLevel)) spreadLevel = 0;
tameLevel = parseFloat(urlParams.get('tame') ?? '1');
spreadLevel = parseFloat(urlParams.get('spread') ?? '0.6');
if (isNaN(spreadLevel)) spreadLevel = 0.6;
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
// IML — fresh random weights each boot, no state restoration
@ -754,11 +754,18 @@ function wireControls() {
});
});
// Output mode toggle
// Output mode toggle (sheet)
document.querySelectorAll('#output-toggle .pill-opt').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
syncOutputToggles(btn.dataset.mode);
setOutputMode(btn.dataset.mode);
});
});
// Output mode toggle (floating)
document.querySelectorAll('#output-toggle-float .otf-opt').forEach(btn => {
btn.addEventListener('click', () => {
syncOutputToggles(btn.dataset.mode);
setOutputMode(btn.dataset.mode);
});
});
@ -800,6 +807,11 @@ function updateModeUI() {
}
}
function syncOutputToggles(mode) {
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
document.querySelectorAll('#output-toggle-float .otf-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
}
function setOutputMode(mode) {
outputMode = mode;
buildHeatmap();
@ -869,6 +881,9 @@ function onTrain() {
function onRandomize() {
iml.randomiseWeights(spreadLevel);
iml.setInput(0, joyX);
iml.setInput(1, joyY);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateHeatmap(outputs);
@ -908,9 +923,10 @@ function onThumbsUp() {
}
function onThumbsDown() {
noiseLevel = Math.min(noiseLevel * 1.5, 0.3);
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
iml.moveWeights(noiseLevel);
iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs();
routeOutputs(outputs);
@ -1340,9 +1356,7 @@ function loadState() {
// Restore output mode
if (state.outputMode && state.outputMode !== outputMode) {
setOutputMode(state.outputMode);
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => {
b.classList.toggle('active', b.dataset.mode === outputMode);
});
syncOutputToggles(outputMode);
}
console.log(`[NISPS] Restored ${state.features?.length || 0} examples from storage`);

View file

@ -46,10 +46,10 @@ let arpeggiator = null;
// Devmode: tame level (0 = no mitigation, 1 = strongest)
// Set via URL ?tame=0.7 or window.setTameLevel(0.7)
const _urlParams = new URLSearchParams(location.search);
let tameLevel = parseFloat(_urlParams.get('tame') ?? '0.7');
if (isNaN(tameLevel)) tameLevel = 0.7;
let tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
if (isNaN(tameLevel)) tameLevel = 1;
tameLevel = Math.max(0, Math.min(1, tameLevel));
let spreadLevel = parseFloat(_urlParams.get('spread') ?? '0');
let spreadLevel = parseFloat(_urlParams.get('spread') ?? '0.6');
if (isNaN(spreadLevel)) spreadLevel = 0.6;
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
window.setTameLevel = (v) => { tameLevel = Math.max(0, Math.min(1, v)); console.log(`[NISPS] tame=${tameLevel}`); };
@ -345,6 +345,9 @@ function onTrain() {
function onRandomize() {
iml.randomiseWeights(spreadLevel);
iml.setInput(0, joystick.x);
iml.setInput(1, joystick.y);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
@ -387,10 +390,12 @@ function onThumbsUp() {
function onThumbsDown() {
// Increase noise for more exploration
noiseLevel = Math.min(noiseLevel * 1.5, 0.3);
// spread reduces the noise cap: at spread=1 cap is 0.05 (vs 0.3 at spread=0)
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
// Perturb weights
iml.moveWeights(noiseLevel);
// Perturb weights (spread scales noise per-layer by 1/sqrt(fan_in))
iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs();
routeOutputs(outputs);

View file

@ -90,8 +90,8 @@ function heatmapColor(t) {
// --- Tame URL param ---
const _urlParams = new URLSearchParams(window.location.search);
const tameLevel = parseFloat(_urlParams.get('tame') ?? '0');
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0') || 0));
const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
// --- State ---
let iml;
@ -736,6 +736,9 @@ function onTrain() {
function onRandomize() {
iml.randomiseWeights(spreadLevel);
iml.setInput(0, joyX);
iml.setInput(1, joyY);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateAllParamBars(outputs);
@ -772,8 +775,9 @@ function onThumbsUp() {
}
function onThumbsDown() {
noiseLevel = Math.min(noiseLevel * 1.5, 0.3);
iml.moveWeights(noiseLevel);
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateAllParamBars(outputs);

View file

@ -48,8 +48,8 @@ const PRESETS = [
// --- Tame URL param ---
const _urlParams = new URLSearchParams(window.location.search);
const tameLevel = parseFloat(_urlParams.get('tame') ?? '0');
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0') || 0));
const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
// ============================================================
// State
@ -630,6 +630,10 @@ function wireTeachActions() {
document.getElementById('btn-clear')?.addEventListener('click', () => {
iml.clearDataset();
iml.randomiseWeights(spreadLevel);
iml.setInput(0, joystickX);
iml.setInput(1, joystickY);
iml.process();
routeOutputs(iml.getOutputs());
selectedPreset = -1;
document.querySelectorAll('.preset-thumb').forEach(b => b.classList.remove('selected'));
updateTeachStatus();
@ -688,8 +692,9 @@ function onThumbsUp() {
}
function onThumbsDown() {
noiseLevel = Math.min(0.3, noiseLevel * 1.5);
iml.moveWeights(noiseLevel);
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseCap, noiseLevel * 1.5);
iml.moveWeights(noiseLevel, spreadLevel);
iml.setInput(0, joystickX);
iml.setInput(1, joystickY);
iml.process();

View file

@ -158,8 +158,9 @@ export class IML {
}
// Add Gaussian noise to weights (for RL exploration)
moveWeights(speed) {
this.mlp.moveWeights(speed);
// spread: 0 = flat noise, 1 = Xavier-scaled per layer
moveWeights(speed, spread = 0) {
this.mlp.moveWeights(speed, spread);
// Run inference to show effect
this.inputUpdated = true;
this.process();

View file

@ -221,16 +221,29 @@ export class MLP {
}
// MoveWeights - add Gaussian noise (port of gen_randn)
moveWeights(speed) {
for (const layer of this.layers) {
for (const node of layer.nodes) {
// spread: 0 = flat noise across all layers (original), 1 = scale noise by 1/sqrt(fan_in)
// per layer so perturbations stay proportional to Xavier-scale weights and don't
// saturate sigmoid outputs.
// Weight decay (proportional to spread) shrinks weights toward zero before adding noise,
// preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is
// no decay (original behavior). At spread=1 each call decays weights by ~10%, creating
// a natural equilibrium where exploration can't permanently saturate sigmoid.
moveWeights(speed, spread = 0) {
const decay = 1 - 0.1 * spread; // spread=0 → 1.0 (no decay), spread=1 → 0.9
for (let l = 0; l < this.layers.length; l++) {
const fanIn = this.layersNodes[l];
const xavierScale = 1 / Math.sqrt(fanIn);
const layerScale = 1 * (1 - spread) + xavierScale * spread;
for (const node of this.layers[l].nodes) {
for (let j = 0; j < node.weights.length; j++) {
// Decay toward zero to prevent magnitude drift
node.weights[j] *= decay;
// gen_randn: sum of 3 uniform randoms * kN_times * stddev + mean
let accum = 0;
for (let n = 0; n < 3; n++) {
accum += Math.random() * 2 - 1; // gen_rand with range 2.0
}
node.weights[j] = 3 * accum * speed + node.weights[j];
node.weights[j] += 3 * accum * speed * layerScale;
}
}
}