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
@@ -0,0 +1,769 @@
1
+ # Copyright (c) 2025 HaoLine Contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Per-Layer Summary Table for HaoLine.
6
+
7
+ Story 5.8: Creates sortable, filterable tables showing per-layer metrics
8
+ (params, FLOPs, latency estimate, memory).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import io
15
+ import json
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from .analyzer import FlopCounts, GraphInfo, MemoryEstimates, ParamCounts
23
+ from .report import InspectionReport
24
+
25
+
26
+ @dataclass
27
+ class LayerMetrics:
28
+ """Metrics for a single layer/node."""
29
+
30
+ name: str
31
+ op_type: str
32
+ input_shapes: list[str] = field(default_factory=list)
33
+ output_shapes: list[str] = field(default_factory=list)
34
+ params: int = 0
35
+ flops: int = 0
36
+ memory_bytes: int = 0
37
+ latency_ms: float = 0.0 # Estimated
38
+ pct_params: float = 0.0
39
+ pct_flops: float = 0.0
40
+ depth: int = 0
41
+
42
+ def to_dict(self) -> dict:
43
+ """Convert to dictionary for JSON export."""
44
+ return {
45
+ "name": self.name,
46
+ "op_type": self.op_type,
47
+ "input_shapes": self.input_shapes,
48
+ "output_shapes": self.output_shapes,
49
+ "params": self.params,
50
+ "flops": self.flops,
51
+ "memory_bytes": self.memory_bytes,
52
+ "latency_ms": self.latency_ms,
53
+ "pct_params": self.pct_params,
54
+ "pct_flops": self.pct_flops,
55
+ "depth": self.depth,
56
+ }
57
+
58
+
59
+ @dataclass
60
+ class LayerSummary:
61
+ """Complete layer summary for a model."""
62
+
63
+ layers: list[LayerMetrics] = field(default_factory=list)
64
+ total_params: int = 0
65
+ total_flops: int = 0
66
+ total_memory: int = 0
67
+
68
+ def to_dict(self) -> dict:
69
+ """Convert to dictionary."""
70
+ return {
71
+ "layers": [layer.to_dict() for layer in self.layers],
72
+ "total_params": self.total_params,
73
+ "total_flops": self.total_flops,
74
+ "total_memory": self.total_memory,
75
+ }
76
+
77
+ def to_json(self, indent: int = 2) -> str:
78
+ """Export to JSON string."""
79
+ return json.dumps(self.to_dict(), indent=indent)
80
+
81
+ def to_csv(self) -> str:
82
+ """
83
+ Export to CSV format.
84
+
85
+ Task 5.8.4: Export table as CSV.
86
+ """
87
+ output = io.StringIO()
88
+ writer = csv.writer(output)
89
+
90
+ # Header
91
+ writer.writerow(
92
+ [
93
+ "Layer Name",
94
+ "Op Type",
95
+ "Input Shape",
96
+ "Output Shape",
97
+ "Parameters",
98
+ "FLOPs",
99
+ "Memory (bytes)",
100
+ "Latency (ms)",
101
+ "% Params",
102
+ "% FLOPs",
103
+ ]
104
+ )
105
+
106
+ # Data rows
107
+ for layer in self.layers:
108
+ writer.writerow(
109
+ [
110
+ layer.name,
111
+ layer.op_type,
112
+ "; ".join(layer.input_shapes),
113
+ "; ".join(layer.output_shapes),
114
+ layer.params,
115
+ layer.flops,
116
+ layer.memory_bytes,
117
+ f"{layer.latency_ms:.4f}",
118
+ f"{layer.pct_params:.2f}",
119
+ f"{layer.pct_flops:.2f}",
120
+ ]
121
+ )
122
+
123
+ return output.getvalue()
124
+
125
+ def save_csv(self, path: Path | str) -> None:
126
+ """Save to CSV file."""
127
+ path = Path(path)
128
+ path.write_text(self.to_csv(), encoding="utf-8")
129
+
130
+ def filter_by_op_type(self, op_types: list[str]) -> LayerSummary:
131
+ """Return a new summary filtered by op type."""
132
+ filtered = [layer for layer in self.layers if layer.op_type in op_types]
133
+ return LayerSummary(
134
+ layers=filtered,
135
+ total_params=self.total_params,
136
+ total_flops=self.total_flops,
137
+ total_memory=self.total_memory,
138
+ )
139
+
140
+ def filter_by_threshold(
141
+ self,
142
+ min_params: int = 0,
143
+ min_flops: int = 0,
144
+ min_pct_params: float = 0.0,
145
+ min_pct_flops: float = 0.0,
146
+ ) -> LayerSummary:
147
+ """Return a new summary filtered by thresholds."""
148
+ filtered = [
149
+ layer
150
+ for layer in self.layers
151
+ if layer.params >= min_params
152
+ and layer.flops >= min_flops
153
+ and layer.pct_params >= min_pct_params
154
+ and layer.pct_flops >= min_pct_flops
155
+ ]
156
+ return LayerSummary(
157
+ layers=filtered,
158
+ total_params=self.total_params,
159
+ total_flops=self.total_flops,
160
+ total_memory=self.total_memory,
161
+ )
162
+
163
+ def sort_by(self, key: str, descending: bool = True) -> LayerSummary:
164
+ """
165
+ Return a new summary sorted by the specified key.
166
+
167
+ Args:
168
+ key: One of 'name', 'op_type', 'params', 'flops', 'memory_bytes',
169
+ 'latency_ms', 'pct_params', 'pct_flops', 'depth'
170
+ descending: Sort in descending order if True
171
+ """
172
+ valid_keys = {
173
+ "name",
174
+ "op_type",
175
+ "params",
176
+ "flops",
177
+ "memory_bytes",
178
+ "latency_ms",
179
+ "pct_params",
180
+ "pct_flops",
181
+ "depth",
182
+ "input_shapes",
183
+ "output_shapes",
184
+ }
185
+ key_normalized = key.replace("-", "_")
186
+ if key_normalized not in valid_keys:
187
+ raise ValueError(f"Invalid sort key: {key}")
188
+
189
+ sorted_layers = sorted(
190
+ self.layers,
191
+ key=lambda layer: getattr(layer, key_normalized),
192
+ reverse=descending,
193
+ )
194
+ return LayerSummary(
195
+ layers=sorted_layers,
196
+ total_params=self.total_params,
197
+ total_flops=self.total_flops,
198
+ total_memory=self.total_memory,
199
+ )
200
+
201
+ def top_n(self, n: int, key: str = "flops") -> LayerSummary:
202
+ """Get top N layers by the specified metric."""
203
+ sorted_summary = self.sort_by(key, descending=True)
204
+ return LayerSummary(
205
+ layers=sorted_summary.layers[:n],
206
+ total_params=self.total_params,
207
+ total_flops=self.total_flops,
208
+ total_memory=self.total_memory,
209
+ )
210
+
211
+
212
+ class LayerSummaryBuilder:
213
+ """
214
+ Build per-layer summary from ONNX graph analysis.
215
+
216
+ Task 5.8.1: Create per-layer summary table.
217
+ """
218
+
219
+ def __init__(self, logger: logging.Logger | None = None):
220
+ self.logger = logger or logging.getLogger("haoline.layer_summary")
221
+
222
+ def build(
223
+ self,
224
+ graph_info: GraphInfo,
225
+ param_counts: ParamCounts | None = None,
226
+ flop_counts: FlopCounts | None = None,
227
+ memory_estimates: MemoryEstimates | None = None,
228
+ ) -> LayerSummary:
229
+ """
230
+ Build layer summary from graph analysis.
231
+
232
+ Args:
233
+ graph_info: Parsed ONNX graph.
234
+ param_counts: Parameter counts from analysis.
235
+ flop_counts: FLOPs from analysis.
236
+ memory_estimates: Memory estimates from analysis.
237
+
238
+ Returns:
239
+ LayerSummary with per-layer metrics.
240
+ """
241
+ layers = []
242
+
243
+ # Calculate totals for percentages
244
+ total_params = param_counts.total if param_counts else 0
245
+ total_flops = flop_counts.total if flop_counts else 0
246
+ total_memory = memory_estimates.model_size_bytes if memory_estimates else 0
247
+
248
+ # Build tensor shape map for input/output shapes
249
+ tensor_shapes: dict[str, str] = {}
250
+ for name, shape in graph_info.input_shapes.items():
251
+ tensor_shapes[name] = str(shape)
252
+ for name, shape in graph_info.output_shapes.items():
253
+ tensor_shapes[name] = str(shape)
254
+
255
+ # Process each node
256
+ for idx, node in enumerate(graph_info.nodes):
257
+ # Get metrics for this node
258
+ node_params = 0
259
+ node_flops = 0
260
+
261
+ if param_counts and param_counts.by_node:
262
+ node_params = int(param_counts.by_node.get(node.name, 0))
263
+
264
+ if flop_counts and flop_counts.by_node:
265
+ node_flops = flop_counts.by_node.get(node.name, 0)
266
+
267
+ # Get shapes
268
+ input_shapes = []
269
+ output_shapes = []
270
+
271
+ for inp in node.inputs:
272
+ if inp in tensor_shapes:
273
+ input_shapes.append(tensor_shapes[inp])
274
+ elif inp in graph_info.initializers:
275
+ init = graph_info.initializers[inp]
276
+ if hasattr(init, "dims"):
277
+ input_shapes.append(str(list(init.dims)))
278
+
279
+ for out in node.outputs:
280
+ if out in tensor_shapes:
281
+ output_shapes.append(tensor_shapes[out])
282
+
283
+ # Calculate percentages
284
+ pct_params = (node_params / total_params * 100) if total_params > 0 else 0.0
285
+ pct_flops = (node_flops / total_flops * 100) if total_flops > 0 else 0.0
286
+
287
+ # Estimate memory for this layer
288
+ node_memory = 0
289
+ if memory_estimates and memory_estimates.breakdown:
290
+ # Use weight memory by op type as approximation
291
+ op_weights = memory_estimates.breakdown.weights_by_op_type
292
+ if op_weights and node.op_type in op_weights:
293
+ # Distribute among nodes of same type
294
+ nodes_of_type = sum(1 for n in graph_info.nodes if n.op_type == node.op_type)
295
+ if nodes_of_type > 0:
296
+ node_memory = op_weights[node.op_type] // nodes_of_type
297
+
298
+ # Estimate latency (rough: assume linear with FLOPs)
299
+ # This is a placeholder - real latency requires profiling
300
+ latency_ms = node_flops / 1e9 * 0.01 if node_flops > 0 else 0.0
301
+
302
+ layer = LayerMetrics(
303
+ name=node.name,
304
+ op_type=node.op_type,
305
+ input_shapes=input_shapes,
306
+ output_shapes=output_shapes,
307
+ params=node_params,
308
+ flops=node_flops,
309
+ memory_bytes=node_memory,
310
+ latency_ms=latency_ms,
311
+ pct_params=pct_params,
312
+ pct_flops=pct_flops,
313
+ depth=idx,
314
+ )
315
+ layers.append(layer)
316
+
317
+ return LayerSummary(
318
+ layers=layers,
319
+ total_params=total_params,
320
+ total_flops=total_flops,
321
+ total_memory=total_memory,
322
+ )
323
+
324
+ def build_from_report(self, report: InspectionReport) -> LayerSummary:
325
+ """Build layer summary from an inspection report."""
326
+ # We need the graph_info which isn't stored in the report
327
+ # This method requires re-loading the model
328
+ raise NotImplementedError(
329
+ "build_from_report requires model path. Use build() with graph_info instead."
330
+ )
331
+
332
+
333
+ def generate_html_table(summary: LayerSummary, include_js: bool = True) -> str:
334
+ """
335
+ Generate HTML table with sortable columns.
336
+
337
+ Task 5.8.2: Add sortable/filterable table to HTML report.
338
+
339
+ Args:
340
+ summary: Layer summary to render.
341
+ include_js: Include JavaScript for sorting/filtering.
342
+
343
+ Returns:
344
+ HTML string for the table.
345
+ """
346
+
347
+ def format_number(n: int | float) -> str:
348
+ if isinstance(n, float):
349
+ if n >= 1e9:
350
+ return f"{n / 1e9:.2f}B"
351
+ if n >= 1e6:
352
+ return f"{n / 1e6:.2f}M"
353
+ if n >= 1e3:
354
+ return f"{n / 1e3:.2f}K"
355
+ return f"{n:.2f}"
356
+ else:
357
+ if n >= 1e9:
358
+ return f"{n / 1e9:.2f}B"
359
+ if n >= 1e6:
360
+ return f"{n / 1e6:.2f}M"
361
+ if n >= 1e3:
362
+ return f"{n / 1e3:.2f}K"
363
+ return str(n)
364
+
365
+ def format_bytes(b: int) -> str:
366
+ if b >= 1e9:
367
+ return f"{b / 1e9:.2f} GB"
368
+ if b >= 1e6:
369
+ return f"{b / 1e6:.2f} MB"
370
+ if b >= 1e3:
371
+ return f"{b / 1e3:.2f} KB"
372
+ return f"{b} B"
373
+
374
+ html_parts = []
375
+
376
+ # CSS styles
377
+ html_parts.append(
378
+ """
379
+ <style>
380
+ .layer-table-container {
381
+ margin: 20px 0;
382
+ overflow-x: auto;
383
+ }
384
+
385
+ .layer-controls {
386
+ display: flex;
387
+ gap: 16px;
388
+ margin-bottom: 12px;
389
+ flex-wrap: wrap;
390
+ align-items: center;
391
+ }
392
+
393
+ .layer-search {
394
+ padding: 8px 12px;
395
+ border: 1px solid var(--border, #30363d);
396
+ border-radius: 6px;
397
+ background: var(--bg-card, #21262d);
398
+ color: var(--text-primary, #e6edf3);
399
+ font-size: 0.875rem;
400
+ min-width: 200px;
401
+ }
402
+
403
+ .layer-filter {
404
+ padding: 8px 12px;
405
+ border: 1px solid var(--border, #30363d);
406
+ border-radius: 6px;
407
+ background: var(--bg-card, #21262d);
408
+ color: var(--text-primary, #e6edf3);
409
+ font-size: 0.875rem;
410
+ }
411
+
412
+ .layer-export-btn {
413
+ padding: 8px 16px;
414
+ border: 1px solid var(--accent-cyan, #00d4ff);
415
+ border-radius: 6px;
416
+ background: transparent;
417
+ color: var(--accent-cyan, #00d4ff);
418
+ cursor: pointer;
419
+ font-size: 0.875rem;
420
+ transition: all 0.2s;
421
+ }
422
+
423
+ .layer-export-btn:hover {
424
+ background: var(--accent-cyan, #00d4ff);
425
+ color: white;
426
+ }
427
+
428
+ .layer-table {
429
+ width: 100%;
430
+ border-collapse: collapse;
431
+ font-size: 0.8125rem;
432
+ }
433
+
434
+ .layer-table th {
435
+ background: var(--bg-secondary, #161b22);
436
+ color: var(--accent-cyan, #00d4ff);
437
+ padding: 10px 12px;
438
+ text-align: left;
439
+ cursor: pointer;
440
+ user-select: none;
441
+ white-space: nowrap;
442
+ border-bottom: 2px solid var(--border, #30363d);
443
+ position: sticky;
444
+ top: 0;
445
+ }
446
+
447
+ .layer-table th:hover {
448
+ background: var(--bg-card, #21262d);
449
+ }
450
+
451
+ .layer-table th .sort-icon {
452
+ margin-left: 4px;
453
+ opacity: 0.5;
454
+ }
455
+
456
+ .layer-table th.sorted .sort-icon {
457
+ opacity: 1;
458
+ }
459
+
460
+ .layer-table td {
461
+ padding: 8px 12px;
462
+ border-bottom: 1px solid var(--border, #30363d);
463
+ vertical-align: top;
464
+ }
465
+
466
+ .layer-table tr:hover {
467
+ background: var(--bg-secondary, #161b22);
468
+ }
469
+
470
+ .layer-table tr.highlight {
471
+ background: rgba(0, 212, 255, 0.1);
472
+ outline: 1px solid var(--accent-cyan, #00d4ff);
473
+ }
474
+
475
+ .layer-table .op-type {
476
+ display: inline-block;
477
+ padding: 2px 8px;
478
+ border-radius: 4px;
479
+ font-size: 0.75rem;
480
+ font-weight: 500;
481
+ }
482
+
483
+ .layer-table .op-type.conv { background: rgba(74, 144, 217, 0.2); color: #4A90D9; }
484
+ .layer-table .op-type.linear { background: rgba(191, 90, 242, 0.2); color: #BF5AF2; }
485
+ .layer-table .op-type.norm { background: rgba(100, 210, 255, 0.2); color: #64D2FF; }
486
+ .layer-table .op-type.activation { background: rgba(255, 214, 10, 0.2); color: #FFD60A; }
487
+ .layer-table .op-type.pool { background: rgba(48, 209, 88, 0.2); color: #30D158; }
488
+ .layer-table .op-type.reshape { background: rgba(94, 92, 230, 0.2); color: #5E5CE6; }
489
+ .layer-table .op-type.elementwise { background: rgba(255, 100, 130, 0.2); color: #FF6482; }
490
+ .layer-table .op-type.default { background: rgba(99, 99, 102, 0.2); color: #636366; }
491
+
492
+ .layer-table .shape {
493
+ font-family: 'SF Mono', Monaco, monospace;
494
+ font-size: 0.7rem;
495
+ color: var(--text-secondary, #8b949e);
496
+ }
497
+
498
+ .layer-table .pct-bar {
499
+ display: inline-block;
500
+ height: 4px;
501
+ background: var(--accent-cyan, #00d4ff);
502
+ border-radius: 2px;
503
+ margin-right: 6px;
504
+ vertical-align: middle;
505
+ }
506
+
507
+ .layer-count {
508
+ font-size: 0.75rem;
509
+ color: var(--text-secondary, #8b949e);
510
+ margin-top: 8px;
511
+ }
512
+ </style>
513
+ """
514
+ )
515
+
516
+ # Controls
517
+ html_parts.append(
518
+ """
519
+ <div class="layer-table-container">
520
+ <div class="layer-controls">
521
+ <input type="text" class="layer-search" id="layerSearch"
522
+ placeholder="Search layers..." oninput="filterLayers()">
523
+ <select class="layer-filter" id="opFilter" onchange="filterLayers()">
524
+ <option value="">All Op Types</option>
525
+ """
526
+ )
527
+
528
+ # Get unique op types
529
+ op_types = sorted({layer.op_type for layer in summary.layers})
530
+ for op_type in op_types:
531
+ html_parts.append(f' <option value="{op_type}">{op_type}</option>\n')
532
+
533
+ html_parts.append(
534
+ """
535
+ </select>
536
+ <button class="layer-export-btn" onclick="exportLayersCSV()">Export CSV</button>
537
+ </div>
538
+ """
539
+ )
540
+
541
+ # Table
542
+ html_parts.append(
543
+ """
544
+ <table class="layer-table" id="layerTable">
545
+ <thead>
546
+ <tr>
547
+ <th onclick="sortTable(0)" data-col="name">Layer Name <span class="sort-icon">^v</span></th>
548
+ <th onclick="sortTable(1)" data-col="op_type">Op Type <span class="sort-icon">^v</span></th>
549
+ <th onclick="sortTable(2)" data-col="input_shape">Input Shape</th>
550
+ <th onclick="sortTable(3)" data-col="output_shape">Output Shape</th>
551
+ <th onclick="sortTable(4)" data-col="params">Parameters <span class="sort-icon">^v</span></th>
552
+ <th onclick="sortTable(5)" data-col="flops">FLOPs <span class="sort-icon">^v</span></th>
553
+ <th onclick="sortTable(6)" data-col="memory">Memory <span class="sort-icon">^v</span></th>
554
+ <th onclick="sortTable(7)" data-col="pct_flops">% Compute <span class="sort-icon">^v</span></th>
555
+ </tr>
556
+ </thead>
557
+ <tbody>
558
+ """
559
+ )
560
+
561
+ # Get op type category for styling
562
+ def get_op_category(op_type: str) -> str:
563
+ op = op_type.lower()
564
+ if "conv" in op:
565
+ return "conv"
566
+ if "matmul" in op or "gemm" in op:
567
+ return "linear"
568
+ if "norm" in op:
569
+ return "norm"
570
+ if any(x in op for x in ["relu", "gelu", "softmax", "sigmoid", "silu", "tanh"]):
571
+ return "activation"
572
+ if "pool" in op:
573
+ return "pool"
574
+ if any(x in op for x in ["reshape", "transpose", "flatten", "squeeze"]):
575
+ return "reshape"
576
+ if any(x in op for x in ["add", "mul", "sub", "div", "concat"]):
577
+ return "elementwise"
578
+ return "default"
579
+
580
+ # Data rows
581
+ for layer in summary.layers:
582
+ op_cat = get_op_category(layer.op_type)
583
+ input_str = "; ".join(layer.input_shapes) if layer.input_shapes else "-"
584
+ output_str = "; ".join(layer.output_shapes) if layer.output_shapes else "-"
585
+
586
+ # Calculate bar width for percentage
587
+ bar_width = min(100, layer.pct_flops * 3) # Scale for visibility
588
+
589
+ html_parts.append(
590
+ f"""
591
+ <tr data-name="{layer.name}" data-op="{layer.op_type}"
592
+ data-params="{layer.params}" data-flops="{layer.flops}">
593
+ <td><code>{layer.name}</code></td>
594
+ <td><span class="op-type {op_cat}">{layer.op_type}</span></td>
595
+ <td class="shape">{input_str}</td>
596
+ <td class="shape">{output_str}</td>
597
+ <td>{format_number(layer.params)}</td>
598
+ <td>{format_number(layer.flops)}</td>
599
+ <td>{format_bytes(layer.memory_bytes)}</td>
600
+ <td>
601
+ <span class="pct-bar" style="width: {bar_width}px"></span>
602
+ {layer.pct_flops:.1f}%
603
+ </td>
604
+ </tr>
605
+ """
606
+ )
607
+
608
+ html_parts.append(
609
+ """
610
+ </tbody>
611
+ </table>
612
+ <div class="layer-count" id="layerCount">
613
+ """
614
+ )
615
+ html_parts.append(
616
+ f' Showing <span id="visibleCount">{len(summary.layers)}</span> of {len(summary.layers)} layers'
617
+ )
618
+ html_parts.append(
619
+ """
620
+ </div>
621
+ </div>
622
+ """
623
+ )
624
+
625
+ # JavaScript for sorting and filtering
626
+ if include_js:
627
+ # Build CSV data for export
628
+ csv_data = summary.to_csv().replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$")
629
+
630
+ html_parts.append(
631
+ f"""
632
+ <script>
633
+ const layerCSVData = `{csv_data}`;
634
+
635
+ let sortColumn = -1;
636
+ let sortAsc = true;
637
+
638
+ function sortTable(col) {{
639
+ const table = document.getElementById('layerTable');
640
+ const tbody = table.querySelector('tbody');
641
+ const rows = Array.from(tbody.querySelectorAll('tr'));
642
+ const headers = table.querySelectorAll('th');
643
+
644
+ // Toggle sort direction if same column
645
+ if (sortColumn === col) {{
646
+ sortAsc = !sortAsc;
647
+ }} else {{
648
+ sortAsc = false; // Default descending for numeric columns
649
+ sortColumn = col;
650
+ }}
651
+
652
+ // Update header styling
653
+ headers.forEach((h, i) => {{
654
+ h.classList.remove('sorted');
655
+ if (i === col) h.classList.add('sorted');
656
+ }});
657
+
658
+ // Sort rows
659
+ rows.sort((a, b) => {{
660
+ let aVal = a.cells[col].textContent.trim();
661
+ let bVal = b.cells[col].textContent.trim();
662
+
663
+ // Try numeric comparison for columns 4-7
664
+ if (col >= 4) {{
665
+ aVal = parseFloat(aVal.replace(/[^0-9.-]/g, '')) || 0;
666
+ bVal = parseFloat(bVal.replace(/[^0-9.-]/g, '')) || 0;
667
+ return sortAsc ? aVal - bVal : bVal - aVal;
668
+ }}
669
+
670
+ // String comparison
671
+ return sortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
672
+ }});
673
+
674
+ // Re-add rows
675
+ rows.forEach(row => tbody.appendChild(row));
676
+ }}
677
+
678
+ function filterLayers() {{
679
+ const searchTerm = document.getElementById('layerSearch').value.toLowerCase();
680
+ const opFilter = document.getElementById('opFilter').value;
681
+ const table = document.getElementById('layerTable');
682
+ const rows = table.querySelectorAll('tbody tr');
683
+
684
+ let visibleCount = 0;
685
+ rows.forEach(row => {{
686
+ const name = row.dataset.name.toLowerCase();
687
+ const op = row.dataset.op;
688
+
689
+ const matchesSearch = !searchTerm || name.includes(searchTerm);
690
+ const matchesOp = !opFilter || op === opFilter;
691
+
692
+ if (matchesSearch && matchesOp) {{
693
+ row.style.display = '';
694
+ visibleCount++;
695
+ }} else {{
696
+ row.style.display = 'none';
697
+ }}
698
+ }});
699
+
700
+ document.getElementById('visibleCount').textContent = visibleCount;
701
+ }}
702
+
703
+ function exportLayersCSV() {{
704
+ const blob = new Blob([layerCSVData], {{ type: 'text/csv;charset=utf-8;' }});
705
+ const link = document.createElement('a');
706
+ link.href = URL.createObjectURL(blob);
707
+ link.download = 'layer_summary.csv';
708
+ link.click();
709
+ }}
710
+
711
+ // Highlight row when clicked (for graph integration)
712
+ document.querySelectorAll('#layerTable tbody tr').forEach(row => {{
713
+ row.addEventListener('click', () => {{
714
+ document.querySelectorAll('#layerTable tbody tr').forEach(r => r.classList.remove('highlight'));
715
+ row.classList.add('highlight');
716
+
717
+ // Dispatch event for graph integration (Task 5.8.3)
718
+ const event = new CustomEvent('layer-selected', {{
719
+ detail: {{ name: row.dataset.name, op: row.dataset.op }}
720
+ }});
721
+ document.dispatchEvent(event);
722
+ }});
723
+ }});
724
+ </script>
725
+ """
726
+ )
727
+
728
+ return "".join(html_parts)
729
+
730
+
731
+ def generate_markdown_table(summary: LayerSummary, max_rows: int = 50) -> str:
732
+ """Generate Markdown table for layer summary."""
733
+
734
+ def format_number(n: int | float) -> str:
735
+ if isinstance(n, float):
736
+ if n >= 1e9:
737
+ return f"{n / 1e9:.1f}B"
738
+ if n >= 1e6:
739
+ return f"{n / 1e6:.1f}M"
740
+ if n >= 1e3:
741
+ return f"{n / 1e3:.1f}K"
742
+ return f"{n:.2f}"
743
+ else:
744
+ if n >= 1e9:
745
+ return f"{n / 1e9:.1f}B"
746
+ if n >= 1e6:
747
+ return f"{n / 1e6:.1f}M"
748
+ if n >= 1e3:
749
+ return f"{n / 1e3:.1f}K"
750
+ return str(n)
751
+
752
+ lines = []
753
+ lines.append("| Layer | Op Type | Params | FLOPs | % Compute |")
754
+ lines.append("|-------|---------|--------|-------|-----------|")
755
+
756
+ # Sort by FLOPs descending to show most important first
757
+ sorted_layers = sorted(summary.layers, key=lambda layer: layer.flops, reverse=True)
758
+
759
+ for layer in sorted_layers[:max_rows]:
760
+ lines.append(
761
+ f"| `{layer.name}` | {layer.op_type} | "
762
+ f"{format_number(layer.params)} | {format_number(layer.flops)} | "
763
+ f"{layer.pct_flops:.1f}% |"
764
+ )
765
+
766
+ if len(summary.layers) > max_rows:
767
+ lines.append(f"| ... | ({len(summary.layers) - max_rows} more layers) | ... | ... | ... |")
768
+
769
+ return "\n".join(lines)