diff --git a/CLAUDE.md b/CLAUDE.md index c887d13..4739185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ See `nisps-core/README.md` for complete documentation and examples. The `playground/` directory contains a browser-based interactive demo of the NISPS ML engine. It's a faithful JavaScript port of nisps-core's MLP + IML, with no build step or dependencies. -- **2 inputs** (virtual joystick X/Y) mapped through a `[3, 10, 10, 14, 8]` MLP to **8 outputs** controlling a Canvas2D flow-field particle system +- **2 inputs** (virtual joystick X/Y) mapped through a `[3, 10, 10, 14, 12]` MLP to **12 outputs** controlling a Canvas2D flow-field particle system - **Two learning modes**: Examples (set slider targets, add examples, train) and RL Feedback (thumbs up/down with exploration noise) - **Serve statically**: `cd playground && python3 -m http.server` - **Mobile-first**: designed for touch/foldable phone use diff --git a/playground/js/app.js b/playground/js/app.js index 707239b..8104658 100644 --- a/playground/js/app.js +++ b/playground/js/app.js @@ -8,7 +8,7 @@ import { Controls } from './ui/controls.js'; import { ParamDisplay } from './ui/param-display.js'; const N_INPUTS = 2; -const N_OUTPUTS = 8; +const N_OUTPUTS = 12; // --- State --- let iml; @@ -20,6 +20,10 @@ let learningMode = 'examples'; // 'examples' | 'rl' let noiseLevel = 0.05; let rlExplorationDecay = 0.97; let animating = true; +let gamepadIndex = -1; +let gamepadButtonsPrev = []; +let gamepadConnected = false; +let gamepadLastAxes = [0.5, 0.5]; // --- Init --- function init() { @@ -65,12 +69,18 @@ function init() { helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden')); } + window.addEventListener('gamepadconnected', () => refreshDashboard()); + window.addEventListener('gamepaddisconnected', () => refreshDashboard()); + // Run initial inference to populate outputs iml.setInput(0, 0.5); iml.setInput(1, 0.5); iml.process(); visualizer.setParams(iml.getOutputs()); paramDisplay.update(iml.getOutputs()); + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + controls.updateLossPlot(iml.lossHistory); + refreshDashboard(); // Start animation animate(); @@ -82,6 +92,7 @@ function init() { // --- Animation loop --- function animate() { if (!animating) return; + pollGamepad(); visualizer.draw(); requestAnimationFrame(animate); } @@ -99,6 +110,8 @@ function onJoystickMove(x, y) { if (learningMode !== 'examples' || paramDisplay.activeBar < 0) { paramDisplay.update(outputs); } + + refreshDashboard(); } // --- Examples mode callbacks --- @@ -108,17 +121,20 @@ function onAddExample() { const outputs = [...paramDisplay.values]; iml.addExample(inputs, outputs); controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + refreshDashboard(); flash('btn-add'); } function onTrain() { - const loss = iml.train(); + const loss = trainModel(); if (loss !== null) { // After training, switch back to inference and update display const outputs = iml.getOutputs(); visualizer.setParams(outputs); paramDisplay.update(outputs); controls.updateStatus(iml.exampleCount, loss, noiseLevel); + controls.updateLossPlot(iml.lossHistory); + refreshDashboard(); flash('btn-train'); } } @@ -130,12 +146,18 @@ function onRandomize() { paramDisplay.update(outputs); noiseLevel = 0.05; // reset noise controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + refreshDashboard(); } function onClear() { iml.clearDataset(); + iml.lossHistory = []; + iml.bestLoss = null; + iml.totalTrainingIterations = 0; noiseLevel = 0.05; controls.updateStatus(0, null, noiseLevel); + controls.updateLossPlot(iml.lossHistory); + refreshDashboard(); clearState(); } @@ -147,13 +169,15 @@ function onThumbsUp() { iml.addExample(inputs, outputs); // Retrain incrementally - iml.train(); + trainModel(); // Decay noise - more positive examples = less exploration noiseLevel *= rlExplorationDecay; noiseLevel = Math.max(noiseLevel, 0.005); controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + controls.updateLossPlot(iml.lossHistory); + refreshDashboard(); flash('btn-thumbsup'); } @@ -168,6 +192,7 @@ function onThumbsDown() { visualizer.setParams(outputs); paramDisplay.update(outputs); controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + refreshDashboard(); flash('btn-thumbsdown'); } @@ -179,6 +204,7 @@ function onModeChange(mode) { paramDisplay.setDraggable(false); } controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + refreshDashboard(); } // --- Presets --- @@ -187,28 +213,30 @@ window.loadPreset = function(name) { if (name === 'calm-to-chaotic') { // Bottom-left: slow, smooth, cool; top-right: fast, turbulent, warm - iml.addExample([0.1, 0.9], [0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05]); - iml.addExample([0.9, 0.1], [0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95]); - iml.addExample([0.5, 0.5], [0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5]); + iml.addExample([0.1, 0.9], [0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05, 0.9, 0.45, 0.25, 0.2]); + iml.addExample([0.9, 0.1], [0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95, 0.3, 0.2, 0.85, 0.7]); + iml.addExample([0.5, 0.5], [0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5, 0.7, 0.6, 0.5, 0.45]); } else if (name === 'rainbow-sweep') { // Left to right sweeps through hues - iml.addExample([0.0, 0.5], [0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3]); - iml.addExample([0.5, 0.5], [0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3]); - iml.addExample([1.0, 0.5], [0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3]); + iml.addExample([0.0, 0.5], [0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.4, 0.3]); + iml.addExample([0.5, 0.5], [0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.55, 0.35]); + iml.addExample([1.0, 0.5], [0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.75, 0.45]); } else if (name === 'vortex') { // Center: tight spiral, edges: wide flow - iml.addExample([0.5, 0.5], [0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0]); - iml.addExample([0.0, 0.0], [0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1]); - iml.addExample([1.0, 1.0], [0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1]); - iml.addExample([0.0, 1.0], [0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5]); - iml.addExample([1.0, 0.0], [0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5]); + iml.addExample([0.5, 0.5], [0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0, 1.0, 0.3, 0.95, 0.85]); + iml.addExample([0.0, 0.0], [0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15]); + iml.addExample([1.0, 1.0], [0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15]); + iml.addExample([0.0, 1.0], [0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45]); + iml.addExample([1.0, 0.0], [0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45]); } - const loss = iml.train(); + const loss = trainModel(); const outputs = iml.getOutputs(); visualizer.setParams(outputs); paramDisplay.update(outputs); controls.updateStatus(iml.exampleCount, loss, noiseLevel); + controls.updateLossPlot(iml.lossHistory); + refreshDashboard(); }; // --- Persistence --- @@ -229,11 +257,13 @@ function loadState() { for (let i = 0; i < data.features.length; i++) { iml.addExample(data.features[i], data.labels[i]); } - iml.train(); + trainModel(); const outputs = iml.getOutputs(); visualizer.setParams(outputs); paramDisplay.update(outputs); controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + controls.updateLossPlot(iml.lossHistory); + refreshDashboard(); } } catch (e) { /* ignore */ } } @@ -253,6 +283,100 @@ function flash(id) { setTimeout(() => el.classList.remove('flash'), 200); } +function trainModel() { + let lastPlotUpdate = 0; + const loss = iml.train({ + onIteration: (iter, iterLoss) => { + if (iter - lastPlotUpdate < 8) return; + lastPlotUpdate = iter; + controls.updateLossPlot([...iml.lossHistory, iterLoss]); + }, + }); + return loss; +} + +function refreshDashboard() { + const outputs = iml.getOutputs(); + let mean = 0; + for (let i = 0; i < outputs.length; i++) mean += outputs[i]; + mean /= Math.max(outputs.length, 1); + + let variance = 0; + for (let i = 0; i < outputs.length; i++) { + const diff = outputs[i] - mean; + variance += diff * diff; + } + variance /= Math.max(outputs.length, 1); + + controls.updateMetrics({ + mode: learningMode, + joystickX: joystick.x, + joystickY: joystick.y, + outputMean: mean, + outputSpread: Math.sqrt(variance), + bestLoss: iml.bestLoss, + totalTrainingIterations: iml.totalTrainingIterations, + gamepadConnected, + }); +} + +function pollGamepad() { + if (!navigator.getGamepads) return; + const gamepads = navigator.getGamepads(); + let gp = null; + + if (gamepadIndex >= 0 && gamepads[gamepadIndex] && gamepads[gamepadIndex].connected) { + gp = gamepads[gamepadIndex]; + } else { + gamepadIndex = -1; + for (let i = 0; i < gamepads.length; i++) { + if (gamepads[i] && gamepads[i].connected) { + gp = gamepads[i]; + gamepadIndex = i; + break; + } + } + } + + const wasConnected = gamepadConnected; + gamepadConnected = !!gp; + if (wasConnected !== gamepadConnected) refreshDashboard(); + if (!gp) { + gamepadButtonsPrev = []; + return; + } + + const deadzone = 0.08; + const rawX = gp.axes[0] || 0; + const rawY = gp.axes[1] || 0; + const axisX = Math.abs(rawX) < deadzone ? 0 : rawX; + const axisY = Math.abs(rawY) < deadzone ? 0 : rawY; + const mappedX = (axisX + 1) * 0.5; + const mappedY = (axisY + 1) * 0.5; + + const moved = Math.abs(mappedX - gamepadLastAxes[0]) > 0.002 || Math.abs(mappedY - gamepadLastAxes[1]) > 0.002; + if (moved && paramDisplay.activeBar < 0) { + gamepadLastAxes = [mappedX, mappedY]; + joystick.setPosition(mappedX, mappedY, { emit: true, touching: true }); + } else if (!moved && joystick.touching) { + joystick.setPosition(joystick.x, joystick.y, { emit: false, touching: false }); + } + + // Standard gamepad mapping: LB=4, RB=5 + const lbPressed = !!gp.buttons[4]?.pressed; + const rbPressed = !!gp.buttons[5]?.pressed; + const lbPrev = !!gamepadButtonsPrev[4]; + const rbPrev = !!gamepadButtonsPrev[5]; + + if (learningMode === 'rl') { + if (rbPressed && !rbPrev) onThumbsUp(); + if (lbPressed && !lbPrev) onThumbsDown(); + } + + gamepadButtonsPrev[4] = lbPressed; + gamepadButtonsPrev[5] = rbPressed; +} + // --- Start --- document.addEventListener('DOMContentLoaded', () => { init(); diff --git a/playground/js/ui/param-display.js b/playground/js/ui/param-display.js index 2a1e00d..38b0415 100644 --- a/playground/js/ui/param-display.js +++ b/playground/js/ui/param-display.js @@ -1,11 +1,11 @@ // Parameter bar display -// Shows 8 output parameters as horizontal bars, draggable in examples mode +// Shows output parameters as horizontal bars, draggable in examples mode -const PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb']; -const PARAM_COLORS = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366']; +const PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt']; +const PARAM_COLORS = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff']; export class ParamDisplay { - constructor(container, numParams = 8) { + constructor(container, numParams = 12) { this.container = container; this.numParams = numParams; this.values = new Array(numParams).fill(0.5); diff --git a/playground/js/ui/visualizer.js b/playground/js/ui/visualizer.js index 4139333..59e4d54 100644 --- a/playground/js/ui/visualizer.js +++ b/playground/js/ui/visualizer.js @@ -1,5 +1,5 @@ // Flow field particle system with Canvas2D -// Controlled by 8 output parameters from the IML network +// Controlled by 12 output parameters from the IML network // Simple value noise (no dependencies) const PERM = new Uint8Array(512); @@ -63,6 +63,10 @@ export class FlowFieldVisualizer { particleSize: 3, // p5: dot radius fadeRate: 0.05, // p6: trail length turbulence: 1, // p7: chaos + attractStrength: 0.8, // p8: pull toward screen center + attractRadius: 200, // p9: radius where attraction is strongest + dispersionRate: 2.0, // p10: speed of outward dispersion pulses + dispersionAmount: 1.0, // p11: strength of outward dispersion }; this.resize(); @@ -95,7 +99,7 @@ export class FlowFieldVisualizer { // Set parameters from IML output (all values 0-1) setParams(outputs) { - if (!outputs || outputs.length < 8) return; + if (!outputs || outputs.length < 12) return; this.params.angleOffset = outputs[0] * TWO_PI; this.params.scale = 0.001 + outputs[1] * 0.009; this.params.speed = 0.5 + outputs[2] * 4.5; @@ -104,6 +108,10 @@ export class FlowFieldVisualizer { this.params.particleSize = 1 + outputs[5] * 5; this.params.fadeRate = 0.01 + outputs[6] * 0.14; this.params.turbulence = outputs[7] * 2; + this.params.attractStrength = 0.1 + outputs[8] * 2.9; + this.params.attractRadius = 40 + outputs[9] * 420; + this.params.dispersionRate = 0.2 + outputs[10] * 8; + this.params.dispersionAmount = outputs[11] * 3; } draw() { @@ -124,8 +132,30 @@ export class FlowFieldVisualizer { // Move particle const vx = Math.cos(angle + curl) * params.speed; const vy = Math.sin(angle + curl) * params.speed; - p.x += vx; - p.y += vy; + let nextX = p.x + vx; + let nextY = p.y + vy; + + // Central attractor keeps trajectories from sticking to the outer edges. + const cx = width * 0.5; + const cy = height * 0.5; + const dx = cx - nextX; + const dy = cy - nextY; + const dist = Math.hypot(dx, dy) + 1e-6; + const nxCenter = dx / dist; + const nyCenter = dy / dist; + const normalizedDist = Math.min(dist / params.attractRadius, 2); + const falloff = 1 / (1 + normalizedDist * normalizedDist); + nextX += nxCenter * params.attractStrength * falloff; + nextY += nyCenter * params.attractStrength * falloff; + + // Time-varying dispersion pushes particles outward near the center. + const dispersionPulse = 0.5 + 0.5 * Math.sin(this.time * params.dispersionRate + p.id * 0.07); + const dispersionForce = params.dispersionAmount * dispersionPulse * falloff; + nextX -= nxCenter * dispersionForce; + nextY -= nyCenter * dispersionForce; + + p.x = nextX; + p.y = nextY; // Wrap around edges if (p.x < 0) p.x += width;