/** * TrainingHealth — the advanced-surface answer to "is the network learning?". * * Every number here is read live out of the C++ core: * - the loss curve is `nisps::ml::MLPCore::loss_history` (one entry per SGD * iteration of the last training run), read through `nisps_ml_loss_history` * and published on the spine by both the sync and the worker train paths; * - the per-layer weight health is `nisps::ml::compute_layer_stats`, read * through the already-plumbed `nisps_ml_get_layer_stats`. * * NOTHING is synthesised. When the core has no history (nothing trained yet) * this renders a plain "no training run yet" line rather than a plausible * placeholder plot — that distinction is the entire point of this panel * (ALIGNMENT defect 6 / simplification-plan §6.5e). * * It is a component (not a plain render helper like its sibling drawer * sections) precisely so it can hold the engine hooks and re-read on version * bumps without dragging the whole Console into a re-render. * * Surfaced only at the Learning drawer's `expanded` depth — Manifold's existing * advanced-surface mechanism (`DrawerDepth`), not a new flag. */ import { useEngine, useEngineVersion } from '../engine'; const W = 320; const H = 64; function fmt(v: number, dp = 4): string { if (!Number.isFinite(v)) return '—'; return v.toFixed(dp); } function pct(v: number): string { if (!Number.isFinite(v)) return '—'; return `${(v * 100).toFixed(1)}%`; } const mono = { fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--fg-mute)', } as const; /** Per-iteration loss curve, log-scaled on y (loss spans orders of magnitude). */ function LossPlot({ history }: { history: ReadonlyArray }) { const n = history.length; // A single point has no curve to draw; the readout below still reports it. if (n < 2) return null; const logs = history.map((v) => Math.log10(Math.max(v, 1e-9))); let lo = Infinity; let hi = -Infinity; for (const l of logs) { if (l < lo) lo = l; if (l > hi) hi = l; } const span = hi - lo < 1e-6 ? 1 : hi - lo; const pts = logs .map((l, i) => { const x = (i / (n - 1)) * W; const y = H - ((l - lo) / span) * H; return `${x.toFixed(2)},${y.toFixed(2)}`; }) .join(' '); return ( ); } export function TrainingHealth() { const engine = useEngine(); // Re-read on every engine state change (training publishes a new history). useEngineVersion(engine); if (!engine) { return

engine not ready

; } const history = engine.lossHistory(); const stats = engine.getLayerStats(); const first = history.length ? history[0] : null; const last = history.length ? history[history.length - 1] : null; return (
{history.length === 0 ? (

no training run yet — the loss curve appears after the first fit

) : ( <>
{history.length} iter start {fmt(first ?? 0)} end {fmt(last ?? 0)} {first !== null && last !== null && last < first ? 'converging' : 'not improving'}
)} {stats.map((s, i) => ( ))}
layer mean|w| max|w| dead sat
L{i} {fmt(s.meanAbs, 3)} {fmt(s.maxAbs, 3)} {pct(s.deadFrac)} {pct(s.saturatingFrac)}

dead = |w| < 0.001, sat = |w| > 3 (nisps/ml/stats.hpp). A layer that is mostly dead or mostly saturating is not learning usefully.

); }