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/report.py ADDED
@@ -0,0 +1,1923 @@
1
+ # Copyright (c) 2025 HaoLine Contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Report generation and ModelInspector orchestrator for HaoLine.
6
+
7
+ This module contains:
8
+ - InspectionReport: The main data structure holding all analysis results
9
+ - ModelInspector: Orchestrator that coordinates all analysis components
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import pathlib
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ if TYPE_CHECKING:
22
+ from .analyzer import (
23
+ FlopCounts,
24
+ MemoryEstimates,
25
+ MetricsEngine,
26
+ ONNXGraphLoader,
27
+ ParamCounts,
28
+ )
29
+ from .hardware import (
30
+ HardwareEstimates,
31
+ HardwareProfile,
32
+ )
33
+ from .operational_profiling import (
34
+ BatchSizeSweep,
35
+ ResolutionSweep,
36
+ SystemRequirements,
37
+ )
38
+ from .patterns import Block, PatternAnalyzer
39
+ from .risks import RiskAnalyzer, RiskSignal
40
+ from .universal_ir import UniversalGraph
41
+
42
+
43
+ @dataclass
44
+ class ModelMetadata:
45
+ """Basic model metadata extracted from ONNX proto."""
46
+
47
+ path: str
48
+ ir_version: int
49
+ producer_name: str
50
+ producer_version: str
51
+ domain: str
52
+ model_version: int
53
+ doc_string: str
54
+ opsets: dict[str, int] # domain -> version
55
+
56
+
57
+ @dataclass
58
+ class GraphSummary:
59
+ """Summary statistics about the ONNX graph."""
60
+
61
+ num_nodes: int
62
+ num_inputs: int
63
+ num_outputs: int
64
+ num_initializers: int
65
+ input_shapes: dict[str, list[int | str]] # name -> shape (may have symbolic dims)
66
+ output_shapes: dict[str, list[int | str]]
67
+ op_type_counts: dict[str, int] # op_type -> count
68
+
69
+
70
+ @dataclass
71
+ class DatasetInfo:
72
+ """Dataset and class information extracted from model metadata."""
73
+
74
+ task: str | None = None # "detect", "classify", "segment", etc.
75
+ num_classes: int | None = None
76
+ class_names: list[str] = field(default_factory=list)
77
+ source: str | None = None # "ultralytics", "output_shape", etc.
78
+
79
+
80
+ def infer_num_classes_from_output(
81
+ output_shapes: dict[str, list[int | str]],
82
+ architecture_type: str = "unknown",
83
+ ) -> DatasetInfo | None:
84
+ """
85
+ Infer number of classes from model output shapes.
86
+
87
+ Analyzes output tensor shapes to detect common patterns:
88
+ - Classification: [batch, num_classes] or [batch, 1, num_classes]
89
+ - Detection (YOLO-style): [batch, num_boxes, 4+num_classes] or [batch, num_boxes, 5+num_classes]
90
+ - Segmentation: [batch, num_classes, height, width]
91
+
92
+ Args:
93
+ output_shapes: Dictionary mapping output names to their shapes.
94
+ architecture_type: Detected architecture type (helps disambiguate patterns).
95
+
96
+ Returns:
97
+ DatasetInfo with inferred num_classes and task, or None if inference failed.
98
+ """
99
+ if not output_shapes:
100
+ return None
101
+
102
+ # Get the primary output (usually the first one, or look for common names)
103
+ primary_output = None
104
+ primary_shape = None
105
+
106
+ # Priority: look for outputs named 'output', 'logits', 'predictions', etc.
107
+ # Use exact match or prefix match to avoid matching "some_random_output" with "output"
108
+ priority_names = ["logits", "predictions", "probs", "classes", "output0", "output"]
109
+ for name in priority_names:
110
+ for out_name, shape in output_shapes.items():
111
+ out_lower = out_name.lower()
112
+ # Exact match or starts with the priority name
113
+ if out_lower == name or out_lower.startswith(name + "_"):
114
+ primary_output = out_name
115
+ primary_shape = shape
116
+ break
117
+ if primary_output:
118
+ break
119
+
120
+ # Fallback to first output
121
+ if not primary_output:
122
+ primary_output = next(iter(output_shapes.keys()))
123
+ primary_shape = output_shapes[primary_output]
124
+
125
+ if not primary_shape:
126
+ return None
127
+
128
+ # Convert shape to list of ints where possible (handle symbolic dims)
129
+ def to_int(dim):
130
+ if isinstance(dim, int):
131
+ return dim
132
+ if isinstance(dim, str):
133
+ # Try to parse as int, otherwise return None
134
+ try:
135
+ return int(dim)
136
+ except ValueError:
137
+ return None
138
+ return None
139
+
140
+ shape = [to_int(d) for d in primary_shape]
141
+
142
+ # Need at least 2 dimensions
143
+ if len(shape) < 2:
144
+ return None
145
+
146
+ # Classification pattern: [batch, num_classes] or [batch, 1, num_classes]
147
+ # Typical num_classes: 2-10000 (ImageNet=1000, CIFAR=10/100, etc.)
148
+ if len(shape) == 2:
149
+ # [batch, num_classes]
150
+ _batch, num_classes = shape
151
+ if isinstance(num_classes, int) and 2 <= num_classes <= 10000:
152
+ return DatasetInfo(
153
+ task="classify",
154
+ num_classes=num_classes,
155
+ source="output_shape",
156
+ )
157
+
158
+ if len(shape) == 3:
159
+ _batch, dim1, dim2 = shape
160
+ # Could be [batch, 1, num_classes] for classification
161
+ if isinstance(dim1, int) and isinstance(dim2, int):
162
+ if dim1 == 1 and 2 <= dim2 <= 10000:
163
+ return DatasetInfo(
164
+ task="classify",
165
+ num_classes=dim2,
166
+ source="output_shape",
167
+ )
168
+ # Could be [batch, num_boxes, 4+nc] or [batch, num_boxes, 5+nc] for detection
169
+ # YOLO format: boxes * (x, y, w, h, obj_conf, class_probs...)
170
+ # Common box counts: 8400, 25200, etc. (depends on input size)
171
+ # Detection heuristic: many boxes, last dim is 4+nc or 5+nc
172
+ # Minimum: 4 box coords + 1 class = 5
173
+ if dim1 >= 100 and dim2 >= 5:
174
+ # Try to infer num_classes from detection output
175
+ # Format could be: [x, y, w, h, class1, class2, ...] (4 + nc) - YOLOv8 format
176
+ # Or: [x, y, w, h, obj_conf, class1, class2, ...] (5 + nc) - YOLOv5 format
177
+ # Assume YOLOv8 format (no obj_conf) which is more common now
178
+ nc = dim2 - 4
179
+ if nc >= 1: # At least 1 class
180
+ return DatasetInfo(
181
+ task="detect",
182
+ num_classes=nc,
183
+ source="output_shape",
184
+ )
185
+
186
+ if len(shape) == 4:
187
+ _batch, dim1, dim2, dim3 = shape
188
+ # Segmentation pattern: [batch, num_classes, height, width]
189
+ # Height/width are typically >= 32 and often equal
190
+ if (
191
+ isinstance(dim1, int)
192
+ and isinstance(dim2, int)
193
+ and isinstance(dim3, int)
194
+ and 2 <= dim1 <= 1000 # num_classes
195
+ and dim2 >= 32 # height
196
+ and dim3 >= 32 # width
197
+ ):
198
+ # Additional check: h/w should be similar (typical segmentation output)
199
+ ratio = max(dim2, dim3) / min(dim2, dim3) if min(dim2, dim3) > 0 else 999
200
+ if ratio <= 4: # Reasonable aspect ratio
201
+ return DatasetInfo(
202
+ task="segment",
203
+ num_classes=dim1,
204
+ source="output_shape",
205
+ )
206
+
207
+ return None
208
+
209
+
210
+ @dataclass
211
+ class InspectionReport:
212
+ """
213
+ Complete inspection report for an ONNX model.
214
+
215
+ This is the primary output of ModelInspector.inspect() and contains
216
+ all analysis results in a structured format suitable for JSON serialization
217
+ or Markdown rendering.
218
+ """
219
+
220
+ # Metadata
221
+ metadata: ModelMetadata
222
+ generated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
223
+ autodoc_version: str = "0.1.0"
224
+
225
+ # Graph structure
226
+ graph_summary: GraphSummary | None = None
227
+
228
+ # Metrics
229
+ param_counts: ParamCounts | None = None
230
+ flop_counts: FlopCounts | None = None
231
+ memory_estimates: MemoryEstimates | None = None
232
+
233
+ # Patterns
234
+ detected_blocks: list[Block] = field(default_factory=list)
235
+ architecture_type: str = "unknown" # "transformer", "cnn", "mlp", "hybrid", "unknown"
236
+
237
+ # Risks
238
+ risk_signals: list[RiskSignal] = field(default_factory=list)
239
+
240
+ # Hardware estimates (optional, set by CLI if --hardware specified)
241
+ hardware_profile: HardwareProfile | None = None
242
+ hardware_estimates: HardwareEstimates | None = None
243
+
244
+ # System Requirements & Scaling (Epic 6C)
245
+ system_requirements: SystemRequirements | None = None
246
+ batch_size_sweep: BatchSizeSweep | None = None
247
+ resolution_sweep: ResolutionSweep | None = None
248
+
249
+ # LLM summary (optional, set by CLI if --llm-summary specified)
250
+ llm_summary: dict[str, Any] | None = None
251
+
252
+ # Dataset info (optional, extracted from model metadata)
253
+ dataset_info: DatasetInfo | None = None
254
+
255
+ # Extra data (profiling results, GPU metrics, etc.)
256
+ extra_data: dict[str, Any] | None = None
257
+
258
+ # Universal IR (optional, format-agnostic graph representation)
259
+ # Enables cross-format comparison, structural diff, and advanced visualization
260
+ universal_graph: UniversalGraph | None = None
261
+
262
+ def to_dict(self) -> dict[str, Any]:
263
+ """Convert report to a JSON-serializable dictionary."""
264
+ import numpy as np
265
+
266
+ # Track visited objects to prevent circular references
267
+ visited: set = set()
268
+
269
+ def _serialize(obj: Any, depth: int = 0) -> Any:
270
+ # Prevent infinite recursion
271
+ if depth > 50:
272
+ return str(obj)
273
+
274
+ # Check for circular references using object id
275
+ obj_id = id(obj)
276
+ if obj_id in visited:
277
+ return "<circular reference>"
278
+ if not isinstance(obj, (str, int, float, bool, type(None))):
279
+ visited.add(obj_id)
280
+
281
+ if obj is None:
282
+ return None
283
+ if isinstance(obj, (str, int, float, bool)):
284
+ return obj
285
+ if isinstance(obj, np.ndarray):
286
+ return obj.tolist()
287
+ if isinstance(obj, (np.integer, np.floating)):
288
+ return obj.item()
289
+ # Handle UniversalGraph specially (Pydantic model with to_dict method)
290
+ if hasattr(obj, "to_dict") and hasattr(obj, "num_nodes"):
291
+ # This is a UniversalGraph - serialize without weights
292
+ return obj.to_dict(include_weights=False)
293
+ # Handle dataclasses (but not by calling to_dict which would recurse)
294
+ if hasattr(obj, "__dataclass_fields__"):
295
+ return {k: _serialize(getattr(obj, k), depth + 1) for k in obj.__dataclass_fields__}
296
+ # Handle Pydantic models
297
+ if hasattr(obj, "model_dump"):
298
+ return obj.model_dump()
299
+ if isinstance(obj, list):
300
+ return [_serialize(item, depth + 1) for item in obj]
301
+ if isinstance(obj, dict):
302
+ return {str(k): _serialize(v, depth + 1) for k, v in obj.items()}
303
+ # Fallback: convert to string
304
+ return str(obj)
305
+
306
+ result = _serialize(self)
307
+ # _serialize on a dataclass always returns a dict
308
+ assert isinstance(result, dict)
309
+ return result
310
+
311
+ def to_json(self, indent: int = 2) -> str:
312
+ """Serialize report to JSON string."""
313
+ return json.dumps(self.to_dict(), indent=indent)
314
+
315
+ def validate(self) -> tuple[bool, list[str]]:
316
+ """
317
+ Validate this report against the JSON schema.
318
+
319
+ Returns:
320
+ Tuple of (is_valid, list of error messages).
321
+ If jsonschema is not installed, returns (True, []) with a warning.
322
+
323
+ Example:
324
+ report = inspector.inspect("model.onnx")
325
+ is_valid, errors = report.validate()
326
+ if not is_valid:
327
+ for error in errors:
328
+ print(f"Validation error: {error}")
329
+ """
330
+ from .schema import validate_report
331
+
332
+ return validate_report(self.to_dict())
333
+
334
+ def validate_strict(self) -> None:
335
+ """
336
+ Validate this report, raising ValidationError on failure.
337
+
338
+ Raises:
339
+ ValidationError: If validation fails with details of errors.
340
+
341
+ Example:
342
+ report = inspector.inspect("model.onnx")
343
+ try:
344
+ report.validate_strict()
345
+ print("Report is valid!")
346
+ except ValidationError as e:
347
+ print(f"Invalid report: {e.errors}")
348
+ """
349
+ from .schema import validate_report_strict
350
+
351
+ validate_report_strict(self.to_dict())
352
+
353
+ def to_markdown(self) -> str:
354
+ """Generate a Markdown model card from this report."""
355
+ lines = []
356
+
357
+ # Header
358
+ model_name = pathlib.Path(self.metadata.path).stem
359
+ lines.append(f"# Model Card: {model_name}")
360
+ lines.append("")
361
+ lines.append(f"*Generated by HaoLine v{self.autodoc_version} on {self.generated_at}*")
362
+ lines.append("")
363
+
364
+ # Executive Summary (if LLM summary available)
365
+ if self.llm_summary and self.llm_summary.get("success"):
366
+ lines.append("## Executive Summary")
367
+ lines.append("")
368
+ if self.llm_summary.get("short_summary"):
369
+ lines.append(f"{self.llm_summary['short_summary']}")
370
+ lines.append("")
371
+ if self.llm_summary.get("detailed_summary"):
372
+ lines.append("")
373
+ lines.append(self.llm_summary["detailed_summary"])
374
+ lines.append("")
375
+ if self.llm_summary.get("model"):
376
+ lines.append(f"*Generated by {self.llm_summary['model']}*")
377
+ lines.append("")
378
+
379
+ # Metadata section
380
+ lines.append("## Metadata")
381
+ lines.append("")
382
+ lines.append("| Property | Value |")
383
+ lines.append("|----------|-------|")
384
+ lines.append(f"| IR Version | {self.metadata.ir_version} |")
385
+ lines.append(
386
+ f"| Producer | {self.metadata.producer_name} {self.metadata.producer_version} |"
387
+ )
388
+ if self.metadata.opsets:
389
+ opset_str = ", ".join(f"{d}:{v}" for d, v in self.metadata.opsets.items())
390
+ lines.append(f"| Opsets | {opset_str} |")
391
+ lines.append("")
392
+
393
+ # Graph summary
394
+ if self.graph_summary:
395
+ lines.append("## Graph Summary")
396
+ lines.append("")
397
+ lines.append(f"- **Nodes**: {self.graph_summary.num_nodes}")
398
+ lines.append(f"- **Inputs**: {self.graph_summary.num_inputs}")
399
+ lines.append(f"- **Outputs**: {self.graph_summary.num_outputs}")
400
+ lines.append(f"- **Initializers**: {self.graph_summary.num_initializers}")
401
+ lines.append("")
402
+
403
+ # Input/output shapes
404
+ if self.graph_summary.input_shapes:
405
+ lines.append("### Inputs")
406
+ lines.append("")
407
+ for name, shape in self.graph_summary.input_shapes.items():
408
+ lines.append(f"- `{name}`: {shape}")
409
+ lines.append("")
410
+
411
+ if self.graph_summary.output_shapes:
412
+ lines.append("### Outputs")
413
+ lines.append("")
414
+ for name, shape in self.graph_summary.output_shapes.items():
415
+ lines.append(f"- `{name}`: {shape}")
416
+ lines.append("")
417
+
418
+ # Top operators
419
+ if self.graph_summary.op_type_counts:
420
+ lines.append("### Operator Distribution")
421
+ lines.append("")
422
+ lines.append("| Operator | Count |")
423
+ lines.append("|----------|-------|")
424
+ sorted_ops = sorted(self.graph_summary.op_type_counts.items(), key=lambda x: -x[1])
425
+ for op, count in sorted_ops[:15]: # Top 15
426
+ lines.append(f"| {op} | {count} |")
427
+ if len(sorted_ops) > 15:
428
+ lines.append(f"| ... | ({len(sorted_ops) - 15} more) |")
429
+ lines.append("")
430
+
431
+ # Metrics
432
+ if self.param_counts or self.flop_counts or self.memory_estimates:
433
+ lines.append("## Complexity Metrics")
434
+ lines.append("")
435
+
436
+ if self.param_counts:
437
+ lines.append(
438
+ f"- **Total Parameters**: {self._format_number(self.param_counts.total)}"
439
+ )
440
+ lines.append(f" - Trainable: {self._format_number(self.param_counts.trainable)}")
441
+ lines.append(
442
+ f" - Non-trainable: {self._format_number(self.param_counts.non_trainable)}"
443
+ )
444
+
445
+ # Shared weights info
446
+ if self.param_counts.num_shared_weights > 0:
447
+ lines.append(
448
+ f" - Shared Weights: {self.param_counts.num_shared_weights} "
449
+ f"(used by multiple nodes)"
450
+ )
451
+
452
+ # Precision breakdown
453
+ if self.param_counts.precision_breakdown:
454
+ breakdown_parts = [
455
+ f"{dtype}: {self._format_number(count)}"
456
+ for dtype, count in sorted(
457
+ self.param_counts.precision_breakdown.items(),
458
+ key=lambda x: -x[1],
459
+ )[:4]
460
+ ]
461
+ if breakdown_parts:
462
+ lines.append(f" - By Precision: {', '.join(breakdown_parts)}")
463
+
464
+ # Quantization info
465
+ if self.param_counts.is_quantized:
466
+ lines.append(" - **Quantization Detected**")
467
+ if self.param_counts.quantized_ops:
468
+ lines.append(
469
+ f" - Quantized Ops: {', '.join(self.param_counts.quantized_ops[:5])}"
470
+ )
471
+ if len(self.param_counts.quantized_ops) > 5:
472
+ lines.append(
473
+ f" - ... and {len(self.param_counts.quantized_ops) - 5} more"
474
+ )
475
+ lines.append("")
476
+
477
+ if self.flop_counts:
478
+ lines.append(
479
+ f"- **Estimated FLOPs**: {self._format_number(self.flop_counts.total)}"
480
+ )
481
+ lines.append("")
482
+
483
+ if self.memory_estimates:
484
+ lines.append(
485
+ f"- **Model Size**: {self._format_bytes(self.memory_estimates.model_size_bytes)}"
486
+ )
487
+ lines.append(
488
+ f"- **Peak Activation Memory** (batch=1): "
489
+ f"{self._format_bytes(self.memory_estimates.peak_activation_bytes)}"
490
+ )
491
+ # KV cache info for transformers
492
+ if self.memory_estimates.kv_cache_bytes_per_token > 0:
493
+ lines.append("")
494
+ lines.append("### KV Cache (Transformer Inference)")
495
+ lines.append("")
496
+ config = self.memory_estimates.kv_cache_config
497
+ lines.append(
498
+ f"- **Per Token**: {self._format_bytes(self.memory_estimates.kv_cache_bytes_per_token)}"
499
+ )
500
+ if config.get("seq_len"):
501
+ lines.append(
502
+ f"- **Full Context** (seq={config['seq_len']}): "
503
+ f"{self._format_bytes(self.memory_estimates.kv_cache_bytes_full_context)}"
504
+ )
505
+ if config.get("num_layers"):
506
+ lines.append(f"- **Layers**: {config['num_layers']}")
507
+ if config.get("hidden_dim"):
508
+ lines.append(f"- **Hidden Dim**: {config['hidden_dim']}")
509
+
510
+ # Memory breakdown by component
511
+ if self.memory_estimates.breakdown:
512
+ bd = self.memory_estimates.breakdown
513
+ if bd.weights_by_op_type:
514
+ lines.append("")
515
+ lines.append("### Memory Breakdown by Op Type")
516
+ lines.append("")
517
+ lines.append("| Component | Size |")
518
+ lines.append("|-----------|------|")
519
+ sorted_weights = sorted(bd.weights_by_op_type.items(), key=lambda x: -x[1])
520
+ for op_type, size in sorted_weights[:8]:
521
+ lines.append(f"| {op_type} | {self._format_bytes(size)} |")
522
+ lines.append("")
523
+
524
+ # Architecture
525
+ if self.architecture_type != "unknown" or self.detected_blocks:
526
+ lines.append("## Architecture")
527
+ lines.append("")
528
+ lines.append(f"**Detected Type**: {self.architecture_type}")
529
+ lines.append("")
530
+
531
+ if self.detected_blocks:
532
+ lines.append("### Detected Blocks")
533
+ lines.append("")
534
+ # Group by block type
535
+ block_types: dict[str, int] = {}
536
+ for block in self.detected_blocks:
537
+ block_types[block.block_type] = block_types.get(block.block_type, 0) + 1
538
+ for bt, count in sorted(block_types.items(), key=lambda x: -x[1]):
539
+ lines.append(f"- {bt}: {count}")
540
+ lines.append("")
541
+
542
+ # Highlight non-standard residual patterns if present
543
+ nonstandard_residuals = [
544
+ b
545
+ for b in self.detected_blocks
546
+ if b.block_type in ("ResidualConcat", "ResidualGate", "ResidualSub")
547
+ ]
548
+ if nonstandard_residuals:
549
+ # Group by type for summary
550
+ by_type: dict[str, int] = {}
551
+ for block in nonstandard_residuals:
552
+ by_type[block.block_type] = by_type.get(block.block_type, 0) + 1
553
+
554
+ type_labels = {
555
+ "ResidualConcat": "Concat-based (DenseNet-style)",
556
+ "ResidualGate": "Gated skip (Highway/attention)",
557
+ "ResidualSub": "Subtraction-based",
558
+ }
559
+
560
+ lines.append("### Non-Standard Skip Connections")
561
+ lines.append("")
562
+ lines.append(
563
+ f"This model uses {len(nonstandard_residuals)} non-standard skip connection(s):"
564
+ )
565
+ lines.append("")
566
+ for block_type, count in by_type.items():
567
+ label = type_labels.get(block_type, block_type)
568
+ lines.append(f"- **{label}**: {count}")
569
+ lines.append("")
570
+
571
+ # Dataset info (if available)
572
+ if self.dataset_info:
573
+ lines.append("## Dataset Info")
574
+ lines.append("")
575
+ if self.dataset_info.task:
576
+ lines.append(f"**Task**: {self.dataset_info.task}")
577
+ if self.dataset_info.num_classes:
578
+ lines.append(f"**Number of Classes**: {self.dataset_info.num_classes}")
579
+ if self.dataset_info.class_names:
580
+ lines.append("")
581
+ lines.append("### Class Names")
582
+ lines.append("")
583
+ for idx, name in enumerate(self.dataset_info.class_names):
584
+ lines.append(f"- `{idx}`: {name}")
585
+ if self.dataset_info.source:
586
+ lines.append("")
587
+ lines.append(f"*Metadata source: {self.dataset_info.source}*")
588
+ lines.append("")
589
+
590
+ # Hardware estimates
591
+ if self.hardware_estimates and self.hardware_profile:
592
+ hw = self.hardware_estimates
593
+ lines.append("## Hardware Estimates")
594
+ lines.append("")
595
+ lines.append(f"**Target Device**: {self.hardware_profile.name}")
596
+ lines.append(f"**Precision**: {hw.precision} | **Batch Size**: {hw.batch_size}")
597
+ lines.append("")
598
+ lines.append("| Metric | Value |")
599
+ lines.append("|--------|-------|")
600
+ lines.append(f"| VRAM Required | {self._format_bytes(hw.vram_required_bytes)} |")
601
+ lines.append(f"| Fits in VRAM | {'Yes' if hw.fits_in_vram else 'No'} |")
602
+ if hw.fits_in_vram:
603
+ lines.append(f"| Theoretical Latency | {hw.theoretical_latency_ms:.2f} ms |")
604
+ lines.append(f"| Bottleneck | {hw.bottleneck} |")
605
+ lines.append(f"| Compute Utilization | {hw.compute_utilization_estimate:.0%} |")
606
+ # GPU Saturation: what % of GPU's 1-second capacity this inference uses
607
+ lines.append(
608
+ f"| GPU Saturation | {hw.gpu_saturation:.2e} ({hw.gpu_saturation * 100:.4f}%) |"
609
+ )
610
+ lines.append("")
611
+
612
+ # Add device specs
613
+ lines.append("### Device Specifications")
614
+ lines.append("")
615
+ lines.append(f"- **VRAM**: {self._format_bytes(self.hardware_profile.vram_bytes)}")
616
+ lines.append(f"- **FP32 Peak**: {self.hardware_profile.peak_fp32_tflops:.1f} TFLOPS")
617
+ lines.append(f"- **FP16 Peak**: {self.hardware_profile.peak_fp16_tflops:.1f} TFLOPS")
618
+ if self.hardware_profile.tdp_watts:
619
+ lines.append(f"- **TDP**: {self.hardware_profile.tdp_watts}W")
620
+ lines.append("")
621
+
622
+ # System Requirements (Story 6C.2)
623
+ if self.system_requirements:
624
+ reqs = self.system_requirements
625
+ lines.append("## System Requirements")
626
+ lines.append("")
627
+ lines.append("| Level | Device | VRAM |")
628
+ lines.append("|-------|--------|------|")
629
+ min_name = reqs.minimum_gpu.device if reqs.minimum_gpu else "N/A"
630
+ rec_name = reqs.recommended_gpu.device if reqs.recommended_gpu else "N/A"
631
+ opt_name = reqs.optimal_gpu.device if reqs.optimal_gpu else "N/A"
632
+ min_vram = f"{reqs.minimum_vram_gb} GB" if reqs.minimum_vram_gb is not None else "-"
633
+ rec_vram = (
634
+ f"{reqs.recommended_vram_gb} GB" if reqs.recommended_vram_gb is not None else "-"
635
+ )
636
+ lines.append(f"| Minimum | {min_name} | {min_vram} |")
637
+ lines.append(f"| Recommended | {rec_name} | {rec_vram} |")
638
+ lines.append(f"| Optimal | {opt_name} | - |")
639
+ lines.append("")
640
+
641
+ # Batch Size Sweep (Story 6C.1)
642
+ if self.batch_size_sweep:
643
+ batch_sweep = self.batch_size_sweep
644
+ lines.append("## Batch Size Scaling")
645
+ lines.append("")
646
+ lines.append(f"**Optimal Batch Size**: {batch_sweep.optimal_batch_size}")
647
+ lines.append("")
648
+ lines.append("| Batch Size | Latency (ms) | Throughput (inf/s) | VRAM (GB) |")
649
+ lines.append("|------------|--------------|--------------------|-----------|")
650
+ for i, bs in enumerate(batch_sweep.batch_sizes):
651
+ lines.append(
652
+ f"| {bs} | {batch_sweep.latencies[i]:.2f} | {batch_sweep.throughputs[i]:.1f} | {batch_sweep.vram_usage_gb[i]:.2f} |"
653
+ )
654
+ lines.append("")
655
+
656
+ # Resolution Sweep (Story 6.8)
657
+ if self.resolution_sweep:
658
+ res_sweep = self.resolution_sweep
659
+ lines.append("## Resolution Scaling")
660
+ lines.append("")
661
+ lines.append(f"**Max Resolution**: {res_sweep.max_resolution}")
662
+ lines.append(f"**Optimal Resolution**: {res_sweep.optimal_resolution}")
663
+ lines.append("")
664
+ lines.append(
665
+ "| Resolution | FLOPs | Memory (GB) | Latency (ms) | Throughput | VRAM (GB) |"
666
+ )
667
+ lines.append(
668
+ "|------------|-------|-------------|--------------|------------|-----------|"
669
+ )
670
+ for i, res in enumerate(res_sweep.resolutions):
671
+ flops_str = self._format_number(res_sweep.flops[i])
672
+ lat_str = (
673
+ f"{res_sweep.latencies[i]:.2f}"
674
+ if res_sweep.latencies[i] != float("inf")
675
+ else "OOM"
676
+ )
677
+ tput_str = (
678
+ f"{res_sweep.throughputs[i]:.1f}" if res_sweep.throughputs[i] > 0 else "-"
679
+ )
680
+ lines.append(
681
+ f"| {res} | {flops_str} | {res_sweep.memory_gb[i]:.2f} | "
682
+ f"{lat_str} | {tput_str} | {res_sweep.vram_usage_gb[i]:.2f} |"
683
+ )
684
+ lines.append("")
685
+
686
+ # Risks
687
+ if self.risk_signals:
688
+ lines.append("## Risk Signals")
689
+ lines.append("")
690
+ for risk in self.risk_signals:
691
+ severity_icon = {"info": "INFO", "warning": "WARN", "high": "HIGH"}
692
+ icon = severity_icon.get(risk.severity, "")
693
+ lines.append(f"### [{icon}] {risk.id}")
694
+ lines.append("")
695
+ lines.append(risk.description)
696
+ lines.append("")
697
+ if risk.recommendation:
698
+ lines.append(f"**Recommendation**: {risk.recommendation}")
699
+ lines.append("")
700
+
701
+ return "\n".join(lines)
702
+
703
+ @staticmethod
704
+ def _format_number(n: int | float) -> str:
705
+ """Format large numbers with K/M/B suffixes."""
706
+ if n >= 1e9:
707
+ return f"{n / 1e9:.2f}B"
708
+ if n >= 1e6:
709
+ return f"{n / 1e6:.2f}M"
710
+ if n >= 1e3:
711
+ return f"{n / 1e3:.2f}K"
712
+ return str(int(n))
713
+
714
+ @staticmethod
715
+ def _format_bytes(b: int) -> str:
716
+ """Format bytes with KB/MB/GB suffixes."""
717
+ if b >= 1e9:
718
+ return f"{b / 1e9:.2f} GB"
719
+ if b >= 1e6:
720
+ return f"{b / 1e6:.2f} MB"
721
+ if b >= 1e3:
722
+ return f"{b / 1e3:.2f} KB"
723
+ return f"{b} bytes"
724
+
725
+ def to_html(
726
+ self,
727
+ image_paths: dict[str, pathlib.Path] | None = None,
728
+ graph_html: str | None = None,
729
+ layer_table_html: str | None = None,
730
+ eval_metrics_html: str | None = None,
731
+ ) -> str:
732
+ """
733
+ Generate a self-contained HTML report with embedded images.
734
+
735
+ Args:
736
+ image_paths: Dictionary mapping image names to file paths.
737
+ Images will be embedded as base64.
738
+ graph_html: Optional HTML for interactive graph visualization (Task 5.7.8).
739
+ This should be the inner graph container, not full HTML document.
740
+ layer_table_html: Optional HTML for per-layer summary table (Story 5.8).
741
+ eval_metrics_html: Optional HTML for evaluation metrics section (Task 12.5.4).
742
+ Generated by haoline.eval.comparison.generate_eval_metrics_html().
743
+
744
+ Returns:
745
+ Complete HTML document as a string.
746
+ """
747
+ import base64
748
+
749
+ # Embed images as base64
750
+ def embed_image(path: pathlib.Path) -> str:
751
+ if path.exists():
752
+ with open(path, "rb") as f:
753
+ data = base64.b64encode(f.read()).decode("utf-8")
754
+ return f"data:image/png;base64,{data}"
755
+ return ""
756
+
757
+ images = {}
758
+ if image_paths:
759
+ for name, path in image_paths.items():
760
+ images[name] = embed_image(path)
761
+
762
+ # Build HTML
763
+ model_name = pathlib.Path(self.metadata.path).stem if self.metadata else "Model"
764
+
765
+ html_parts = [self._html_head(model_name)]
766
+ html_parts.append('<body><div class="container">')
767
+
768
+ # Header
769
+ html_parts.append(
770
+ f"""
771
+ <header>
772
+ <h1>{model_name}</h1>
773
+ <p class="subtitle">ONNX Model Analysis Report</p>
774
+ <p class="timestamp">Generated by HaoLine v0.1.0 on {datetime.utcnow().isoformat()}Z</p>
775
+ </header>
776
+ """
777
+ )
778
+
779
+ # Executive Summary (LLM) - Prominent AI-generated summary
780
+ if self.llm_summary and self.llm_summary.get("success"):
781
+ html_parts.append(
782
+ """
783
+ <section class="executive-summary">
784
+ <h2>AI Executive Summary</h2>
785
+ """
786
+ )
787
+ if self.llm_summary.get("short_summary"):
788
+ html_parts.append(f'<p class="tldr">{self.llm_summary["short_summary"]}</p>')
789
+ if self.llm_summary.get("detailed_summary"):
790
+ html_parts.append(f"<p>{self.llm_summary['detailed_summary']}</p>")
791
+ html_parts.append(
792
+ f'<p class="llm-credit">Generated by {self.llm_summary.get("model", "LLM")}</p>'
793
+ )
794
+ html_parts.append("</section>")
795
+
796
+ # Key Metrics Cards
797
+ html_parts.append('<section class="metrics-cards">')
798
+ if self.param_counts:
799
+ html_parts.append(
800
+ f"""
801
+ <div class="card">
802
+ <div class="card-value">{self._format_number(self.param_counts.total)}</div>
803
+ <div class="card-label">Parameters</div>
804
+ </div>
805
+ """
806
+ )
807
+ if self.flop_counts:
808
+ html_parts.append(
809
+ f"""
810
+ <div class="card">
811
+ <div class="card-value">{self._format_number(self.flop_counts.total)}</div>
812
+ <div class="card-label">FLOPs</div>
813
+ </div>
814
+ """
815
+ )
816
+ if self.memory_estimates:
817
+ html_parts.append(
818
+ f"""
819
+ <div class="card">
820
+ <div class="card-value">{self._format_bytes(self.memory_estimates.model_size_bytes)}</div>
821
+ <div class="card-label">Model Size</div>
822
+ </div>
823
+ """
824
+ )
825
+ if self.architecture_type:
826
+ html_parts.append(
827
+ f"""
828
+ <div class="card">
829
+ <div class="card-value">{self.architecture_type.upper()}</div>
830
+ <div class="card-label">Architecture</div>
831
+ </div>
832
+ """
833
+ )
834
+ # Quantization indicator card
835
+ if self.param_counts and self.param_counts.is_quantized:
836
+ html_parts.append(
837
+ """
838
+ <div class="card" style="border-color: #4CAF50;">
839
+ <div class="card-value" style="color: #4CAF50;">Yes</div>
840
+ <div class="card-label">Quantized</div>
841
+ </div>
842
+ """
843
+ )
844
+ html_parts.append("</section>")
845
+
846
+ # Interactive Graph Visualization - Placed prominently after metrics (Task 5.7.8)
847
+ if graph_html:
848
+ html_parts.append('<section class="graph-section">')
849
+ html_parts.append("<h2>Neural Network Architecture</h2>")
850
+ html_parts.append(
851
+ '<p class="section-desc">Click nodes to expand/collapse blocks. '
852
+ "Use the search box to find specific operations.</p>"
853
+ )
854
+ html_parts.append('<div class="graph-container">')
855
+ html_parts.append(graph_html)
856
+ html_parts.append("</div></section>")
857
+
858
+ # Evaluation Metrics (Task 12.5.4)
859
+ if eval_metrics_html:
860
+ html_parts.append(eval_metrics_html)
861
+
862
+ # Complexity Metrics Details (KV Cache + Memory Breakdown)
863
+ if self.memory_estimates:
864
+ # KV Cache section (Task 4.4.2)
865
+ if self.memory_estimates.kv_cache_bytes_per_token > 0:
866
+ html_parts.append('<section class="kv-cache">')
867
+ html_parts.append("<h2>KV Cache (Transformer Inference)</h2>")
868
+ config = self.memory_estimates.kv_cache_config
869
+ html_parts.append("<table>")
870
+ html_parts.append("<tr><th>Metric</th><th>Value</th></tr>")
871
+ html_parts.append(
872
+ f"<tr><td>Per Token</td><td>{self._format_bytes(self.memory_estimates.kv_cache_bytes_per_token)}</td></tr>"
873
+ )
874
+ if config.get("seq_len"):
875
+ html_parts.append(
876
+ f"<tr><td>Full Context (seq={config['seq_len']})</td>"
877
+ f"<td>{self._format_bytes(self.memory_estimates.kv_cache_bytes_full_context)}</td></tr>"
878
+ )
879
+ if config.get("num_layers"):
880
+ html_parts.append(f"<tr><td>Layers</td><td>{config['num_layers']}</td></tr>")
881
+ if config.get("hidden_dim"):
882
+ html_parts.append(
883
+ f"<tr><td>Hidden Dim</td><td>{config['hidden_dim']}</td></tr>"
884
+ )
885
+ html_parts.append("</table></section>")
886
+
887
+ # Memory Breakdown by Op Type (Task 4.4.3)
888
+ if self.memory_estimates.breakdown:
889
+ bd = self.memory_estimates.breakdown
890
+ if bd.weights_by_op_type:
891
+ html_parts.append('<section class="memory-breakdown">')
892
+ html_parts.append("<h2>Memory Breakdown by Op Type</h2>")
893
+ html_parts.append("<table>")
894
+ html_parts.append("<tr><th>Component</th><th>Size</th></tr>")
895
+ sorted_weights = sorted(bd.weights_by_op_type.items(), key=lambda x: -x[1])
896
+ for op_type, size in sorted_weights[:8]:
897
+ html_parts.append(
898
+ f"<tr><td>{op_type}</td><td>{self._format_bytes(size)}</td></tr>"
899
+ )
900
+ if len(sorted_weights) > 8:
901
+ remaining = sum(s for _, s in sorted_weights[8:])
902
+ html_parts.append(
903
+ f"<tr><td>Other ({len(sorted_weights) - 8} types)</td>"
904
+ f"<td>{self._format_bytes(remaining)}</td></tr>"
905
+ )
906
+ html_parts.append("</table></section>")
907
+
908
+ # Parameter Details section (shared weights, precision, quantization)
909
+ if self.param_counts:
910
+ has_content = (
911
+ self.param_counts.num_shared_weights > 0
912
+ or self.param_counts.precision_breakdown
913
+ or self.param_counts.is_quantized
914
+ )
915
+ if has_content:
916
+ html_parts.append('<section class="param-details">')
917
+ html_parts.append("<h2>Parameter Details</h2>")
918
+
919
+ # Precision breakdown
920
+ if self.param_counts.precision_breakdown:
921
+ html_parts.append("<h3>Precision Breakdown</h3>")
922
+ html_parts.append("<table>")
923
+ html_parts.append("<tr><th>Data Type</th><th>Parameters</th></tr>")
924
+ for dtype, count in sorted(
925
+ self.param_counts.precision_breakdown.items(),
926
+ key=lambda x: -x[1],
927
+ ):
928
+ html_parts.append(
929
+ f"<tr><td>{dtype}</td><td>{self._format_number(count)}</td></tr>"
930
+ )
931
+ html_parts.append("</table>")
932
+
933
+ # Shared weights info
934
+ if self.param_counts.num_shared_weights > 0:
935
+ html_parts.append("<h3>Shared Weights</h3>")
936
+ html_parts.append(
937
+ f"<p><strong>{self.param_counts.num_shared_weights}</strong> weights are shared across multiple nodes.</p>"
938
+ )
939
+ if self.param_counts.shared_weights:
940
+ html_parts.append("<details><summary>Show shared weight details</summary>")
941
+ html_parts.append("<table>")
942
+ html_parts.append("<tr><th>Weight Name</th><th>Used By Nodes</th></tr>")
943
+ for name, nodes in list(self.param_counts.shared_weights.items())[:10]:
944
+ nodes_str = ", ".join(nodes[:5])
945
+ if len(nodes) > 5:
946
+ nodes_str += f" (+{len(nodes) - 5} more)"
947
+ html_parts.append(f"<tr><td>{name}</td><td>{nodes_str}</td></tr>")
948
+ if len(self.param_counts.shared_weights) > 10:
949
+ html_parts.append(
950
+ f"<tr><td colspan='2'>... and {len(self.param_counts.shared_weights) - 10} more shared weights</td></tr>"
951
+ )
952
+ html_parts.append("</table></details>")
953
+
954
+ # Quantization info
955
+ if self.param_counts.is_quantized:
956
+ html_parts.append("<h3>Quantization</h3>")
957
+ html_parts.append(
958
+ '<p style="color: #4CAF50; font-weight: bold;">Model is quantized</p>'
959
+ )
960
+ if self.param_counts.quantized_ops:
961
+ html_parts.append(
962
+ f"<p>Quantized operations: {', '.join(self.param_counts.quantized_ops)}</p>"
963
+ )
964
+
965
+ html_parts.append("</section>")
966
+
967
+ # Visualizations
968
+ if images:
969
+ html_parts.append('<section class="visualizations">')
970
+ html_parts.append("<h2>Visualizations</h2>")
971
+ html_parts.append('<div class="chart-grid">')
972
+ for name, data_uri in images.items():
973
+ if data_uri:
974
+ label = name.replace("_", " ").title()
975
+ html_parts.append(
976
+ f"""
977
+ <div class="chart-container">
978
+ <img src="{data_uri}" alt="{label}">
979
+ </div>
980
+ """
981
+ )
982
+ html_parts.append("</div></section>")
983
+
984
+ # Per-Layer Summary Table (Story 5.8)
985
+ if layer_table_html:
986
+ html_parts.append('<section class="layer-summary">')
987
+ html_parts.append("<h2>Layer-by-Layer Analysis</h2>")
988
+ html_parts.append(
989
+ '<p class="section-desc">Click column headers to sort. '
990
+ "Use the search box to filter layers.</p>"
991
+ )
992
+ html_parts.append(layer_table_html)
993
+ html_parts.append("</section>")
994
+
995
+ # Model Details
996
+ html_parts.append('<section class="details">')
997
+ html_parts.append("<h2>Model Details</h2>")
998
+
999
+ # Metadata table
1000
+ if self.metadata:
1001
+ html_parts.append(
1002
+ """
1003
+ <h3>Metadata</h3>
1004
+ <table>
1005
+ <tr><th>Property</th><th>Value</th></tr>
1006
+ """
1007
+ )
1008
+ html_parts.append(f"<tr><td>IR Version</td><td>{self.metadata.ir_version}</td></tr>")
1009
+ html_parts.append(
1010
+ f"<tr><td>Producer</td><td>{self.metadata.producer_name} {self.metadata.producer_version}</td></tr>"
1011
+ )
1012
+ opsets = ", ".join(f"{d}:{v}" for d, v in self.metadata.opsets.items())
1013
+ html_parts.append(f"<tr><td>Opsets</td><td>{opsets}</td></tr>")
1014
+ html_parts.append("</table>")
1015
+
1016
+ # Graph summary
1017
+ if self.graph_summary:
1018
+ html_parts.append(
1019
+ """
1020
+ <h3>Graph Summary</h3>
1021
+ <table>
1022
+ <tr><th>Metric</th><th>Value</th></tr>
1023
+ """
1024
+ )
1025
+ html_parts.append(f"<tr><td>Nodes</td><td>{self.graph_summary.num_nodes}</td></tr>")
1026
+ html_parts.append(f"<tr><td>Inputs</td><td>{self.graph_summary.num_inputs}</td></tr>")
1027
+ html_parts.append(f"<tr><td>Outputs</td><td>{self.graph_summary.num_outputs}</td></tr>")
1028
+ html_parts.append(
1029
+ f"<tr><td>Initializers</td><td>{self.graph_summary.num_initializers}</td></tr>"
1030
+ )
1031
+ html_parts.append("</table>")
1032
+
1033
+ # I/O shapes
1034
+ if self.graph_summary.input_shapes:
1035
+ html_parts.append("<h4>Inputs</h4><ul>")
1036
+ for name, shape in self.graph_summary.input_shapes.items():
1037
+ html_parts.append(f"<li><code>{name}</code>: {shape}</li>")
1038
+ html_parts.append("</ul>")
1039
+
1040
+ if self.graph_summary.output_shapes:
1041
+ html_parts.append("<h4>Outputs</h4><ul>")
1042
+ for name, shape in self.graph_summary.output_shapes.items():
1043
+ html_parts.append(f"<li><code>{name}</code>: {shape}</li>")
1044
+ html_parts.append("</ul>")
1045
+
1046
+ # Operator Distribution (Task 4.4.1)
1047
+ if self.graph_summary.op_type_counts:
1048
+ html_parts.append("<h3>Operator Distribution</h3>")
1049
+ html_parts.append("<table>")
1050
+ html_parts.append("<tr><th>Operator</th><th>Count</th></tr>")
1051
+ sorted_ops = sorted(self.graph_summary.op_type_counts.items(), key=lambda x: -x[1])
1052
+ for op, count in sorted_ops[:15]:
1053
+ html_parts.append(f"<tr><td>{op}</td><td>{count}</td></tr>")
1054
+ if len(sorted_ops) > 15:
1055
+ html_parts.append(
1056
+ f"<tr><td>...</td><td>({len(sorted_ops) - 15} more)</td></tr>"
1057
+ )
1058
+ html_parts.append("</table>")
1059
+
1060
+ html_parts.append("</section>")
1061
+
1062
+ # Architecture section
1063
+ if self.architecture_type != "unknown" or self.detected_blocks:
1064
+ html_parts.append('<section class="architecture">')
1065
+ html_parts.append("<h2>Architecture</h2>")
1066
+ html_parts.append(
1067
+ f'<p><strong>Detected Type:</strong> <span class="arch-type">{self.architecture_type.upper()}</span></p>'
1068
+ )
1069
+
1070
+ if self.detected_blocks:
1071
+ # Group by block type
1072
+ block_types: dict[str, int] = {}
1073
+ for block in self.detected_blocks:
1074
+ block_types[block.block_type] = block_types.get(block.block_type, 0) + 1
1075
+
1076
+ html_parts.append("<h3>Detected Blocks</h3>")
1077
+ html_parts.append("<table>")
1078
+ html_parts.append("<tr><th>Block Type</th><th>Count</th></tr>")
1079
+ for bt, count in sorted(block_types.items(), key=lambda x: -x[1]):
1080
+ html_parts.append(f"<tr><td>{bt}</td><td>{count}</td></tr>")
1081
+ html_parts.append("</table>")
1082
+
1083
+ # Non-standard residual patterns
1084
+ nonstandard_residuals = [
1085
+ b
1086
+ for b in self.detected_blocks
1087
+ if b.block_type in ("ResidualConcat", "ResidualGate", "ResidualSub")
1088
+ ]
1089
+ if nonstandard_residuals:
1090
+ # Group by type
1091
+ by_type: dict[str, list] = {}
1092
+ for block in nonstandard_residuals:
1093
+ if block.block_type not in by_type:
1094
+ by_type[block.block_type] = []
1095
+ by_type[block.block_type].append(block)
1096
+
1097
+ type_labels = {
1098
+ "ResidualConcat": "Concat-based (DenseNet-style)",
1099
+ "ResidualGate": "Gated skip (Highway/attention)",
1100
+ "ResidualSub": "Subtraction-based",
1101
+ }
1102
+
1103
+ html_parts.append('<div class="nonstandard-residuals">')
1104
+ html_parts.append("<h3>Non-Standard Skip Connections</h3>")
1105
+ html_parts.append(
1106
+ f"<p>This model uses {len(nonstandard_residuals)} non-standard skip connection(s):</p>"
1107
+ )
1108
+
1109
+ # Create collapsible section for each type
1110
+ for block_type, blocks in by_type.items():
1111
+ label = type_labels.get(block_type, block_type)
1112
+ html_parts.append(f"<details><summary>{label} ({len(blocks)})</summary>")
1113
+ html_parts.append('<div class="skip-connections-grid">')
1114
+ for block in blocks:
1115
+ if block_type == "ResidualConcat":
1116
+ depth_diff = block.attributes.get("depth_diff", "?")
1117
+ html_parts.append(
1118
+ f'<div class="skip-item">{block.name} '
1119
+ f'<span class="skip-detail">depth: {depth_diff}</span></div>'
1120
+ )
1121
+ else:
1122
+ html_parts.append(f'<div class="skip-item">{block.name}</div>')
1123
+ html_parts.append("</div></details>")
1124
+ html_parts.append("</div>")
1125
+
1126
+ html_parts.append("</section>")
1127
+
1128
+ # Dataset Info
1129
+ if self.dataset_info:
1130
+ html_parts.append('<section class="dataset-info">')
1131
+ html_parts.append("<h2>Dataset Info</h2>")
1132
+ if self.dataset_info.task:
1133
+ html_parts.append(f"<p><strong>Task:</strong> {self.dataset_info.task}</p>")
1134
+ if self.dataset_info.num_classes:
1135
+ html_parts.append(
1136
+ f"<p><strong>Number of Classes:</strong> {self.dataset_info.num_classes}</p>"
1137
+ )
1138
+ if self.dataset_info.class_names:
1139
+ num_classes = len(self.dataset_info.class_names)
1140
+ html_parts.append(
1141
+ f'<details class="class-names-details">'
1142
+ f"<summary>Class Names ({num_classes} classes) - click to expand</summary>"
1143
+ )
1144
+ html_parts.append('<div class="class-grid">')
1145
+ for idx, name in enumerate(self.dataset_info.class_names):
1146
+ html_parts.append(f'<div class="class-item"><code>{idx}</code> {name}</div>')
1147
+ html_parts.append("</div></details>")
1148
+ if self.dataset_info.source:
1149
+ html_parts.append(
1150
+ f'<p class="metadata-source"><em>Source: {self.dataset_info.source}</em></p>'
1151
+ )
1152
+ html_parts.append("</section>")
1153
+
1154
+ # Hardware Estimates
1155
+ if self.hardware_estimates and self.hardware_profile:
1156
+ hw = self.hardware_estimates
1157
+ html_parts.append('<section class="hardware">')
1158
+ html_parts.append("<h2>Hardware Estimates</h2>")
1159
+ html_parts.append(f'<p class="device-name">{hw.device}</p>')
1160
+ html_parts.append(
1161
+ f'<p class="precision-info">Precision: {hw.precision} | Batch Size: {hw.batch_size}</p>'
1162
+ )
1163
+
1164
+ html_parts.append(
1165
+ """
1166
+ <table>
1167
+ <tr><th>Metric</th><th>Value</th></tr>
1168
+ """
1169
+ )
1170
+ html_parts.append(
1171
+ f"<tr><td>VRAM Required</td><td>{self._format_bytes(hw.vram_required_bytes)}</td></tr>"
1172
+ )
1173
+ html_parts.append(
1174
+ f"<tr><td>Fits in VRAM</td><td>{'Yes' if hw.fits_in_vram else 'No'}</td></tr>"
1175
+ )
1176
+ if hw.fits_in_vram:
1177
+ html_parts.append(
1178
+ f"<tr><td>Theoretical Latency</td><td>{hw.theoretical_latency_ms:.4f} ms</td></tr>"
1179
+ )
1180
+ html_parts.append(f"<tr><td>Bottleneck</td><td>{hw.bottleneck}</td></tr>")
1181
+ html_parts.append(
1182
+ f"<tr><td>Compute Utilization</td><td>{hw.compute_utilization_estimate:.0%}</td></tr>"
1183
+ )
1184
+ html_parts.append(
1185
+ f"<tr><td>GPU Saturation</td><td>{hw.gpu_saturation:.2e} ({hw.gpu_saturation * 100:.4f}%)</td></tr>"
1186
+ )
1187
+ html_parts.append("</table>")
1188
+
1189
+ # Device specs
1190
+ html_parts.append("<h3>Device Specifications</h3>")
1191
+ html_parts.append("<ul>")
1192
+ html_parts.append(
1193
+ f"<li><strong>VRAM:</strong> {self._format_bytes(self.hardware_profile.vram_bytes)}</li>"
1194
+ )
1195
+ html_parts.append(
1196
+ f"<li><strong>FP32 Peak:</strong> {self.hardware_profile.peak_fp32_tflops:.1f} TFLOPS</li>"
1197
+ )
1198
+ html_parts.append(
1199
+ f"<li><strong>FP16 Peak:</strong> {self.hardware_profile.peak_fp16_tflops:.1f} TFLOPS</li>"
1200
+ )
1201
+ if self.hardware_profile.tdp_watts:
1202
+ html_parts.append(
1203
+ f"<li><strong>TDP:</strong> {self.hardware_profile.tdp_watts}W</li>"
1204
+ )
1205
+ html_parts.append("</ul></section>")
1206
+
1207
+ # System Requirements
1208
+ if self.system_requirements:
1209
+ reqs = self.system_requirements
1210
+ html_parts.append('<section class="system-requirements">')
1211
+ html_parts.append("<h2>System Requirements</h2>")
1212
+ html_parts.append("<table>")
1213
+ html_parts.append("<tr><th>Level</th><th>Device</th><th>VRAM</th></tr>")
1214
+ min_name = reqs.minimum_gpu.device if reqs.minimum_gpu else "N/A"
1215
+ rec_name = reqs.recommended_gpu.device if reqs.recommended_gpu else "N/A"
1216
+ opt_name = reqs.optimal_gpu.device if reqs.optimal_gpu else "N/A"
1217
+ min_vram = f"{reqs.minimum_vram_gb} GB" if reqs.minimum_vram_gb is not None else "-"
1218
+ rec_vram = (
1219
+ f"{reqs.recommended_vram_gb} GB" if reqs.recommended_vram_gb is not None else "-"
1220
+ )
1221
+ html_parts.append(f"<tr><td>Minimum</td><td>{min_name}</td><td>{min_vram}</td></tr>")
1222
+ html_parts.append(
1223
+ f"<tr><td>Recommended</td><td>{rec_name}</td><td>{rec_vram}</td></tr>"
1224
+ )
1225
+ html_parts.append(f"<tr><td>Optimal</td><td>{opt_name}</td><td>-</td></tr>")
1226
+ html_parts.append("</table></section>")
1227
+
1228
+ # Batch Size Sweep
1229
+ if self.batch_size_sweep:
1230
+ batch_sweep = self.batch_size_sweep
1231
+ html_parts.append('<section class="batch-scaling">')
1232
+ html_parts.append("<h2>Batch Size Scaling</h2>")
1233
+ html_parts.append(
1234
+ f"<p><strong>Optimal Batch Size:</strong> {batch_sweep.optimal_batch_size}</p>"
1235
+ )
1236
+ html_parts.append("<table>")
1237
+ html_parts.append(
1238
+ "<tr><th>Batch Size</th><th>Latency (ms)</th><th>Throughput (inf/s)</th><th>VRAM (GB)</th></tr>"
1239
+ )
1240
+ for i, bs in enumerate(batch_sweep.batch_sizes):
1241
+ html_parts.append(
1242
+ f"<tr><td>{bs}</td><td>{batch_sweep.latencies[i]:.2f}</td><td>{batch_sweep.throughputs[i]:.1f}</td><td>{batch_sweep.vram_usage_gb[i]:.2f}</td></tr>"
1243
+ )
1244
+ html_parts.append("</table></section>")
1245
+
1246
+ # Resolution Sweep (Story 6.8)
1247
+ if self.resolution_sweep:
1248
+ res_sweep = self.resolution_sweep
1249
+ html_parts.append('<section class="resolution-scaling">')
1250
+ html_parts.append("<h2>Resolution Scaling</h2>")
1251
+ html_parts.append(
1252
+ f"<p><strong>Max Resolution:</strong> {res_sweep.max_resolution} | "
1253
+ f"<strong>Optimal:</strong> {res_sweep.optimal_resolution}</p>"
1254
+ )
1255
+ html_parts.append("<table>")
1256
+ html_parts.append(
1257
+ "<tr><th>Resolution</th><th>FLOPs</th><th>Memory (GB)</th>"
1258
+ "<th>Latency (ms)</th><th>Throughput</th><th>VRAM (GB)</th></tr>"
1259
+ )
1260
+ for i, res in enumerate(res_sweep.resolutions):
1261
+ flops_str = self._format_number(res_sweep.flops[i])
1262
+ lat_str = (
1263
+ f"{res_sweep.latencies[i]:.2f}"
1264
+ if res_sweep.latencies[i] != float("inf")
1265
+ else "OOM"
1266
+ )
1267
+ tput_str = (
1268
+ f"{res_sweep.throughputs[i]:.1f}" if res_sweep.throughputs[i] > 0 else "-"
1269
+ )
1270
+ html_parts.append(
1271
+ f"<tr><td>{res}</td><td>{flops_str}</td><td>{res_sweep.memory_gb[i]:.2f}</td>"
1272
+ f"<td>{lat_str}</td><td>{tput_str}</td><td>{res_sweep.vram_usage_gb[i]:.2f}</td></tr>"
1273
+ )
1274
+ html_parts.append("</table></section>")
1275
+
1276
+ # Risk Signals
1277
+ if self.risk_signals:
1278
+ html_parts.append('<section class="risks">')
1279
+ html_parts.append("<h2>Risk Signals</h2>")
1280
+ for risk in self.risk_signals:
1281
+ severity_class = {
1282
+ "info": "info",
1283
+ "warning": "warning",
1284
+ "high": "high",
1285
+ }.get(risk.severity, "info")
1286
+ html_parts.append(
1287
+ f"""
1288
+ <div class="risk-card {severity_class}">
1289
+ <div class="risk-header">
1290
+ <span class="severity">{risk.severity.upper()}</span>
1291
+ <span class="risk-id">{risk.id}</span>
1292
+ </div>
1293
+ <p>{risk.description}</p>
1294
+ """
1295
+ )
1296
+ if risk.recommendation:
1297
+ html_parts.append(
1298
+ f'<p class="recommendation"><strong>Recommendation:</strong> {risk.recommendation}</p>'
1299
+ )
1300
+ html_parts.append("</div>")
1301
+ html_parts.append("</section>")
1302
+
1303
+ html_parts.append("</div></body></html>")
1304
+ return "".join(html_parts)
1305
+
1306
+ def _html_head(self, title: str) -> str:
1307
+ """Generate HTML head with embedded CSS."""
1308
+ return f"""<!DOCTYPE html>
1309
+ <html lang="en">
1310
+ <head>
1311
+ <meta charset="UTF-8">
1312
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1313
+ <title>{title} - HaoLine Report</title>
1314
+ <style>
1315
+ :root {{
1316
+ --bg-primary: #0d1117;
1317
+ --bg-secondary: #161b22;
1318
+ --bg-card: #21262d;
1319
+ --text-primary: #e6edf3;
1320
+ --text-secondary: #8b949e;
1321
+ --accent-cyan: #00d4ff;
1322
+ --accent-coral: #ff6b6b;
1323
+ --accent-green: #3fb950;
1324
+ --accent-yellow: #d29922;
1325
+ --border: #30363d;
1326
+ }}
1327
+
1328
+ * {{
1329
+ margin: 0;
1330
+ padding: 0;
1331
+ box-sizing: border-box;
1332
+ }}
1333
+
1334
+ body {{
1335
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
1336
+ background: var(--bg-primary);
1337
+ color: var(--text-primary);
1338
+ line-height: 1.6;
1339
+ }}
1340
+
1341
+ .container {{
1342
+ max-width: 1200px;
1343
+ margin: 0 auto;
1344
+ padding: 2rem;
1345
+ }}
1346
+
1347
+ header {{
1348
+ text-align: center;
1349
+ margin-bottom: 3rem;
1350
+ padding-bottom: 2rem;
1351
+ border-bottom: 1px solid var(--border);
1352
+ }}
1353
+
1354
+ header h1 {{
1355
+ font-size: 2.5rem;
1356
+ color: var(--accent-cyan);
1357
+ margin-bottom: 0.5rem;
1358
+ }}
1359
+
1360
+ header .subtitle {{
1361
+ font-size: 1.2rem;
1362
+ color: var(--text-secondary);
1363
+ }}
1364
+
1365
+ header .timestamp {{
1366
+ font-size: 0.85rem;
1367
+ color: var(--text-secondary);
1368
+ margin-top: 0.5rem;
1369
+ }}
1370
+
1371
+ section {{
1372
+ margin-bottom: 3rem;
1373
+ }}
1374
+
1375
+ h2 {{
1376
+ font-size: 1.5rem;
1377
+ color: var(--accent-cyan);
1378
+ margin-bottom: 1.5rem;
1379
+ padding-bottom: 0.5rem;
1380
+ border-bottom: 2px solid var(--accent-cyan);
1381
+ }}
1382
+
1383
+ h3 {{
1384
+ font-size: 1.2rem;
1385
+ color: var(--text-primary);
1386
+ margin: 1.5rem 0 1rem;
1387
+ }}
1388
+
1389
+ h4 {{
1390
+ font-size: 1rem;
1391
+ color: var(--text-secondary);
1392
+ margin: 1rem 0 0.5rem;
1393
+ }}
1394
+
1395
+ /* Executive Summary */
1396
+ .executive-summary {{
1397
+ background: linear-gradient(135deg, #1a2a3a 0%, #0d1a26 100%);
1398
+ padding: 2rem;
1399
+ border-radius: 12px;
1400
+ border: 2px solid var(--accent-cyan);
1401
+ margin: 2rem 0;
1402
+ box-shadow: 0 4px 20px rgba(0, 200, 255, 0.15);
1403
+ }}
1404
+
1405
+ .executive-summary .tldr {{
1406
+ font-size: 1.1rem;
1407
+ color: var(--accent-cyan);
1408
+ margin-bottom: 1rem;
1409
+ }}
1410
+
1411
+ .executive-summary .llm-credit {{
1412
+ font-size: 0.8rem;
1413
+ color: var(--text-secondary);
1414
+ margin-top: 1rem;
1415
+ font-style: italic;
1416
+ }}
1417
+
1418
+ /* Metrics Cards */
1419
+ .metrics-cards {{
1420
+ display: grid;
1421
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1422
+ gap: 1.5rem;
1423
+ margin-bottom: 2rem;
1424
+ }}
1425
+
1426
+ .card {{
1427
+ background: var(--bg-card);
1428
+ padding: 1.5rem;
1429
+ border-radius: 12px;
1430
+ text-align: center;
1431
+ border: 1px solid var(--border);
1432
+ transition: transform 0.2s, border-color 0.2s;
1433
+ }}
1434
+
1435
+ .card:hover {{
1436
+ transform: translateY(-2px);
1437
+ border-color: var(--accent-cyan);
1438
+ }}
1439
+
1440
+ .card-value {{
1441
+ font-size: 2rem;
1442
+ font-weight: bold;
1443
+ color: var(--accent-cyan);
1444
+ }}
1445
+
1446
+ .card-label {{
1447
+ font-size: 0.9rem;
1448
+ color: var(--text-secondary);
1449
+ margin-top: 0.5rem;
1450
+ }}
1451
+
1452
+ /* Visualizations */
1453
+ .chart-grid {{
1454
+ display: grid;
1455
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
1456
+ gap: 1.5rem;
1457
+ }}
1458
+
1459
+ .chart-container {{
1460
+ background: var(--bg-card);
1461
+ padding: 1rem;
1462
+ border-radius: 12px;
1463
+ }}
1464
+
1465
+ /* Section descriptions */
1466
+ .section-desc {{
1467
+ font-size: 0.875rem;
1468
+ color: var(--text-secondary);
1469
+ margin-bottom: 1rem;
1470
+ }}
1471
+
1472
+ /* Interactive Graph Section (Task 5.7.8) */
1473
+ .graph-section {{
1474
+ margin-bottom: 3rem;
1475
+ }}
1476
+
1477
+ .graph-container {{
1478
+ background: var(--bg-card);
1479
+ border: 1px solid var(--border);
1480
+ border-radius: 12px;
1481
+ height: 600px;
1482
+ overflow: hidden;
1483
+ position: relative;
1484
+ }}
1485
+
1486
+ .graph-container iframe {{
1487
+ width: 100%;
1488
+ height: 100%;
1489
+ border: none;
1490
+ }}
1491
+
1492
+ /* Layer Summary Section (Story 5.8) */
1493
+ .layer-summary {{
1494
+ margin-bottom: 3rem;
1495
+ border: 1px solid var(--border);
1496
+ }}
1497
+
1498
+ .chart-container img {{
1499
+ width: 100%;
1500
+ height: auto;
1501
+ border-radius: 8px;
1502
+ }}
1503
+
1504
+ /* Tables */
1505
+ table {{
1506
+ width: 100%;
1507
+ border-collapse: collapse;
1508
+ margin: 1rem 0;
1509
+ background: var(--bg-card);
1510
+ border-radius: 8px;
1511
+ overflow: hidden;
1512
+ }}
1513
+
1514
+ th, td {{
1515
+ padding: 0.75rem 1rem;
1516
+ text-align: left;
1517
+ border-bottom: 1px solid var(--border);
1518
+ }}
1519
+
1520
+ th {{
1521
+ background: var(--bg-secondary);
1522
+ color: var(--accent-cyan);
1523
+ font-weight: 600;
1524
+ }}
1525
+
1526
+ tr:last-child td {{
1527
+ border-bottom: none;
1528
+ }}
1529
+
1530
+ tr:hover {{
1531
+ background: var(--bg-secondary);
1532
+ }}
1533
+
1534
+ /* Lists */
1535
+ ul {{
1536
+ list-style: none;
1537
+ padding-left: 0;
1538
+ }}
1539
+
1540
+ ul li {{
1541
+ padding: 0.5rem 0;
1542
+ padding-left: 1.5rem;
1543
+ position: relative;
1544
+ }}
1545
+
1546
+ ul li::before {{
1547
+ content: "";
1548
+ position: absolute;
1549
+ left: 0;
1550
+ top: 50%;
1551
+ transform: translateY(-50%);
1552
+ width: 6px;
1553
+ height: 6px;
1554
+ background: var(--accent-cyan);
1555
+ border-radius: 50%;
1556
+ }}
1557
+
1558
+ code {{
1559
+ background: var(--bg-secondary);
1560
+ padding: 0.2rem 0.5rem;
1561
+ border-radius: 4px;
1562
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
1563
+ font-size: 0.9em;
1564
+ color: var(--accent-coral);
1565
+ }}
1566
+
1567
+ /* Collapsible Details */
1568
+ details {{
1569
+ background: var(--bg-card);
1570
+ border-radius: 8px;
1571
+ margin: 1rem 0;
1572
+ border: 1px solid var(--border);
1573
+ }}
1574
+
1575
+ details summary {{
1576
+ padding: 1rem;
1577
+ cursor: pointer;
1578
+ font-weight: 600;
1579
+ color: var(--accent-cyan);
1580
+ user-select: none;
1581
+ transition: background 0.2s;
1582
+ }}
1583
+
1584
+ details summary:hover {{
1585
+ background: var(--bg-secondary);
1586
+ }}
1587
+
1588
+ details[open] summary {{
1589
+ border-bottom: 1px solid var(--border);
1590
+ }}
1591
+
1592
+ details summary::marker {{
1593
+ color: var(--accent-cyan);
1594
+ }}
1595
+
1596
+ /* Class Names Grid */
1597
+ .class-grid {{
1598
+ display: grid;
1599
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
1600
+ gap: 0.5rem;
1601
+ padding: 1rem;
1602
+ max-height: 400px;
1603
+ overflow-y: auto;
1604
+ }}
1605
+
1606
+ .class-item {{
1607
+ padding: 0.4rem 0.6rem;
1608
+ background: var(--bg-secondary);
1609
+ border-radius: 4px;
1610
+ font-size: 0.85rem;
1611
+ white-space: nowrap;
1612
+ overflow: hidden;
1613
+ text-overflow: ellipsis;
1614
+ }}
1615
+
1616
+ .class-item code {{
1617
+ margin-right: 0.3rem;
1618
+ font-size: 0.75rem;
1619
+ }}
1620
+
1621
+ /* KV Cache Section */
1622
+ .kv-cache {{
1623
+ background: var(--bg-secondary);
1624
+ padding: 1.5rem;
1625
+ border-radius: 12px;
1626
+ border-left: 4px solid var(--accent-green);
1627
+ }}
1628
+
1629
+ .kv-cache h2 {{
1630
+ color: var(--accent-green);
1631
+ border-bottom-color: var(--accent-green);
1632
+ }}
1633
+
1634
+ /* Memory Breakdown Section */
1635
+ .memory-breakdown {{
1636
+ background: var(--bg-secondary);
1637
+ padding: 1.5rem;
1638
+ border-radius: 12px;
1639
+ }}
1640
+
1641
+ /* Architecture Section */
1642
+ .architecture .arch-type {{
1643
+ color: var(--accent-cyan);
1644
+ font-weight: bold;
1645
+ }}
1646
+
1647
+ .nonstandard-residuals {{
1648
+ background: var(--bg-card);
1649
+ padding: 1.5rem;
1650
+ border-radius: 8px;
1651
+ margin-top: 1rem;
1652
+ border-left: 4px solid var(--accent-yellow);
1653
+ }}
1654
+
1655
+ .nonstandard-residuals h3 {{
1656
+ color: var(--accent-yellow);
1657
+ margin-top: 0;
1658
+ }}
1659
+
1660
+ .nonstandard-residuals details {{
1661
+ margin: 0.5rem 0;
1662
+ background: var(--bg-primary);
1663
+ }}
1664
+
1665
+ .nonstandard-residuals summary {{
1666
+ padding: 0.75rem 1rem;
1667
+ color: var(--text-primary);
1668
+ }}
1669
+
1670
+ .skip-connections-grid {{
1671
+ display: grid;
1672
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1673
+ gap: 0.5rem;
1674
+ padding: 1rem;
1675
+ max-height: 300px;
1676
+ overflow-y: auto;
1677
+ }}
1678
+
1679
+ .skip-item {{
1680
+ padding: 0.4rem 0.6rem;
1681
+ background: var(--bg-secondary);
1682
+ border-radius: 4px;
1683
+ font-size: 0.8rem;
1684
+ font-family: 'SF Mono', Monaco, monospace;
1685
+ }}
1686
+
1687
+ .skip-detail {{
1688
+ color: var(--text-secondary);
1689
+ font-size: 0.7rem;
1690
+ }}
1691
+
1692
+ /* Hardware Section */
1693
+ .hardware .device-name {{
1694
+ font-size: 1.2rem;
1695
+ color: var(--accent-cyan);
1696
+ margin-bottom: 0.5rem;
1697
+ }}
1698
+
1699
+ .hardware .precision-info {{
1700
+ color: var(--text-secondary);
1701
+ margin-bottom: 1rem;
1702
+ }}
1703
+
1704
+ /* Risk Signals */
1705
+ .risk-card {{
1706
+ background: var(--bg-card);
1707
+ padding: 1.5rem;
1708
+ border-radius: 8px;
1709
+ margin-bottom: 1rem;
1710
+ border-left: 4px solid var(--text-secondary);
1711
+ }}
1712
+
1713
+ .risk-card.info {{
1714
+ border-left-color: var(--accent-cyan);
1715
+ }}
1716
+
1717
+ .risk-card.warning {{
1718
+ border-left-color: var(--accent-yellow);
1719
+ }}
1720
+
1721
+ .risk-card.high {{
1722
+ border-left-color: var(--accent-coral);
1723
+ }}
1724
+
1725
+ .risk-header {{
1726
+ display: flex;
1727
+ gap: 1rem;
1728
+ align-items: center;
1729
+ margin-bottom: 0.75rem;
1730
+ }}
1731
+
1732
+ .severity {{
1733
+ font-size: 0.75rem;
1734
+ font-weight: bold;
1735
+ padding: 0.25rem 0.5rem;
1736
+ border-radius: 4px;
1737
+ background: var(--bg-secondary);
1738
+ }}
1739
+
1740
+ .risk-card.info .severity {{ color: var(--accent-cyan); }}
1741
+ .risk-card.warning .severity {{ color: var(--accent-yellow); }}
1742
+ .risk-card.high .severity {{ color: var(--accent-coral); }}
1743
+
1744
+ .risk-id {{
1745
+ font-weight: 600;
1746
+ }}
1747
+
1748
+ .recommendation {{
1749
+ margin-top: 0.75rem;
1750
+ padding-top: 0.75rem;
1751
+ border-top: 1px solid var(--border);
1752
+ font-size: 0.9rem;
1753
+ }}
1754
+
1755
+ /* Responsive */
1756
+ @media (max-width: 768px) {{
1757
+ .container {{
1758
+ padding: 1rem;
1759
+ }}
1760
+
1761
+ header h1 {{
1762
+ font-size: 1.8rem;
1763
+ }}
1764
+
1765
+ .chart-grid {{
1766
+ grid-template-columns: 1fr;
1767
+ }}
1768
+
1769
+ .metrics-cards {{
1770
+ grid-template-columns: repeat(2, 1fr);
1771
+ }}
1772
+ }}
1773
+ </style>
1774
+ </head>
1775
+ """
1776
+
1777
+
1778
+ class ModelInspector:
1779
+ """
1780
+ Main orchestrator for ONNX model analysis.
1781
+
1782
+ Coordinates the loader, metrics engine, pattern analyzer, and risk analyzer
1783
+ to produce a comprehensive InspectionReport.
1784
+
1785
+ Example:
1786
+ inspector = ModelInspector()
1787
+ report = inspector.inspect("model.onnx")
1788
+ report.to_json()
1789
+ """
1790
+
1791
+ def __init__(
1792
+ self,
1793
+ loader: ONNXGraphLoader | None = None,
1794
+ metrics: MetricsEngine | None = None,
1795
+ patterns: PatternAnalyzer | None = None,
1796
+ risks: RiskAnalyzer | None = None,
1797
+ logger: logging.Logger | None = None,
1798
+ ):
1799
+ """
1800
+ Initialize ModelInspector with optional component overrides.
1801
+
1802
+ Args:
1803
+ loader: Custom graph loader. If None, uses default ONNXGraphLoader.
1804
+ metrics: Custom metrics engine. If None, uses default MetricsEngine.
1805
+ patterns: Custom pattern analyzer. If None, uses default PatternAnalyzer.
1806
+ risks: Custom risk analyzer. If None, uses default RiskAnalyzer.
1807
+ logger: Logger for diagnostic output.
1808
+ """
1809
+ # Defer imports to avoid circular dependencies
1810
+ from .analyzer import MetricsEngine, ONNXGraphLoader
1811
+ from .patterns import PatternAnalyzer
1812
+ from .risks import RiskAnalyzer
1813
+
1814
+ self.loader = loader or ONNXGraphLoader()
1815
+ self.metrics = metrics or MetricsEngine()
1816
+ self.patterns = patterns or PatternAnalyzer()
1817
+ self.risks = risks or RiskAnalyzer()
1818
+ self.logger = logger or logging.getLogger("haoline")
1819
+
1820
+ def inspect(self, model_path: str | pathlib.Path) -> InspectionReport:
1821
+ """
1822
+ Run full analysis pipeline on an ONNX model.
1823
+
1824
+ Args:
1825
+ model_path: Path to the ONNX model file.
1826
+
1827
+ Returns:
1828
+ InspectionReport with all analysis results.
1829
+ """
1830
+ model_path = pathlib.Path(model_path)
1831
+ self.logger.info(f"Inspecting model: {model_path}")
1832
+
1833
+ # Load model
1834
+ model, graph_info = self.loader.load(model_path)
1835
+
1836
+ # Extract metadata
1837
+ metadata = self._extract_metadata(model, model_path)
1838
+
1839
+ # Build graph summary
1840
+ graph_summary = self._build_graph_summary(graph_info)
1841
+
1842
+ # Compute metrics
1843
+ self.logger.debug("Computing metrics...")
1844
+ param_counts = self.metrics.count_parameters(graph_info)
1845
+ flop_counts = self.metrics.estimate_flops(graph_info)
1846
+ memory_estimates = self.metrics.estimate_memory(graph_info)
1847
+
1848
+ # Detect patterns
1849
+ self.logger.debug("Detecting patterns...")
1850
+ detected_blocks = self.patterns.group_into_blocks(graph_info)
1851
+ architecture_type = self.patterns.classify_architecture(graph_info, detected_blocks)
1852
+
1853
+ # Analyze risks
1854
+ self.logger.debug("Analyzing risks...")
1855
+ risk_signals = self.risks.analyze(graph_info, detected_blocks)
1856
+
1857
+ # Try to infer num_classes from output shapes
1858
+ dataset_info = infer_num_classes_from_output(graph_info.output_shapes, architecture_type)
1859
+ if dataset_info:
1860
+ self.logger.debug(
1861
+ f"Inferred {dataset_info.task} task with {dataset_info.num_classes} classes from output shape"
1862
+ )
1863
+
1864
+ # Load Universal IR representation (optional, for advanced analysis)
1865
+ universal_graph = None
1866
+ try:
1867
+ from .format_adapters import load_model
1868
+
1869
+ self.logger.debug("Loading Universal IR representation...")
1870
+ universal_graph = load_model(model_path)
1871
+ self.logger.debug(
1872
+ f"Universal IR loaded: {universal_graph.num_nodes} nodes, "
1873
+ f"{universal_graph.total_parameters:,} params"
1874
+ )
1875
+ except Exception as e:
1876
+ self.logger.debug(f"Universal IR loading skipped: {e}")
1877
+
1878
+ report = InspectionReport(
1879
+ metadata=metadata,
1880
+ graph_summary=graph_summary,
1881
+ param_counts=param_counts,
1882
+ flop_counts=flop_counts,
1883
+ memory_estimates=memory_estimates,
1884
+ detected_blocks=detected_blocks,
1885
+ architecture_type=architecture_type,
1886
+ risk_signals=risk_signals,
1887
+ dataset_info=dataset_info,
1888
+ universal_graph=universal_graph,
1889
+ )
1890
+
1891
+ self.logger.info(
1892
+ f"Inspection complete. Found {len(detected_blocks)} blocks, {len(risk_signals)} risks."
1893
+ )
1894
+ return report
1895
+
1896
+ def _extract_metadata(self, model, model_path: pathlib.Path) -> ModelMetadata:
1897
+ """Extract metadata from ONNX ModelProto."""
1898
+ from .analyzer import get_opsets_imported
1899
+
1900
+ opsets = get_opsets_imported(model)
1901
+
1902
+ return ModelMetadata(
1903
+ path=str(model_path),
1904
+ ir_version=model.ir_version,
1905
+ producer_name=model.producer_name or "unknown",
1906
+ producer_version=model.producer_version or "",
1907
+ domain=model.domain or "",
1908
+ model_version=model.model_version,
1909
+ doc_string=model.doc_string or "",
1910
+ opsets=opsets,
1911
+ )
1912
+
1913
+ def _build_graph_summary(self, graph_info) -> GraphSummary:
1914
+ """Build summary statistics from GraphInfo."""
1915
+ return GraphSummary(
1916
+ num_nodes=graph_info.num_nodes,
1917
+ num_inputs=len(graph_info.inputs),
1918
+ num_outputs=len(graph_info.outputs),
1919
+ num_initializers=len(graph_info.initializers),
1920
+ input_shapes=graph_info.input_shapes,
1921
+ output_shapes=graph_info.output_shapes,
1922
+ op_type_counts=graph_info.op_type_counts,
1923
+ )