219 lines
6.5 KiB
JavaScript
219 lines
6.5 KiB
JavaScript
|
|
/**
|
||
|
|
* Weight Health Indicator — ambient indicator showing network weight statistics.
|
||
|
|
*
|
||
|
|
* Analyzes a flat weight array and produces health status + compact visualization.
|
||
|
|
* Call update() periodically (not every frame) with the current flat weights.
|
||
|
|
*
|
||
|
|
* @module weight-health
|
||
|
|
*/
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Thresholds
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** Weight magnitude below this is considered "dead" */
|
||
|
|
const DEAD_THRESHOLD = 0.01;
|
||
|
|
|
||
|
|
/** Weight magnitude above this drives sigmoid >99% saturated */
|
||
|
|
const SATURATING_THRESHOLD = 3.0;
|
||
|
|
|
||
|
|
/** If more than this fraction of weights are dead, status = 'dead' */
|
||
|
|
const DEAD_FRACTION_LIMIT = 0.30;
|
||
|
|
|
||
|
|
/** If more than this fraction of weights are saturating, status = 'saturating' */
|
||
|
|
const SATURATED_FRACTION_LIMIT = 0.40;
|
||
|
|
|
||
|
|
/** Number of histogram bins for weight magnitude distribution */
|
||
|
|
const HISTOGRAM_BINS = 10;
|
||
|
|
|
||
|
|
/** Histogram bin range: [0, MAX_HISTOGRAM_MAG] */
|
||
|
|
const MAX_HISTOGRAM_MAG = 5.0;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Colors
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const COLOR_HEALTHY = { r: 100, g: 200, b: 120 }; // calm green
|
||
|
|
const COLOR_SATURATING = { r: 255, g: 120, b: 40 }; // hot orange
|
||
|
|
const COLOR_DEAD = { r: 120, g: 120, b: 120 }; // dim gray
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// WeightHealthIndicator
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export class WeightHealthIndicator {
|
||
|
|
constructor() {
|
||
|
|
this._status = null;
|
||
|
|
this._pulsePhase = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update from current weights. Call periodically (e.g. every 500ms).
|
||
|
|
*
|
||
|
|
* @param {number[]|Float32Array} weightsArray — flat array of all network weights
|
||
|
|
*/
|
||
|
|
update(weightsArray) {
|
||
|
|
if (!weightsArray || weightsArray.length === 0) {
|
||
|
|
this._status = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const n = weightsArray.length;
|
||
|
|
let sumMag = 0;
|
||
|
|
let maxMag = 0;
|
||
|
|
let deadCount = 0;
|
||
|
|
let saturatedCount = 0;
|
||
|
|
|
||
|
|
const histogram = new Array(HISTOGRAM_BINS).fill(0);
|
||
|
|
const binWidth = MAX_HISTOGRAM_MAG / HISTOGRAM_BINS;
|
||
|
|
|
||
|
|
for (let i = 0; i < n; i++) {
|
||
|
|
const mag = Math.abs(weightsArray[i]);
|
||
|
|
sumMag += mag;
|
||
|
|
if (mag > maxMag) maxMag = mag;
|
||
|
|
|
||
|
|
if (mag < DEAD_THRESHOLD) deadCount++;
|
||
|
|
if (mag > SATURATING_THRESHOLD) saturatedCount++;
|
||
|
|
|
||
|
|
const bin = Math.min(Math.floor(mag / binWidth), HISTOGRAM_BINS - 1);
|
||
|
|
histogram[bin]++;
|
||
|
|
}
|
||
|
|
|
||
|
|
const meanMagnitude = sumMag / n;
|
||
|
|
const deadFraction = deadCount / n;
|
||
|
|
const saturatedFraction = saturatedCount / n;
|
||
|
|
|
||
|
|
// Normalize histogram to fractions
|
||
|
|
for (let i = 0; i < HISTOGRAM_BINS; i++) {
|
||
|
|
histogram[i] /= n;
|
||
|
|
}
|
||
|
|
|
||
|
|
let health;
|
||
|
|
if (deadFraction > DEAD_FRACTION_LIMIT) {
|
||
|
|
health = 'dead';
|
||
|
|
} else if (saturatedFraction > SATURATED_FRACTION_LIMIT) {
|
||
|
|
health = 'saturating';
|
||
|
|
} else {
|
||
|
|
health = 'healthy';
|
||
|
|
}
|
||
|
|
|
||
|
|
this._status = {
|
||
|
|
health,
|
||
|
|
meanMagnitude,
|
||
|
|
maxMagnitude: maxMag,
|
||
|
|
deadFraction,
|
||
|
|
saturatedFraction,
|
||
|
|
histogram,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the current health status.
|
||
|
|
* @returns {object|null} Status object or null if never updated.
|
||
|
|
*/
|
||
|
|
getStatus() {
|
||
|
|
return this._status;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Draw a compact visual indicator (ambient glow bar).
|
||
|
|
*
|
||
|
|
* @param {CanvasRenderingContext2D} ctx
|
||
|
|
* @param {number} x — left edge
|
||
|
|
* @param {number} y — top edge
|
||
|
|
* @param {number} width — total width
|
||
|
|
* @param {number} height — total height
|
||
|
|
*/
|
||
|
|
draw(ctx, x, y, width, height) {
|
||
|
|
if (!this._status) {
|
||
|
|
// No data — draw a dim placeholder
|
||
|
|
ctx.fillStyle = 'rgba(60, 60, 60, 0.4)';
|
||
|
|
ctx.fillRect(x, y, width, height);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const { health, deadFraction, saturatedFraction, histogram } = this._status;
|
||
|
|
|
||
|
|
// Advance pulse phase
|
||
|
|
this._pulsePhase += 0.04;
|
||
|
|
|
||
|
|
// Determine base color + glow intensity
|
||
|
|
let color;
|
||
|
|
let glowAlpha;
|
||
|
|
let pulseAmount = 0;
|
||
|
|
|
||
|
|
if (health === 'healthy') {
|
||
|
|
color = COLOR_HEALTHY;
|
||
|
|
glowAlpha = 0.6 + 0.15 * Math.sin(this._pulsePhase * 0.3); // gentle breathing
|
||
|
|
pulseAmount = 0;
|
||
|
|
} else if (health === 'saturating') {
|
||
|
|
color = COLOR_SATURATING;
|
||
|
|
glowAlpha = 0.7;
|
||
|
|
pulseAmount = 0.25 * (0.5 + 0.5 * Math.sin(this._pulsePhase * 1.5)); // pulsing
|
||
|
|
} else {
|
||
|
|
// dead
|
||
|
|
color = COLOR_DEAD;
|
||
|
|
glowAlpha = 0.3;
|
||
|
|
pulseAmount = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
const alpha = glowAlpha + pulseAmount;
|
||
|
|
|
||
|
|
// Background
|
||
|
|
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
|
||
|
|
ctx.fillRect(x, y, width, height);
|
||
|
|
|
||
|
|
// Draw mini histogram bars
|
||
|
|
const barWidth = width / HISTOGRAM_BINS;
|
||
|
|
const maxBinVal = Math.max(...histogram, 0.01);
|
||
|
|
|
||
|
|
for (let i = 0; i < HISTOGRAM_BINS; i++) {
|
||
|
|
const barH = (histogram[i] / maxBinVal) * height;
|
||
|
|
const bx = x + i * barWidth;
|
||
|
|
const by = y + height - barH;
|
||
|
|
|
||
|
|
// Color gradient: first bins (low magnitude) lean toward dead color,
|
||
|
|
// last bins (high magnitude) lean toward saturating color
|
||
|
|
const t = i / (HISTOGRAM_BINS - 1);
|
||
|
|
let r, g, b;
|
||
|
|
if (t < 0.3) {
|
||
|
|
// Low magnitude — blend dead/healthy
|
||
|
|
const lt = t / 0.3;
|
||
|
|
r = COLOR_DEAD.r + (color.r - COLOR_DEAD.r) * lt;
|
||
|
|
g = COLOR_DEAD.g + (color.g - COLOR_DEAD.g) * lt;
|
||
|
|
b = COLOR_DEAD.b + (color.b - COLOR_DEAD.b) * lt;
|
||
|
|
} else if (t > 0.7) {
|
||
|
|
// High magnitude — blend toward saturating
|
||
|
|
const lt = (t - 0.7) / 0.3;
|
||
|
|
r = color.r + (COLOR_SATURATING.r - color.r) * lt;
|
||
|
|
g = color.g + (COLOR_SATURATING.g - color.g) * lt;
|
||
|
|
b = color.b + (COLOR_SATURATING.b - color.b) * lt;
|
||
|
|
} else {
|
||
|
|
r = color.r;
|
||
|
|
g = color.g;
|
||
|
|
b = color.b;
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha.toFixed(2)})`;
|
||
|
|
ctx.fillRect(bx, by, barWidth - 1, barH);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Top glow line
|
||
|
|
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.8).toFixed(2)})`;
|
||
|
|
ctx.fillRect(x, y, width, 1);
|
||
|
|
|
||
|
|
// Status label
|
||
|
|
ctx.font = '8px monospace';
|
||
|
|
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.9).toFixed(2)})`;
|
||
|
|
ctx.textBaseline = 'top';
|
||
|
|
ctx.textAlign = 'left';
|
||
|
|
|
||
|
|
const label = health === 'healthy'
|
||
|
|
? `W: ok`
|
||
|
|
: health === 'saturating'
|
||
|
|
? `W: sat ${(saturatedFraction * 100).toFixed(0)}%`
|
||
|
|
: `W: dead ${(deadFraction * 100).toFixed(0)}%`;
|
||
|
|
ctx.fillText(label, x + 2, y + 1);
|
||
|
|
}
|
||
|
|
}
|