memlnaut-nisps/playground/js/ui/output-pipeline.js

313 lines
9.5 KiB
JavaScript
Raw Normal View History

feat(playground): implement Phases 2-4 of control surface spec Phase 2 — Pinning + History: - snapshot-stack.js: ring buffer (20 max) with auto-snapshot on train/randomize/thumbs-down, multi-level undo, tagged entries - ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert - region-pin.js: pin rectangular input-space regions (Approach A: example pinning), pinned examples always included in training - param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights - phase2-ui.js: undo button with history popup, A/B toggle, long-press region pin, double-tap param pin - Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights Phase 3 — Input Refinement + Exploration: - pressure-feedback.js: touch force + hold duration → intensity multiplier - auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled - input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/ divergence), zoom-aware resampling, offscreen canvas rendering - phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators, settings drawer section - joy-map-enhanced.js: added setHeatmap() for background layer rendering Phase 4 — Output Pipeline + Visualization + Polish: - output-pipeline.js: global curve → smoothing → slew rate → freeze gate - weight-health.js: weight magnitude histogram, dead/saturating/healthy status - gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection - session-presets.js: save/load full state, URL sharing via compact params - phase4-ui.js: freeze button, network health panel, session preset UI All phases merged into a-app.js with proper integration: auto-snapshots, pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs, gradient capture around training, persistence for all new state.
2026-03-26 09:48:12 +01:00
/**
* Output Pipeline processes MLP outputs before they reach the synth/visualizer.
*
* Pipeline stages (in order):
* 1. Global Curve power curve applied to all outputs
* 2. Output Smoothing per-output EMA (frame-rate-independent)
* 3. Slew Rate Limiting max change per second per output
* 4. Freeze Gate when frozen, outputs don't update
*
* Pure math module no DOM dependencies.
*
* @module output-pipeline
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_GLOBAL_CURVE = 1.0;
const GLOBAL_CURVE_MIN = 0.2;
const GLOBAL_CURVE_MAX = 5.0;
const DEFAULT_SMOOTHING = 0;
const SMOOTHING_MAX = 0.95;
const DEFAULT_SLEW_RATE = Infinity; // unlimited
const SLEW_RATE_MIN = 0.005;
const REFERENCE_DT = 1 / 60; // 60fps reference for frame-rate-independent EMA
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
/**
* Frame-rate-independent EMA.
* Same approach as input pipeline: converts per-frame smoothing factor into
* a time-domain factor so perceived smoothing is consistent regardless of frame rate.
*/
function emaSmooth(prev, raw, smoothing, dt) {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
// ---------------------------------------------------------------------------
// OutputPipeline
// ---------------------------------------------------------------------------
export class OutputPipeline {
/**
* @param {number} [numOutputs=126] number of MLP outputs
*/
constructor(numOutputs = 126) {
this._numOutputs = numOutputs;
// Configuration
this._globalCurve = DEFAULT_GLOBAL_CURVE;
this._smoothing = DEFAULT_SMOOTHING;
this._slewRate = DEFAULT_SLEW_RATE;
this._frozen = false;
// Per-output state
this._smoothed = new Float32Array(numOutputs);
this._lastOutput = new Float32Array(numOutputs);
this._initialized = false;
// Per-output freeze mask (for parameter pinning)
this._outputFrozen = new Uint8Array(numOutputs); // 0 = not frozen, 1 = frozen
// Working buffer for processed output (avoid allocation per frame)
this._processed = new Float32Array(numOutputs);
// Store raw outputs separately (for heatmap preview while globally frozen)
this._lastRawAfterCurve = new Float32Array(numOutputs);
}
// -----------------------------------------------------------------------
// Main processing
// -----------------------------------------------------------------------
/**
* Process raw MLP outputs through the pipeline.
*
* @param {number[]|Float32Array} rawOutputs raw MLP output values in [0,1]
* @param {number} deltaTime time since last call in seconds (e.g. 0.016)
* @returns {Float32Array} processed outputs
*/
process(rawOutputs, deltaTime) {
const dt = Math.max(0, deltaTime || 0);
const n = Math.min(rawOutputs.length, this._numOutputs);
// On first call, initialize smoothed state to raw values
if (!this._initialized) {
for (let i = 0; i < n; i++) {
this._smoothed[i] = rawOutputs[i];
this._lastOutput[i] = rawOutputs[i];
}
this._initialized = true;
}
// Stage 1: Global Curve
for (let i = 0; i < n; i++) {
const raw = clamp(rawOutputs[i], 0, 1);
this._lastRawAfterCurve[i] = this._globalCurve === 1.0
? raw
: Math.pow(raw, this._globalCurve);
}
// If globally frozen, return the last non-frozen output
// (raw-after-curve is still updated for preview access)
if (this._frozen) {
this._processed.set(this._lastOutput);
return this._processed;
}
for (let i = 0; i < n; i++) {
// Per-output freeze: skip this output if individually pinned
if (this._outputFrozen[i]) {
this._processed[i] = this._lastOutput[i];
continue;
}
let value = this._lastRawAfterCurve[i];
// Stage 2: Output Smoothing (EMA)
value = emaSmooth(this._smoothed[i], value, this._smoothing, dt);
this._smoothed[i] = value;
// Stage 3: Slew Rate Limiting
if (this._slewRate !== Infinity && isFinite(this._slewRate)) {
const maxDelta = this._slewRate * dt;
if (maxDelta > 0) {
const delta = value - this._lastOutput[i];
if (Math.abs(delta) > maxDelta) {
value = this._lastOutput[i] + Math.sign(delta) * maxDelta;
}
}
}
value = clamp(value, 0, 1);
this._processed[i] = value;
this._lastOutput[i] = value;
}
return this._processed;
}
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
/**
* Set global curve exponent.
* <1 pushes outputs toward extremes (0 and 1), >1 pushes toward center.
* @param {number} exponent 0.2 to 5.0, default 1.0 (linear)
*/
setGlobalCurve(exponent) {
this._globalCurve = clamp(exponent, GLOBAL_CURVE_MIN, GLOBAL_CURVE_MAX);
}
/** @returns {number} current global curve exponent */
getGlobalCurve() {
return this._globalCurve;
}
/**
* Set output smoothing factor (EMA).
* @param {number} factor 0 (off) to 0.95
*/
setSmoothing(factor) {
this._smoothing = clamp(factor, 0, SMOOTHING_MAX);
}
/** @returns {number} current smoothing factor */
getSmoothing() {
return this._smoothing;
}
/**
* Set slew rate limit (max change per second per output).
* @param {number} maxChangePerSec 0.005 to Infinity, default Infinity (unlimited)
*/
setSlewRate(maxChangePerSec) {
if (maxChangePerSec >= 1.0 || !isFinite(maxChangePerSec)) {
this._slewRate = Infinity;
} else {
this._slewRate = Math.max(SLEW_RATE_MIN, maxChangePerSec);
}
}
/** @returns {number} current slew rate */
getSlewRate() {
return this._slewRate;
}
/**
* Set global freeze state.
* When frozen, process() returns the last non-frozen output.
* @param {boolean} frozen
*/
setFrozen(frozen) {
this._frozen = !!frozen;
}
// -----------------------------------------------------------------------
// State queries
// -----------------------------------------------------------------------
/** @returns {boolean} whether output is globally frozen */
isFrozen() {
return this._frozen;
}
/** @returns {Float32Array} last processed (post-pipeline) output */
getLastOutput() {
return new Float32Array(this._lastOutput);
}
/**
* Get the last raw-after-curve output (useful for heatmap preview while frozen).
* @returns {Float32Array}
*/
getRawPreview() {
return new Float32Array(this._lastRawAfterCurve);
}
// -----------------------------------------------------------------------
// Per-output freeze (parameter pinning)
// -----------------------------------------------------------------------
/**
* Freeze a specific output index (its value will not update).
* @param {number} index
*/
freezeOutput(index) {
if (index >= 0 && index < this._numOutputs) {
this._outputFrozen[index] = 1;
}
}
/**
* Unfreeze a specific output index.
* @param {number} index
*/
unfreezeOutput(index) {
if (index >= 0 && index < this._numOutputs) {
this._outputFrozen[index] = 0;
}
}
/**
* Check if a specific output is frozen.
* @param {number} index
* @returns {boolean}
*/
isOutputFrozen(index) {
return index >= 0 && index < this._numOutputs && this._outputFrozen[index] === 1;
}
// -----------------------------------------------------------------------
// Serialization
// -----------------------------------------------------------------------
/** Export configuration as a plain object (no internal state). */
getConfig() {
return {
globalCurve: this._globalCurve,
smoothing: this._smoothing,
slewRate: this._slewRate === Infinity ? 1.0 : this._slewRate,
frozen: this._frozen,
frozenOutputs: Array.from(this._outputFrozen),
};
}
/** Restore configuration from a plain object. */
setConfig(config) {
if (config.globalCurve != null) this.setGlobalCurve(config.globalCurve);
if (config.smoothing != null) this.setSmoothing(config.smoothing);
if (config.slewRate != null) this.setSlewRate(config.slewRate);
if (config.frozen != null) this.setFrozen(config.frozen);
if (Array.isArray(config.frozenOutputs)) {
for (let i = 0; i < config.frozenOutputs.length && i < this._numOutputs; i++) {
this._outputFrozen[i] = config.frozenOutputs[i] ? 1 : 0;
}
}
}
// -----------------------------------------------------------------------
// Reset
// -----------------------------------------------------------------------
/** Reset all settings to defaults and clear internal state. */
reset() {
this._globalCurve = DEFAULT_GLOBAL_CURVE;
this._smoothing = DEFAULT_SMOOTHING;
this._slewRate = DEFAULT_SLEW_RATE;
this._frozen = false;
this._smoothed.fill(0);
this._lastOutput.fill(0);
this._lastRawAfterCurve.fill(0);
this._outputFrozen.fill(0);
this._processed.fill(0);
this._initialized = false;
}
}