- input-pipeline.js: guard circular clamp against div-by-zero when input is exactly at center (0.5, 0.5) — dist=0 produced NaN - snapshot-stack.js: jumpTo() used slice(0, index) which excluded the target snapshot; fixed to slice(0, index + 1) - a-app.js: _lastSentParams not resized when MLP output count changes during mode switch, causing stale throttle state and potential param flood on first frame after switch
169 lines
4.6 KiB
JavaScript
169 lines
4.6 KiB
JavaScript
// Snapshot Stack — Multi-level undo for weight states
|
|
//
|
|
// A ring buffer of weight snapshots tagged with context (e.g. "before thumbs-down",
|
|
// "before train"). Auto-snapshot triggers in the app push snapshots before destructive
|
|
// operations. Users can undo (pop), peek, list, or jump to any snapshot.
|
|
//
|
|
// Each snapshot stores:
|
|
// - weights: the full flat weight array (Array<number> or Float32Array)
|
|
// - noiseLevel: the RL noise level at time of snapshot
|
|
// - zoomLevel: the input pipeline zoom level (optional)
|
|
// - tag: human-readable label for UI display
|
|
// - timestamp: Date.now() when captured
|
|
//
|
|
// Usage:
|
|
// import { SnapshotStack } from './snapshot-stack.js';
|
|
// const stack = new SnapshotStack(20);
|
|
// stack.push('before thumbs-down', { weights, noiseLevel, zoomLevel });
|
|
// const prev = stack.pop(); // undo
|
|
|
|
export class SnapshotStack {
|
|
/**
|
|
* @param {number} maxSnapshots Ring buffer capacity. Oldest evicted when full.
|
|
*/
|
|
constructor(maxSnapshots = 20) {
|
|
this._max = maxSnapshots;
|
|
this._stack = []; // Array of { tag, timestamp, state: { weights, noiseLevel, zoomLevel } }
|
|
}
|
|
|
|
// ---- Core operations ----
|
|
|
|
/**
|
|
* Push a snapshot onto the stack.
|
|
* @param {string} tag Human-readable label (e.g. "before thumbs-down")
|
|
* @param {{ weights: Array|Float32Array, noiseLevel: number, zoomLevel?: number }} state
|
|
*/
|
|
push(tag, state) {
|
|
const snapshot = {
|
|
tag,
|
|
timestamp: Date.now(),
|
|
state: {
|
|
weights: Array.isArray(state.weights)
|
|
? [...state.weights]
|
|
: Array.from(state.weights),
|
|
noiseLevel: state.noiseLevel ?? 0,
|
|
zoomLevel: state.zoomLevel ?? 1.0,
|
|
},
|
|
};
|
|
|
|
this._stack.push(snapshot);
|
|
|
|
// Ring buffer eviction: drop oldest if over capacity
|
|
if (this._stack.length > this._max) {
|
|
this._stack.shift();
|
|
}
|
|
|
|
this._dispatch('snapshot:push', { tag, depth: this.depth });
|
|
}
|
|
|
|
/**
|
|
* Pop the most recent snapshot (undo).
|
|
* @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null}
|
|
*/
|
|
pop() {
|
|
if (this._stack.length === 0) return null;
|
|
const snapshot = this._stack.pop();
|
|
this._dispatch('snapshot:pop', { tag: snapshot.tag, depth: this.depth });
|
|
return snapshot.state;
|
|
}
|
|
|
|
/**
|
|
* Peek at the most recent snapshot without removing it.
|
|
* @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null}
|
|
*/
|
|
peek() {
|
|
if (this._stack.length === 0) return null;
|
|
return this._stack[this._stack.length - 1].state;
|
|
}
|
|
|
|
/**
|
|
* List all snapshots (newest last) for UI display.
|
|
* @returns {Array<{ tag: string, timestamp: number, index: number }>}
|
|
*/
|
|
list() {
|
|
return this._stack.map((snap, i) => ({
|
|
tag: snap.tag,
|
|
timestamp: snap.timestamp,
|
|
index: i,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Jump to a specific snapshot by index. Removes everything above it.
|
|
* @param {number} index
|
|
* @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null}
|
|
*/
|
|
jumpTo(index) {
|
|
if (index < 0 || index >= this._stack.length) return null;
|
|
const snapshot = this._stack[index];
|
|
// Truncate: keep entries 0..index (inclusive), remove the rest
|
|
this._stack = this._stack.slice(0, index + 1);
|
|
this._dispatch('snapshot:jump', { tag: snapshot.tag, depth: this.depth });
|
|
return snapshot.state;
|
|
}
|
|
|
|
/**
|
|
* Current stack depth.
|
|
* @returns {number}
|
|
*/
|
|
get depth() {
|
|
return this._stack.length;
|
|
}
|
|
|
|
/**
|
|
* Clear all snapshots.
|
|
*/
|
|
clear() {
|
|
this._stack = [];
|
|
this._dispatch('snapshot:clear', { depth: 0 });
|
|
}
|
|
|
|
// ---- Serialization ----
|
|
|
|
/**
|
|
* Serialize for localStorage persistence.
|
|
*/
|
|
getState() {
|
|
return {
|
|
max: this._max,
|
|
snapshots: this._stack.map(s => ({
|
|
tag: s.tag,
|
|
timestamp: s.timestamp,
|
|
state: {
|
|
weights: s.state.weights,
|
|
noiseLevel: s.state.noiseLevel,
|
|
zoomLevel: s.state.zoomLevel,
|
|
},
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore from serialized state.
|
|
*/
|
|
setState(saved) {
|
|
if (!saved) return;
|
|
if (typeof saved.max === 'number') this._max = saved.max;
|
|
if (Array.isArray(saved.snapshots)) {
|
|
this._stack = saved.snapshots.map(s => ({
|
|
tag: s.tag || 'restored',
|
|
timestamp: s.timestamp || Date.now(),
|
|
state: {
|
|
weights: s.state?.weights || [],
|
|
noiseLevel: s.state?.noiseLevel ?? 0,
|
|
zoomLevel: s.state?.zoomLevel ?? 1.0,
|
|
},
|
|
}));
|
|
// Trim to capacity
|
|
while (this._stack.length > this._max) {
|
|
this._stack.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Internal ----
|
|
|
|
_dispatch(type, detail) {
|
|
document.dispatchEvent(new CustomEvent(type, { detail }));
|
|
}
|
|
}
|