diff --git a/CLAUDE.md b/CLAUDE.md index b0b3b59..09f27fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | 0–1 | 1 | Constrains synth output ranges toward safe limits | +| `spread` | 0–1 | 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: diff --git a/playground/js/a-app.js b/playground/js/a-app.js index cc2c0f5..9be6394 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -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`); diff --git a/playground/js/app.js b/playground/js/app.js index 3970176..cc84cfb 100644 --- a/playground/js/app.js +++ b/playground/js/app.js @@ -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); diff --git a/playground/js/b-app.js b/playground/js/b-app.js index ee5ee8e..ffd186e 100644 --- a/playground/js/b-app.js +++ b/playground/js/b-app.js @@ -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); diff --git a/playground/js/c-app.js b/playground/js/c-app.js index 6039cde..fa18c65 100644 --- a/playground/js/c-app.js +++ b/playground/js/c-app.js @@ -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(); diff --git a/playground/js/nisps/iml.js b/playground/js/nisps/iml.js index 0e0acad..e9c962c 100644 --- a/playground/js/nisps/iml.js +++ b/playground/js/nisps/iml.js @@ -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(); diff --git a/playground/js/nisps/mlp.js b/playground/js/nisps/mlp.js index 743d54a..1f04172 100644 --- a/playground/js/nisps/mlp.js +++ b/playground/js/nisps/mlp.js @@ -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; } } }