nnscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,856 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>nnscope</title>
7
+ <script>/*__NNSCOPE_BOOT__*/</script>
8
+ <style>
9
+ :root {
10
+ --bg: #f4f5f7; --card: #ffffff; --ink: #1d2330; --muted: #6b7280; --faint: #9aa1ab; --line: #e3e6ea;
11
+ --accent: #183d4e; --pos: #0877bd; --neg: #f59322; --mid: #e8eaeb; --bad: #d64545; --warn: #c7811a; --ok: #2f9e6e;
12
+ --chip: #eef1f4; --shadow: 0 1px 3px rgba(16,24,40,.08);
13
+ }
14
+ :root[data-theme="dark"] {
15
+ --bg: #0f1216; --card: #171b21; --ink: #e6e9ee; --muted: #9aa3ae; --faint: #6c7581; --line: #2a313b;
16
+ --accent: #7cc4ff; --mid: #2a3038; --chip: #222831; --shadow: none;
17
+ }
18
+ @media (prefers-color-scheme: dark) {
19
+ :root:not([data-theme="light"]) {
20
+ --bg: #0f1216; --card: #171b21; --ink: #e6e9ee; --muted: #9aa3ae; --faint: #6c7581; --line: #2a313b;
21
+ --accent: #7cc4ff; --mid: #2a3038; --chip: #222831; --shadow: none;
22
+ }
23
+ }
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; background: var(--bg); color: var(--ink);
26
+ font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
27
+ button, select { font: inherit; color: var(--ink); background: var(--chip); border: 1px solid var(--line); border-radius: 8px;
28
+ padding: 6px 10px; cursor: pointer; min-height: 36px; }
29
+ button:hover, select:hover { border-color: var(--faint); }
30
+ :focus-visible { outline: 2px solid var(--pos); outline-offset: 2px; }
31
+
32
+ header.top { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
33
+ padding: 10px 16px; background: var(--card); border-bottom: 1px solid var(--line); }
34
+ .brand { font-weight: 700; letter-spacing: .2px; }
35
+ .brand small { font-weight: 400; color: var(--muted); margin-left: 6px; }
36
+ .pill { font-size: 12px; padding: 2px 9px; border-radius: 999px; background: var(--chip); color: var(--muted); font-weight: 600; }
37
+ .pill.live { background: #e3f6ec; color: #17794b; } .pill.live::before { content: "● "; }
38
+ .pill.dead { background: #fde8e8; color: var(--bad); }
39
+ :root[data-theme="dark"] .pill.live { background: #173326; color: #6fe0a6; }
40
+ .spacer { flex: 1; }
41
+ .stepnow { color: var(--muted); font-variant-numeric: tabular-nums; }
42
+ .stepnow b { color: var(--ink); }
43
+ .menu { position: relative; }
44
+ .menu-list { position: absolute; right: 0; top: 42px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
45
+ box-shadow: 0 8px 24px rgba(0,0,0,.15); padding: 6px; display: none; min-width: 190px; z-index: 10; }
46
+ .menu-list.open { display: block; }
47
+ .menu-list button { display: block; width: 100%; text-align: left; border: 0; background: transparent; }
48
+ .menu-list button:hover { background: var(--chip); }
49
+
50
+ main.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; padding: 14px 16px 90px;
51
+ max-width: 1600px; margin: 0 auto; }
52
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow);
53
+ min-width: 0; }
54
+ .card.wide { grid-column: 1 / -1; }
55
+ .card.flash { animation: flash 1.2s; }
56
+ @keyframes flash { 0%, 60% { box-shadow: 0 0 0 3px var(--warn); } }
57
+ .card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
58
+ .card-head h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .7px; color: var(--muted); margin: 0; font-weight: 650; }
59
+ .card-head .ctrls { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
60
+ .help-btn { width: 26px; height: 26px; min-height: 26px; padding: 0; border-radius: 50%; font-weight: 700; color: var(--muted); }
61
+ .help { display: none; background: var(--chip); border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; font-size: 13.5px; color: var(--ink); }
62
+ .help.open { display: block; }
63
+ .help p { margin: 6px 0; }
64
+ .help .eq { font-family: "Cambria Math", "STIX Two Math", Georgia, serif; font-size: 15px; }
65
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
66
+ .seg button { border: 0; border-radius: 0; background: transparent; color: var(--muted); min-height: 32px; padding: 4px 10px; }
67
+ .seg button.on { background: var(--chip); color: var(--ink); font-weight: 600; }
68
+ canvas { display: block; width: 100%; }
69
+ .graphwrap { position: relative; }
70
+ .empty { color: var(--faint); font-size: 13px; padding: 20px 4px; text-align: center; }
71
+
72
+ .inspector { margin-top: 10px; border-top: 1px dashed var(--line); padding-top: 10px; display: none; }
73
+ .inspector.open { display: block; }
74
+ .inspector h3 { margin: 0 0 6px; font-size: 14px; }
75
+ .inspector .eq { font-family: "Cambria Math", "STIX Two Math", Georgia, serif; font-size: 15px; margin: 6px 0; overflow-x: auto; }
76
+ .inspector table { border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; margin-top: 6px; }
77
+ .inspector td, .inspector th { padding: 3px 10px 3px 0; text-align: right; }
78
+ .inspector th { color: var(--muted); font-weight: 600; }
79
+ .legend { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
80
+ .legend button { min-height: 28px; padding: 2px 8px; font-size: 12.5px; display: inline-flex; align-items: center; gap: 6px; }
81
+ .legend button.off { opacity: .4; }
82
+ .sw { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
83
+
84
+ .feed { list-style: none; margin: 0; padding: 0; max-height: 340px; overflow-y: auto; }
85
+ .feed li { padding: 8px 6px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13.5px; }
86
+ .feed li:hover { background: var(--chip); }
87
+ .feed .badge { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; margin-right: 6px; background: var(--chip); }
88
+ .feed .warn .badge { color: var(--warn); } .feed .error .badge { color: var(--bad); } .feed .info .badge { color: var(--pos); }
89
+
90
+ .health-row { display: grid; grid-template-columns: 92px repeat(3, minmax(0, 1fr)); gap: 8px; align-items: center; padding: 6px 0;
91
+ border-bottom: 1px solid var(--line); }
92
+ .health-row .nm { font-size: 12.5px; color: var(--muted); }
93
+ .health-row .nm b { color: var(--ink); display: block; }
94
+ .health-head { font-size: 11.5px; color: var(--faint); }
95
+ .hist-row { display: grid; grid-template-columns: 92px repeat(2, minmax(0, 1fr)); gap: 8px; align-items: center; padding: 4px 0; }
96
+ .act-row { padding: 6px 0; border-bottom: 1px solid var(--line); }
97
+ .act-row .nm { font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
98
+ .act-row .nm b { color: var(--ink); }
99
+ .act-row .nm .dead { color: var(--bad); font-weight: 600; }
100
+
101
+ footer.timeline { position: fixed; left: 0; right: 0; bottom: 0; z-index: 6; display: flex; align-items: center; gap: 10px;
102
+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); background: var(--card); border-top: 1px solid var(--line); }
103
+ footer input[type=range] { flex: 1; accent-color: var(--accent); height: 28px; }
104
+ footer label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; white-space: nowrap; }
105
+ #playBtn { width: 44px; }
106
+ #tip { position: fixed; pointer-events: none; z-index: 20; background: #111827; color: #fff; font-size: 12.5px; padding: 6px 9px;
107
+ border-radius: 6px; display: none; max-width: 280px; font-variant-numeric: tabular-nums; }
108
+
109
+ @media (max-width: 760px) {
110
+ main.grid { grid-template-columns: 1fr; padding: 10px 10px 96px; gap: 10px; }
111
+ header.top { padding: 8px 10px; gap: 8px; }
112
+ .brand small { display: none; }
113
+ .health-row { grid-template-columns: 70px repeat(3, minmax(0, 1fr)); }
114
+ .hist-row { grid-template-columns: 70px repeat(2, minmax(0, 1fr)); }
115
+ button, select { min-height: 40px; }
116
+ footer label span { display: none; }
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <header class="top">
122
+ <div class="brand">nnscope <small id="runName"></small></div>
123
+ <span id="status" class="pill">loading</span>
124
+ <span class="stepnow">step <b id="stepNow">–</b></span>
125
+ <div class="spacer"></div>
126
+ <div class="menu">
127
+ <button id="exportBtn" aria-haspopup="true" aria-expanded="false">Export ▾</button>
128
+ <div class="menu-list" id="exportMenu" role="menu">
129
+ <button data-x="net-svg" role="menuitem">Network (SVG)</button>
130
+ <button data-x="net-png" role="menuitem">Network (PNG)</button>
131
+ <button data-x="loss-png" role="menuitem">Loss curve (PNG)</button>
132
+ <button data-x="dash-png" role="menuitem">Whole dashboard (PNG)</button>
133
+ <button data-x="json" role="menuitem">Run data (JSON)</button>
134
+ </div>
135
+ </div>
136
+ <button id="themeBtn" title="Toggle light/dark" aria-label="Toggle theme">◐</button>
137
+ </header>
138
+
139
+ <main class="grid">
140
+ <section class="card wide" id="card-graph" data-title="Network">
141
+ <div class="card-head"><h2>Network</h2>
142
+ <div class="ctrls">
143
+ <div class="seg" id="edgeSeg" aria-label="Edges show">
144
+ <button data-v="w" class="on">weights</button><button data-v="g">gradients</button><button data-v="u">updates</button></div>
145
+ <div class="seg" id="nodeSeg" aria-label="Nodes show">
146
+ <button data-v="mean" class="on">mean act.</button><button data-v="frac">% active</button></div>
147
+ <select id="topk" aria-label="Edges per neuron"><option value="0">all edges</option><option value="8">top 8</option>
148
+ <option value="4">top 4</option><option value="2">top 2</option></select>
149
+ <button class="help-btn" data-help="help-graph" aria-label="What am I seeing?">?</button></div></div>
150
+ <div class="help" id="help-graph">
151
+ <p><b>Circles are neurons, lines are weights.</b> Column k shows layer k's outputs. The line from neuron i to neuron j is the
152
+ weight w<sub>ij</sub>: <span style="color:var(--pos)">blue</span> is positive, <span style="color:var(--neg)">orange</span> is
153
+ negative, and thickness is |w|. Forward pass for neuron j: <span class="eq">z<sub>j</sub> = Σ<sub>i</sub> w<sub>ij</sub> a<sub>i</sub> + b<sub>j</sub>, a<sub>j</sub> = f(z<sub>j</sub>)</span>.</p>
154
+ <p><b>gradients</b> shows <span class="eq">∂L/∂w<sub>ij</sub> = mean over the batch of δ<sub>j</sub>·a<sub>i</sub></span>, the direction
155
+ backprop pushes each weight (SGD moves it the opposite way). <b>updates</b> shows how much each weight changed since the previous
156
+ snapshot. Node colour is the neuron's mean activation on the logged batch, or its % active (a ReLU that is never active is
157
+ <b style="color:var(--bad)">dead</b>, ringed in red). Click a neuron to inspect it.</p>
158
+ <p>Large layers draw at most 32 evenly spaced neurons; all statistics still use every neuron.</p></div>
159
+ <div class="graphwrap"><canvas id="graph" height="420"></canvas></div>
160
+ <div class="inspector" id="inspector"></div>
161
+ </section>
162
+
163
+ <section class="card" id="card-loss" data-title="Loss & metrics">
164
+ <div class="card-head"><h2>Loss &amp; metrics</h2>
165
+ <div class="ctrls"><div class="seg" id="scaleSeg"><button data-v="lin">linear</button><button data-v="log" class="on">log</button></div>
166
+ <button class="help-btn" data-help="help-loss" aria-label="What am I seeing?">?</button></div></div>
167
+ <div class="help" id="help-loss">
168
+ <p>Every scalar you log (<code>loss=</code>, <code>metrics={...}</code>) over training steps. The vertical line marks the step
169
+ selected on the timeline.</p>
170
+ <p><b>What to look for:</b> training loss should fall steadily. Bumps and spikes mean the learning rate is too high. If the test/validation loss
171
+ turns upward while the training loss keeps falling, the network is <b>overfitting</b>: it memorises the training set instead of generalising.</p></div>
172
+ <canvas id="loss" height="240"></canvas>
173
+ <div class="legend" id="lossLegend"></div>
174
+ </section>
175
+
176
+ <section class="card" id="card-insights" data-title="Insights">
177
+ <div class="card-head"><h2>Insights</h2>
178
+ <div class="ctrls"><button class="help-btn" data-help="help-ins" aria-label="What am I seeing?">?</button></div></div>
179
+ <div class="help" id="help-ins"><p>Automatic, plain-language diagnoses of what the numbers mean: vanishing or exploding gradients,
180
+ dead or saturated units, a learning rate that is too high or too low, overfitting. Each fires once, at the step where it first held.
181
+ Click one to jump there.</p></div>
182
+ <ul class="feed" id="feed"></ul>
183
+ <div class="empty" id="feedEmpty">No issues detected so far.</div>
184
+ </section>
185
+
186
+ <section class="card" id="card-flow" data-title="Gradient flow">
187
+ <div class="card-head"><h2>Gradient flow</h2>
188
+ <div class="ctrls"><button class="help-btn" data-help="help-flow" aria-label="What am I seeing?">?</button></div></div>
189
+ <div class="help" id="help-flow">
190
+ <p>Mean |∂L/∂W| for each layer at the selected step (log scale), from the input side to the output side. Backprop computes these from the
191
+ output backwards: <span class="eq">δ<sup>ℓ</sup> = (W<sup>ℓ+1</sup> δ<sup>ℓ+1</sup>) ⊙ f′(z<sup>ℓ</sup>)</span>, so each layer multiplies
192
+ the signal by its weights and by f′.</p>
193
+ <p><b>Healthy:</b> bars of similar height. <b>Vanishing:</b> bars shrink towards the input (a sigmoid's f′ ≤ 0.25 per layer).
194
+ <b>Exploding:</b> bars grow towards the input.</p></div>
195
+ <canvas id="flow" height="200"></canvas>
196
+ </section>
197
+
198
+ <section class="card" id="card-health" data-title="Layer health">
199
+ <div class="card-head"><h2>Layer health</h2>
200
+ <div class="ctrls"><button class="help-btn" data-help="help-health" aria-label="What am I seeing?">?</button></div></div>
201
+ <div class="help" id="help-health">
202
+ <p>Per layer over time: the weight norm ‖W‖, the gradient norm ‖∇W‖ and the <b>update ratio</b> ‖ΔW‖/‖W‖ (how much the weights changed
203
+ since the previous log, relative to their size). Below: histograms of W and ∇W at the selected step.</p>
204
+ <p><b>Rule of thumb:</b> an update ratio around 10<sup>−3</sup> is healthy. Around 10<sup>−1</sup> the learning rate is too high; below 10<sup>−6</sup> nothing
205
+ is learning. A histogram of W that keeps widening means the weights are growing (consider weight decay).</p></div>
206
+ <div id="health"></div>
207
+ </section>
208
+
209
+ <section class="card" id="card-acts" data-title="Activations">
210
+ <div class="card-head"><h2>Activations</h2>
211
+ <div class="ctrls"><button class="help-btn" data-help="help-acts" aria-label="What am I seeing?">?</button></div></div>
212
+ <div class="help" id="help-acts">
213
+ <p>For each hidden layer, one cell per drawn neuron: the top strip is its <b>mean activation</b> on the logged batch, the bottom strip the
214
+ <b>fraction of samples where it is active</b> (ReLU: output &gt; 0; tanh/sigmoid: not saturated).</p>
215
+ <p>A neuron with 0 % active passes no gradient back, so it has stopped learning (<b style="color:var(--bad)">dead ReLU</b> or
216
+ <b style="color:var(--bad)">saturated</b> unit). Activation stats need activations: they are read automatically from layers that cache
217
+ their input (e.g. <code>Linear.x</code>), or pass <code>activations=[a1, a2, …]</code>.</p></div>
218
+ <div id="acts"></div>
219
+ </section>
220
+
221
+ <section class="card" id="card-latent" data-title="Latent space">
222
+ <div class="card-head"><h2>Latent space</h2>
223
+ <div class="ctrls"><button class="help-btn" data-help="help-latent" aria-label="What am I seeing?">?</button></div></div>
224
+ <div class="help" id="help-latent">
225
+ <p>The features of the layer you pass as <code>latent=Z</code>, one dot per sample, coloured by <code>labels=</code>. With more than 2
226
+ dimensions they are projected onto their first two principal components (PCA); the axis titles show how much variance each keeps.</p>
227
+ <p>Scrub the timeline to watch training <b>reshape the representation</b>: the classes usually start mixed and end up (linearly)
228
+ separable, because the output layer is linear in these features.</p></div>
229
+ <canvas id="latent" height="300"></canvas>
230
+ <div class="empty" id="latentEmpty">Pass <code>latent=Z, labels=y</code> to <code>scope.log(...)</code> to see this view.</div>
231
+ </section>
232
+ </main>
233
+
234
+ <footer class="timeline" aria-label="Timeline">
235
+ <button id="playBtn" aria-label="Play replay">▶</button>
236
+ <input type="range" id="slider" min="0" max="0" value="0" aria-label="Snapshot">
237
+ <label id="followWrap"><input type="checkbox" id="follow" checked><span>follow live</span></label>
238
+ </footer>
239
+ <div id="tip" role="tooltip"></div>
240
+
241
+ <script>
242
+ "use strict";
243
+ // ======================================================================= state
244
+ const BOOT = window.NNSCOPE || { live: false, data: null };
245
+ const D = { name: "run", structure: null, records: [], snapshots: [], insights: [] };
246
+ const UI = { cur: 0, follow: true, edge: "w", node: "mean", topk: 0, logScale: true, hidden: new Set(), playing: false,
247
+ selected: null, status: "loading" };
248
+ const $ = (id) => document.getElementById(id);
249
+ const PALETTE = ["#f59322", "#0877bd", "#2ca02c", "#9467bd", "#d62728", "#8c564b", "#e377c2", "#17becf"];
250
+ const SERIES = ["#1d2330", "#f59322", "#0877bd", "#2ca02c", "#9467bd", "#d62728", "#8c564b", "#17becf"];
251
+
252
+ function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
253
+ function isDark() { return css("--bg").toLowerCase() !== "#f4f5f7"; }
254
+
255
+ // diverging colour: orange (−1) … neutral (0) … blue (+1)
256
+ function rgbOf(v) {
257
+ const t = Math.max(-1, Math.min(1, v)), c = t < 0 ? [245, 147, 34] : [8, 119, 189];
258
+ const mid = isDark() ? [42, 48, 56] : [232, 234, 235], a = Math.abs(t);
259
+ return `rgb(${mid.map((m, i) => Math.round(m + (c[i] - m) * a)).join(",")})`;
260
+ }
261
+ function fmt(v, d = 3) {
262
+ if (v === null || v === undefined || !isFinite(v)) return "–";
263
+ const a = Math.abs(v);
264
+ return a !== 0 && (a < 1e-3 || a >= 1e4) ? v.toExponential(2) : (+v.toFixed(d)).toString();
265
+ }
266
+
267
+ // ======================================================================= data intake
268
+ function reset(structure) {
269
+ D.structure = structure; D.records = []; D.snapshots = []; D.insights = []; UI.cur = 0; UI.selected = null;
270
+ }
271
+ function load(data) {
272
+ if (!data) return;
273
+ D.name = data.name || "run";
274
+ D.structure = data.structure; D.records = data.records || []; D.snapshots = data.snapshots || [];
275
+ D.insights = data.insights || [];
276
+ UI.cur = Math.max(0, D.snapshots.length - 1);
277
+ }
278
+ function handle(msg) {
279
+ if (msg.type === "structure") { reset(msg.structure); }
280
+ else if (msg.type === "record") D.records.push(msg);
281
+ else if (msg.type === "snapshot") { D.snapshots.push(msg); if (UI.follow) UI.cur = D.snapshots.length - 1; }
282
+ else if (msg.type === "insight") D.insights.push(msg);
283
+ else if (msg.type === "end") setStatus("finished");
284
+ }
285
+ function setStatus(s) {
286
+ UI.status = s;
287
+ const el = $("status");
288
+ el.textContent = { live: "LIVE", replay: "REPLAY", finished: "FINISHED", disconnected: "DISCONNECTED", loading: "LOADING",
289
+ waiting: "WAITING FOR DATA" }[s] || s;
290
+ el.className = "pill" + (s === "live" ? " live" : s === "disconnected" ? " dead" : "");
291
+ $("followWrap").style.display = s === "live" ? "" : "none";
292
+ }
293
+
294
+ async function boot() {
295
+ $("runName").textContent = "";
296
+ if (!BOOT.live) { load(BOOT.data); setStatus("replay"); render(); return; }
297
+ const sync = async () => {
298
+ try { load(await (await fetch("api/history")).json()); } catch (e) { /* server gone */ }
299
+ setStatus(D.records.length ? "live" : "waiting"); render();
300
+ };
301
+ await sync();
302
+ const es = new EventSource("api/stream");
303
+ let broken = false;
304
+ es.onmessage = (ev) => {
305
+ for (const m of JSON.parse(ev.data)) handle(m);
306
+ if (UI.status !== "finished") setStatus("live");
307
+ render();
308
+ };
309
+ es.onerror = () => { if (UI.status !== "finished") setStatus("disconnected"); broken = true; };
310
+ es.onopen = () => { if (broken) { broken = false; sync(); } };
311
+ }
312
+
313
+ // ======================================================================= current position
314
+ function curSnap() { return D.snapshots[Math.min(UI.cur, D.snapshots.length - 1)] || null; }
315
+ function prevSnap() { return UI.cur > 0 ? D.snapshots[UI.cur - 1] : null; }
316
+ function curRecord() {
317
+ const s = curSnap();
318
+ if (!D.records.length) return null;
319
+ if (!s) return D.records[D.records.length - 1];
320
+ let best = D.records[0];
321
+ for (const r of D.records) { if (r.step <= s.step) best = r; else break; }
322
+ return best;
323
+ }
324
+
325
+ // ======================================================================= render scheduling
326
+ let pending = false;
327
+ function render() {
328
+ if (pending) return;
329
+ pending = true;
330
+ requestAnimationFrame(() => { pending = false; draw(); });
331
+ }
332
+ function draw() {
333
+ $("runName").textContent = D.name;
334
+ const s = curSnap(), rec = curRecord();
335
+ $("stepNow").textContent = s ? s.step : rec ? rec.step : "–";
336
+ const sl = $("slider");
337
+ sl.max = Math.max(0, D.snapshots.length - 1); sl.value = UI.cur;
338
+ drawGraph(); drawLoss(); drawFeed(); drawFlow(); drawHealth(); drawActs(); drawLatent(); drawInspector();
339
+ }
340
+
341
+ // ======================================================================= canvas helpers
342
+ function setupCanvas(cv, cssHeight) {
343
+ const dpr = window.devicePixelRatio || 1, w = cv.clientWidth || cv.parentElement.clientWidth || 300;
344
+ if (cssHeight) cv.style.height = cssHeight + "px";
345
+ const h = cv.clientHeight || cssHeight || 200;
346
+ if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
347
+ const ctx = cv.getContext("2d");
348
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
349
+ ctx.clearRect(0, 0, w, h);
350
+ return { ctx, w, h };
351
+ }
352
+ const tip = $("tip");
353
+ function showTip(x, y, html) {
354
+ tip.innerHTML = html; tip.style.display = "block";
355
+ const r = tip.getBoundingClientRect();
356
+ tip.style.left = Math.min(x + 14, window.innerWidth - r.width - 8) + "px";
357
+ tip.style.top = Math.min(y + 12, window.innerHeight - r.height - 8) + "px";
358
+ }
359
+ function hideTip() { tip.style.display = "none"; }
360
+
361
+ // ======================================================================= controls
362
+ function seg(id, key, after) {
363
+ $(id).querySelectorAll("button").forEach((b) => b.addEventListener("click", () => {
364
+ $(id).querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b));
365
+ UI[key] = b.dataset.v; if (after) after(); render();
366
+ }));
367
+ }
368
+ seg("edgeSeg", "edge"); seg("nodeSeg", "node");
369
+ seg("scaleSeg", "scale", () => { UI.logScale = document.querySelector("#scaleSeg .on").dataset.v === "log"; });
370
+ $("topk").addEventListener("change", (e) => { UI.topk = +e.target.value; render(); });
371
+ document.querySelectorAll(".help-btn").forEach((b) => b.addEventListener("click", () => {
372
+ const h = $(b.dataset.help); h.classList.toggle("open"); b.setAttribute("aria-expanded", h.classList.contains("open"));
373
+ }));
374
+ $("slider").addEventListener("input", (e) => {
375
+ UI.cur = +e.target.value; UI.follow = UI.cur === D.snapshots.length - 1 && UI.follow; $("follow").checked = UI.follow; render();
376
+ });
377
+ $("follow").addEventListener("change", (e) => { UI.follow = e.target.checked; if (UI.follow) UI.cur = D.snapshots.length - 1; render(); });
378
+ let playTimer = null;
379
+ $("playBtn").addEventListener("click", () => togglePlay());
380
+ function togglePlay(force) {
381
+ UI.playing = force !== undefined ? force : !UI.playing;
382
+ $("playBtn").textContent = UI.playing ? "❚❚" : "▶";
383
+ clearInterval(playTimer);
384
+ if (UI.playing) {
385
+ if (UI.cur >= D.snapshots.length - 1) UI.cur = 0;
386
+ UI.follow = false; $("follow").checked = false;
387
+ playTimer = setInterval(() => {
388
+ if (UI.cur >= D.snapshots.length - 1) { togglePlay(false); return; }
389
+ UI.cur++; render();
390
+ }, Math.max(40, 6000 / Math.max(1, D.snapshots.length)));
391
+ }
392
+ }
393
+ document.addEventListener("keydown", (e) => {
394
+ if (["INPUT", "SELECT", "TEXTAREA"].includes(e.target.tagName) && e.target.type !== "range") return;
395
+ if (e.code === "Space") { e.preventDefault(); togglePlay(); }
396
+ else if (e.key === "ArrowLeft") { UI.cur = Math.max(0, UI.cur - 1); UI.follow = false; render(); }
397
+ else if (e.key === "ArrowRight") { UI.cur = Math.min(D.snapshots.length - 1, UI.cur + 1); render(); }
398
+ });
399
+ $("themeBtn").addEventListener("click", () => {
400
+ const root = document.documentElement, dark = isDark();
401
+ root.setAttribute("data-theme", dark ? "light" : "dark");
402
+ try { localStorage.setItem("nnscope-theme", root.getAttribute("data-theme")); } catch (e) { /* storage blocked */ }
403
+ render();
404
+ });
405
+ try { const t = localStorage.getItem("nnscope-theme"); if (t) document.documentElement.setAttribute("data-theme", t); } catch (e) { /* ignore */ }
406
+ $("exportBtn").addEventListener("click", (e) => {
407
+ e.stopPropagation(); const m = $("exportMenu"); m.classList.toggle("open");
408
+ $("exportBtn").setAttribute("aria-expanded", m.classList.contains("open"));
409
+ });
410
+ document.addEventListener("click", () => $("exportMenu").classList.remove("open"));
411
+ $("exportMenu").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => doExport(b.dataset.x)));
412
+ let resizeT;
413
+ new ResizeObserver(() => { clearTimeout(resizeT); resizeT = setTimeout(render, 60); }).observe(document.querySelector("main"));
414
+
415
+ // ======================================================================= network graph
416
+ const G = { pos: [], vertical: false };
417
+ function layerLabel(c) {
418
+ const L = D.structure.layers;
419
+ if (c === 0) return `input · ${L[0].in}`;
420
+ const l = L[c - 1], hidden = l.out - l.shown_out.length;
421
+ return `${c === L.length ? "output" : "layer " + c} · ${l.out}${l.act ? " " + l.act : ""}${hidden > 0 ? ` (+${hidden})` : ""}`;
422
+ }
423
+ function graphLayout(w, h, vertical) {
424
+ const L = D.structure.layers, cols = [L[0].shown_in, ...L.map((l) => l.shown_out)], n = cols.length;
425
+ return cols.map((shown, c) => {
426
+ const k = shown.length;
427
+ if (!vertical) {
428
+ const x = 70 + (w - 140) * c / Math.max(1, n - 1), gap = Math.min(28, (h - 70) / Math.max(k, 1));
429
+ const top = 40 + (h - 50) / 2 - gap * (k - 1) / 2;
430
+ return shown.map((_, j) => [x, top + gap * j]);
431
+ }
432
+ const y = 50 + (h - 90) * c / Math.max(1, n - 1), gap = Math.min(28, (w - 30) / Math.max(k, 1));
433
+ const left = w / 2 - gap * (k - 1) / 2;
434
+ return shown.map((_, j) => [left + gap * j, y]);
435
+ });
436
+ }
437
+ function edgeValues() {
438
+ const s = curSnap(), p = prevSnap();
439
+ if (!s) return null;
440
+ if (UI.edge === "g") return s.G;
441
+ if (UI.edge === "u") return s.W.map((Wl, l) => Wl.map((row, i) => row.map((v, j) => (p && p.W[l] ? v - p.W[l][i][j] : 0))));
442
+ return s.W;
443
+ }
444
+ function keptEdges(M) { // top-k incoming edges per target neuron (by |value|)
445
+ if (!UI.topk || !M.length) return null;
446
+ const keep = M.map((row) => row.map(() => false));
447
+ for (let j = 0; j < M[0].length; j++) {
448
+ const col = M.map((row, i) => [Math.abs(row[j]), i]).sort((a, b) => b[0] - a[0]).slice(0, UI.topk);
449
+ for (const [, i] of col) keep[i][j] = true;
450
+ }
451
+ return keep;
452
+ }
453
+ function nodeFill(c, j, s) {
454
+ if (c === 0 || !s || !s.nodes[c - 1]) return isDark() ? "#3a424d" : "#ffffff";
455
+ const nd = s.nodes[c - 1];
456
+ if (UI.node === "frac") return rgbOf(nd.frac[j]);
457
+ const m = Math.max(1e-9, ...nd.mean.map(Math.abs));
458
+ return rgbOf(nd.mean[j] / m);
459
+ }
460
+ function drawGraph() {
461
+ const cv = $("graph");
462
+ if (!D.structure) { setupCanvas(cv, 120).ctx.fillText("", 0, 0); return; }
463
+ const w = cv.parentElement.clientWidth, vertical = w < 640;
464
+ const cols = D.structure.layers.length + 1, maxN = Math.max(...[D.structure.layers[0].shown_in.length, ...D.structure.layers.map((l) => l.shown_out.length)]);
465
+ const H = vertical ? Math.max(360, cols * 120) : Math.max(300, Math.min(900, maxN * 26 + 90));
466
+ const { ctx, h } = setupCanvas(cv, H);
467
+ G.vertical = vertical; G.pos = graphLayout(w, h, vertical);
468
+ const s = curSnap(), vals = edgeValues();
469
+ ctx.lineCap = "round";
470
+ if (vals) {
471
+ let m = 1e-12;
472
+ for (const M of vals) if (M) for (const row of M) for (const v of row) m = Math.max(m, Math.abs(v));
473
+ vals.forEach((M, l) => {
474
+ if (!M) return;
475
+ const keep = keptEdges(M);
476
+ M.forEach((row, i) => row.forEach((v, j) => {
477
+ if (keep && !keep[i][j]) return;
478
+ const [x1, y1] = G.pos[l][i], [x2, y2] = G.pos[l + 1][j], a = Math.abs(v) / m;
479
+ if (a < 0.02 && !keep) return;
480
+ ctx.strokeStyle = (v >= 0 ? "rgba(8,119,189," : "rgba(245,147,34,") + (0.15 + 0.85 * a).toFixed(2) + ")";
481
+ ctx.lineWidth = 0.4 + 4 * a;
482
+ ctx.beginPath(); ctx.moveTo(x1, y1);
483
+ if (!vertical) { const xm = (x1 + x2) / 2; ctx.bezierCurveTo(xm, y1, xm, y2, x2, y2); }
484
+ else { const ym = (y1 + y2) / 2; ctx.bezierCurveTo(x1, ym, x2, ym, x2, y2); }
485
+ ctx.stroke();
486
+ }));
487
+ });
488
+ if (UI.edge === "g" && vals.every((M) => !M)) {
489
+ ctx.fillStyle = css("--faint"); ctx.font = "13px system-ui"; ctx.textAlign = "center";
490
+ ctx.fillText("No gradients logged: pass grads=[...] or a model whose layers store dW.", w / 2, h / 2);
491
+ }
492
+ }
493
+ const r = Math.max(3, Math.min(8, (vertical ? (w - 30) : (h - 70)) / (maxN * 2.6)));
494
+ G.r = r;
495
+ G.pos.forEach((col, c) => {
496
+ col.forEach(([x, y], j) => {
497
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI);
498
+ ctx.fillStyle = nodeFill(c, j, s); ctx.fill();
499
+ const dead = c > 0 && s && s.nodes[c - 1] && s.nodes[c - 1].frac[j] === 0;
500
+ ctx.lineWidth = dead ? 2.4 : (UI.selected && UI.selected.c === c && UI.selected.j === j ? 3 : 1);
501
+ ctx.strokeStyle = dead ? css("--bad") : (UI.selected && UI.selected.c === c && UI.selected.j === j ? css("--ink") : css("--faint"));
502
+ ctx.stroke();
503
+ });
504
+ ctx.fillStyle = css("--muted"); ctx.font = "12px system-ui";
505
+ const [x0, y0] = col[0] || [0, 0];
506
+ if (!vertical) { ctx.textAlign = "center"; ctx.fillText(layerLabel(c), x0, 18); }
507
+ else { ctx.textAlign = "left"; ctx.fillText(layerLabel(c), 8, y0 - r - 8); }
508
+ });
509
+ }
510
+ function hitNode(ev) {
511
+ const cv = $("graph"), rect = cv.getBoundingClientRect(), x = ev.clientX - rect.left, y = ev.clientY - rect.top;
512
+ let best = null, bd = 1e9;
513
+ G.pos.forEach((col, c) => col.forEach(([px, py], j) => { const d = Math.hypot(px - x, py - y); if (d < bd) { bd = d; best = { c, j }; } }));
514
+ return bd <= Math.max(12, G.r + 6) ? best : null;
515
+ }
516
+ $("graph").addEventListener("mousemove", (ev) => {
517
+ const hn = D.structure && hitNode(ev);
518
+ if (!hn) { hideTip(); $("graph").style.cursor = "default"; return; }
519
+ $("graph").style.cursor = "pointer";
520
+ const s = curSnap(), L = D.structure.layers, c = hn.c, j = hn.j;
521
+ if (c === 0) { showTip(ev.clientX, ev.clientY, `input feature ${L[0].shown_in[j]}`); return; }
522
+ const nd = s && s.nodes[c - 1], b = s && s.B[c - 1];
523
+ showTip(ev.clientX, ev.clientY, `<b>${c === L.length ? "output" : "layer " + c}</b> · neuron ${L[c - 1].shown_out[j]}<br>` +
524
+ (nd ? `mean a = ${fmt(nd.mean[j])} · active ${Math.round(nd.frac[j] * 100)}%<br>` : "") +
525
+ (b ? `bias b = ${fmt(b[j])}` : "") + "<br><i>click to inspect</i>");
526
+ });
527
+ $("graph").addEventListener("mouseleave", hideTip);
528
+ $("graph").addEventListener("click", (ev) => {
529
+ const hn = D.structure && hitNode(ev);
530
+ UI.selected = hn && hn.c > 0 ? hn : null; hideTip(); render();
531
+ });
532
+
533
+ // ======================================================================= neuron inspector
534
+ function drawInspector() {
535
+ const el = $("inspector"), sel = UI.selected, s = curSnap();
536
+ if (!sel || !s || !D.structure) { el.classList.remove("open"); el.innerHTML = ""; return; }
537
+ const L = D.structure.layers, l = sel.c - 1, j = sel.j, lay = L[l], p = prevSnap();
538
+ const W = s.W[l], Gm = s.G[l], b = s.B[l] ? s.B[l][j] : 0, prevNodes = l > 0 ? s.nodes[l - 1] : null, nd = s.nodes[l];
539
+ const rows = W.map((row, i) => {
540
+ const a = prevNodes ? prevNodes.mean[i] : null, w = row[j];
541
+ return { i: lay.shown_in[i], w, a, wa: a === null ? null : w * a, g: Gm ? Gm[i][j] : null,
542
+ dw: p && p.W[l] ? w - p.W[l][i][j] : null };
543
+ });
544
+ rows.sort((x, y) => Math.abs(y.wa ?? y.w) - Math.abs(x.wa ?? x.w));
545
+ const partial = lay.shown_in.length < lay.in;
546
+ const zbar = prevNodes ? rows.reduce((acc, r) => acc + r.wa, 0) + b : null;
547
+ const actName = lay.act || "linear";
548
+ el.innerHTML = `<h3>${sel.c === L.length ? "Output" : "Layer " + sel.c} · neuron ${lay.shown_out[j]} <span class="pill">${actName}</span>
549
+ <button id="closeInsp" style="float:right;min-height:28px;padding:2px 10px">close</button></h3>
550
+ <div class="eq">z<sub>j</sub> = Σ<sub>i</sub> w<sub>ij</sub> a<sub>i</sub> + b<sub>j</sub> ${prevNodes ? `≈ Σ w·ā + b = <b>${fmt(zbar)}</b>` : ""}
551
+ &nbsp;→&nbsp; a<sub>j</sub> = ${actName}(z<sub>j</sub>)${nd ? `; logged mean ā<sub>j</sub> = <b>${fmt(nd.mean[j])}</b>, active ${Math.round(nd.frac[j] * 100)}%` : ""}</div>
552
+ <div class="eq">∂L/∂w<sub>ij</sub> = mean<sub>n</sub>(δ<sub>j</sub> · a<sub>i</sub>)&nbsp; and SGD does w<sub>ij</sub> ← w<sub>ij</sub> − η ∂L/∂w<sub>ij</sub></div>
553
+ <div style="color:var(--muted);font-size:12.5px">Bias b = ${fmt(b)}${s.GB[l] ? `, ∂L/∂b = ${fmt(s.GB[l][j])}` : ""}.
554
+ ${prevNodes ? "ā<sub>i</sub> is the mean activation of input neuron i on the logged batch." : "This layer's inputs are the network inputs; their values are not logged."}
555
+ ${partial ? ` Sum over the ${lay.shown_in.length} drawn inputs of ${lay.in}.` : ""}
556
+ ${actName !== "linear" && prevNodes ? " For a nonlinear f the mean of f(z) is not f(mean z), so compare the two numbers only roughly." : ""}</div>
557
+ <table><thead><tr><th>input i</th><th>w<sub>ij</sub></th><th>ā<sub>i</sub></th><th>w·ā</th><th>∂L/∂w</th><th>Δw since last</th></tr></thead>
558
+ <tbody>${rows.slice(0, 10).map((r) => `<tr><td>${r.i}</td><td>${fmt(r.w)}</td><td>${fmt(r.a)}</td><td>${fmt(r.wa)}</td>
559
+ <td>${fmt(r.g)}</td><td>${fmt(r.dw)}</td></tr>`).join("")}</tbody></table>
560
+ <div style="color:var(--faint);font-size:12px">Showing the ${Math.min(10, rows.length)} strongest of ${rows.length} drawn inputs.</div>`;
561
+ el.classList.add("open");
562
+ $("closeInsp").onclick = () => { UI.selected = null; render(); };
563
+ }
564
+
565
+ // ======================================================================= loss & metrics
566
+ function seriesData() {
567
+ const out = {};
568
+ for (const r of D.records) for (const [k, v] of Object.entries(r.scalars)) {
569
+ if (v === null || !isFinite(v)) continue;
570
+ (out[k] = out[k] || []).push([r.step, v]);
571
+ }
572
+ return out;
573
+ }
574
+ function drawLoss() {
575
+ const cv = $("loss"), { ctx, w, h } = setupCanvas(cv, 240), S = seriesData(), names = Object.keys(S);
576
+ const leg = $("lossLegend");
577
+ if (leg.dataset.names !== names.join("|")) {
578
+ leg.dataset.names = names.join("|");
579
+ leg.innerHTML = names.map((n, k) => `<button data-n="${n}"><span class="sw" style="background:${k ? SERIES[k % SERIES.length] : css("--ink")}"></span>${n}</button>`).join("");
580
+ leg.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => {
581
+ UI.hidden.has(b.dataset.n) ? UI.hidden.delete(b.dataset.n) : UI.hidden.add(b.dataset.n);
582
+ b.classList.toggle("off"); render();
583
+ }));
584
+ }
585
+ if (!names.length) { ctx.fillStyle = css("--faint"); ctx.font = "13px system-ui"; ctx.textAlign = "center"; ctx.fillText("waiting for scope.log(..., loss=...)", w / 2, h / 2); return; }
586
+ const shown = names.filter((n) => !UI.hidden.has(n));
587
+ const all = shown.flatMap((n) => S[n]);
588
+ if (!all.length) return;
589
+ const useLog = UI.logScale && all.every(([, v]) => v > 0);
590
+ const f = useLog ? Math.log10 : (v) => v;
591
+ let lo = Math.min(...all.map(([, v]) => f(v))), hi = Math.max(...all.map(([, v]) => f(v)));
592
+ if (hi - lo < 1e-9) { lo -= 0.5; hi += 0.5; }
593
+ const s0 = Math.min(...all.map(([s]) => s)), s1 = Math.max(s0 + 1, ...all.map(([s]) => s));
594
+ const P = { l: 44, r: 10, t: 8, b: 22 };
595
+ const X = (s) => P.l + (w - P.l - P.r) * (s - s0) / (s1 - s0), Y = (v) => P.t + (h - P.t - P.b) * (1 - (f(v) - lo) / (hi - lo));
596
+ ctx.strokeStyle = css("--line"); ctx.fillStyle = css("--faint"); ctx.font = "11px system-ui"; ctx.lineWidth = 1;
597
+ for (let k = 0; k <= 4; k++) {
598
+ const yv = lo + (hi - lo) * k / 4, y = P.t + (h - P.t - P.b) * (1 - k / 4);
599
+ ctx.beginPath(); ctx.moveTo(P.l, y); ctx.lineTo(w - P.r, y); ctx.stroke();
600
+ ctx.textAlign = "right"; ctx.fillText(fmt(useLog ? 10 ** yv : yv, 3), P.l - 4, y + 4);
601
+ }
602
+ ctx.textAlign = "left"; ctx.fillText(s0, P.l, h - 6); ctx.textAlign = "right"; ctx.fillText(s1, w - P.r, h - 6);
603
+ names.forEach((n, k) => {
604
+ if (UI.hidden.has(n)) return;
605
+ ctx.strokeStyle = k ? SERIES[k % SERIES.length] : css("--ink"); ctx.lineWidth = 1.8; ctx.beginPath();
606
+ S[n].forEach(([s, v], i) => (i ? ctx.lineTo(X(s), Y(v)) : ctx.moveTo(X(s), Y(v)))); ctx.stroke();
607
+ });
608
+ const cs = curSnap();
609
+ if (cs) { ctx.strokeStyle = css("--warn"); ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(X(cs.step), P.t); ctx.lineTo(X(cs.step), h - P.b); ctx.stroke(); ctx.setLineDash([]); }
610
+ cv.onmousemove = (ev) => {
611
+ const rect = cv.getBoundingClientRect(), s = s0 + (ev.clientX - rect.left - P.l) / (w - P.l - P.r) * (s1 - s0);
612
+ let rec = null, bd = Infinity;
613
+ for (const r of D.records) { const d = Math.abs(r.step - s); if (d < bd) { bd = d; rec = r; } }
614
+ if (!rec) return;
615
+ showTip(ev.clientX, ev.clientY, `step <b>${rec.step}</b><br>` + Object.entries(rec.scalars).map(([k, v]) => `${k}: ${fmt(v, 5)}`).join("<br>"));
616
+ };
617
+ cv.onmouseleave = hideTip;
618
+ }
619
+
620
+ // ======================================================================= insights feed
621
+ function drawFeed() {
622
+ const ul = $("feed");
623
+ $("feedEmpty").style.display = D.insights.length ? "none" : "";
624
+ if (ul.dataset.n === String(D.insights.length)) return;
625
+ ul.dataset.n = D.insights.length;
626
+ ul.innerHTML = D.insights.slice().reverse().map((ins, k) =>
627
+ `<li class="${ins.level}" data-k="${D.insights.length - 1 - k}"><span class="badge">step ${ins.step}</span>${ins.message}</li>`).join("");
628
+ ul.querySelectorAll("li").forEach((li) => li.addEventListener("click", () => {
629
+ const ins = D.insights[+li.dataset.k];
630
+ let best = 0, bd = Infinity;
631
+ D.snapshots.forEach((s, i) => { const d = Math.abs(s.step - ins.step); if (d < bd) { bd = d; best = i; } });
632
+ UI.cur = best; UI.follow = false; $("follow").checked = false; render();
633
+ const card = $({ loss: "card-loss", health: "card-health", activations: "card-acts", graph: "card-graph" }[ins.panel] || "card-flow");
634
+ card.scrollIntoView({ behavior: "smooth", block: "center" });
635
+ card.classList.remove("flash"); void card.offsetWidth; card.classList.add("flash");
636
+ }));
637
+ }
638
+
639
+ // ======================================================================= gradient flow
640
+ function drawFlow() {
641
+ const cv = $("flow"), { ctx, w, h } = setupCanvas(cv, 200), rec = curRecord();
642
+ if (!rec || !D.structure) return;
643
+ const g = rec.layers.map((l) => l.g_mean_abs), d = rec.layers.map((l) => l.delta_mean_abs);
644
+ if (g.every((v) => v === null || v === undefined)) {
645
+ ctx.fillStyle = css("--faint"); ctx.font = "13px system-ui"; ctx.textAlign = "center";
646
+ ctx.fillText("No gradients logged yet (grads=[...] or layers with dW).", w / 2, h / 2); return;
647
+ }
648
+ const allv = D.records.flatMap((r) => r.layers.map((l) => l.g_mean_abs)).filter((v) => v > 0);
649
+ const lo = Math.log10(Math.min(...allv)) - 0.3, hi = Math.log10(Math.max(...allv)) + 0.3;
650
+ const n = g.length, P = { l: 44, r: 8, t: 10, b: 34 }, bw = (w - P.l - P.r) / n;
651
+ const Y = (v) => P.t + (h - P.t - P.b) * (1 - (Math.log10(Math.max(v, 1e-30)) - lo) / (hi - lo));
652
+ ctx.strokeStyle = css("--line"); ctx.fillStyle = css("--faint"); ctx.font = "11px system-ui";
653
+ for (let e = Math.ceil(lo); e <= Math.floor(hi); e++) {
654
+ const y = Y(10 ** e); ctx.beginPath(); ctx.moveTo(P.l, y); ctx.lineTo(w - P.r, y); ctx.stroke();
655
+ ctx.textAlign = "right"; ctx.fillText(`1e${e}`, P.l - 4, y + 4);
656
+ }
657
+ g.forEach((v, k) => {
658
+ const x = P.l + bw * k + bw * 0.18, bwid = bw * 0.64;
659
+ if (v > 0) { ctx.fillStyle = "#0877bd"; ctx.fillRect(x, Y(v), bwid, h - P.b - Y(v)); }
660
+ if (d[k] > 0) { ctx.fillStyle = css("--neg"); ctx.fillRect(x + bwid * 0.35, Y(d[k]), bwid * 0.3, h - P.b - Y(d[k])); }
661
+ ctx.fillStyle = css("--muted"); ctx.textAlign = "center";
662
+ const act = D.structure.layers[k] && D.structure.layers[k].act;
663
+ ctx.fillText(`L${k + 1}${act ? " " + act : ""}`, x + bwid / 2, h - P.b + 14);
664
+ ctx.fillStyle = css("--ink"); ctx.fillText(fmt(v), x + bwid / 2, Math.max(P.t + 10, Y(v) - 4));
665
+ });
666
+ if (g[0] > 0 && g[n - 1] > 0) {
667
+ ctx.fillStyle = css("--muted"); ctx.textAlign = "left";
668
+ ctx.fillText(`first/last layer ratio: ${fmt(g[0] / g[n - 1])}`, P.l, h - 4);
669
+ }
670
+ }
671
+
672
+ // ======================================================================= layer health
673
+ function spark(cv, pts, curStep, logY, color) {
674
+ const { ctx, w, h } = setupCanvas(cv, 46);
675
+ const vals = pts.filter(([, v]) => v !== null && v !== undefined && (!logY || v > 0));
676
+ if (vals.length < 2) { ctx.fillStyle = css("--faint"); ctx.font = "11px system-ui"; ctx.fillText("–", 4, 28); return; }
677
+ const f = logY ? (v) => Math.log10(v) : (v) => v;
678
+ let lo = Math.min(...vals.map(([, v]) => f(v))), hi = Math.max(...vals.map(([, v]) => f(v)));
679
+ if (hi - lo < 1e-9) { lo -= 0.5; hi += 0.5; }
680
+ const s0 = vals[0][0], s1 = Math.max(s0 + 1, vals[vals.length - 1][0]);
681
+ const X = (s) => 2 + (w - 4) * (s - s0) / (s1 - s0), Y = (v) => 4 + (h - 16) * (1 - (f(v) - lo) / (hi - lo));
682
+ ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath();
683
+ vals.forEach(([s, v], i) => (i ? ctx.lineTo(X(s), Y(v)) : ctx.moveTo(X(s), Y(v)))); ctx.stroke();
684
+ let cur = vals[vals.length - 1];
685
+ for (const p of vals) if (p[0] <= curStep) cur = p;
686
+ ctx.fillStyle = css("--warn"); ctx.beginPath(); ctx.arc(X(cur[0]), Y(cur[1]), 3, 0, 2 * Math.PI); ctx.fill();
687
+ ctx.fillStyle = css("--ink"); ctx.font = "11px system-ui"; ctx.textAlign = "right"; ctx.fillText(fmt(cur[1]), w - 2, h - 2);
688
+ }
689
+ function hist(cv, H, color) {
690
+ const { ctx, w, h } = setupCanvas(cv, 50);
691
+ if (!H) { ctx.fillStyle = css("--faint"); ctx.font = "11px system-ui"; ctx.fillText("–", 4, 28); return; }
692
+ const m = Math.max(1, ...H.counts), bw = w / H.counts.length;
693
+ ctx.fillStyle = color;
694
+ H.counts.forEach((c, k) => { const bh = (h - 14) * c / m; ctx.fillRect(k * bw, h - 14 - bh, Math.max(1, bw - 1), bh); });
695
+ ctx.fillStyle = css("--faint"); ctx.font = "10px system-ui";
696
+ ctx.textAlign = "left"; ctx.fillText(fmt(H.lo), 0, h - 2); ctx.textAlign = "right"; ctx.fillText(fmt(H.hi), w, h - 2);
697
+ }
698
+ function drawHealth() {
699
+ const box = $("health");
700
+ if (!D.structure) { box.innerHTML = '<div class="empty">waiting for data</div>'; return; }
701
+ const L = D.structure.layers, key = D.structure.signature;
702
+ if (box.dataset.key !== key) {
703
+ box.dataset.key = key;
704
+ box.innerHTML = `<div class="health-row health-head"><div></div><div>‖W‖</div><div>‖∇W‖</div><div>update ratio</div></div>` +
705
+ L.map((l, k) => `<div class="health-row"><div class="nm"><b>layer ${k + 1}</b>${l.in}→${l.out}</div>
706
+ <canvas data-k="${k}" data-m="w_norm"></canvas><canvas data-k="${k}" data-m="g_norm"></canvas><canvas data-k="${k}" data-m="update_ratio"></canvas></div>
707
+ <div class="hist-row"><div class="nm health-head">W · ∇W now</div><canvas data-h="${k}" data-t="w"></canvas><canvas data-h="${k}" data-t="g"></canvas></div>`).join("");
708
+ }
709
+ const cs = curSnap(), cur = cs ? cs.step : Infinity;
710
+ box.querySelectorAll("canvas[data-m]").forEach((cv) => {
711
+ const k = +cv.dataset.k, m = cv.dataset.m;
712
+ spark(cv, D.records.map((r) => [r.step, r.layers[k] ? r.layers[k][m] : null]), cur, m !== "w_norm",
713
+ m === "w_norm" ? "#0877bd" : m === "g_norm" ? "#f59322" : "#2ca02c");
714
+ });
715
+ box.querySelectorAll("canvas[data-h]").forEach((cv) => {
716
+ const k = +cv.dataset.h, t = cv.dataset.t;
717
+ hist(cv, cs && cs.hist[k] ? cs.hist[k][t] : null, t === "w" ? "#0877bd" : "#f59322");
718
+ });
719
+ }
720
+
721
+ // ======================================================================= activations
722
+ function drawActs() {
723
+ const box = $("acts"), s = curSnap();
724
+ if (!D.structure || !s) { box.innerHTML = '<div class="empty">waiting for data</div>'; return; }
725
+ const L = D.structure.layers, rows = L.map((l, k) => ({ l, k, nd: s.nodes[k], rec: curRecord() && curRecord().layers[k] })).filter((x) => x.nd);
726
+ if (!rows.length) { box.innerHTML = '<div class="empty">No activations logged. They are read from layers that cache their input (e.g. <code>Linear.x</code>) or from <code>activations=[...]</code>.</div>'; box.dataset.key = ""; return; }
727
+ const key = D.structure.signature + rows.length;
728
+ if (box.dataset.key !== key) {
729
+ box.dataset.key = key;
730
+ box.innerHTML = rows.map((r) => `<div class="act-row"><div class="nm" id="actnm${r.k}"></div><canvas data-a="${r.k}"></canvas></div>`).join("");
731
+ }
732
+ rows.forEach(({ l, k, nd, rec }) => {
733
+ const dead = rec && rec.dead ? rec.dead : 0, kind = rec && rec.dead_kind === "saturated" ? "saturated" : "dead";
734
+ $("actnm" + k).innerHTML = `<b>layer ${k + 1}</b> · ${l.act || "linear"} · ${l.out} units` +
735
+ (rec && rec.frac_active !== undefined && rec.frac_active !== null ? ` · ${Math.round(rec.frac_active * 100)}% active on average` : "") +
736
+ (dead ? ` · <span class="dead">${dead} ${kind}</span>` : "");
737
+ const cv = box.querySelector(`canvas[data-a="${k}"]`), { ctx, w } = setupCanvas(cv, 38), n = nd.mean.length, cw = w / n;
738
+ const m = Math.max(1e-9, ...nd.mean.map(Math.abs));
739
+ nd.mean.forEach((v, j) => {
740
+ ctx.fillStyle = rgbOf(v / m); ctx.fillRect(j * cw, 0, Math.max(1, cw - 1), 17);
741
+ ctx.fillStyle = nd.frac[j] === 0 ? css("--bad") : rgbOf(nd.frac[j]); ctx.fillRect(j * cw, 19, Math.max(1, cw - 1), 17);
742
+ });
743
+ cv.onmousemove = (ev) => {
744
+ const j = Math.min(n - 1, Math.floor((ev.clientX - cv.getBoundingClientRect().left) / cw));
745
+ showTip(ev.clientX, ev.clientY, `layer ${k + 1} · neuron ${l.shown_out[j]}<br>mean a = ${fmt(nd.mean[j])} ± ${fmt(nd.std[j])}<br>active ${Math.round(nd.frac[j] * 100)}%`);
746
+ };
747
+ cv.onmouseleave = hideTip;
748
+ });
749
+ }
750
+
751
+ // ======================================================================= latent scatter
752
+ function drawLatent() {
753
+ const cv = $("latent");
754
+ let s = null;
755
+ for (let i = Math.min(UI.cur, D.snapshots.length - 1); i >= 0; i--) if (D.snapshots[i].latent) { s = D.snapshots[i]; break; }
756
+ $("latentEmpty").style.display = s ? "none" : "";
757
+ cv.style.display = s ? "" : "none";
758
+ if (!s) return;
759
+ const { ctx, w, h } = setupCanvas(cv, Math.min(420, Math.max(260, cv.parentElement.clientWidth * 0.75)));
760
+ const { xy, labels, explained, dim } = s.latent, P = { l: 34, r: 10, t: 10, b: 30 };
761
+ const xs = xy.map((p) => p[0]), ys = xy.map((p) => p[1]);
762
+ const pad = (a, b) => { const d = (b - a) || 1; return [a - 0.06 * d, b + 0.06 * d]; };
763
+ const [x0, x1] = pad(Math.min(...xs), Math.max(...xs)), [y0, y1] = pad(Math.min(...ys), Math.max(...ys));
764
+ const X = (v) => P.l + (w - P.l - P.r) * (v - x0) / (x1 - x0), Y = (v) => P.t + (h - P.t - P.b) * (1 - (v - y0) / (y1 - y0));
765
+ const cats = labels && labels.every((v) => Number.isInteger(v) || typeof v === "boolean") && new Set(labels).size <= 12;
766
+ let lo = 0, hi = 1;
767
+ if (labels && !cats) { lo = Math.min(...labels); hi = Math.max(...labels); }
768
+ ctx.strokeStyle = css("--line"); ctx.strokeRect(P.l, P.t, w - P.l - P.r, h - P.t - P.b);
769
+ xy.forEach(([x, y], i) => {
770
+ ctx.beginPath(); ctx.arc(X(x), Y(y), 3.2, 0, 2 * Math.PI);
771
+ ctx.fillStyle = !labels ? css("--muted") : cats ? PALETTE[(+labels[i]) % PALETTE.length] : rgbOf(2 * (labels[i] - lo) / ((hi - lo) || 1) - 1);
772
+ ctx.globalAlpha = 0.8; ctx.fill(); ctx.globalAlpha = 1;
773
+ });
774
+ ctx.fillStyle = css("--muted"); ctx.font = "12px system-ui"; ctx.textAlign = "center";
775
+ const ax = explained ? [`PC1 (${Math.round(explained[0] * 100)}% var)`, `PC2 (${Math.round(explained[1] * 100)}% var)`] : ["z1", "z2"];
776
+ ctx.fillText(`${ax[0]} · ${dim}-D features · step ${s.step}`, P.l + (w - P.l - P.r) / 2, h - 8);
777
+ ctx.save(); ctx.translate(12, P.t + (h - P.t - P.b) / 2); ctx.rotate(-Math.PI / 2); ctx.fillText(ax[1], 0, 0); ctx.restore();
778
+ if (cats) {
779
+ const uniq = [...new Set(labels)].sort((a, b) => a - b);
780
+ ctx.textAlign = "left";
781
+ uniq.forEach((u, k) => { ctx.fillStyle = PALETTE[(+u) % PALETTE.length]; ctx.fillRect(P.l + 8, P.t + 8 + k * 16, 10, 10);
782
+ ctx.fillStyle = css("--ink"); ctx.fillText(`class ${+u}`, P.l + 24, P.t + 17 + k * 16); });
783
+ }
784
+ }
785
+
786
+ // ======================================================================= exports
787
+ function download(href, name) {
788
+ const a = document.createElement("a"); a.href = href; a.download = name; document.body.appendChild(a); a.click(); a.remove();
789
+ }
790
+ function safeName() { return (D.name || "run").replace(/[^\w.-]+/g, "_"); }
791
+ function withBackground(cv) {
792
+ const out = document.createElement("canvas"); out.width = cv.width; out.height = cv.height;
793
+ const ctx = out.getContext("2d"); ctx.fillStyle = css("--card"); ctx.fillRect(0, 0, out.width, out.height); ctx.drawImage(cv, 0, 0);
794
+ return out;
795
+ }
796
+ function networkSVG() {
797
+ const s = curSnap(); if (!s || !D.structure) return null;
798
+ const Lc = D.structure.layers.length + 1, maxN = Math.max(D.structure.layers[0].shown_in.length, ...D.structure.layers.map((l) => l.shown_out.length));
799
+ const w = Math.max(700, Lc * 190), h = Math.max(320, Math.min(900, maxN * 26 + 90)), pos = graphLayout(w, h, false), vals = edgeValues();
800
+ let m = 1e-12; for (const M of vals) if (M) for (const row of M) for (const v of row) m = Math.max(m, Math.abs(v));
801
+ const out = [`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + 30}" viewBox="0 0 ${w} ${h + 30}" font-family="Helvetica, Arial, sans-serif" font-size="12">`,
802
+ `<rect width="100%" height="100%" fill="${css("--card")}"/>`,
803
+ `<text x="12" y="${h + 22}" fill="${css("--muted")}">${D.name} · step ${s.step} · edges: ${{ w: "weights", g: "gradients", u: "updates" }[UI.edge]}</text>`];
804
+ vals.forEach((M, l) => M && M.forEach((row, i) => row.forEach((v, j) => {
805
+ const [x1, y1] = pos[l][i], [x2, y2] = pos[l + 1][j], a = Math.abs(v) / m, xm = (x1 + x2) / 2;
806
+ if (a < 0.02) return;
807
+ out.push(`<path d="M${x1.toFixed(1)},${y1.toFixed(1)} C${xm.toFixed(1)},${y1.toFixed(1)} ${xm.toFixed(1)},${y2.toFixed(1)} ${x2.toFixed(1)},${y2.toFixed(1)}" fill="none" stroke="${v >= 0 ? "#0877bd" : "#f59322"}" stroke-opacity="${(0.15 + 0.85 * a).toFixed(2)}" stroke-width="${(0.4 + 4 * a).toFixed(2)}"/>`);
808
+ })));
809
+ pos.forEach((col, c) => {
810
+ col.forEach(([x, y], j) => out.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="7" fill="${nodeFill(c, j, s)}" stroke="${css("--faint")}"/>`));
811
+ out.push(`<text x="${col[0][0].toFixed(1)}" y="18" text-anchor="middle" fill="${css("--muted")}">${layerLabel(c)}</text>`);
812
+ });
813
+ out.push("</svg>");
814
+ return out.join("\n");
815
+ }
816
+ function dashboardPNG() {
817
+ const cards = [...document.querySelectorAll(".card")].filter((c) => c.querySelector("canvas") && c.querySelectorAll("canvas").length);
818
+ const W = 1600, colW = 780, dpr = window.devicePixelRatio || 1, parts = [];
819
+ for (const card of cards) {
820
+ const cvs = [...card.querySelectorAll("canvas")].filter((c) => c.width && c.offsetParent !== null);
821
+ if (!cvs.length) continue;
822
+ const full = card.classList.contains("wide"), cw = full ? W - 40 : colW;
823
+ const hs = cvs.map((c) => c.height / c.width * cw);
824
+ parts.push({ title: card.dataset.title, cvs, hs, full, cw, h: 30 + hs.reduce((a, b) => a + b + 6, 0) });
825
+ }
826
+ let x = 20, y = 60, rowH = 0; const placed = [];
827
+ for (const p of parts) {
828
+ if (p.full || x + p.cw > W - 20) { if (x > 20) { y += rowH + 16; x = 20; rowH = 0; } }
829
+ placed.push({ ...p, x, y }); rowH = Math.max(rowH, p.h);
830
+ if (p.full) { y += p.h + 16; x = 20; rowH = 0; } else x += p.cw + 20;
831
+ }
832
+ const H = y + rowH + 20, out = document.createElement("canvas"); out.width = W; out.height = H;
833
+ const ctx = out.getContext("2d"); ctx.fillStyle = css("--bg"); ctx.fillRect(0, 0, W, H);
834
+ ctx.fillStyle = css("--ink"); ctx.font = "bold 22px system-ui"; ctx.fillText(`nnscope · ${D.name} · step ${$("stepNow").textContent}`, 20, 38);
835
+ for (const p of placed) {
836
+ ctx.fillStyle = css("--card"); ctx.fillRect(p.x - 6, p.y - 6, p.cw + 12, p.h + 6);
837
+ ctx.fillStyle = css("--muted"); ctx.font = "600 14px system-ui"; ctx.fillText(p.title.toUpperCase(), p.x, p.y + 16);
838
+ let yy = p.y + 28;
839
+ p.cvs.forEach((c, k) => { ctx.drawImage(c, p.x, yy, p.cw, p.hs[k]); yy += p.hs[k] + 6; });
840
+ }
841
+ return out;
842
+ }
843
+ function doExport(kind) {
844
+ $("exportMenu").classList.remove("open");
845
+ const base = `${safeName()}_step${$("stepNow").textContent}`;
846
+ if (kind === "net-svg") { const svg = networkSVG(); if (svg) download(URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })), base + "_network.svg"); }
847
+ else if (kind === "net-png") download(withBackground($("graph")).toDataURL("image/png"), base + "_network.png");
848
+ else if (kind === "loss-png") download(withBackground($("loss")).toDataURL("image/png"), base + "_loss.png");
849
+ else if (kind === "dash-png") download(dashboardPNG().toDataURL("image/png"), base + "_dashboard.png");
850
+ else if (kind === "json") download(URL.createObjectURL(new Blob([JSON.stringify(D)], { type: "application/json" })), safeName() + ".json");
851
+ }
852
+
853
+ boot();
854
+ </script>
855
+ </body>
856
+ </html>