haoline 0.3.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.
Files changed (70) hide show
  1. haoline/.streamlit/config.toml +10 -0
  2. haoline/__init__.py +248 -0
  3. haoline/analyzer.py +935 -0
  4. haoline/cli.py +2712 -0
  5. haoline/compare.py +811 -0
  6. haoline/compare_visualizations.py +1564 -0
  7. haoline/edge_analysis.py +525 -0
  8. haoline/eval/__init__.py +131 -0
  9. haoline/eval/adapters.py +844 -0
  10. haoline/eval/cli.py +390 -0
  11. haoline/eval/comparison.py +542 -0
  12. haoline/eval/deployment.py +633 -0
  13. haoline/eval/schemas.py +833 -0
  14. haoline/examples/__init__.py +15 -0
  15. haoline/examples/basic_inspection.py +74 -0
  16. haoline/examples/compare_models.py +117 -0
  17. haoline/examples/hardware_estimation.py +78 -0
  18. haoline/format_adapters.py +1001 -0
  19. haoline/formats/__init__.py +123 -0
  20. haoline/formats/coreml.py +250 -0
  21. haoline/formats/gguf.py +483 -0
  22. haoline/formats/openvino.py +255 -0
  23. haoline/formats/safetensors.py +273 -0
  24. haoline/formats/tflite.py +369 -0
  25. haoline/hardware.py +2307 -0
  26. haoline/hierarchical_graph.py +462 -0
  27. haoline/html_export.py +1573 -0
  28. haoline/layer_summary.py +769 -0
  29. haoline/llm_summarizer.py +465 -0
  30. haoline/op_icons.py +618 -0
  31. haoline/operational_profiling.py +1492 -0
  32. haoline/patterns.py +1116 -0
  33. haoline/pdf_generator.py +265 -0
  34. haoline/privacy.py +250 -0
  35. haoline/pydantic_models.py +241 -0
  36. haoline/report.py +1923 -0
  37. haoline/report_sections.py +539 -0
  38. haoline/risks.py +521 -0
  39. haoline/schema.py +523 -0
  40. haoline/streamlit_app.py +2024 -0
  41. haoline/tests/__init__.py +4 -0
  42. haoline/tests/conftest.py +123 -0
  43. haoline/tests/test_analyzer.py +868 -0
  44. haoline/tests/test_compare_visualizations.py +293 -0
  45. haoline/tests/test_edge_analysis.py +243 -0
  46. haoline/tests/test_eval.py +604 -0
  47. haoline/tests/test_format_adapters.py +460 -0
  48. haoline/tests/test_hardware.py +237 -0
  49. haoline/tests/test_hardware_recommender.py +90 -0
  50. haoline/tests/test_hierarchical_graph.py +326 -0
  51. haoline/tests/test_html_export.py +180 -0
  52. haoline/tests/test_layer_summary.py +428 -0
  53. haoline/tests/test_llm_patterns.py +540 -0
  54. haoline/tests/test_llm_summarizer.py +339 -0
  55. haoline/tests/test_patterns.py +774 -0
  56. haoline/tests/test_pytorch.py +327 -0
  57. haoline/tests/test_report.py +383 -0
  58. haoline/tests/test_risks.py +398 -0
  59. haoline/tests/test_schema.py +417 -0
  60. haoline/tests/test_tensorflow.py +380 -0
  61. haoline/tests/test_visualizations.py +316 -0
  62. haoline/universal_ir.py +856 -0
  63. haoline/visualizations.py +1086 -0
  64. haoline/visualize_yolo.py +44 -0
  65. haoline/web.py +110 -0
  66. haoline-0.3.0.dist-info/METADATA +471 -0
  67. haoline-0.3.0.dist-info/RECORD +70 -0
  68. haoline-0.3.0.dist-info/WHEEL +4 -0
  69. haoline-0.3.0.dist-info/entry_points.txt +5 -0
  70. haoline-0.3.0.dist-info/licenses/LICENSE +22 -0
haoline/html_export.py ADDED
@@ -0,0 +1,1573 @@
1
+ # Copyright (c) 2025 HaoLine Contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Interactive HTML Export for graph visualization.
6
+
7
+ Task 5.8: Creates standalone HTML files with embedded visualization
8
+ that can be opened in any browser without a server.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .edge_analysis import EdgeAnalysisResult
20
+ from .hierarchical_graph import HierarchicalGraph
21
+
22
+
23
+ # HTML template with embedded D3.js visualization - Jony Ive Edition
24
+ HTML_TEMPLATE = """<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="UTF-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
29
+ <title>{title} - Neural Architecture</title>
30
+ <link href="https://fonts.googleapis.com/css2?family=SF+Pro+Display:wght@300;400;500;600&display=swap" rel="stylesheet">
31
+ <script src="https://d3js.org/d3.v7.min.js"></script>
32
+ <style>
33
+ :root {{
34
+ --bg-deep: #000000;
35
+ --bg-primary: #0a0a0a;
36
+ --bg-elevated: #1a1a1a;
37
+ --bg-glass: rgba(255, 255, 255, 0.03);
38
+ --text-primary: rgba(255, 255, 255, 0.92);
39
+ --text-secondary: rgba(255, 255, 255, 0.55);
40
+ --text-tertiary: rgba(255, 255, 255, 0.35);
41
+ --accent: #0A84FF;
42
+ --accent-glow: rgba(10, 132, 255, 0.3);
43
+ --border: rgba(255, 255, 255, 0.08);
44
+ --success: #30D158;
45
+ --warning: #FFD60A;
46
+ --error: #FF453A;
47
+ --purple: #BF5AF2;
48
+ --orange: #FF9F0A;
49
+ --teal: #64D2FF;
50
+ }}
51
+
52
+ * {{
53
+ box-sizing: border-box;
54
+ margin: 0;
55
+ padding: 0;
56
+ }}
57
+
58
+ body {{
59
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
60
+ background: var(--bg-deep);
61
+ color: var(--text-primary);
62
+ overflow: hidden;
63
+ -webkit-font-smoothing: antialiased;
64
+ }}
65
+
66
+ .container {{
67
+ display: flex;
68
+ height: 100vh;
69
+ }}
70
+
71
+ .sidebar {{
72
+ width: 280px;
73
+ background: var(--bg-primary);
74
+ border-right: 1px solid var(--border);
75
+ padding: 32px 24px;
76
+ overflow-y: auto;
77
+ backdrop-filter: blur(20px);
78
+ transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
79
+ position: relative;
80
+ }}
81
+
82
+ .sidebar.collapsed {{
83
+ width: 0;
84
+ padding: 0;
85
+ overflow: hidden;
86
+ border-right: none;
87
+ }}
88
+
89
+ .sidebar-toggle {{
90
+ position: fixed;
91
+ top: 12px;
92
+ left: 248px;
93
+ width: 28px;
94
+ height: 28px;
95
+ background: var(--bg-elevated);
96
+ border: 1px solid var(--border);
97
+ border-radius: 6px;
98
+ color: var(--text-secondary);
99
+ cursor: pointer;
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ font-size: 14px;
104
+ z-index: 1000;
105
+ transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
106
+ }}
107
+
108
+ .sidebar-toggle:hover {{
109
+ background: var(--accent);
110
+ color: white;
111
+ border-color: var(--accent);
112
+ }}
113
+
114
+ .sidebar.collapsed + .main .sidebar-toggle,
115
+ .sidebar.collapsed ~ .sidebar-toggle {{
116
+ left: 8px;
117
+ }}
118
+
119
+ .sidebar h1 {{
120
+ font-size: 1.25rem;
121
+ font-weight: 600;
122
+ letter-spacing: -0.02em;
123
+ margin-bottom: 32px;
124
+ background: linear-gradient(135deg, #fff 0%, rgba(255,255,255,0.7) 100%);
125
+ -webkit-background-clip: text;
126
+ -webkit-text-fill-color: transparent;
127
+ }}
128
+
129
+ .sidebar h2 {{
130
+ font-size: 0.6875rem;
131
+ font-weight: 500;
132
+ color: var(--text-tertiary);
133
+ margin: 24px 0 12px;
134
+ text-transform: uppercase;
135
+ letter-spacing: 0.08em;
136
+ }}
137
+
138
+ .stats {{
139
+ display: grid;
140
+ grid-template-columns: 1fr 1fr;
141
+ gap: 8px;
142
+ }}
143
+
144
+ .stat-card {{
145
+ background: var(--bg-glass);
146
+ border: 1px solid var(--border);
147
+ border-radius: 12px;
148
+ padding: 16px;
149
+ transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
150
+ }}
151
+
152
+ .stat-card:hover {{
153
+ background: rgba(255, 255, 255, 0.06);
154
+ transform: translateY(-1px);
155
+ }}
156
+
157
+ .stat-value {{
158
+ font-size: 1.5rem;
159
+ font-weight: 600;
160
+ letter-spacing: -0.03em;
161
+ background: linear-gradient(135deg, var(--accent) 0%, var(--teal) 100%);
162
+ -webkit-background-clip: text;
163
+ -webkit-text-fill-color: transparent;
164
+ }}
165
+
166
+ .stat-label {{
167
+ font-size: 0.6875rem;
168
+ color: var(--text-tertiary);
169
+ margin-top: 4px;
170
+ }}
171
+
172
+ .controls {{
173
+ display: flex;
174
+ flex-wrap: wrap;
175
+ gap: 6px;
176
+ }}
177
+
178
+ .btn {{
179
+ padding: 8px 14px;
180
+ background: var(--bg-glass);
181
+ border: 1px solid var(--border);
182
+ border-radius: 8px;
183
+ color: var(--text-secondary);
184
+ cursor: pointer;
185
+ font-size: 0.75rem;
186
+ font-weight: 500;
187
+ transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
188
+ }}
189
+
190
+ .btn:hover {{
191
+ background: var(--accent);
192
+ border-color: var(--accent);
193
+ color: white;
194
+ box-shadow: 0 4px 16px var(--accent-glow);
195
+ }}
196
+
197
+ .legend-item {{
198
+ display: flex;
199
+ align-items: center;
200
+ padding: 6px 8px;
201
+ margin: 2px 0;
202
+ font-size: 0.75rem;
203
+ color: var(--text-secondary);
204
+ cursor: pointer;
205
+ border-radius: 6px;
206
+ transition: all 0.15s ease;
207
+ }}
208
+
209
+ .legend-item:hover {{
210
+ background: rgba(255,255,255,0.05);
211
+ }}
212
+
213
+ .legend-item.active {{
214
+ background: rgba(10, 132, 255, 0.2);
215
+ color: var(--text-primary);
216
+ }}
217
+
218
+ .legend-dot {{
219
+ width: 10px;
220
+ height: 10px;
221
+ border-radius: 50%;
222
+ margin-right: 12px;
223
+ flex-shrink: 0;
224
+ }}
225
+
226
+ .legend-symbol {{
227
+ font-size: 1rem;
228
+ width: 20px;
229
+ text-align: center;
230
+ margin-right: 10px;
231
+ }}
232
+
233
+ .main {{
234
+ flex: 1;
235
+ position: relative;
236
+ background: radial-gradient(ellipse at center, #0a0a0a 0%, #000 100%);
237
+ }}
238
+
239
+ /* Subtle grid pattern */
240
+ .main::before {{
241
+ content: '';
242
+ position: absolute;
243
+ inset: 0;
244
+ background-image:
245
+ linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
246
+ linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
247
+ background-size: 40px 40px;
248
+ pointer-events: none;
249
+ }}
250
+
251
+ svg {{
252
+ width: 100%;
253
+ height: 100%;
254
+ }}
255
+
256
+ .node {{
257
+ cursor: pointer;
258
+ }}
259
+
260
+ .node:hover .node-circle {{
261
+ filter: brightness(1.2) drop-shadow(0 0 12px currentColor);
262
+ }}
263
+
264
+ .node-circle {{
265
+ stroke: rgba(255,255,255,0.1);
266
+ stroke-width: 1;
267
+ filter: drop-shadow(0 2px 8px rgba(0,0,0,0.4));
268
+ }}
269
+
270
+ .node-label {{
271
+ font-size: 9px;
272
+ font-weight: 500;
273
+ fill: white;
274
+ text-anchor: middle;
275
+ dominant-baseline: middle;
276
+ pointer-events: none;
277
+ text-shadow: 0 1px 2px rgba(0,0,0,0.8);
278
+ opacity: 0;
279
+ transition: opacity 0.2s ease;
280
+ }}
281
+
282
+ /* Show labels on hover */
283
+ .node:hover .node-label {{
284
+ opacity: 1;
285
+ }}
286
+
287
+ /* Always show labels for large/important nodes */
288
+ .node.show-label .node-label {{
289
+ opacity: 1;
290
+ }}
291
+
292
+ .node-sublabel {{
293
+ font-size: 7px;
294
+ fill: rgba(255,255,255,0.5);
295
+ text-anchor: middle;
296
+ pointer-events: none;
297
+ opacity: 0;
298
+ transition: opacity 0.2s ease;
299
+ }}
300
+
301
+ .node:hover .node-sublabel {{
302
+ opacity: 1;
303
+ }}
304
+
305
+ .edge {{
306
+ fill: none;
307
+ stroke-linecap: round;
308
+ opacity: 0.6;
309
+ }}
310
+
311
+ .edge:hover {{
312
+ opacity: 1;
313
+ stroke-width: 3;
314
+ }}
315
+
316
+ .tooltip {{
317
+ position: fixed;
318
+ background: rgba(20, 20, 20, 0.95);
319
+ backdrop-filter: blur(20px);
320
+ border: 1px solid var(--border);
321
+ border-radius: 12px;
322
+ padding: 16px;
323
+ font-size: 0.8125rem;
324
+ pointer-events: none;
325
+ opacity: 0;
326
+ transform: translateY(4px);
327
+ transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
328
+ max-width: 280px;
329
+ z-index: 1000;
330
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6);
331
+ }}
332
+
333
+ .tooltip.visible {{
334
+ opacity: 1;
335
+ transform: translateY(0);
336
+ }}
337
+
338
+ .tooltip-title {{
339
+ font-weight: 600;
340
+ font-size: 0.9375rem;
341
+ margin-bottom: 6px;
342
+ color: var(--accent);
343
+ }}
344
+
345
+ .tooltip-desc {{
346
+ font-size: 0.75rem;
347
+ color: var(--text-secondary);
348
+ line-height: 1.4;
349
+ margin-bottom: 12px;
350
+ padding-bottom: 10px;
351
+ border-bottom: 1px solid var(--border);
352
+ }}
353
+
354
+ .tooltip-row {{
355
+ display: flex;
356
+ justify-content: space-between;
357
+ padding: 3px 0;
358
+ font-size: 0.75rem;
359
+ }}
360
+
361
+ .tooltip-label {{
362
+ color: var(--text-tertiary);
363
+ }}
364
+
365
+ .tooltip-value {{
366
+ color: var(--text-primary);
367
+ font-weight: 500;
368
+ font-family: 'SF Mono', 'Menlo', monospace;
369
+ }}
370
+
371
+ .block-indicator {{
372
+ position: absolute;
373
+ top: -6px;
374
+ right: -6px;
375
+ width: 14px;
376
+ height: 14px;
377
+ background: var(--accent);
378
+ border-radius: 50%;
379
+ font-size: 10px;
380
+ display: flex;
381
+ align-items: center;
382
+ justify-content: center;
383
+ color: white;
384
+ font-weight: 600;
385
+ box-shadow: 0 2px 8px var(--accent-glow);
386
+ }}
387
+
388
+ /* Zoom controls */
389
+ .zoom-controls {{
390
+ position: absolute;
391
+ bottom: 24px;
392
+ right: 24px;
393
+ display: flex;
394
+ flex-direction: column;
395
+ gap: 4px;
396
+ }}
397
+
398
+ .zoom-btn {{
399
+ width: 36px;
400
+ height: 36px;
401
+ background: rgba(20, 20, 20, 0.9);
402
+ backdrop-filter: blur(10px);
403
+ border: 1px solid var(--border);
404
+ border-radius: 8px;
405
+ color: var(--text-secondary);
406
+ cursor: pointer;
407
+ font-size: 1.25rem;
408
+ display: flex;
409
+ align-items: center;
410
+ justify-content: center;
411
+ transition: all 0.2s;
412
+ }}
413
+
414
+ .zoom-btn:hover {{
415
+ background: var(--bg-elevated);
416
+ color: var(--text-primary);
417
+ }}
418
+
419
+ /* Search functionality - Task 5.7.6 */
420
+ .search-input {{
421
+ width: 100%;
422
+ padding: 10px 14px;
423
+ background: var(--bg-glass);
424
+ border: 1px solid var(--border);
425
+ border-radius: 8px;
426
+ color: var(--text-primary);
427
+ font-size: 0.875rem;
428
+ margin-bottom: 8px;
429
+ transition: all 0.2s;
430
+ }}
431
+
432
+ .search-input:focus {{
433
+ outline: none;
434
+ border-color: var(--accent);
435
+ box-shadow: 0 0 0 3px var(--accent-glow);
436
+ }}
437
+
438
+ .search-input::placeholder {{
439
+ color: var(--text-tertiary);
440
+ }}
441
+
442
+ .search-results {{
443
+ max-height: 200px;
444
+ overflow-y: auto;
445
+ margin-bottom: 16px;
446
+ }}
447
+
448
+ .search-result {{
449
+ padding: 8px 12px;
450
+ font-size: 0.75rem;
451
+ cursor: pointer;
452
+ border-radius: 6px;
453
+ display: flex;
454
+ align-items: center;
455
+ gap: 8px;
456
+ transition: background 0.15s;
457
+ }}
458
+
459
+ .search-result:hover {{
460
+ background: rgba(255,255,255,0.05);
461
+ }}
462
+
463
+ .search-result .result-name {{
464
+ flex: 1;
465
+ overflow: hidden;
466
+ text-overflow: ellipsis;
467
+ white-space: nowrap;
468
+ }}
469
+
470
+ .search-result .result-type {{
471
+ font-size: 0.65rem;
472
+ color: var(--text-tertiary);
473
+ padding: 2px 6px;
474
+ background: var(--bg-glass);
475
+ border-radius: 4px;
476
+ }}
477
+
478
+ .search-highlight {{
479
+ filter: drop-shadow(0 0 20px var(--accent)) brightness(1.5);
480
+ z-index: 100;
481
+ }}
482
+
483
+ /* Performance mode indicator */
484
+ .perf-mode {{
485
+ font-size: 0.65rem;
486
+ color: var(--warning);
487
+ padding: 4px 8px;
488
+ background: rgba(255, 214, 10, 0.1);
489
+ border-radius: 4px;
490
+ margin-bottom: 12px;
491
+ text-align: center;
492
+ }}
493
+ </style>
494
+ </head>
495
+ <body>
496
+ <div class="container">
497
+ <button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()" title="Toggle sidebar (more graph space)">◀</button>
498
+ <aside class="sidebar" id="graphSidebar">
499
+ <h1>{title}</h1>
500
+
501
+ <h2>Overview</h2>
502
+ <div class="stats">
503
+ <div class="stat-card">
504
+ <div class="stat-value" id="node-count">0</div>
505
+ <div class="stat-label">Nodes</div>
506
+ </div>
507
+ <div class="stat-card">
508
+ <div class="stat-value" id="edge-count">0</div>
509
+ <div class="stat-label">Edges</div>
510
+ </div>
511
+ <div class="stat-card">
512
+ <div class="stat-value" id="peak-memory">0</div>
513
+ <div class="stat-label">Peak Activation</div>
514
+ </div>
515
+ <div class="stat-card">
516
+ <div class="stat-value" id="model-size">-</div>
517
+ <div class="stat-label">Model Size</div>
518
+ </div>
519
+ </div>
520
+
521
+ <h2>Search</h2>
522
+ <input type="text" id="nodeSearch" class="search-input"
523
+ placeholder="Search nodes..." oninput="searchNodes(this.value)">
524
+ <div id="searchResults" class="search-results"></div>
525
+
526
+ <h2>Navigation</h2>
527
+ <div class="controls">
528
+ <button class="btn" onclick="expandAll()">Expand</button>
529
+ <button class="btn" onclick="collapseAll()">Collapse</button>
530
+ <button class="btn" onclick="fitToScreen()">Fit</button>
531
+ <button class="btn" onclick="resetZoom()">Reset</button>
532
+ </div>
533
+
534
+ <h2>Visualization</h2>
535
+ <div class="controls">
536
+ <button class="btn" id="labels-btn" onclick="toggleAllLabels()">Show All Labels</button>
537
+ <button class="btn" id="heatmap-btn" onclick="toggleHeatMap()" style="margin-top:6px;">FLOPs Heat Map</button>
538
+ </div>
539
+
540
+ <h2>Op Types <span style="font-size:0.6rem;color:var(--text-tertiary)">(click to filter)</span></h2>
541
+ <div class="legend" id="op-legend">
542
+ <div class="legend-item" data-category="conv" onclick="filterByCategory('conv')">
543
+ <div class="legend-symbol" style="color: #4A90D9;">▣</div>
544
+ <span>Convolution</span>
545
+ </div>
546
+ <div class="legend-item" data-category="linear" onclick="filterByCategory('linear')">
547
+ <div class="legend-symbol" style="color: #BF5AF2;">◆</div>
548
+ <span>Linear/MatMul</span>
549
+ </div>
550
+ <div class="legend-item" data-category="attention" onclick="filterByCategory('attention')">
551
+ <div class="legend-symbol" style="color: #FF9F0A;">◎</div>
552
+ <span>Attention</span>
553
+ </div>
554
+ <div class="legend-item" data-category="norm" onclick="filterByCategory('norm')">
555
+ <div class="legend-symbol" style="color: #64D2FF;">≡</div>
556
+ <span>Normalization</span>
557
+ </div>
558
+ <div class="legend-item" data-category="activation" onclick="filterByCategory('activation')">
559
+ <div class="legend-symbol" style="color: #FFD60A;">⚡</div>
560
+ <span>Activation</span>
561
+ </div>
562
+ <div class="legend-item" data-category="pool" onclick="filterByCategory('pool')">
563
+ <div class="legend-symbol" style="color: #30D158;">▼</div>
564
+ <span>Pooling</span>
565
+ </div>
566
+ <div class="legend-item" data-category="reshape" onclick="filterByCategory('reshape')">
567
+ <div class="legend-symbol" style="color: #5E5CE6;">⤨</div>
568
+ <span>Reshape</span>
569
+ </div>
570
+ <div class="legend-item" data-category="elementwise" onclick="filterByCategory('elementwise')">
571
+ <div class="legend-symbol" style="color: #FF6482;">+</div>
572
+ <span>Math ops</span>
573
+ </div>
574
+ <div class="legend-item" data-category="default" onclick="filterByCategory('default')">
575
+ <div class="legend-symbol" style="color: #636366;">●</div>
576
+ <span>Other</span>
577
+ </div>
578
+ <div class="legend-item" data-category="all" onclick="filterByCategory('all')" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;">
579
+ <span style="color:var(--accent)">↺ Show all</span>
580
+ </div>
581
+ </div>
582
+
583
+ <h2>Heat Map Scale</h2>
584
+ <div class="legend" id="heatmap-legend" style="display: none;">
585
+ <div class="legend-item">
586
+ <div class="legend-dot" style="background: #0A84FF; box-shadow: 0 0 8px #0A84FF;"></div>
587
+ <span>Low FLOPs</span>
588
+ </div>
589
+ <div class="legend-item">
590
+ <div class="legend-dot" style="background: #64D2FF; box-shadow: 0 0 8px #64D2FF;"></div>
591
+ <span>Light</span>
592
+ </div>
593
+ <div class="legend-item">
594
+ <div class="legend-dot" style="background: #30D158; box-shadow: 0 0 8px #30D158;"></div>
595
+ <span>Medium</span>
596
+ </div>
597
+ <div class="legend-item">
598
+ <div class="legend-dot" style="background: #FFD60A; box-shadow: 0 0 8px #FFD60A;"></div>
599
+ <span>Heavy</span>
600
+ </div>
601
+ <div class="legend-item">
602
+ <div class="legend-dot" style="background: #FF9F0A; box-shadow: 0 0 8px #FF9F0A;"></div>
603
+ <span>Very heavy</span>
604
+ </div>
605
+ <div class="legend-item">
606
+ <div class="legend-dot" style="background: #FF453A; box-shadow: 0 0 8px #FF453A;"></div>
607
+ <span>Compute hotspot</span>
608
+ </div>
609
+ </div>
610
+ <div class="legend" id="optype-legend-note">
611
+ <span style="font-size: 0.7rem; color: var(--text-tertiary);">Toggle heat maps to visualize compute or timing</span>
612
+ </div>
613
+ </aside>
614
+
615
+ <main class="main">
616
+ <svg id="graph"></svg>
617
+ <div class="tooltip" id="tooltip"></div>
618
+ <div class="zoom-controls">
619
+ <button class="zoom-btn" onclick="zoomIn()">+</button>
620
+ <button class="zoom-btn" onclick="zoomOut()">-</button>
621
+ </div>
622
+ </main>
623
+ </div>
624
+
625
+ <script>
626
+ // Embedded graph data
627
+ const graphData = {graph_json};
628
+ const edgeData = {edge_json};
629
+
630
+ // Get label for a node
631
+ function getNodeLabel(node) {{
632
+ if (node.node_type === 'op' && node.op_type) {{
633
+ return node.op_type;
634
+ }}
635
+ if (node.display_name) {{
636
+ return node.display_name;
637
+ }}
638
+ return node.name;
639
+ }}
640
+
641
+ // Apple-inspired color palette with symbols
642
+ const opStyles = {{
643
+ conv: {{ color: '#4A90D9', symbol: '▣', name: 'Convolution' }},
644
+ linear: {{ color: '#BF5AF2', symbol: '◆', name: 'Linear' }},
645
+ attention: {{ color: '#FF9F0A', symbol: '◎', name: 'Attention' }},
646
+ norm: {{ color: '#64D2FF', symbol: '≡', name: 'Normalize' }},
647
+ activation: {{ color: '#FFD60A', symbol: '⚡', name: 'Activation' }},
648
+ pool: {{ color: '#30D158', symbol: '▼', name: 'Pooling' }},
649
+ embed: {{ color: '#AF52DE', symbol: '⊞', name: 'Embed' }},
650
+ reshape: {{ color: '#5E5CE6', symbol: '⤨', name: 'Reshape' }},
651
+ elementwise: {{ color: '#FF6482', symbol: '+', name: 'Math' }},
652
+ reduce: {{ color: '#FF453A', symbol: 'Σ', name: 'Reduce' }},
653
+ default: {{ color: '#636366', symbol: '●', name: 'Other' }}
654
+ }};
655
+
656
+ // For backwards compat
657
+ const colors = Object.fromEntries(
658
+ Object.entries(opStyles).map(([k, v]) => [k, v.color])
659
+ );
660
+
661
+ // Get op category key
662
+ function getOpCategory(node) {{
663
+ if (node.node_type === 'block') {{
664
+ const blockType = (node.attributes?.block_type || '').toLowerCase();
665
+ if (blockType.includes('attention')) return 'attention';
666
+ if (blockType.includes('mlp') || blockType.includes('ffn')) return 'linear';
667
+ if (blockType.includes('conv')) return 'conv';
668
+ if (blockType.includes('norm')) return 'norm';
669
+ if (blockType.includes('embed')) return 'embed';
670
+ return 'default';
671
+ }}
672
+
673
+ const op = (node.op_type || '').toLowerCase();
674
+ if (op.includes('conv')) return 'conv';
675
+ if (op.includes('matmul') || op.includes('gemm')) return 'linear';
676
+ if (op.includes('norm') || op.includes('layer')) return 'norm';
677
+ if (op.includes('relu') || op.includes('gelu') || op.includes('softmax') || op.includes('sigmoid') || op.includes('silu') || op.includes('tanh')) return 'activation';
678
+ if (op.includes('pool')) return 'pool';
679
+ if (op.includes('reshape') || op.includes('transpose') || op.includes('flatten') || op.includes('squeeze') || op.includes('unsqueeze')) return 'reshape';
680
+ if (op.includes('add') || op.includes('mul') || op.includes('sub') || op.includes('div') || op.includes('concat') || op.includes('split')) return 'elementwise';
681
+ if (op.includes('reduce')) return 'reduce';
682
+ if (op.includes('gather') || op.includes('embed')) return 'embed';
683
+ return 'default';
684
+ }}
685
+
686
+ // Get color for op type
687
+ function getNodeColor(node) {{
688
+ return opStyles[getOpCategory(node)].color;
689
+ }}
690
+
691
+ // Get symbol for op type
692
+ function getNodeSymbol(node) {{
693
+ return opStyles[getOpCategory(node)].symbol;
694
+ }}
695
+
696
+ // Get node size based on type and compute
697
+ // Track max FLOPs for scaling (computed once during render)
698
+ let globalMaxFlops = 1;
699
+
700
+ function getNodeSize(node) {{
701
+ // Scale by FLOPs - expensive ops are visually bigger
702
+ const flops = node.total_flops || 0;
703
+
704
+ if (flops > 0 && globalMaxFlops > 1) {{
705
+ // Log scale: 12px min, 45px max based on FLOPs
706
+ const logFlops = Math.log10(flops + 1);
707
+ const logMax = Math.log10(globalMaxFlops + 1);
708
+ const ratio = logFlops / logMax;
709
+ return 12 + ratio * 33;
710
+ }}
711
+
712
+ // Fallback for nodes without FLOPs data - use hierarchy
713
+ const base = node.node_type === 'model' ? 40 :
714
+ node.node_type === 'layer' ? 30 :
715
+ node.node_type === 'block' ? 25 : 15;
716
+ return base;
717
+ }}
718
+
719
+ // Heat map mode toggle
720
+ let heatMapMode = false;
721
+
722
+ // Get heat map color based on compute intensity
723
+ function getHeatColor(flops, maxFlops) {{
724
+ if (!flops || flops === 0) return null;
725
+ const intensity = Math.log10(flops + 1) / Math.log10(maxFlops + 1);
726
+ // Blue -> Cyan -> Green -> Yellow -> Orange -> Red
727
+ if (intensity < 0.2) return '#0A84FF';
728
+ if (intensity < 0.4) return '#64D2FF';
729
+ if (intensity < 0.6) return '#30D158';
730
+ if (intensity < 0.8) return '#FFD60A';
731
+ if (intensity < 0.9) return '#FF9F0A';
732
+ return '#FF453A';
733
+ }}
734
+
735
+ // Initialize visualization
736
+ const svg = d3.select('#graph');
737
+ const container = svg.append('g');
738
+
739
+ // Zoom behavior
740
+ const zoom = d3.zoom()
741
+ .scaleExtent([0.1, 4])
742
+ .on('zoom', (event) => {{
743
+ container.attr('transform', event.transform);
744
+ }});
745
+
746
+ svg.call(zoom);
747
+
748
+ // Tooltip
749
+ const tooltip = d3.select('#tooltip');
750
+
751
+ // Op type explanations for demystifying the model
752
+ const opDescriptions = {{
753
+ 'Conv': 'Convolution: Slides filters across input to detect features like edges, textures, shapes',
754
+ 'MatMul': 'Matrix Multiply: Core linear transformation - learns weighted combinations of inputs',
755
+ 'Gemm': 'General Matrix Multiply: Linear layer with weights and bias',
756
+ 'Relu': 'ReLU Activation: Keeps positive values, zeros negatives - adds non-linearity',
757
+ 'Gelu': 'GELU Activation: Smooth activation used in transformers - better gradients',
758
+ 'Softmax': 'Softmax: Converts scores to probabilities (sums to 1) - used in attention',
759
+ 'Sigmoid': 'Sigmoid: Squashes values to 0-1 range - used for gates/probabilities',
760
+ 'LayerNormalization': 'Layer Norm: Normalizes activations for stable training',
761
+ 'BatchNormalization': 'Batch Norm: Normalizes across batch - speeds up training',
762
+ 'Add': 'Addition: Element-wise sum - often a residual/skip connection',
763
+ 'Mul': 'Multiply: Element-wise product - used in attention and gating',
764
+ 'Div': 'Division: Often scaling (e.g., by sqrt(d) in attention)',
765
+ 'Transpose': 'Transpose: Rearranges tensor dimensions',
766
+ 'Reshape': 'Reshape: Changes tensor shape without changing data',
767
+ 'Gather': 'Gather: Lookup operation - retrieves embeddings by index',
768
+ 'Concat': 'Concatenate: Joins tensors along an axis',
769
+ 'Split': 'Split: Divides tensor into parts',
770
+ 'MaxPool': 'Max Pooling: Downsamples by taking maximum in each window',
771
+ 'GlobalAveragePool': 'Global Avg Pool: Reduces spatial dims to single values',
772
+ 'Flatten': 'Flatten: Collapses dimensions into 1D for dense layers',
773
+ 'Dropout': 'Dropout: Randomly zeros values during training for regularization',
774
+ 'Attention': 'Self-Attention: Computes relationships between all positions',
775
+ 'AttentionHead': 'Attention Head: Q/K/V projections + scaled dot-product attention',
776
+ 'MLPBlock': 'MLP/FFN: Feed-forward network - expands then contracts dimensions',
777
+ }};
778
+
779
+ function getOpDescription(node) {{
780
+ const opType = node.op_type || node.attributes?.block_type || '';
781
+ return opDescriptions[opType] || `${{opType}}: Neural network operation`;
782
+ }}
783
+
784
+ function showTooltip(event, node) {{
785
+ const opType = node.op_type || node.node_type;
786
+ const description = getOpDescription(node);
787
+
788
+ // Build detailed info
789
+ let details = '';
790
+
791
+ // Input/output info
792
+ if (node.inputs && node.inputs.length > 0) {{
793
+ details += `<div class="tooltip-row"><span class="tooltip-label">Inputs:</span><span class="tooltip-value">${{node.inputs.length}} tensor(s)</span></div>`;
794
+ }}
795
+ if (node.outputs && node.outputs.length > 0) {{
796
+ details += `<div class="tooltip-row"><span class="tooltip-label">Outputs:</span><span class="tooltip-value">${{node.outputs.length}} tensor(s)</span></div>`;
797
+ }}
798
+
799
+ // Child count for blocks
800
+ if (node.children && node.children.length > 0) {{
801
+ details += `<div class="tooltip-row"><span class="tooltip-label">Contains:</span><span class="tooltip-value">${{node.node_count || node.children.length}} ops</span></div>`;
802
+ }}
803
+
804
+ // Compute info
805
+ if (node.total_flops > 0) {{
806
+ const intensity = node.total_flops > 1e9 ? 'high' : node.total_flops > 1e6 ? 'medium' : 'low';
807
+ details += `<div class="tooltip-row"><span class="tooltip-label">Compute:</span><span class="tooltip-value">${{formatNumber(node.total_flops)}} FLOPs (${{intensity}})</span></div>`;
808
+ }}
809
+
810
+ // Memory info
811
+ if (node.total_memory_bytes > 0) {{
812
+ details += `<div class="tooltip-row"><span class="tooltip-label">Memory:</span><span class="tooltip-value">${{formatBytes(node.total_memory_bytes)}}</span></div>`;
813
+ }}
814
+
815
+ const html = `
816
+ <div class="tooltip-title">${{opType}}</div>
817
+ <div class="tooltip-desc">${{description}}</div>
818
+ ${{details}}
819
+ `;
820
+
821
+ tooltip.html(html);
822
+
823
+ // Smart positioning using fixed coordinates (relative to viewport)
824
+ const tooltipNode = tooltip.node();
825
+ const tooltipWidth = tooltipNode.offsetWidth || 280;
826
+ const tooltipHeight = tooltipNode.offsetHeight || 150;
827
+ const viewportWidth = window.innerWidth;
828
+ const viewportHeight = window.innerHeight;
829
+
830
+ // Use clientX/clientY for fixed positioning (viewport coords)
831
+ let left = event.clientX + 15;
832
+ let top = event.clientY + 15;
833
+
834
+ // Check right edge - flip to left side if needed
835
+ if (left + tooltipWidth > viewportWidth - 20) {{
836
+ left = event.clientX - tooltipWidth - 15;
837
+ }}
838
+
839
+ // Check bottom edge - flip to top if needed
840
+ if (top + tooltipHeight > viewportHeight - 20) {{
841
+ top = event.clientY - tooltipHeight - 15;
842
+ }}
843
+
844
+ // Ensure not off left or top edge
845
+ left = Math.max(10, left);
846
+ top = Math.max(10, top);
847
+
848
+ tooltip
849
+ .style('left', left + 'px')
850
+ .style('top', top + 'px')
851
+ .classed('visible', true);
852
+ }}
853
+
854
+ function hideTooltip() {{
855
+ tooltip.classed('visible', false);
856
+ }}
857
+
858
+ // Improved grid layout with depth calculation attempt
859
+ // Falls back to clean grid if depth calc fails
860
+ function layoutNodes(nodes) {{
861
+ // Check if sidebar is collapsed for width calculation
862
+ const sidebar = document.getElementById('graphSidebar');
863
+ const sidebarWidth = sidebar && sidebar.classList.contains('collapsed') ? 0 : 280;
864
+ const width = window.innerWidth - sidebarWidth - 40;
865
+ const height = window.innerHeight;
866
+ const padding = 40;
867
+
868
+ // Try to calculate depths based on inputs/outputs
869
+ const outputToNode = {{}};
870
+ const depths = {{}};
871
+
872
+ nodes.forEach(node => {{
873
+ if (node.outputs) {{
874
+ node.outputs.forEach(out => {{ outputToNode[out] = node; }});
875
+ }}
876
+ }});
877
+
878
+ // Simple depth: count parent chain length
879
+ function getDepth(node, visited) {{
880
+ if (!node) return 0;
881
+ if (depths[node.id] !== undefined) return depths[node.id];
882
+ if (visited[node.id]) return 0;
883
+ visited[node.id] = true;
884
+
885
+ let maxParent = -1;
886
+ if (node.inputs && node.inputs.length > 0) {{
887
+ for (const inp of node.inputs) {{
888
+ const parent = outputToNode[inp];
889
+ if (parent) {{
890
+ maxParent = Math.max(maxParent, getDepth(parent, visited));
891
+ }}
892
+ }}
893
+ }}
894
+ depths[node.id] = maxParent + 1;
895
+ return depths[node.id];
896
+ }}
897
+
898
+ // Calculate all depths
899
+ nodes.forEach(n => getDepth(n, {{}}));
900
+
901
+ // Group by depth
902
+ const byDepth = {{}};
903
+ let maxDepth = 0;
904
+ nodes.forEach(node => {{
905
+ const d = depths[node.id] || 0;
906
+ maxDepth = Math.max(maxDepth, d);
907
+ if (!byDepth[d]) byDepth[d] = [];
908
+ byDepth[d].push(node);
909
+ }});
910
+
911
+ // If we got meaningful depths, use horizontal flow layout
912
+ if (maxDepth > 0) {{
913
+ // Prefer horizontal spread - use full width
914
+ const colWidth = (width - padding * 2) / (maxDepth + 1);
915
+ const availableHeight = height - padding * 2;
916
+
917
+ for (let d = 0; d <= maxDepth; d++) {{
918
+ const nodesAtDepth = byDepth[d] || [];
919
+ const x = padding + d * colWidth + colWidth / 2;
920
+
921
+ // Distribute vertically within available height
922
+ const rowH = availableHeight / Math.max(nodesAtDepth.length, 1);
923
+
924
+ nodesAtDepth.forEach((node, i) => {{
925
+ node.x = x;
926
+ node.y = padding + i * rowH + rowH / 2;
927
+ node.r = getNodeSize(node);
928
+ }});
929
+ }}
930
+ }} else {{
931
+ // Fallback: horizontal-biased grid (more columns than rows)
932
+ const cols = Math.ceil(Math.sqrt(nodes.length * 2.5));
933
+ const rows = Math.ceil(nodes.length / cols);
934
+ const cellW = (width - padding * 2) / cols;
935
+ const cellH = (height - padding * 2) / rows;
936
+
937
+ nodes.forEach((node, i) => {{
938
+ node.x = padding + (i % cols) * cellW + cellW / 2;
939
+ node.y = padding + Math.floor(i / cols) * cellH + cellH / 2;
940
+ node.r = getNodeSize(node);
941
+ }});
942
+ }}
943
+
944
+ return nodes;
945
+ }}
946
+
947
+ // Render graph with performance optimizations (Task 5.7.9)
948
+ function render() {{
949
+ container.selectAll('*').remove();
950
+
951
+ // Flatten visible nodes
952
+ let visibleNodes = [];
953
+ function collectVisible(node) {{
954
+ visibleNodes.push(node);
955
+ if (!node.is_collapsed && node.children) {{
956
+ node.children.forEach(collectVisible);
957
+ }}
958
+ }}
959
+
960
+ if (graphData.root) {{
961
+ collectVisible(graphData.root);
962
+ }}
963
+
964
+ // Performance: limit visible nodes in performance mode
965
+ if (performanceMode && visibleNodes.length > 1000) {{
966
+ console.log(`Limiting display to 1000 nodes (had ${{visibleNodes.length}})`);
967
+ // Prioritize by FLOPs/importance
968
+ visibleNodes = visibleNodes
969
+ .sort((a, b) => (b.total_flops || 0) - (a.total_flops || 0))
970
+ .slice(0, 1000);
971
+ }}
972
+
973
+ // Compute max FLOPs for size scaling (expensive ops = bigger nodes)
974
+ globalMaxFlops = Math.max(1, ...visibleNodes.map(n => n.total_flops || 0));
975
+
976
+ // Layout
977
+ layoutNodes(visibleNodes);
978
+
979
+ // Create node lookup for edges
980
+ const nodeById = {{}};
981
+ visibleNodes.forEach(n => {{
982
+ nodeById[n.id] = n;
983
+ nodeById[n.name] = n;
984
+ }});
985
+
986
+ // Draw edges first (so they're behind nodes)
987
+ const edges = [];
988
+ visibleNodes.forEach(node => {{
989
+ if (node.outputs) {{
990
+ node.outputs.forEach(output => {{
991
+ // Find nodes that consume this output
992
+ visibleNodes.forEach(target => {{
993
+ if (target.inputs && target.inputs.includes(output)) {{
994
+ edges.push({{
995
+ source: node,
996
+ target: target,
997
+ output: output
998
+ }});
999
+ }}
1000
+ }});
1001
+ }});
1002
+ }}
1003
+ }});
1004
+
1005
+ // Setup defs for markers and filters
1006
+ const defs = container.append('defs');
1007
+
1008
+ // Arrow marker for edges
1009
+ defs.append('marker')
1010
+ .attr('id', 'arrowhead')
1011
+ .attr('viewBox', '0 -5 10 10')
1012
+ .attr('refX', 8)
1013
+ .attr('refY', 0)
1014
+ .attr('markerWidth', 5)
1015
+ .attr('markerHeight', 5)
1016
+ .attr('orient', 'auto')
1017
+ .append('path')
1018
+ .attr('d', 'M0,-3L8,0L0,3')
1019
+ .attr('fill', 'rgba(255,255,255,0.5)');
1020
+
1021
+ // Glow filter for nodes
1022
+ const filter = defs.append('filter')
1023
+ .attr('id', 'glow');
1024
+ filter.append('feGaussianBlur')
1025
+ .attr('stdDeviation', '2')
1026
+ .attr('result', 'coloredBlur');
1027
+
1028
+ // Draw edges with arrows
1029
+ container.selectAll('.edge')
1030
+ .data(edges)
1031
+ .enter()
1032
+ .append('path')
1033
+ .attr('class', 'edge')
1034
+ .attr('d', d => {{
1035
+ const sr = d.source.r || 20;
1036
+ const tr = d.target.r || 20;
1037
+ const sx = d.source.x + sr;
1038
+ const sy = d.source.y;
1039
+ const tx = d.target.x - tr;
1040
+ const ty = d.target.y;
1041
+
1042
+ // Bezier curve
1043
+ const midX = (sx + tx) / 2;
1044
+ return `M${{sx}},${{sy}} C${{midX}},${{sy}} ${{midX}},${{ty}} ${{tx}},${{ty}}`;
1045
+ }})
1046
+ .attr('stroke', 'rgba(255,255,255,0.3)')
1047
+ .attr('stroke-width', 1.5)
1048
+ .attr('marker-end', 'url(#arrowhead)');
1049
+
1050
+ // Draw nodes as circles
1051
+ const nodeGroups = container.selectAll('.node')
1052
+ .data(visibleNodes)
1053
+ .enter()
1054
+ .append('g')
1055
+ .attr('class', d => {{
1056
+ // Show labels for important nodes: model, layers, blocks
1057
+ const isImportant = d.node_type === 'model' ||
1058
+ d.node_type === 'layer' ||
1059
+ d.node_type === 'block' ||
1060
+ d.r > 35;
1061
+ return 'node' + (isImportant ? ' show-label' : '');
1062
+ }})
1063
+ .attr('transform', d => `translate(${{d.x}}, ${{d.y}})`)
1064
+ .on('mouseover', showTooltip)
1065
+ .on('mouseout', hideTooltip)
1066
+ .on('click', (event, d) => {{
1067
+ if (d.children && d.children.length > 0) {{
1068
+ d.is_collapsed = !d.is_collapsed;
1069
+ render();
1070
+ }}
1071
+ }});
1072
+
1073
+ // Calculate max FLOPs for heat map
1074
+ const maxFlops = getMaxFlops(visibleNodes);
1075
+
1076
+ // Circle nodes - use heat map or category colors
1077
+ nodeGroups.append('circle')
1078
+ .attr('class', 'node-circle')
1079
+ .attr('r', d => d.r)
1080
+ .attr('fill', d => {{
1081
+ if (heatMapMode && d.total_flops > 0) {{
1082
+ return getHeatColor(d.total_flops, maxFlops);
1083
+ }}
1084
+ return getNodeColor(d);
1085
+ }})
1086
+ .attr('opacity', d => d.node_type === 'model' ? 0.9 : 0.85)
1087
+ .style('filter', 'url(#glow)');
1088
+
1089
+ // Inner highlight
1090
+ nodeGroups.append('circle')
1091
+ .attr('r', d => d.r - 2)
1092
+ .attr('fill', 'none')
1093
+ .attr('stroke', 'rgba(255,255,255,0.2)')
1094
+ .attr('stroke-width', 1);
1095
+
1096
+ // Symbol in center
1097
+ nodeGroups.append('text')
1098
+ .attr('class', 'node-symbol')
1099
+ .attr('y', d => d.r > 28 ? -4 : 0)
1100
+ .attr('font-size', d => d.r > 28 ? '14px' : '12px')
1101
+ .attr('fill', 'white')
1102
+ .attr('text-anchor', 'middle')
1103
+ .attr('dominant-baseline', 'middle')
1104
+ .text(d => getNodeSymbol(d));
1105
+
1106
+ // Labels below symbol for larger nodes
1107
+ nodeGroups.filter(d => d.r > 28)
1108
+ .append('text')
1109
+ .attr('class', 'node-label')
1110
+ .attr('y', 10)
1111
+ .text(d => truncate(getNodeLabel(d), 8));
1112
+
1113
+ // Just label for small nodes
1114
+ nodeGroups.filter(d => d.r <= 28)
1115
+ .append('text')
1116
+ .attr('class', 'node-label')
1117
+ .attr('y', d => d.r + 14)
1118
+ .text(d => truncate(getNodeLabel(d), 6));
1119
+
1120
+ // Expand indicator for blocks
1121
+ nodeGroups.filter(d => d.children && d.children.length > 0)
1122
+ .append('circle')
1123
+ .attr('cx', d => d.r * 0.7)
1124
+ .attr('cy', d => -d.r * 0.7)
1125
+ .attr('r', 8)
1126
+ .attr('fill', '#0A84FF')
1127
+ .attr('stroke', '#000')
1128
+ .attr('stroke-width', 1);
1129
+
1130
+ nodeGroups.filter(d => d.children && d.children.length > 0)
1131
+ .append('text')
1132
+ .attr('x', d => d.r * 0.7)
1133
+ .attr('y', d => -d.r * 0.7 + 1)
1134
+ .attr('text-anchor', 'middle')
1135
+ .attr('dominant-baseline', 'middle')
1136
+ .attr('fill', 'white')
1137
+ .attr('font-size', '10px')
1138
+ .attr('font-weight', '600')
1139
+ .text(d => d.is_collapsed ? '+' : '-');
1140
+
1141
+ // Update stats
1142
+ document.getElementById('node-count').textContent = visibleNodes.length;
1143
+ document.getElementById('edge-count').textContent = edges.length || edgeData?.num_edges || 0;
1144
+ document.getElementById('peak-memory').textContent = formatBytes(edgeData?.peak_activation_bytes || 0);
1145
+ document.getElementById('model-size').textContent = formatBytes(graphData.model_size_bytes || 0);
1146
+ }}
1147
+
1148
+ // Sidebar toggle for more graph space
1149
+ function toggleSidebar() {{
1150
+ const sidebar = document.getElementById('graphSidebar');
1151
+ const toggleBtn = document.getElementById('sidebarToggle');
1152
+ const isCollapsed = sidebar.classList.toggle('collapsed');
1153
+ toggleBtn.textContent = isCollapsed ? '▶' : '◀';
1154
+ toggleBtn.style.left = isCollapsed ? '8px' : '248px';
1155
+ // Re-fit graph after sidebar toggle
1156
+ setTimeout(fitToScreen, 350);
1157
+ }}
1158
+
1159
+ function zoomIn() {{
1160
+ svg.transition().duration(300).call(zoom.scaleBy, 1.3);
1161
+ }}
1162
+
1163
+ function zoomOut() {{
1164
+ svg.transition().duration(300).call(zoom.scaleBy, 0.7);
1165
+ }}
1166
+
1167
+ let activeFilter = 'all';
1168
+ let showAllLabels = false;
1169
+
1170
+ function toggleAllLabels() {{
1171
+ showAllLabels = !showAllLabels;
1172
+ const btn = document.getElementById('labels-btn');
1173
+ btn.style.background = showAllLabels ? 'var(--accent)' : '';
1174
+ btn.style.color = showAllLabels ? 'white' : '';
1175
+ btn.textContent = showAllLabels ? 'Hide Labels' : 'Show All Labels';
1176
+
1177
+ // Toggle show-label class on all nodes
1178
+ container.selectAll('.node').classed('show-label', showAllLabels);
1179
+ }}
1180
+
1181
+ function toggleHeatMap() {{
1182
+ heatMapMode = !heatMapMode;
1183
+
1184
+ const btn = document.getElementById('heatmap-btn');
1185
+ btn.style.background = heatMapMode ? 'var(--accent)' : '';
1186
+ btn.style.color = heatMapMode ? 'white' : '';
1187
+
1188
+ // Toggle legend visibility
1189
+ document.getElementById('heatmap-legend').style.display = heatMapMode ? 'block' : 'none';
1190
+ document.getElementById('optype-legend-note').style.display = heatMapMode ? 'none' : 'block';
1191
+
1192
+ render();
1193
+ }}
1194
+
1195
+ function filterByCategory(category) {{
1196
+ activeFilter = category;
1197
+
1198
+ // Update legend active state
1199
+ document.querySelectorAll('#op-legend .legend-item').forEach(item => {{
1200
+ item.classList.remove('active');
1201
+ if (item.dataset.category === category) {{
1202
+ item.classList.add('active');
1203
+ }}
1204
+ }});
1205
+
1206
+ // Update node visibility/opacity
1207
+ container.selectAll('.node').each(function(d) {{
1208
+ const nodeCategory = getOpCategory(d);
1209
+ const visible = category === 'all' || nodeCategory === category;
1210
+ d3.select(this)
1211
+ .transition()
1212
+ .duration(200)
1213
+ .style('opacity', visible ? 1 : 0.15);
1214
+ }});
1215
+ }}
1216
+
1217
+ // Calculate max FLOPs for heat map scaling
1218
+ function getMaxFlops(nodes) {{
1219
+ let max = 0;
1220
+ nodes.forEach(n => {{
1221
+ if (n.total_flops > max) max = n.total_flops;
1222
+ }});
1223
+ return max || 1;
1224
+ }}
1225
+
1226
+ // Helper functions
1227
+ function truncate(str, len) {{
1228
+ return str.length > len ? str.slice(0, len) + '...' : str;
1229
+ }}
1230
+
1231
+ function formatNumber(n) {{
1232
+ if (n >= 1e12) return (n / 1e12).toFixed(1) + 'T';
1233
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'G';
1234
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1235
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
1236
+ return n.toString();
1237
+ }}
1238
+
1239
+ function formatBytes(bytes) {{
1240
+ if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + ' GB';
1241
+ if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB';
1242
+ if (bytes >= 1e3) return (bytes / 1e3).toFixed(1) + ' KB';
1243
+ return bytes + ' B';
1244
+ }}
1245
+
1246
+ // Control functions
1247
+ function expandAll() {{
1248
+ function expand(node) {{
1249
+ node.is_collapsed = false;
1250
+ if (node.children) node.children.forEach(expand);
1251
+ }}
1252
+ if (graphData.root) expand(graphData.root);
1253
+ render();
1254
+ }}
1255
+
1256
+ function collapseAll() {{
1257
+ function collapse(node) {{
1258
+ if (node.children && node.children.length > 0) {{
1259
+ node.is_collapsed = true;
1260
+ }}
1261
+ if (node.children) node.children.forEach(collapse);
1262
+ }}
1263
+ if (graphData.root) collapse(graphData.root);
1264
+ render();
1265
+ }}
1266
+
1267
+ function resetZoom() {{
1268
+ svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
1269
+ }}
1270
+
1271
+ function fitToScreen() {{
1272
+ const bounds = container.node().getBBox();
1273
+ const parent = svg.node().parentElement;
1274
+ const fullWidth = parent.clientWidth;
1275
+ const fullHeight = parent.clientHeight;
1276
+ const width = bounds.width;
1277
+ const height = bounds.height;
1278
+ const midX = bounds.x + width / 2;
1279
+ const midY = bounds.y + height / 2;
1280
+
1281
+ const scale = 0.9 / Math.max(width / fullWidth, height / fullHeight);
1282
+ const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY];
1283
+
1284
+ svg.transition().duration(500).call(
1285
+ zoom.transform,
1286
+ d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
1287
+ );
1288
+ }}
1289
+
1290
+ // Task 5.7.6: Search functionality
1291
+ let allFlatNodes = [];
1292
+
1293
+ function buildSearchIndex() {{
1294
+ allFlatNodes = [];
1295
+ function collect(node) {{
1296
+ allFlatNodes.push(node);
1297
+ if (node.children) node.children.forEach(collect);
1298
+ }}
1299
+ if (graphData.root) collect(graphData.root);
1300
+ }}
1301
+
1302
+ function searchNodes(query) {{
1303
+ const resultsDiv = document.getElementById('searchResults');
1304
+ resultsDiv.innerHTML = '';
1305
+
1306
+ if (!query || query.length < 2) {{
1307
+ // Clear highlights
1308
+ container.selectAll('.node').classed('search-highlight', false);
1309
+ return;
1310
+ }}
1311
+
1312
+ query = query.toLowerCase();
1313
+ const matches = allFlatNodes.filter(node => {{
1314
+ const name = (node.name || '').toLowerCase();
1315
+ const opType = (node.op_type || '').toLowerCase();
1316
+ const blockType = (node.attributes?.block_type || '').toLowerCase();
1317
+ return name.includes(query) || opType.includes(query) || blockType.includes(query);
1318
+ }}).slice(0, 20); // Limit to 20 results
1319
+
1320
+ if (matches.length === 0) {{
1321
+ resultsDiv.innerHTML = '<div style="font-size: 0.7rem; color: var(--text-tertiary); padding: 8px;">No matches found</div>';
1322
+ return;
1323
+ }}
1324
+
1325
+ matches.forEach(node => {{
1326
+ const div = document.createElement('div');
1327
+ div.className = 'search-result';
1328
+ div.innerHTML = `
1329
+ <span class="result-name">${{node.name}}</span>
1330
+ <span class="result-type">${{node.op_type || node.node_type}}</span>
1331
+ `;
1332
+ div.onclick = () => highlightAndFocusNode(node);
1333
+ resultsDiv.appendChild(div);
1334
+ }});
1335
+ }}
1336
+
1337
+ function highlightAndFocusNode(targetNode) {{
1338
+ // Expand path to node if collapsed
1339
+ function expandToNode(node, target) {{
1340
+ if (node.id === target.id) return true;
1341
+ if (node.children) {{
1342
+ for (const child of node.children) {{
1343
+ if (expandToNode(child, target)) {{
1344
+ node.is_collapsed = false;
1345
+ return true;
1346
+ }}
1347
+ }}
1348
+ }}
1349
+ return false;
1350
+ }}
1351
+
1352
+ if (graphData.root) expandToNode(graphData.root, targetNode);
1353
+ render();
1354
+
1355
+ // Highlight the node
1356
+ setTimeout(() => {{
1357
+ container.selectAll('.node').classed('search-highlight', false);
1358
+ container.selectAll('.node')
1359
+ .filter(d => d.id === targetNode.id)
1360
+ .classed('search-highlight', true);
1361
+
1362
+ // Pan to node
1363
+ const nodeElem = container.selectAll('.node').filter(d => d.id === targetNode.id);
1364
+ if (!nodeElem.empty()) {{
1365
+ const d = nodeElem.datum();
1366
+ const width = window.innerWidth - 320;
1367
+ const height = window.innerHeight;
1368
+ const scale = 1.5;
1369
+ const x = width / 2 - d.x * scale;
1370
+ const y = height / 2 - d.y * scale;
1371
+
1372
+ svg.transition().duration(500).call(
1373
+ zoom.transform,
1374
+ d3.zoomIdentity.translate(x, y).scale(scale)
1375
+ );
1376
+ }}
1377
+ }}, 100);
1378
+ }}
1379
+
1380
+ // Task 5.7.9: Performance mode for large graphs
1381
+ const LARGE_GRAPH_THRESHOLD = 500;
1382
+ const VERY_LARGE_THRESHOLD = 5000;
1383
+ let performanceMode = false;
1384
+ let cullingEnabled = false;
1385
+
1386
+ function checkPerformanceMode() {{
1387
+ const totalNodes = graphData.total_nodes || 0;
1388
+
1389
+ if (totalNodes > VERY_LARGE_THRESHOLD) {{
1390
+ performanceMode = true;
1391
+ cullingEnabled = true;
1392
+ console.log(`Performance mode: ON (${{totalNodes}} nodes). Culling enabled.`);
1393
+
1394
+ // Add indicator
1395
+ const sidebar = document.querySelector('.sidebar');
1396
+ const perfDiv = document.createElement('div');
1397
+ perfDiv.className = 'perf-mode';
1398
+ perfDiv.innerHTML = `Large model (${{formatNumber(totalNodes)}} nodes) - simplified view`;
1399
+ sidebar.insertBefore(perfDiv, sidebar.firstChild.nextSibling);
1400
+
1401
+ // Auto-collapse to depth 1
1402
+ if (graphData.root) {{
1403
+ function collapseDeep(node, depth) {{
1404
+ if (depth > 1 && node.children && node.children.length > 0) {{
1405
+ node.is_collapsed = true;
1406
+ }}
1407
+ if (node.children) node.children.forEach(c => collapseDeep(c, depth + 1));
1408
+ }}
1409
+ collapseDeep(graphData.root, 0);
1410
+ }}
1411
+ }} else if (totalNodes > LARGE_GRAPH_THRESHOLD) {{
1412
+ performanceMode = true;
1413
+ console.log(`Performance mode: ON (${{totalNodes}} nodes). Simplified rendering.`);
1414
+ }}
1415
+ }}
1416
+
1417
+ // Viewport culling for very large graphs
1418
+ function getViewportBounds() {{
1419
+ const transform = d3.zoomTransform(svg.node());
1420
+ const width = window.innerWidth - 320;
1421
+ const height = window.innerHeight;
1422
+
1423
+ return {{
1424
+ x: -transform.x / transform.k - 100,
1425
+ y: -transform.y / transform.k - 100,
1426
+ width: width / transform.k + 200,
1427
+ height: height / transform.k + 200
1428
+ }};
1429
+ }}
1430
+
1431
+ function isInViewport(node, bounds) {{
1432
+ if (!cullingEnabled) return true;
1433
+ if (!node.x || !node.y) return true;
1434
+ const r = node.r || 30;
1435
+ return node.x + r >= bounds.x &&
1436
+ node.x - r <= bounds.x + bounds.width &&
1437
+ node.y + r >= bounds.y &&
1438
+ node.y - r <= bounds.y + bounds.height;
1439
+ }}
1440
+
1441
+ // Initialize
1442
+ buildSearchIndex();
1443
+ checkPerformanceMode();
1444
+
1445
+ // Check if embedded in iframe (Streamlit) - auto-collapse sidebar for more space
1446
+ const isEmbedded = window.self !== window.top;
1447
+ if (isEmbedded) {{
1448
+ // Start with sidebar collapsed in embedded mode
1449
+ setTimeout(() => {{
1450
+ const sidebar = document.getElementById('graphSidebar');
1451
+ const toggleBtn = document.getElementById('sidebarToggle');
1452
+ if (sidebar && toggleBtn) {{
1453
+ sidebar.classList.add('collapsed');
1454
+ toggleBtn.textContent = '▶';
1455
+ toggleBtn.style.left = '8px';
1456
+ }}
1457
+ }}, 50);
1458
+ }}
1459
+
1460
+ // Initial render
1461
+ render();
1462
+ // Auto-fit after a short delay to ensure everything is laid out
1463
+ setTimeout(fitToScreen, isEmbedded ? 200 : 100);
1464
+ </script>
1465
+ </body>
1466
+ </html>
1467
+ """
1468
+
1469
+
1470
+ def generate_html(
1471
+ graph: HierarchicalGraph,
1472
+ edge_result: EdgeAnalysisResult | None = None,
1473
+ title: str = "Model Architecture",
1474
+ output_path: Path | str | None = None,
1475
+ model_size_bytes: int | None = None,
1476
+ layer_timing: dict[str, float] | None = None,
1477
+ ) -> str:
1478
+ """
1479
+ Generate interactive HTML visualization.
1480
+
1481
+ Task 5.8.1-5.8.5: Create standalone HTML with embedded visualization.
1482
+
1483
+ Args:
1484
+ graph: HierarchicalGraph to visualize.
1485
+ edge_result: Optional edge analysis results.
1486
+ title: Page title.
1487
+ output_path: Optional path to save HTML file.
1488
+ model_size_bytes: Optional model file size in bytes.
1489
+ layer_timing: Optional dict mapping layer name to execution time (ms).
1490
+ Used to color-code nodes by execution time (hotspots).
1491
+
1492
+ Returns:
1493
+ HTML content as string.
1494
+ """
1495
+ # Convert graph to JSON, adding model_size_bytes and layer_timing
1496
+ graph_dict = graph.to_dict()
1497
+ if model_size_bytes is not None:
1498
+ graph_dict["model_size_bytes"] = model_size_bytes
1499
+ if layer_timing is not None:
1500
+ graph_dict["layer_timing"] = layer_timing
1501
+ graph_json = json.dumps(graph_dict)
1502
+
1503
+ # Convert edge analysis to JSON
1504
+ if edge_result:
1505
+ edge_json = json.dumps(edge_result.to_dict())
1506
+ else:
1507
+ edge_json = "{}"
1508
+
1509
+ # Generate HTML
1510
+ html = HTML_TEMPLATE.format(
1511
+ title=title,
1512
+ graph_json=graph_json,
1513
+ edge_json=edge_json,
1514
+ )
1515
+
1516
+ # Save to file if path provided
1517
+ if output_path:
1518
+ output_path = Path(output_path)
1519
+ output_path.write_text(html, encoding="utf-8")
1520
+
1521
+ return html
1522
+
1523
+
1524
+ class HTMLExporter:
1525
+ """
1526
+ Export ONNX models to interactive HTML visualization.
1527
+
1528
+ Combines HierarchicalGraph and EdgeAnalysis into a single
1529
+ standalone HTML file.
1530
+ """
1531
+
1532
+ def __init__(self, logger: logging.Logger | None = None):
1533
+ self.logger = logger or logging.getLogger("haoline.html")
1534
+
1535
+ def export(
1536
+ self,
1537
+ graph: HierarchicalGraph,
1538
+ edge_result: EdgeAnalysisResult | None = None,
1539
+ output_path: Path | str = "model_graph.html",
1540
+ title: str | None = None,
1541
+ model_size_bytes: int | None = None,
1542
+ layer_timing: dict[str, float] | None = None,
1543
+ ) -> Path:
1544
+ """
1545
+ Export graph to HTML file.
1546
+
1547
+ Args:
1548
+ graph: HierarchicalGraph to export.
1549
+ edge_result: Optional EdgeAnalysisResult for edge data.
1550
+ output_path: Output file path.
1551
+ title: Optional page title.
1552
+ model_size_bytes: Optional model file size in bytes.
1553
+ layer_timing: Optional dict mapping layer name to execution time (ms).
1554
+
1555
+ Returns:
1556
+ Path to generated HTML file.
1557
+ """
1558
+ output_path = Path(output_path)
1559
+
1560
+ if title is None:
1561
+ title = graph.root.name if graph.root else "Model"
1562
+
1563
+ generate_html(
1564
+ graph=graph,
1565
+ edge_result=edge_result,
1566
+ title=title,
1567
+ output_path=output_path,
1568
+ model_size_bytes=model_size_bytes,
1569
+ layer_timing=layer_timing,
1570
+ )
1571
+
1572
+ self.logger.info(f"HTML visualization exported to {output_path}")
1573
+ return output_path