memlnaut-nisps/playground/js/ui/ab-compare.js

154 lines
4.6 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
// A/B Compare — Rapid toggle between two weight states
//
// Workflow:
// 1. User presses "A" to capture current state as the reference
// 2. User continues exploring (this becomes the live "B" state)
// 3. Toggle switches instantly between A and B
// 4. "Accept B" discards A and exits A/B mode
// 5. "Revert to A" restores the A snapshot and discards B
//
// The state object stored for each side includes weights, noiseLevel, and
// an optional controlState (for any control surface params worth preserving).
//
// Usage:
// import { ABCompare } from './ab-compare.js';
// const ab = new ABCompare();
// ab.captureA({ weights, noiseLevel, controlState });
// // ... user explores ...
// const stateToRestore = ab.toggle(); // switches to A, returns A's state
// const stateToRestore2 = ab.toggle(); // switches back to B, returns B's state
export class ABCompare {
constructor() {
this._a = null; // { weights, noiseLevel, controlState }
this._b = null; // { weights, noiseLevel, controlState }
this._current = null; // 'a' | 'b' | null (null = inactive)
}
// ---- Public API ----
/**
* Capture the current state as side "A" and enter A/B mode.
* The live state after this call becomes "B".
* @param {{ weights: Array, noiseLevel: number, controlState?: any }} state
*/
captureA(state) {
this._a = {
weights: Array.isArray(state.weights)
? [...state.weights]
: Array.from(state.weights),
noiseLevel: state.noiseLevel ?? 0,
controlState: state.controlState ?? null,
};
this._b = null; // B is "live" — captured on first toggle away from B
this._current = 'b'; // We're currently hearing the B (live) side
this._dispatch('ab:activate', { side: 'b' });
}
/**
* Whether A/B mode is currently active.
* @returns {boolean}
*/
get active() {
return this._current !== null;
}
/**
* Which side is currently active: 'a', 'b', or null if inactive.
* @returns {'a'|'b'|null}
*/
get current() {
return this._current;
}
/**
* Toggle between A and B. Returns the state for whichever side we're
* switching TO, so the caller can restore weights/noise from it.
*
* The caller must pass the current live state so we can snapshot whichever
* side we're leaving.
*
* @param {{ weights: Array, noiseLevel: number, controlState?: any }} currentLiveState
* @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null}
*/
toggle(currentLiveState) {
if (!this.active) return null;
if (this._current === 'b') {
// Switching B → A: save current live state as B, return A
this._b = {
weights: Array.isArray(currentLiveState.weights)
? [...currentLiveState.weights]
: Array.from(currentLiveState.weights),
noiseLevel: currentLiveState.noiseLevel ?? 0,
controlState: currentLiveState.controlState ?? null,
};
this._current = 'a';
this._dispatch('ab:toggle', { side: 'a' });
return this._cloneState(this._a);
} else {
// Switching A → B: save current live state as A, return B
this._a = {
weights: Array.isArray(currentLiveState.weights)
? [...currentLiveState.weights]
: Array.from(currentLiveState.weights),
noiseLevel: currentLiveState.noiseLevel ?? 0,
controlState: currentLiveState.controlState ?? null,
};
this._current = 'b';
this._dispatch('ab:toggle', { side: 'b' });
return this._cloneState(this._b);
}
}
/**
* Accept B: discard A, exit A/B mode.
* The current live state (B) is kept as-is nothing to restore.
*/
acceptB() {
this._a = null;
this._b = null;
this._current = null;
this._dispatch('ab:deactivate', { accepted: 'b' });
}
/**
* Revert to A: restore A state and discard B, exit A/B mode.
* @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null}
*/
revertToA() {
if (!this._a) return null;
const state = this._cloneState(this._a);
this._a = null;
this._b = null;
this._current = null;
this._dispatch('ab:deactivate', { accepted: 'a' });
return state;
}
/**
* Cancel A/B mode without choosing. Stays on current live state.
*/
cancel() {
this._a = null;
this._b = null;
this._current = null;
this._dispatch('ab:deactivate', { accepted: null });
}
// ---- Internal ----
_cloneState(s) {
if (!s) return null;
return {
weights: [...s.weights],
noiseLevel: s.noiseLevel,
controlState: s.controlState,
};
}
_dispatch(type, detail) {
document.dispatchEvent(new CustomEvent(type, { detail }));
}
}