Omar commited on
Commit
36079e7
·
0 Parent(s):

Init commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ .git
5
+ .gitignore
6
+ .env
7
+ *.md
8
+ .venv
9
+ venv
10
+ .idea
11
+ .vscode
12
+ *.egg-info
13
+ dist
14
+ build
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY backend/requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy application code
10
+ COPY backend/ ./backend/
11
+ COPY frontend/ ./frontend/
12
+
13
+ # Set working directory to backend
14
+ WORKDIR /app/backend
15
+
16
+ # Expose port for HuggingFace Spaces
17
+ EXPOSE 7860
18
+
19
+ # Run the application
20
+ CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omar Kamali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---
2
+ title: "LLM Scope"
3
+ subtitle: "Explore the inners of your favorite LLMs"
4
+ sdk: docker
5
+ license: mit
6
+ tags:
7
+ - llm
8
+ - visualization
9
+ - transformer
10
+ ---
11
+
12
+ # LLM Scope — Explore the inners of your favorite LLMs
13
+
14
+ Explore the inners of your favorite LLMs. Visualize transformer architectures and parameter counts without downloading weights.
15
+
16
+ **Space:** https://huggingface.co/spaces/omarkamali/llm-scope
17
+
18
+ **Contact:** omarkamali.com · omneitylabs.com · hf:omarkamali · x.com/omarkamali · linkedin.com/in/omar-kamali
19
+
20
+ ---
21
+
22
+ ## About / Model Inspector
23
+
24
+ A beautiful, interactive treemap visualizer for transformer model architectures. Enter a HuggingFace model ID or upload a config.json to see a proportional visualization of the model's structure - all without downloading weights.
25
+
26
+ ## Features
27
+
28
+ - **No Weights Required**: Calculates parameter counts mathematically from config dimensions only
29
+ - **Interactive Treemap**: D3.js zoomable treemap with area proportional to parameter count
30
+ - **Multiple Architectures**: Supports LLaMA, Mistral, Mixtral, GPT-2, BERT, T5, Falcon, Gemma, Qwen, and more
31
+ - **Beautiful Dark Theme**: Modern, minimal UI designed for ML/AI workflows
32
+ - **Drag & Drop Upload**: Upload your own config.json files for analysis
33
+
34
+ ## Local Development
35
+
36
+ ### Prerequisites
37
+
38
+ - Python 3.9+
39
+ - Node.js (for serving frontend during development)
40
+
41
+ ### Setup
42
+
43
+ 1. Clone the repository:
44
+ ```bash
45
+ git clone https://huggingface.co/omarkamali/llm-scope.git
46
+ cd model-inspector
47
+ ```
48
+
49
+ 2. Install Python dependencies:
50
+ ```bash
51
+ cd backend
52
+ pip install -r requirements.txt
53
+ ```
54
+
55
+ 3. Run the server:
56
+ ```bash
57
+ uvicorn main:app --reload --port 7860
58
+ ```
59
+
60
+ 4. Open http://localhost:7860 in your browser
61
+
62
+ ### Running with Docker
63
+
64
+ ```bash
65
+ docker build -t model-inspector .
66
+ docker run -p 7860:7860 model-inspector
67
+ ```
68
+
69
+ ## API Endpoints
70
+
71
+ ### POST /api/inspect
72
+
73
+ Inspect a model by HuggingFace model ID or config object.
74
+
75
+ **Request Body:**
76
+ ```json
77
+ {
78
+ "model_id": "qwen/qwen3-4b-instruct-2507"
79
+ }
80
+ ```
81
+
82
+ or
83
+
84
+ ```json
85
+ {
86
+ "config": { /* config.json contents */ }
87
+ }
88
+ ```
89
+
90
+ **Response:**
91
+ ```json
92
+ {
93
+ "tree": {
94
+ "name": "Qwen Model",
95
+ "type": "model",
96
+ "params": 1234,
97
+ "children": [...]
98
+ },
99
+ "metadata": {
100
+ "model_id": "qwen/qwen3-4b-instruct-2507",
101
+ "model_type": "llama",
102
+ "total_params": 1234,
103
+ "formatted_params": "...",
104
+ "config": {...}
105
+ }
106
+ }
107
+ ```
108
+
109
+ ### POST /api/upload
110
+
111
+ Upload a config.json file for inspection.
112
+
113
+ **Request:** `multipart/form-data` with `file` field
114
+
115
+ ### GET /api/health
116
+
117
+ Health check endpoint.
118
+
119
+ ## Parameter Calculation
120
+
121
+ Parameters are calculated mathematically from config dimensions:
122
+
123
+ | Component | Formula |
124
+ |-----------|---------|
125
+ | Embedding | vocab_size × hidden_size |
126
+ | Attention Q/K/V | hidden_size × (num_heads × head_dim) |
127
+ | Attention Output | (num_heads × head_dim) × hidden_size |
128
+ | MLP Up | hidden_size × intermediate_size |
129
+ | MLP Gate (SwiGLU) | hidden_size × intermediate_size |
130
+ | MLP Down | intermediate_size × hidden_size |
131
+ | LayerNorm | 2 × hidden_size (weight + bias) |
132
+ | RMSNorm | hidden_size (weight only) |
133
+ | LM Head | hidden_size × vocab_size (if not tied) |
134
+
135
+ ## Tech Stack
136
+
137
+ - **Backend**: FastAPI (Python)
138
+ - **Frontend**: Vanilla JS + D3.js
139
+ - **Deployment**: Docker-based HuggingFace Space
140
+
141
+ ## License
142
+
143
+ MIT
backend/architecture_parser.py ADDED
@@ -0,0 +1,959 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Architecture parser - produces LINEAR PIPELINE representation of transformer models.
3
+ Shows the sequential flow of data through the model as a flowchart.
4
+ """
5
+
6
+ import re
7
+ from typing import Dict, Any, List, Optional, Tuple
8
+ from collections import OrderedDict
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ from transformers import AutoConfig, AutoModel, AutoModelForCausalLM, AutoModelForSeq2SeqLM
13
+
14
+
15
+ def format_params(count: int) -> str:
16
+ """Format parameter count in human-readable form."""
17
+ if count >= 1e12:
18
+ return f"{count / 1e12:.2f}T"
19
+ elif count >= 1e9:
20
+ return f"{count / 1e9:.2f}B"
21
+ elif count >= 1e6:
22
+ return f"{count / 1e6:.2f}M"
23
+ elif count >= 1e3:
24
+ return f"{count / 1e3:.2f}K"
25
+ else:
26
+ return str(count)
27
+
28
+
29
+ def get_module_type(module: nn.Module, name: str) -> str:
30
+ """Infer module type from class name and module name."""
31
+ class_name = module.__class__.__name__.lower()
32
+ name_lower = name.lower()
33
+
34
+ # Check if this is a model wrapper (contains "model" in class name) - should be treated as module
35
+ is_model_wrapper = 'model' in class_name and ('for' in class_name or class_name.endswith('model'))
36
+
37
+ if is_model_wrapper:
38
+ return 'module'
39
+
40
+ if 'embedding' in class_name:
41
+ return 'embedding'
42
+ elif 'attention' in class_name or 'attn' in class_name:
43
+ return 'attention'
44
+ elif 'mlp' in class_name or 'feedforward' in class_name or 'ffn' in class_name:
45
+ return 'mlp'
46
+ elif 'layernorm' in class_name or 'rmsnorm' in class_name:
47
+ return 'norm'
48
+ elif 'linear' in class_name:
49
+ return 'linear'
50
+ elif 'conv' in class_name:
51
+ return 'linear'
52
+ elif 'dropout' in class_name:
53
+ return 'dropout'
54
+ elif 'pool' in class_name:
55
+ return 'pooler'
56
+ elif 'head' in class_name or 'lm_head' in name_lower:
57
+ return 'head'
58
+ # Check for MoE/expert - but only for actual MoE layers, not model wrappers
59
+ elif ('expert' in class_name or 'moe' in class_name) and 'layer' in class_name:
60
+ return 'mlp'
61
+ elif 'expert' in class_name and 'model' not in class_name:
62
+ return 'mlp'
63
+
64
+ # Check name patterns
65
+ if 'embed' in name_lower:
66
+ return 'embedding'
67
+ elif 'attn' in name_lower or 'attention' in name_lower:
68
+ return 'attention'
69
+ elif 'mlp' in name_lower or 'fc' in name_lower or 'ffn' in name_lower:
70
+ return 'mlp'
71
+ elif 'norm' in name_lower or 'ln' in name_lower:
72
+ return 'norm'
73
+ elif 'head' in name_lower:
74
+ return 'head'
75
+ elif 'expert' in name_lower and 'model' not in name_lower:
76
+ return 'mlp'
77
+
78
+ return 'module'
79
+
80
+
81
+ def count_parameters(module: nn.Module) -> int:
82
+ """Count all parameters in a module recursively."""
83
+ return sum(p.numel() for p in module.parameters())
84
+
85
+
86
+ def humanize_name(name: str) -> str:
87
+ """Convert module name to human-readable format."""
88
+ # Handle indexed names like "0", "1" etc
89
+ if name.isdigit():
90
+ return f"Layer {name}"
91
+
92
+ # Convert snake_case to Title Case
93
+ name = name.replace('_', ' ')
94
+
95
+ # Handle common abbreviations
96
+ replacements = {
97
+ 'Wte': 'Token Embedding',
98
+ 'Wpe': 'Position Embedding',
99
+ 'Ln F': 'Final LayerNorm',
100
+ 'Ln 1': 'LayerNorm 1',
101
+ 'Ln 2': 'LayerNorm 2',
102
+ 'Attn': 'Attention',
103
+ 'Mlp': 'MLP',
104
+ 'Lm Head': 'LM Head',
105
+ 'Q Proj': 'Query',
106
+ 'K Proj': 'Key',
107
+ 'V Proj': 'Value',
108
+ 'O Proj': 'Output',
109
+ 'Out Proj': 'Output',
110
+ 'C Attn': 'QKV Projection',
111
+ 'C Proj': 'Output Projection',
112
+ 'C Fc': 'Up Projection',
113
+ 'Up Proj': 'Up Projection',
114
+ 'Down Proj': 'Down Projection',
115
+ 'Gate Proj': 'Gate Projection',
116
+ }
117
+
118
+ result = name.title()
119
+ for old, new in replacements.items():
120
+ result = result.replace(old, new)
121
+
122
+ return result
123
+
124
+
125
+ def is_modality_encoder(name: str, module: nn.Module) -> bool:
126
+ """
127
+ Check if a module is a separate MODALITY encoder (vision tower, audio encoder, etc.)
128
+ This should only match top-level modality-specific encoders, not internal components.
129
+ """
130
+ name_lower = name.lower()
131
+ class_lower = module.__class__.__name__.lower()
132
+
133
+ # Specific patterns for modality encoders (must have modality keyword)
134
+ modality_keywords = ['vision', 'image', 'audio', 'video', 'visual', 'pixel']
135
+
136
+ # Must contain a modality keyword
137
+ has_modality = any(kw in name_lower or kw in class_lower for kw in modality_keywords)
138
+ if not has_modality:
139
+ return False
140
+
141
+ # And should be a substantial module (tower, model, encoder)
142
+ structure_keywords = ['tower', 'model', 'encoder', 'backbone']
143
+ has_structure = any(kw in name_lower or kw in class_lower for kw in structure_keywords)
144
+
145
+ # Or just "vision_tower", "image_encoder" style names
146
+ return has_structure or name_lower in ['vision', 'visual', 'image']
147
+
148
+
149
+ def extract_pipeline_steps(module: nn.Module, name: str, depth: int = 0, max_depth: int = 4, detect_parallel: bool = True) -> List[Dict[str, Any]]:
150
+ """
151
+ Extract pipeline steps from a module.
152
+ Handles both linear and parallel (multimodal) architectures.
153
+ Returns a list of steps where parallel branches are marked.
154
+
155
+ detect_parallel: Only look for parallel modality encoders at top level (depth 0-1)
156
+ """
157
+ steps = []
158
+ children = list(module.named_children())
159
+
160
+ if not children:
161
+ return steps
162
+
163
+ # Categorize children
164
+ embeddings = []
165
+ vision_modules = [] # Vision tower, projector
166
+ language_model = None # Main language model
167
+ layer_container = None
168
+ layer_list = []
169
+ norms = []
170
+ heads = []
171
+ others = []
172
+
173
+ for child_name, child_module in children:
174
+ child_params = count_parameters(child_module)
175
+ if child_params == 0:
176
+ continue
177
+
178
+ child_type = get_module_type(child_module, child_name)
179
+ name_lower = child_name.lower()
180
+ class_lower = child_module.__class__.__name__.lower()
181
+
182
+ # Detect multimodal components at appropriate depth
183
+ if detect_parallel and depth <= 1:
184
+ # Vision tower or projector
185
+ if is_modality_encoder(child_name, child_module) or 'projector' in name_lower or 'projector' in class_lower:
186
+ vision_modules.append((child_name, child_module))
187
+ continue
188
+ # Main language model (separate from vision)
189
+ if 'language_model' in name_lower or 'text_model' in name_lower:
190
+ language_model = (child_name, child_module)
191
+ continue
192
+
193
+ if child_type == 'embedding':
194
+ embeddings.append((child_name, child_module))
195
+ elif child_type == 'norm':
196
+ norms.append((child_name, child_module))
197
+ elif child_type == 'head':
198
+ heads.append((child_name, child_module))
199
+ elif child_name.isdigit():
200
+ layer_list.append((child_name, child_module))
201
+ elif 'layer' in name_lower or 'block' in name_lower or name_lower == 'h':
202
+ sub_children = list(child_module.named_children())
203
+ if sub_children and sub_children[0][0].isdigit():
204
+ layer_container = (child_name, child_module)
205
+ else:
206
+ others.append((child_name, child_module))
207
+ else:
208
+ others.append((child_name, child_module))
209
+
210
+ # Handle multimodal: vision path + language model as parallel branches
211
+ if vision_modules and language_model:
212
+ parallel_branches = []
213
+
214
+ # Vision branch: vision_tower + projector in sequence
215
+ vision_steps = []
216
+ for vm_name, vm_module in vision_modules:
217
+ vm_substeps = extract_pipeline_steps(vm_module, vm_name, depth + 1, max_depth, detect_parallel=False)
218
+ if vm_substeps:
219
+ step = {
220
+ "name": humanize_name(vm_name),
221
+ "type": "encoder",
222
+ "params": count_parameters(vm_module),
223
+ "class": vm_module.__class__.__name__,
224
+ "substeps": vm_substeps,
225
+ "_collapsed": True,
226
+ }
227
+ else:
228
+ step = build_step(vm_module, vm_name, depth + 1, max_depth)
229
+ vision_steps.append(step)
230
+
231
+ vision_branch = {
232
+ "name": "Vision Path",
233
+ "type": "encoder",
234
+ "params": sum(count_parameters(m) for _, m in vision_modules),
235
+ "substeps": vision_steps,
236
+ "_collapsed": False,
237
+ }
238
+ parallel_branches.append(vision_branch)
239
+
240
+ # Language model branch
241
+ lm_name, lm_module = language_model
242
+ lm_steps = extract_pipeline_steps(lm_module, lm_name, depth + 1, max_depth, detect_parallel=False)
243
+ if not lm_steps:
244
+ lm_steps = [build_step(lm_module, lm_name, depth + 1, max_depth)]
245
+
246
+ lang_branch = {
247
+ "name": "Language Model",
248
+ "type": "module",
249
+ "params": count_parameters(lm_module),
250
+ "class": lm_module.__class__.__name__,
251
+ "substeps": lm_steps,
252
+ "_collapsed": False,
253
+ }
254
+ parallel_branches.append(lang_branch)
255
+
256
+ steps.append({
257
+ "name": "Multimodal Processing",
258
+ "type": "parallel",
259
+ "params": sum(b.get("params", 0) for b in parallel_branches),
260
+ "branches": parallel_branches,
261
+ "_collapsed": False,
262
+ })
263
+
264
+ # Skip normal processing - we handled everything
265
+ embeddings = []
266
+ norms = []
267
+ layer_container = None
268
+ layer_list = []
269
+ others = []
270
+
271
+ # Handle case where only vision modules exist (no separate language_model)
272
+ elif vision_modules:
273
+ for enc_name, enc_module in vision_modules:
274
+ enc_steps = extract_pipeline_steps(enc_module, enc_name, depth + 1, max_depth, detect_parallel=False)
275
+ if enc_steps:
276
+ steps.append({
277
+ "name": humanize_name(enc_name),
278
+ "type": "encoder",
279
+ "params": count_parameters(enc_module),
280
+ "class": enc_module.__class__.__name__,
281
+ "substeps": enc_steps,
282
+ "_collapsed": True,
283
+ })
284
+ else:
285
+ steps.append(build_step(enc_module, enc_name, depth + 1, max_depth))
286
+
287
+ # 1. Regular embeddings (if not already handled in parallel)
288
+ for child_name, child_module in embeddings:
289
+ step = build_step(child_module, child_name, depth + 1, max_depth)
290
+ steps.append(step)
291
+
292
+ # 2. Transformer layers
293
+ if layer_container:
294
+ container_name, container_module = layer_container
295
+ layer_children = [(n, m) for n, m in container_module.named_children() if count_parameters(m) > 0]
296
+
297
+ if layer_children:
298
+ first_layer = layer_children[0][1]
299
+ total_params = sum(count_parameters(m) for _, m in layer_children)
300
+ layer_substeps = extract_layer_internals(first_layer, depth + 2, max_depth)
301
+ layer_shape = get_layer_shape_info(first_layer)
302
+
303
+ layer_step = {
304
+ "name": f"Transformer Layers",
305
+ "type": "layers",
306
+ "params": total_params,
307
+ "class": first_layer.__class__.__name__,
308
+ "count": len(layer_children),
309
+ "substeps": layer_substeps,
310
+ "_collapsed": False,
311
+ }
312
+ if layer_shape:
313
+ layer_step["shape"] = layer_shape
314
+ steps.append(layer_step)
315
+ elif layer_list:
316
+ first_layer = layer_list[0][1]
317
+ total_params = sum(count_parameters(m) for _, m in layer_list)
318
+ layer_substeps = extract_layer_internals(first_layer, depth + 2, max_depth)
319
+ layer_shape = get_layer_shape_info(first_layer)
320
+
321
+ layer_step = {
322
+ "name": f"Transformer Layers",
323
+ "type": "layers",
324
+ "params": total_params,
325
+ "class": first_layer.__class__.__name__,
326
+ "count": len(layer_list),
327
+ "substeps": layer_substeps,
328
+ "_collapsed": False,
329
+ }
330
+ if layer_shape:
331
+ layer_step["shape"] = layer_shape
332
+ steps.append(layer_step)
333
+
334
+ # 3. Other modules
335
+ for child_name, child_module in others:
336
+ child_type = get_module_type(child_module, child_name)
337
+ if child_type == 'module':
338
+ sub_steps = extract_pipeline_steps(child_module, child_name, depth + 1, max_depth, detect_parallel=detect_parallel)
339
+ if sub_steps:
340
+ steps.extend(sub_steps)
341
+ else:
342
+ step = build_step(child_module, child_name, depth + 1, max_depth)
343
+ steps.append(step)
344
+ else:
345
+ step = build_step(child_module, child_name, depth + 1, max_depth)
346
+ steps.append(step)
347
+
348
+ # 4. Final norms
349
+ for child_name, child_module in norms:
350
+ step = build_step(child_module, child_name, depth + 1, max_depth)
351
+ steps.append(step)
352
+
353
+ # 5. Output heads
354
+ for child_name, child_module in heads:
355
+ step = build_step(child_module, child_name, depth + 1, max_depth)
356
+ steps.append(step)
357
+
358
+ return steps
359
+
360
+
361
+ def extract_layer_internals(layer_module: nn.Module, depth: int, max_depth: int) -> List[Dict[str, Any]]:
362
+ """Extract the internal flow of a single transformer layer."""
363
+ steps = []
364
+ children = list(layer_module.named_children())
365
+
366
+ # Categorize
367
+ norms = []
368
+ attentions = []
369
+ mlps = []
370
+ others = []
371
+
372
+ for child_name, child_module in children:
373
+ child_params = count_parameters(child_module)
374
+ if child_params == 0:
375
+ continue
376
+
377
+ child_type = get_module_type(child_module, child_name)
378
+
379
+ if child_type == 'norm':
380
+ norms.append((child_name, child_module))
381
+ elif child_type == 'attention':
382
+ attentions.append((child_name, child_module))
383
+ elif child_type == 'mlp':
384
+ mlps.append((child_name, child_module))
385
+ else:
386
+ others.append((child_name, child_module))
387
+
388
+ # Typical transformer layer flow: norm1 -> attn -> norm2 -> mlp
389
+ # But order depends on architecture (pre-norm vs post-norm)
390
+
391
+ # For now, just order: attention first, then MLP, with norms interspersed
392
+ norm_idx = 0
393
+
394
+ # Attention block
395
+ if norms and norm_idx < len(norms):
396
+ step = build_step(norms[norm_idx][1], norms[norm_idx][0], depth, max_depth)
397
+ steps.append(step)
398
+ norm_idx += 1
399
+
400
+ for child_name, child_module in attentions:
401
+ step = build_step(child_module, child_name, depth, max_depth)
402
+ steps.append(step)
403
+
404
+ # MLP block
405
+ if norms and norm_idx < len(norms):
406
+ step = build_step(norms[norm_idx][1], norms[norm_idx][0], depth, max_depth)
407
+ steps.append(step)
408
+ norm_idx += 1
409
+
410
+ for child_name, child_module in mlps:
411
+ step = build_step(child_module, child_name, depth, max_depth)
412
+ steps.append(step)
413
+
414
+ # Remaining norms
415
+ while norm_idx < len(norms):
416
+ step = build_step(norms[norm_idx][1], norms[norm_idx][0], depth, max_depth)
417
+ steps.append(step)
418
+ norm_idx += 1
419
+
420
+ # Others
421
+ for child_name, child_module in others:
422
+ step = build_step(child_module, child_name, depth, max_depth)
423
+ steps.append(step)
424
+
425
+ return steps
426
+
427
+
428
+ def get_module_shape(module: nn.Module) -> Optional[str]:
429
+ """Extract shape information from a module."""
430
+ class_name = module.__class__.__name__
431
+
432
+ # Linear layers
433
+ if hasattr(module, 'in_features') and hasattr(module, 'out_features'):
434
+ return f"{module.in_features} → {module.out_features}"
435
+
436
+ # Embedding layers
437
+ if hasattr(module, 'num_embeddings') and hasattr(module, 'embedding_dim'):
438
+ return f"{module.num_embeddings} × {module.embedding_dim}"
439
+
440
+ # LayerNorm / RMSNorm - check multiple possible attribute names
441
+ if hasattr(module, 'normalized_shape'):
442
+ shape = module.normalized_shape
443
+ if isinstance(shape, (list, tuple)):
444
+ return f"dim={shape[0]}" if len(shape) == 1 else str(shape)
445
+ return f"dim={shape}"
446
+
447
+ # RMSNorm often uses 'weight' shape
448
+ if 'rmsnorm' in class_name.lower() or 'layernorm' in class_name.lower():
449
+ if hasattr(module, 'weight') and module.weight is not None:
450
+ return f"dim={module.weight.shape[0]}"
451
+
452
+ # Conv layers
453
+ if hasattr(module, 'in_channels') and hasattr(module, 'out_channels'):
454
+ kernel = getattr(module, 'kernel_size', None)
455
+ if kernel:
456
+ return f"{module.in_channels}→{module.out_channels}, k={kernel}"
457
+ return f"{module.in_channels} → {module.out_channels}"
458
+
459
+ # Attention - try to get num_heads and head_dim
460
+ if hasattr(module, 'num_heads'):
461
+ head_dim = getattr(module, 'head_dim', None)
462
+ if head_dim:
463
+ return f"heads={module.num_heads}, dim={head_dim}"
464
+ return f"heads={module.num_heads}"
465
+
466
+ if hasattr(module, 'num_attention_heads'):
467
+ head_dim = getattr(module, 'head_dim', None)
468
+ if head_dim:
469
+ return f"heads={module.num_attention_heads}, dim={head_dim}"
470
+ return f"heads={module.num_attention_heads}"
471
+
472
+ # MLP/FFN - try to infer from children
473
+ if 'mlp' in class_name.lower() or 'feedforward' in class_name.lower():
474
+ # Look for up/gate projection to get intermediate size
475
+ for child_name, child in module.named_children():
476
+ if hasattr(child, 'out_features'):
477
+ return f"→ {child.out_features}"
478
+
479
+ # Try to get hidden_size from config stored on module
480
+ if hasattr(module, 'config'):
481
+ cfg = module.config
482
+ if hasattr(cfg, 'hidden_size'):
483
+ return f"hidden={cfg.hidden_size}"
484
+
485
+ return None
486
+
487
+
488
+ def get_layer_shape_info(layer_module: nn.Module) -> Optional[str]:
489
+ """Extract shape info from a transformer layer by looking at its components."""
490
+ hidden_size = None
491
+ intermediate_size = None
492
+ num_heads = None
493
+
494
+ for name, child in layer_module.named_modules():
495
+ name_lower = name.lower()
496
+
497
+ # Find num_heads
498
+ if not num_heads:
499
+ if hasattr(child, 'num_heads'):
500
+ num_heads = child.num_heads
501
+ elif hasattr(child, 'num_attention_heads'):
502
+ num_heads = child.num_attention_heads
503
+
504
+ # Find hidden_size from multiple sources
505
+ if not hidden_size:
506
+ # From attention head_dim * num_heads
507
+ if hasattr(child, 'num_heads') and hasattr(child, 'head_dim'):
508
+ hidden_size = child.num_heads * child.head_dim
509
+ # From hidden_size attribute
510
+ elif hasattr(child, 'hidden_size'):
511
+ hidden_size = child.hidden_size
512
+ # From norm layers
513
+ elif hasattr(child, 'normalized_shape'):
514
+ shape = child.normalized_shape
515
+ if isinstance(shape, (list, tuple)):
516
+ hidden_size = shape[0]
517
+ else:
518
+ hidden_size = shape
519
+ # From norm weight shape
520
+ elif ('norm' in name_lower or 'ln' in name_lower) and hasattr(child, 'weight') and child.weight is not None:
521
+ try:
522
+ hidden_size = child.weight.shape[0]
523
+ except:
524
+ pass
525
+ # From q_proj or similar linear layers (in_features = hidden_size)
526
+ elif ('q_proj' in name_lower or 'query' in name_lower) and hasattr(child, 'in_features'):
527
+ hidden_size = child.in_features
528
+ # From o_proj output (out_features = hidden_size)
529
+ elif ('o_proj' in name_lower or 'out_proj' in name_lower) and hasattr(child, 'out_features'):
530
+ hidden_size = child.out_features
531
+
532
+ # Find intermediate size from MLP
533
+ if not intermediate_size:
534
+ if ('up' in name_lower or 'gate' in name_lower or 'fc1' in name_lower or 'w1' in name_lower or 'w2' in name_lower) and hasattr(child, 'out_features'):
535
+ intermediate_size = child.out_features
536
+
537
+ parts = []
538
+ if hidden_size:
539
+ parts.append(f"d={hidden_size}")
540
+ if intermediate_size:
541
+ parts.append(f"ffn={intermediate_size}")
542
+ if num_heads:
543
+ parts.append(f"h={num_heads}")
544
+
545
+ return ", ".join(parts) if parts else None
546
+
547
+
548
+ def build_step(module: nn.Module, name: str, depth: int, max_depth: int) -> Dict[str, Any]:
549
+ """Build a single pipeline step from a module."""
550
+ params = count_parameters(module)
551
+ module_type = get_module_type(module, name)
552
+ display_name = humanize_name(name)
553
+
554
+ step = {
555
+ "name": display_name,
556
+ "type": module_type,
557
+ "params": params,
558
+ "class": module.__class__.__name__,
559
+ }
560
+
561
+ # Add shape information
562
+ shape = get_module_shape(module)
563
+ if shape:
564
+ step["shape"] = shape
565
+
566
+ # Add substeps for complex modules (if not too deep)
567
+ if depth < max_depth:
568
+ children = list(module.named_children())
569
+ if children:
570
+ substeps = []
571
+ for child_name, child_module in children:
572
+ child_params = count_parameters(child_module)
573
+ if child_params > 0:
574
+ child_step = build_step(child_module, child_name, depth + 1, max_depth)
575
+ substeps.append(child_step)
576
+ if substeps:
577
+ step["substeps"] = substeps
578
+ step["_collapsed"] = True
579
+
580
+ return step
581
+
582
+
583
+ def build_pipeline(model: nn.Module, model_name: str = "Model") -> Dict[str, Any]:
584
+ """
585
+ Build a linear pipeline structure from a PyTorch model.
586
+ This shows the sequential flow of data through the model.
587
+ """
588
+ total_params = count_parameters(model)
589
+
590
+ # Extract pipeline steps
591
+ steps = extract_pipeline_steps(model, model_name, depth=0, max_depth=4)
592
+
593
+ return {
594
+ "name": model_name,
595
+ "params": total_params,
596
+ "class": model.__class__.__name__,
597
+ "steps": steps
598
+ }
599
+
600
+
601
+ def load_model_for_inspection(model_id: str) -> Tuple[nn.Module, AutoConfig]:
602
+ """Load a model architecture without downloading weights."""
603
+ from huggingface_hub import hf_hub_download, list_repo_files
604
+ import json
605
+
606
+ # Check if this repo uses Mistral's native format (params.json instead of config.json)
607
+ try:
608
+ repo_files = list_repo_files(repo_id=model_id)
609
+ has_params_json = 'params.json' in repo_files
610
+ has_config_json = 'config.json' in repo_files
611
+ except:
612
+ has_params_json = False
613
+ has_config_json = True
614
+
615
+ if has_params_json and not has_config_json:
616
+ # Load Mistral native format and convert to pipeline directly
617
+ return None, None # Signal to use parse_mistral_params instead
618
+
619
+ config = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
620
+
621
+ # Use meta device to avoid allocating actual memory for weights
622
+ with torch.device('meta'):
623
+ model = None
624
+ errors = []
625
+
626
+ try:
627
+ model = AutoModelForCausalLM.from_config(config, trust_remote_code=True)
628
+ except Exception as e:
629
+ errors.append(f"CausalLM: {e}")
630
+
631
+ if model is None:
632
+ try:
633
+ model = AutoModelForSeq2SeqLM.from_config(config, trust_remote_code=True)
634
+ except Exception as e:
635
+ errors.append(f"Seq2SeqLM: {e}")
636
+
637
+ if model is None:
638
+ try:
639
+ model = AutoModel.from_config(config, trust_remote_code=True)
640
+ except Exception as e:
641
+ errors.append(f"AutoModel: {e}")
642
+
643
+ if model is None:
644
+ raise ValueError(f"Could not load model architecture. Errors: {errors}")
645
+
646
+ return model, config
647
+
648
+
649
+ def parse_mistral_native_format(model_id: str) -> Dict[str, Any]:
650
+ """Parse Mistral's native params.json format."""
651
+ from huggingface_hub import hf_hub_download
652
+ import json
653
+
654
+ params_path = hf_hub_download(repo_id=model_id, filename='params.json')
655
+ with open(params_path) as f:
656
+ params = json.load(f)
657
+
658
+ # Extract dimensions
659
+ hidden_size = params.get('dim', 0)
660
+ num_layers = params.get('n_layers', 0)
661
+ num_heads = params.get('n_heads', 0)
662
+ num_kv_heads = params.get('n_kv_heads', num_heads)
663
+ vocab_size = params.get('vocab_size', 0)
664
+ intermediate_size = params.get('hidden_dim', hidden_size * 4)
665
+ head_dim = params.get('head_dim', hidden_size // num_heads if num_heads > 0 else 0)
666
+
667
+ # Check for MoE
668
+ moe_config = params.get('moe', {})
669
+ num_experts = moe_config.get('num_experts', 0)
670
+ num_experts_per_tok = moe_config.get('num_experts_per_tok', 2)
671
+ expert_hidden_dim = moe_config.get('expert_hidden_dim', intermediate_size)
672
+ num_shared_experts = moe_config.get('num_shared_experts', 0)
673
+ first_k_dense = moe_config.get('first_k_dense_replace', 0) # First K layers use dense MLP
674
+
675
+ # Check for vision encoder
676
+ vision_config = params.get('vision_encoder', None)
677
+
678
+ # Calculate parameters
679
+ embed_params = vocab_size * hidden_size
680
+
681
+ # Attention params per layer (with potential LoRA/MLA components)
682
+ q_lora_rank = params.get('q_lora_rank', 0)
683
+ kv_lora_rank = params.get('kv_lora_rank', 0)
684
+ v_head_dim = params.get('v_head_dim', head_dim) # V uses different head dim
685
+
686
+ if q_lora_rank > 0:
687
+ # Multi-head Latent Attention (MLA) - compressed projections
688
+ # Q: down_proj + up_proj
689
+ q_params = hidden_size * q_lora_rank + q_lora_rank * num_heads * head_dim
690
+ # K: down_proj + up_proj (shared with V in latent space)
691
+ k_params = hidden_size * kv_lora_rank + kv_lora_rank * num_kv_heads * head_dim
692
+ # V: uses v_head_dim
693
+ v_params = hidden_size * kv_lora_rank + kv_lora_rank * num_kv_heads * v_head_dim
694
+ # O: output projection from v_head_dim back to hidden
695
+ o_params = num_heads * v_head_dim * hidden_size
696
+ attn_params = q_params + k_params + v_params + o_params
697
+ else:
698
+ q_params = hidden_size * num_heads * head_dim
699
+ kv_params = hidden_size * num_kv_heads * head_dim
700
+ attn_params = q_params + 2 * kv_params + num_heads * head_dim * hidden_size
701
+
702
+ norm_params = hidden_size
703
+
704
+ # MLP params - handle dense vs MoE layers
705
+ dense_mlp_params = 3 * hidden_size * intermediate_size
706
+
707
+ if num_experts > 0:
708
+ # MoE: each expert has gate + up + down projections
709
+ single_expert_params = 3 * hidden_size * expert_hidden_dim
710
+ moe_mlp_params = num_experts * single_expert_params
711
+ if num_shared_experts > 0:
712
+ # Shared experts use same size as routed experts
713
+ moe_mlp_params += num_shared_experts * single_expert_params
714
+ moe_mlp_params += hidden_size * num_experts # Router
715
+
716
+ # Calculate layer params for dense and MoE layers separately
717
+ num_dense_layers = min(first_k_dense, num_layers)
718
+ num_moe_layers = num_layers - num_dense_layers
719
+
720
+ dense_layer_params = attn_params + dense_mlp_params + 2 * norm_params
721
+ moe_layer_params = attn_params + moe_mlp_params + 2 * norm_params
722
+
723
+ total_layer_params = (dense_layer_params * num_dense_layers) + (moe_layer_params * num_moe_layers)
724
+ mlp_params = moe_mlp_params # For display purposes, show MoE params
725
+ else:
726
+ mlp_params = dense_mlp_params
727
+ layer_params = attn_params + mlp_params + 2 * norm_params
728
+ total_layer_params = layer_params * num_layers
729
+ lm_head_params = 0 if params.get('tied_embeddings', True) else vocab_size * hidden_size
730
+ total_params = embed_params + total_layer_params + norm_params + lm_head_params
731
+
732
+ # Vision encoder params
733
+ vision_params = 0
734
+ vision_steps = []
735
+ if vision_config:
736
+ v_hidden = vision_config.get('hidden_size', 0)
737
+ v_layers = vision_config.get('num_hidden_layers', 0)
738
+ v_intermediate = vision_config.get('intermediate_size', v_hidden * 4)
739
+ v_heads = vision_config.get('num_attention_heads', 0)
740
+ patch_size = vision_config.get('patch_size', 14)
741
+
742
+ patch_embed_params = 3 * (patch_size ** 2) * v_hidden
743
+ v_attn = 4 * v_hidden * v_hidden
744
+ v_mlp = 2 * v_hidden * v_intermediate
745
+ v_layer_params = v_attn + v_mlp + 2 * v_hidden
746
+ vision_params = patch_embed_params + v_layer_params * v_layers
747
+
748
+ vision_steps = [
749
+ {
750
+ "name": "Patch Embedding",
751
+ "type": "embedding",
752
+ "params": patch_embed_params,
753
+ "shape": f"{patch_size}×{patch_size} patches → {v_hidden}",
754
+ "class": "Conv2d"
755
+ },
756
+ {
757
+ "name": "Vision Transformer Layers",
758
+ "type": "layers",
759
+ "params": v_layer_params * v_layers,
760
+ "count": v_layers,
761
+ "shape": f"d={v_hidden}, h={v_heads}",
762
+ "class": "ViTBlock",
763
+ "_collapsed": True
764
+ }
765
+ ]
766
+ total_params += vision_params
767
+
768
+ # Build pipeline
769
+ steps = []
770
+
771
+ # Embedding
772
+ steps.append({
773
+ "name": "Token Embedding",
774
+ "type": "embedding",
775
+ "params": embed_params,
776
+ "shape": f"{vocab_size:,} × {hidden_size}",
777
+ "class": "Embedding"
778
+ })
779
+
780
+ # Build layer substeps
781
+ layer_substeps = [
782
+ {
783
+ "name": "Input LayerNorm",
784
+ "type": "norm",
785
+ "params": norm_params,
786
+ "shape": f"dim={hidden_size}",
787
+ "class": "RMSNorm"
788
+ },
789
+ {
790
+ "name": "Self Attention",
791
+ "type": "attention",
792
+ "params": attn_params,
793
+ "shape": f"heads={num_heads}, kv_heads={num_kv_heads}, dim={head_dim}",
794
+ "class": "Attention",
795
+ "_collapsed": True
796
+ },
797
+ {
798
+ "name": "Post-Attention LayerNorm",
799
+ "type": "norm",
800
+ "params": norm_params,
801
+ "shape": f"dim={hidden_size}",
802
+ "class": "RMSNorm"
803
+ }
804
+ ]
805
+
806
+ if num_experts > 0:
807
+ layer_substeps.append({
808
+ "name": "MoE",
809
+ "type": "mlp",
810
+ "params": mlp_params,
811
+ "shape": f"{num_experts} experts, top-{num_experts_per_tok}",
812
+ "class": "MixtureOfExperts",
813
+ "_collapsed": True
814
+ })
815
+ layer_shape = f"d={hidden_size}, ffn={expert_hidden_dim}, h={num_heads}, experts={num_experts}"
816
+ else:
817
+ layer_substeps.append({
818
+ "name": "MLP",
819
+ "type": "mlp",
820
+ "params": mlp_params,
821
+ "shape": f"{hidden_size} → {intermediate_size} → {hidden_size}",
822
+ "class": "MLP",
823
+ "_collapsed": True
824
+ })
825
+ layer_shape = f"d={hidden_size}, ffn={intermediate_size}, h={num_heads}"
826
+
827
+ moe_label = " (MoE)" if num_experts > 0 else ""
828
+ steps.append({
829
+ "name": f"Transformer Layers{moe_label}",
830
+ "type": "layers",
831
+ "params": total_layer_params,
832
+ "count": num_layers,
833
+ "shape": layer_shape,
834
+ "class": "TransformerBlock",
835
+ "substeps": layer_substeps,
836
+ "_collapsed": False
837
+ })
838
+
839
+ # Final norm
840
+ steps.append({
841
+ "name": "Final LayerNorm",
842
+ "type": "norm",
843
+ "params": norm_params,
844
+ "shape": f"dim={hidden_size}",
845
+ "class": "RMSNorm"
846
+ })
847
+
848
+ # LM Head
849
+ steps.append({
850
+ "name": "LM Head",
851
+ "type": "head",
852
+ "params": lm_head_params if lm_head_params > 0 else embed_params,
853
+ "shape": f"{hidden_size} → {vocab_size:,}" + (" (tied)" if lm_head_params == 0 else ""),
854
+ "class": "Linear"
855
+ })
856
+
857
+ # Wrap with vision if present
858
+ if vision_config:
859
+ vision_branch = {
860
+ "name": "Vision Encoder",
861
+ "type": "encoder",
862
+ "params": vision_params,
863
+ "substeps": vision_steps,
864
+ "_collapsed": True
865
+ }
866
+
867
+ lang_branch = {
868
+ "name": "Language Model",
869
+ "type": "module",
870
+ "params": total_params - vision_params,
871
+ "substeps": steps,
872
+ "_collapsed": False
873
+ }
874
+
875
+ steps = [{
876
+ "name": "Multimodal Processing",
877
+ "type": "parallel",
878
+ "params": total_params,
879
+ "branches": [vision_branch, lang_branch],
880
+ "_collapsed": False
881
+ }]
882
+
883
+ model_type = "mistral"
884
+ if num_experts > 0:
885
+ model_type = "mistral_moe"
886
+
887
+ return {
888
+ "name": model_type.upper(),
889
+ "params": total_params,
890
+ "formatted_params": format_params(total_params),
891
+ "model_type": model_type,
892
+ "class": "MistralModel",
893
+ "steps": steps
894
+ }
895
+
896
+
897
+ def load_model_from_config(config_dict: Dict[str, Any]) -> Tuple[nn.Module, AutoConfig]:
898
+ """Load a model architecture from a config dictionary."""
899
+ config = AutoConfig.for_model(**config_dict)
900
+
901
+ with torch.device('meta'):
902
+ model = None
903
+ errors = []
904
+
905
+ try:
906
+ model = AutoModelForCausalLM.from_config(config, trust_remote_code=True)
907
+ except Exception as e:
908
+ errors.append(f"CausalLM: {e}")
909
+
910
+ if model is None:
911
+ try:
912
+ model = AutoModelForSeq2SeqLM.from_config(config, trust_remote_code=True)
913
+ except Exception as e:
914
+ errors.append(f"Seq2SeqLM: {e}")
915
+
916
+ if model is None:
917
+ try:
918
+ model = AutoModel.from_config(config, trust_remote_code=True)
919
+ except Exception as e:
920
+ errors.append(f"AutoModel: {e}")
921
+
922
+ if model is None:
923
+ raise ValueError(f"Could not load model from config. Errors: {errors}")
924
+
925
+ return model, config
926
+
927
+
928
+ def parse_model(model_id: str) -> Dict[str, Any]:
929
+ """Parse a model from HuggingFace and return pipeline structure."""
930
+ model, config = load_model_for_inspection(model_id)
931
+
932
+ # If model is None, it means we need to use Mistral native format
933
+ if model is None and config is None:
934
+ return parse_mistral_native_format(model_id)
935
+
936
+ model_name = getattr(config, 'model_type', 'Model').upper()
937
+ pipeline = build_pipeline(model, model_name)
938
+
939
+ total_params = count_parameters(model)
940
+ pipeline["params"] = total_params
941
+ pipeline["formatted_params"] = format_params(total_params)
942
+ pipeline["model_type"] = getattr(config, 'model_type', 'unknown')
943
+
944
+ return pipeline
945
+
946
+
947
+ def parse_config(config_dict: Dict[str, Any]) -> Dict[str, Any]:
948
+ """Parse a model from config dict and return pipeline structure."""
949
+ model, config = load_model_from_config(config_dict)
950
+
951
+ model_name = getattr(config, 'model_type', 'Model').upper()
952
+ pipeline = build_pipeline(model, model_name)
953
+
954
+ total_params = count_parameters(model)
955
+ pipeline["params"] = total_params
956
+ pipeline["formatted_params"] = format_params(total_params)
957
+ pipeline["model_type"] = getattr(config, 'model_type', 'unknown')
958
+
959
+ return pipeline
backend/main.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for Model Inspector.
3
+ Provides endpoints to inspect model architectures from HuggingFace.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional, Dict, Any
10
+
11
+ from fastapi import FastAPI, HTTPException, UploadFile, File
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.responses import FileResponse, HTMLResponse
15
+ from pydantic import BaseModel
16
+
17
+ from architecture_parser import parse_model, parse_config, format_params
18
+
19
+
20
+ # Get paths
21
+ BACKEND_DIR = Path(__file__).parent
22
+ FRONTEND_DIR = BACKEND_DIR.parent / "frontend"
23
+
24
+
25
+ app = FastAPI(
26
+ title="Model Inspector API",
27
+ description="Inspect transformer model architectures without downloading weights",
28
+ version="1.0.0"
29
+ )
30
+
31
+ # CORS for local development
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=["*"],
35
+ allow_credentials=True,
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+
41
+ class InspectRequest(BaseModel):
42
+ model_config = {"protected_namespaces": ()}
43
+ model_id: Optional[str] = None
44
+ config: Optional[Dict[str, Any]] = None
45
+
46
+
47
+ class ModelMetadata(BaseModel):
48
+ model_config = {"protected_namespaces": ()}
49
+ model_id: Optional[str]
50
+ model_type: str
51
+ total_params: int
52
+ formatted_params: str
53
+
54
+
55
+ class InspectResponse(BaseModel):
56
+ pipeline: Dict[str, Any]
57
+ metadata: ModelMetadata
58
+
59
+
60
+ @app.post("/api/inspect", response_model=InspectResponse)
61
+ async def inspect_model(request: InspectRequest):
62
+ """
63
+ Inspect a model architecture.
64
+
65
+ Provide either:
66
+ - model_id: HuggingFace model ID (e.g., "meta-llama/Llama-2-7b-hf")
67
+ - config: Direct config.json object
68
+ """
69
+ if request.model_id is None and request.config is None:
70
+ raise HTTPException(
71
+ status_code=400,
72
+ detail="Must provide either model_id or config"
73
+ )
74
+
75
+ try:
76
+ if request.model_id is not None:
77
+ # Parse from HuggingFace model ID
78
+ pipeline = parse_model(request.model_id)
79
+ model_id = request.model_id
80
+ else:
81
+ # Parse from config dict
82
+ pipeline = parse_config(request.config)
83
+ model_id = None
84
+
85
+ metadata = ModelMetadata(
86
+ model_id=model_id,
87
+ model_type=pipeline.get("model_type", "unknown"),
88
+ total_params=pipeline.get("params", 0),
89
+ formatted_params=pipeline.get("formatted_params", "0"),
90
+ )
91
+
92
+ return InspectResponse(pipeline=pipeline, metadata=metadata)
93
+
94
+ except Exception as e:
95
+ raise HTTPException(
96
+ status_code=500,
97
+ detail=f"Error inspecting model: {str(e)}"
98
+ )
99
+
100
+
101
+ @app.post("/api/upload", response_model=InspectResponse)
102
+ async def upload_config(file: UploadFile = File(...)):
103
+ """
104
+ Upload a config.json file for inspection.
105
+ """
106
+ if not file.filename.endswith('.json'):
107
+ raise HTTPException(
108
+ status_code=400,
109
+ detail="File must be a JSON file"
110
+ )
111
+
112
+ try:
113
+ content = await file.read()
114
+ config_dict = json.loads(content)
115
+ except json.JSONDecodeError:
116
+ raise HTTPException(
117
+ status_code=400,
118
+ detail="Invalid JSON file"
119
+ )
120
+
121
+ try:
122
+ pipeline = parse_config(config_dict)
123
+
124
+ metadata = ModelMetadata(
125
+ model_id=None,
126
+ model_type=pipeline.get("model_type", "unknown"),
127
+ total_params=pipeline.get("params", 0),
128
+ formatted_params=pipeline.get("formatted_params", "0"),
129
+ )
130
+
131
+ return InspectResponse(pipeline=pipeline, metadata=metadata)
132
+
133
+ except Exception as e:
134
+ raise HTTPException(
135
+ status_code=500,
136
+ detail=f"Error parsing config: {str(e)}"
137
+ )
138
+
139
+
140
+ @app.get("/api/health")
141
+ async def health_check():
142
+ """Health check endpoint."""
143
+ return {"status": "healthy"}
144
+
145
+
146
+ # Serve frontend static files
147
+ @app.get("/")
148
+ async def serve_index():
149
+ """Serve the main index.html."""
150
+ return FileResponse(FRONTEND_DIR / "index.html")
151
+
152
+
153
+ @app.get("/css/{path:path}")
154
+ async def serve_css(path: str):
155
+ """Serve CSS files."""
156
+ file_path = FRONTEND_DIR / "css" / path
157
+ if file_path.exists():
158
+ return FileResponse(file_path, media_type="text/css")
159
+ raise HTTPException(status_code=404, detail="File not found")
160
+
161
+
162
+ @app.get("/js/{path:path}")
163
+ async def serve_js(path: str):
164
+ """Serve JavaScript files."""
165
+ file_path = FRONTEND_DIR / "js" / path
166
+ if file_path.exists():
167
+ return FileResponse(file_path, media_type="application/javascript")
168
+ raise HTTPException(status_code=404, detail="File not found")
169
+
170
+
171
+ if __name__ == "__main__":
172
+ import uvicorn
173
+ uvicorn.run(app, host="0.0.0.0", port=7860)
backend/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ httpx==0.26.0
4
+ pydantic==2.5.3
5
+ python-multipart==0.0.6
6
+ transformers>=4.36.0
7
+ torch>=2.0.0
8
+ accelerate>=0.25.0
9
+ huggingface_hub>=0.20.0
backend/run.sh ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Run the Model Inspector server with the correct Python
3
+
4
+ cd "$(dirname "$0")"
5
+
6
+ # Use python3 from pyenv (not system uvicorn which uses Python 3.9)
7
+ exec python3 -m uvicorn main:app --host 0.0.0.0 --port 7860 "$@"
frontend/css/styles.css ADDED
@@ -0,0 +1,885 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* LLM Scope - Professional Minimalist Theme */
2
+
3
+ :root {
4
+ /* Base colors - dark, muted palette */
5
+ --bg-primary: #0a0a0b;
6
+ --bg-secondary: #111113;
7
+ --bg-tertiary: #18181b;
8
+ --bg-elevated: #1f1f23;
9
+
10
+ /* Text colors */
11
+ --text-primary: #fafafa;
12
+ --text-secondary: #a1a1aa;
13
+ --text-muted: #52525b;
14
+
15
+ /* Borders */
16
+ --border-subtle: #27272a;
17
+ --border-default: #3f3f46;
18
+
19
+ /* Accent - single professional blue */
20
+ --accent: #3b82f6;
21
+ --accent-muted: #1d4ed8;
22
+
23
+ /* Semantic colors for node types - muted, professional */
24
+ --color-embedding: #f59e0b;
25
+ --color-attention: #8b5cf6;
26
+ --color-mlp: #10b981;
27
+ --color-norm: #06b6d4;
28
+ --color-head: #ec4899;
29
+ --color-layers: #6366f1;
30
+ --color-default: #71717a;
31
+
32
+ /* Layout */
33
+ --header-height: 52px;
34
+ --legend-height: 32px;
35
+
36
+ /* Spacing */
37
+ --space-1: 4px;
38
+ --space-2: 8px;
39
+ --space-3: 12px;
40
+ --space-4: 16px;
41
+ --space-5: 20px;
42
+ --space-6: 24px;
43
+
44
+ /* Radius */
45
+ --radius-sm: 4px;
46
+ --radius-md: 6px;
47
+ --radius-lg: 8px;
48
+
49
+ /* Transitions */
50
+ --transition: 150ms ease;
51
+
52
+ /* Typography */
53
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
54
+ --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
55
+ }
56
+
57
+ /* Reset */
58
+ *, *::before, *::after {
59
+ box-sizing: border-box;
60
+ margin: 0;
61
+ padding: 0;
62
+ }
63
+
64
+ html, body {
65
+ height: 100%;
66
+ overflow: hidden;
67
+ }
68
+
69
+ body {
70
+ font-family: var(--font-sans);
71
+ background-color: var(--bg-primary);
72
+ color: var(--text-primary);
73
+ line-height: 1.5;
74
+ -webkit-font-smoothing: antialiased;
75
+ }
76
+
77
+ /* App Layout */
78
+ .app {
79
+ display: flex;
80
+ flex-direction: column;
81
+ height: 100vh;
82
+ width: 100vw;
83
+ overflow: hidden;
84
+ }
85
+
86
+ /* Header */
87
+ .header {
88
+ height: var(--header-height);
89
+ min-height: var(--header-height);
90
+ background: var(--bg-secondary);
91
+ border-bottom: 1px solid var(--border-subtle);
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ padding: 0 var(--space-4);
96
+ gap: var(--space-4);
97
+ }
98
+
99
+ .header-left, .header-right {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: var(--space-3);
103
+ }
104
+
105
+ .header-center {
106
+ flex: 1;
107
+ display: flex;
108
+ justify-content: center;
109
+ }
110
+
111
+ /* Logo */
112
+ .logo {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: var(--space-3);
116
+ }
117
+
118
+ .logo-icon {
119
+ width: 26px;
120
+ height: 26px;
121
+ color: var(--text-primary);
122
+ opacity: 0.9;
123
+ }
124
+
125
+ .logo-text {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 0;
129
+ }
130
+
131
+ .logo h1 {
132
+ font-size: 1rem;
133
+ font-weight: 600;
134
+ letter-spacing: -0.02em;
135
+ color: var(--text-primary);
136
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
137
+ line-height: 1.2;
138
+ }
139
+
140
+ .attribution {
141
+ font-size: 0.65rem;
142
+ color: var(--text-muted);
143
+ text-decoration: none;
144
+ transition: color var(--transition);
145
+ line-height: 1;
146
+ }
147
+
148
+ .attribution:hover {
149
+ color: var(--text-secondary);
150
+ }
151
+
152
+ /* Quick Models */
153
+ .quick-models {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: var(--space-1);
157
+ }
158
+
159
+ .quick-btn {
160
+ background: transparent;
161
+ border: 1px solid var(--border-subtle);
162
+ border-radius: var(--radius-sm);
163
+ padding: var(--space-1) var(--space-2);
164
+ color: var(--text-muted);
165
+ font-size: 0.7rem;
166
+ font-weight: 500;
167
+ cursor: pointer;
168
+ transition: all var(--transition);
169
+ }
170
+
171
+ .quick-btn:hover {
172
+ background: var(--bg-tertiary);
173
+ color: var(--text-secondary);
174
+ border-color: var(--border-default);
175
+ }
176
+
177
+ /* Toggle Control */
178
+ .toggle-control {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: var(--space-2);
182
+ cursor: pointer;
183
+ user-select: none;
184
+ }
185
+
186
+ .toggle-control input {
187
+ width: 14px;
188
+ height: 14px;
189
+ cursor: pointer;
190
+ accent-color: var(--accent);
191
+ }
192
+
193
+ .toggle-label {
194
+ color: var(--text-muted);
195
+ font-size: 0.7rem;
196
+ font-weight: 500;
197
+ }
198
+
199
+ /* Header Buttons */
200
+ .header-btn {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ width: 32px;
205
+ height: 32px;
206
+ background: var(--bg-tertiary);
207
+ border: 1px solid var(--border-subtle);
208
+ border-radius: var(--radius-md);
209
+ color: var(--text-secondary);
210
+ cursor: pointer;
211
+ transition: all var(--transition);
212
+ }
213
+
214
+ .header-btn:hover {
215
+ background: var(--bg-elevated);
216
+ color: var(--text-primary);
217
+ border-color: var(--border-default);
218
+ }
219
+
220
+ .header-btn.primary {
221
+ background: var(--accent-muted);
222
+ border-color: var(--accent);
223
+ color: white;
224
+ }
225
+
226
+ .header-btn.primary:hover {
227
+ background: var(--accent);
228
+ }
229
+
230
+ .header-btn svg {
231
+ width: 16px;
232
+ height: 16px;
233
+ }
234
+
235
+ /* Legend Bar */
236
+ .legend-bar {
237
+ height: var(--legend-height);
238
+ min-height: var(--legend-height);
239
+ background: var(--bg-secondary);
240
+ border-bottom: 1px solid var(--border-subtle);
241
+ display: flex;
242
+ align-items: center;
243
+ padding: 0 var(--space-4);
244
+ overflow-x: auto;
245
+ }
246
+
247
+ .legend {
248
+ display: flex;
249
+ gap: var(--space-4);
250
+ }
251
+
252
+ .legend-item {
253
+ display: flex;
254
+ align-items: center;
255
+ gap: var(--space-1);
256
+ white-space: nowrap;
257
+ }
258
+
259
+ .legend-color {
260
+ width: 10px;
261
+ height: 10px;
262
+ border-radius: 2px;
263
+ flex-shrink: 0;
264
+ }
265
+
266
+ .legend-label {
267
+ font-size: 0.65rem;
268
+ color: var(--text-muted);
269
+ text-transform: lowercase;
270
+ font-weight: 500;
271
+ }
272
+
273
+ /* Panels Container */
274
+ .panels-container {
275
+ flex: 1;
276
+ display: flex;
277
+ overflow: hidden;
278
+ }
279
+
280
+ /* Panel */
281
+ .panel {
282
+ flex: 1;
283
+ min-width: 280px;
284
+ display: flex;
285
+ flex-direction: column;
286
+ border-right: 1px solid var(--border-subtle);
287
+ background: var(--bg-primary);
288
+ }
289
+
290
+ .panel:last-child {
291
+ border-right: none;
292
+ }
293
+
294
+ .panel-header {
295
+ display: flex;
296
+ align-items: center;
297
+ gap: var(--space-2);
298
+ padding: var(--space-2) var(--space-3);
299
+ background: var(--bg-secondary);
300
+ border-bottom: 1px solid var(--border-subtle);
301
+ }
302
+
303
+ .panel-input-wrapper {
304
+ flex: 1;
305
+ display: flex;
306
+ gap: var(--space-1);
307
+ }
308
+
309
+ .panel-input {
310
+ flex: 1;
311
+ background: var(--bg-tertiary);
312
+ border: 1px solid var(--border-subtle);
313
+ border-radius: var(--radius-md);
314
+ padding: var(--space-1) var(--space-2);
315
+ font-size: 0.75rem;
316
+ color: var(--text-primary);
317
+ font-family: var(--font-mono);
318
+ transition: border-color var(--transition);
319
+ }
320
+
321
+ .panel-input:focus {
322
+ outline: none;
323
+ border-color: var(--accent);
324
+ }
325
+
326
+ .panel-input::placeholder {
327
+ color: var(--text-muted);
328
+ }
329
+
330
+ .panel-go-btn {
331
+ background: var(--accent-muted);
332
+ color: white;
333
+ border: none;
334
+ border-radius: var(--radius-md);
335
+ padding: var(--space-1) var(--space-3);
336
+ font-size: 0.75rem;
337
+ font-weight: 500;
338
+ cursor: pointer;
339
+ transition: background var(--transition);
340
+ }
341
+
342
+ .panel-go-btn:hover:not(:disabled) {
343
+ background: var(--accent);
344
+ }
345
+
346
+ .panel-go-btn:disabled {
347
+ opacity: 0.5;
348
+ cursor: not-allowed;
349
+ }
350
+
351
+ .panel-close-btn {
352
+ background: transparent;
353
+ border: none;
354
+ color: var(--text-muted);
355
+ cursor: pointer;
356
+ padding: var(--space-1);
357
+ border-radius: var(--radius-sm);
358
+ transition: all var(--transition);
359
+ display: flex;
360
+ align-items: center;
361
+ justify-content: center;
362
+ }
363
+
364
+ .panel-close-btn:hover {
365
+ background: rgba(239, 68, 68, 0.15);
366
+ color: #ef4444;
367
+ }
368
+
369
+ .panel-close-btn svg {
370
+ width: 14px;
371
+ height: 14px;
372
+ }
373
+
374
+ /* Panel Info */
375
+ .panel-info {
376
+ display: flex;
377
+ align-items: center;
378
+ gap: var(--space-3);
379
+ padding: var(--space-2) var(--space-3);
380
+ background: var(--bg-tertiary);
381
+ border-bottom: 1px solid var(--border-subtle);
382
+ font-size: 0.7rem;
383
+ }
384
+
385
+ .panel-model-name {
386
+ font-weight: 600;
387
+ font-family: var(--font-mono);
388
+ color: var(--text-primary);
389
+ flex: 1;
390
+ overflow: hidden;
391
+ text-overflow: ellipsis;
392
+ white-space: nowrap;
393
+ }
394
+
395
+ .panel-params {
396
+ color: var(--accent);
397
+ font-family: var(--font-mono);
398
+ font-weight: 600;
399
+ }
400
+
401
+ .panel-type {
402
+ color: var(--text-muted);
403
+ text-transform: uppercase;
404
+ font-size: 0.6rem;
405
+ letter-spacing: 0.05em;
406
+ font-weight: 500;
407
+ }
408
+
409
+ /* Panel Error */
410
+ .panel-error {
411
+ background: rgba(239, 68, 68, 0.1);
412
+ border-bottom: 1px solid rgba(239, 68, 68, 0.2);
413
+ padding: var(--space-2) var(--space-3);
414
+ color: #fca5a5;
415
+ font-size: 0.7rem;
416
+ }
417
+
418
+ /* Panel Canvas */
419
+ .panel-canvas {
420
+ flex: 1;
421
+ position: relative;
422
+ overflow: hidden;
423
+ }
424
+
425
+ .panel-viz {
426
+ width: 100%;
427
+ height: 100%;
428
+ cursor: grab;
429
+ }
430
+
431
+ .panel-viz:active {
432
+ cursor: grabbing;
433
+ }
434
+
435
+ .panel-viz svg {
436
+ width: 100%;
437
+ height: 100%;
438
+ }
439
+
440
+ /* Panel Empty State */
441
+ .panel-empty {
442
+ position: absolute;
443
+ top: 50%;
444
+ left: 50%;
445
+ transform: translate(-50%, -50%);
446
+ text-align: center;
447
+ color: var(--text-muted);
448
+ pointer-events: none;
449
+ }
450
+
451
+ .panel-empty svg {
452
+ width: 48px;
453
+ height: 48px;
454
+ margin-bottom: var(--space-2);
455
+ opacity: 0.2;
456
+ }
457
+
458
+ .panel-empty p {
459
+ font-size: 0.75rem;
460
+ }
461
+
462
+ /* Spinner */
463
+ .spinner {
464
+ width: 14px;
465
+ height: 14px;
466
+ animation: spin 1s linear infinite;
467
+ }
468
+
469
+ @keyframes spin {
470
+ from { transform: rotate(0deg); }
471
+ to { transform: rotate(360deg); }
472
+ }
473
+
474
+ /* Tooltip */
475
+ .tooltip {
476
+ position: fixed;
477
+ background: var(--bg-elevated);
478
+ border: 1px solid var(--border-default);
479
+ border-radius: var(--radius-lg);
480
+ padding: var(--space-3);
481
+ pointer-events: none;
482
+ z-index: 1000;
483
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
484
+ max-width: 260px;
485
+ }
486
+
487
+ .tooltip-title {
488
+ font-weight: 600;
489
+ margin-bottom: var(--space-1);
490
+ font-size: 0.8rem;
491
+ color: var(--text-primary);
492
+ }
493
+
494
+ .tooltip-content {
495
+ font-size: 0.7rem;
496
+ color: var(--text-secondary);
497
+ }
498
+
499
+ .tooltip-row {
500
+ display: flex;
501
+ justify-content: space-between;
502
+ gap: var(--space-3);
503
+ padding: 1px 0;
504
+ }
505
+
506
+ .tooltip-label {
507
+ color: var(--text-muted);
508
+ }
509
+
510
+ .tooltip-value {
511
+ font-family: var(--font-mono);
512
+ color: var(--text-primary);
513
+ }
514
+
515
+ /* Modal */
516
+ .modal {
517
+ position: fixed;
518
+ inset: 0;
519
+ z-index: 1000;
520
+ display: flex;
521
+ align-items: center;
522
+ justify-content: center;
523
+ }
524
+
525
+ .modal-backdrop {
526
+ position: absolute;
527
+ inset: 0;
528
+ background: rgba(0, 0, 0, 0.7);
529
+ }
530
+
531
+ .modal-content {
532
+ position: relative;
533
+ background: var(--bg-secondary);
534
+ border: 1px solid var(--border-default);
535
+ border-radius: var(--radius-lg);
536
+ width: 90%;
537
+ max-width: 800px;
538
+ max-height: 80vh;
539
+ display: flex;
540
+ flex-direction: column;
541
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
542
+ }
543
+
544
+ .modal-header {
545
+ display: flex;
546
+ align-items: center;
547
+ justify-content: space-between;
548
+ padding: var(--space-4);
549
+ border-bottom: 1px solid var(--border-subtle);
550
+ }
551
+
552
+ .modal-header h2 {
553
+ font-size: 0.95rem;
554
+ font-weight: 600;
555
+ color: var(--text-primary);
556
+ }
557
+
558
+ .modal-close {
559
+ background: transparent;
560
+ border: none;
561
+ color: var(--text-muted);
562
+ cursor: pointer;
563
+ padding: var(--space-1);
564
+ border-radius: var(--radius-sm);
565
+ display: flex;
566
+ align-items: center;
567
+ justify-content: center;
568
+ }
569
+
570
+ .modal-close:hover {
571
+ color: var(--text-primary);
572
+ background: var(--bg-tertiary);
573
+ }
574
+
575
+ .modal-close svg {
576
+ width: 18px;
577
+ height: 18px;
578
+ }
579
+
580
+ .modal-body {
581
+ flex: 1;
582
+ overflow-y: auto;
583
+ padding: var(--space-4);
584
+ }
585
+
586
+ /* Comparison Results */
587
+ .compare-empty {
588
+ color: var(--text-muted);
589
+ text-align: center;
590
+ padding: var(--space-6);
591
+ font-size: 0.85rem;
592
+ }
593
+
594
+ .compare-summary {
595
+ display: grid;
596
+ grid-template-columns: 1fr 1fr;
597
+ gap: var(--space-4);
598
+ margin-bottom: var(--space-4);
599
+ }
600
+
601
+ .compare-model {
602
+ background: var(--bg-tertiary);
603
+ border-radius: var(--radius-md);
604
+ padding: var(--space-3);
605
+ }
606
+
607
+ .compare-model-name {
608
+ font-family: var(--font-mono);
609
+ font-weight: 600;
610
+ font-size: 0.8rem;
611
+ color: var(--text-primary);
612
+ margin-bottom: var(--space-1);
613
+ }
614
+
615
+ .compare-model-params {
616
+ color: var(--accent);
617
+ font-family: var(--font-mono);
618
+ font-size: 0.75rem;
619
+ }
620
+
621
+ .compare-section {
622
+ margin-bottom: var(--space-4);
623
+ }
624
+
625
+ .compare-section-title {
626
+ font-size: 0.75rem;
627
+ font-weight: 600;
628
+ color: var(--text-secondary);
629
+ text-transform: uppercase;
630
+ letter-spacing: 0.05em;
631
+ margin-bottom: var(--space-2);
632
+ display: flex;
633
+ align-items: center;
634
+ gap: var(--space-2);
635
+ }
636
+
637
+ .compare-badge {
638
+ font-size: 0.65rem;
639
+ padding: 2px 6px;
640
+ border-radius: var(--radius-sm);
641
+ font-weight: 500;
642
+ }
643
+
644
+ .compare-badge.same {
645
+ background: rgba(16, 185, 129, 0.15);
646
+ color: #10b981;
647
+ }
648
+
649
+ .compare-badge.diff {
650
+ background: rgba(245, 158, 11, 0.15);
651
+ color: #f59e0b;
652
+ }
653
+
654
+ .compare-badge.added {
655
+ background: rgba(59, 130, 246, 0.15);
656
+ color: #3b82f6;
657
+ }
658
+
659
+ .compare-badge.removed {
660
+ background: rgba(239, 68, 68, 0.15);
661
+ color: #ef4444;
662
+ }
663
+
664
+ .compare-list {
665
+ list-style: none;
666
+ font-size: 0.75rem;
667
+ }
668
+
669
+ .compare-list li {
670
+ padding: var(--space-2) var(--space-3);
671
+ background: var(--bg-tertiary);
672
+ border-radius: var(--radius-sm);
673
+ margin-bottom: var(--space-1);
674
+ display: flex;
675
+ justify-content: space-between;
676
+ align-items: center;
677
+ }
678
+
679
+ .compare-list .item-name {
680
+ color: var(--text-primary);
681
+ font-family: var(--font-mono);
682
+ }
683
+
684
+ .compare-list .item-detail {
685
+ color: var(--text-muted);
686
+ font-family: var(--font-mono);
687
+ font-size: 0.7rem;
688
+ }
689
+
690
+ .compare-list .item-diff {
691
+ display: flex;
692
+ gap: var(--space-2);
693
+ font-family: var(--font-mono);
694
+ font-size: 0.7rem;
695
+ }
696
+
697
+ .compare-list .model-a {
698
+ color: #3b82f6;
699
+ }
700
+
701
+ .compare-list .model-b {
702
+ color: #8b5cf6;
703
+ }
704
+
705
+ .compare-similarity {
706
+ display: flex;
707
+ align-items: center;
708
+ gap: var(--space-3);
709
+ padding: var(--space-3);
710
+ background: var(--bg-tertiary);
711
+ border-radius: var(--radius-md);
712
+ margin-bottom: var(--space-4);
713
+ }
714
+
715
+ .similarity-bar {
716
+ flex: 1;
717
+ height: 8px;
718
+ background: var(--bg-elevated);
719
+ border-radius: 4px;
720
+ overflow: hidden;
721
+ }
722
+
723
+ .similarity-fill {
724
+ height: 100%;
725
+ background: linear-gradient(90deg, #ef4444 0%, #f59e0b 50%, #10b981 100%);
726
+ border-radius: 4px;
727
+ transition: width 0.3s ease;
728
+ }
729
+
730
+ .similarity-value {
731
+ font-family: var(--font-mono);
732
+ font-weight: 600;
733
+ font-size: 0.85rem;
734
+ color: var(--text-primary);
735
+ min-width: 48px;
736
+ text-align: right;
737
+ }
738
+
739
+ .similarity-label {
740
+ font-size: 0.75rem;
741
+ color: var(--text-secondary);
742
+ }
743
+
744
+ /* Error hint */
745
+ .error-hint {
746
+ margin-top: var(--space-2);
747
+ padding: var(--space-2) var(--space-3);
748
+ background: rgba(59, 130, 246, 0.1);
749
+ border: 1px solid rgba(59, 130, 246, 0.2);
750
+ border-radius: var(--radius-md);
751
+ font-size: 0.7rem;
752
+ color: #93c5fd;
753
+ }
754
+
755
+ .error-hint code {
756
+ background: rgba(0,0,0,0.3);
757
+ padding: 1px 4px;
758
+ border-radius: 3px;
759
+ font-family: var(--font-mono);
760
+ }
761
+
762
+ /* Header buttons with text */
763
+ .header-btn.with-text {
764
+ width: auto;
765
+ padding: 0 var(--space-3);
766
+ gap: var(--space-1);
767
+ }
768
+
769
+ .header-btn.with-text span {
770
+ font-size: 0.7rem;
771
+ font-weight: 500;
772
+ }
773
+
774
+ .header-btn .dropdown-arrow {
775
+ width: 12px;
776
+ height: 12px;
777
+ margin-left: var(--space-1);
778
+ }
779
+
780
+ /* Export Dropdown */
781
+ .export-dropdown {
782
+ position: relative;
783
+ }
784
+
785
+ .export-menu {
786
+ position: absolute;
787
+ top: 100%;
788
+ right: 0;
789
+ margin-top: var(--space-1);
790
+ background: var(--bg-elevated);
791
+ border: 1px solid var(--border-default);
792
+ border-radius: var(--radius-md);
793
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
794
+ opacity: 0;
795
+ visibility: hidden;
796
+ transform: translateY(-4px);
797
+ transition: all var(--transition);
798
+ z-index: 100;
799
+ min-width: 140px;
800
+ }
801
+
802
+ .export-dropdown:hover .export-menu,
803
+ .export-menu:hover {
804
+ opacity: 1;
805
+ visibility: visible;
806
+ transform: translateY(0);
807
+ }
808
+
809
+ .export-option {
810
+ display: block;
811
+ width: 100%;
812
+ padding: var(--space-2) var(--space-3);
813
+ background: transparent;
814
+ border: none;
815
+ color: var(--text-secondary);
816
+ font-size: 0.75rem;
817
+ text-align: left;
818
+ cursor: pointer;
819
+ transition: all var(--transition);
820
+ }
821
+
822
+ .export-option:first-child {
823
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
824
+ }
825
+
826
+ .export-option:last-child {
827
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
828
+ }
829
+
830
+ .export-option:hover {
831
+ background: var(--bg-tertiary);
832
+ color: var(--text-primary);
833
+ }
834
+
835
+ /* Loading Status */
836
+ .loading-status {
837
+ display: flex;
838
+ flex-direction: column;
839
+ align-items: center;
840
+ gap: var(--space-3);
841
+ }
842
+
843
+ .loading-spinner {
844
+ width: 32px;
845
+ height: 32px;
846
+ animation: spin 1s linear infinite;
847
+ opacity: 0.5;
848
+ }
849
+
850
+ .status-text {
851
+ font-size: 0.75rem;
852
+ color: var(--text-muted);
853
+ animation: pulse 2s ease-in-out infinite;
854
+ }
855
+
856
+ @keyframes pulse {
857
+ 0%, 100% { opacity: 0.5; }
858
+ 50% { opacity: 1; }
859
+ }
860
+
861
+ /* Utility */
862
+ .hidden {
863
+ display: none !important;
864
+ }
865
+
866
+ /* Responsive */
867
+ @media (max-width: 768px) {
868
+ .header-center {
869
+ display: none;
870
+ }
871
+
872
+ .panels-container {
873
+ flex-direction: column;
874
+ }
875
+
876
+ .panel {
877
+ min-width: 100%;
878
+ border-right: none;
879
+ border-bottom: 1px solid var(--border-subtle);
880
+ }
881
+
882
+ .panel:last-child {
883
+ border-bottom: none;
884
+ }
885
+ }
frontend/index.html ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LLM Scope - Visualize Model Architectures</title>
7
+ <link rel="stylesheet" href="css/styles.css">
8
+ <script src="https://d3js.org/d3.v7.min.js"></script>
9
+ </head>
10
+ <body>
11
+ <div class="app">
12
+ <!-- Top Header -->
13
+ <header class="header">
14
+ <div class="header-left">
15
+ <div class="logo">
16
+ <svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
17
+ <circle cx="10" cy="10" r="7"/>
18
+ <line x1="15" y1="15" x2="21" y2="21" stroke-width="2.5" stroke-linecap="round"/>
19
+ <circle cx="10" cy="10" r="3" stroke-dasharray="2 2"/>
20
+ </svg>
21
+ <div class="logo-text">
22
+ <h1>LLM Scope</h1>
23
+ <a href="https://omarkamali.com" target="_blank" rel="noopener" class="attribution">by Omar Kamali</a>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ <div class="header-center">
28
+ <div class="quick-models">
29
+ <button class="quick-btn" data-model="openai/gpt-oss-120b">GPT-OSS 120B</button>
30
+ <button class="quick-btn" data-model="Qwen/Qwen3-4B">Qwen3 4B</button>
31
+ <button class="quick-btn" data-model="Qwen/Qwen3-30B-A3B">Qwen MoE 30B</button>
32
+ <button class="quick-btn" data-model="deepseek-ai/DeepSeek-V3">DeepSeek V3</button>
33
+ <button class="quick-btn" data-model="google/gemma-3-27b-it">Gemma3 27B</button>
34
+ <button class="quick-btn" data-model="moonshotai/Kimi-K2-Instruct">Kimi K2</button>
35
+ <button class="quick-btn" data-model="mistralai/Mistral-Large-3-675B-Instruct-2512">Mistral 675B</button>
36
+ <button class="quick-btn" data-model="nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16">Nemotron 30B</button>
37
+ </div>
38
+ </div>
39
+ <div class="header-right">
40
+ <label class="toggle-control">
41
+ <input type="checkbox" id="zoom-lock" checked>
42
+ <span class="toggle-label">Lock Scale</span>
43
+ </label>
44
+ <button id="compare-btn" class="header-btn with-text" title="Compare architectures">
45
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
46
+ <path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M12 8v8M8 12h8"/>
47
+ </svg>
48
+ <span>Compare</span>
49
+ </button>
50
+ <div class="export-dropdown">
51
+ <button id="export-btn" class="header-btn with-text" title="Export visualization">
52
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
53
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
54
+ <polyline points="7 10 12 15 17 10"/>
55
+ <line x1="12" y1="15" x2="12" y2="3"/>
56
+ </svg>
57
+ <span>Export</span>
58
+ <svg class="dropdown-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
59
+ <polyline points="6 9 12 15 18 9"/>
60
+ </svg>
61
+ </button>
62
+ <div class="export-menu" id="export-menu">
63
+ <button class="export-option" data-format="svg">Export as SVG</button>
64
+ <button class="export-option" data-format="png">Export as PNG</button>
65
+ </div>
66
+ </div>
67
+ <button id="add-panel-btn" class="header-btn primary" title="Add panel">
68
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
69
+ <line x1="12" y1="5" x2="12" y2="19"/>
70
+ <line x1="5" y1="12" x2="19" y2="12"/>
71
+ </svg>
72
+ </button>
73
+ </div>
74
+ </header>
75
+
76
+ <!-- Legend Bar -->
77
+ <div class="legend-bar" id="legend-bar">
78
+ <div class="legend" id="legend"></div>
79
+ </div>
80
+
81
+ <!-- Panels Container -->
82
+ <main class="panels-container" id="panels-container">
83
+ </main>
84
+ </div>
85
+
86
+ <!-- Panel Template -->
87
+ <template id="panel-template">
88
+ <div class="panel" data-panel-id="">
89
+ <div class="panel-header">
90
+ <div class="panel-input-wrapper">
91
+ <input type="text" class="panel-input" placeholder="Model ID (e.g., gpt2)" />
92
+ <button class="panel-go-btn">
93
+ <span class="btn-text">Load</span>
94
+ <span class="btn-loader hidden">
95
+ <svg class="spinner" viewBox="0 0 24 24">
96
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="30 70"/>
97
+ </svg>
98
+ </span>
99
+ </button>
100
+ </div>
101
+ <button class="panel-close-btn" title="Remove panel">
102
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
103
+ <line x1="18" y1="6" x2="6" y2="18"/>
104
+ <line x1="6" y1="6" x2="18" y2="18"/>
105
+ </svg>
106
+ </button>
107
+ </div>
108
+ <div class="panel-info hidden">
109
+ <span class="panel-model-name"></span>
110
+ <span class="panel-params"></span>
111
+ <span class="panel-type"></span>
112
+ </div>
113
+ <div class="panel-error hidden"></div>
114
+ <div class="panel-canvas">
115
+ <div class="panel-viz"></div>
116
+ <div class="panel-empty">
117
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
118
+ <circle cx="10" cy="10" r="7"/>
119
+ <line x1="15" y1="15" x2="21" y2="21" stroke-width="2" stroke-linecap="round"/>
120
+ </svg>
121
+ <p>Enter a model ID to inspect</p>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </template>
126
+
127
+ <!-- Tooltip -->
128
+ <div id="tooltip" class="tooltip hidden"></div>
129
+
130
+ <!-- Comparison Modal -->
131
+ <div id="compare-modal" class="modal hidden">
132
+ <div class="modal-backdrop"></div>
133
+ <div class="modal-content">
134
+ <div class="modal-header">
135
+ <h2>Architecture Comparison</h2>
136
+ <button class="modal-close" id="compare-modal-close">
137
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
138
+ <line x1="18" y1="6" x2="6" y2="18"/>
139
+ <line x1="6" y1="6" x2="18" y2="18"/>
140
+ </svg>
141
+ </button>
142
+ </div>
143
+ <div class="modal-body" id="compare-results">
144
+ <p class="compare-empty">Load at least 2 models to compare architectures</p>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <script src="js/api.js"></script>
150
+ <script src="js/treemap.js"></script>
151
+ <script src="js/app.js"></script>
152
+ </body>
153
+ </html>
frontend/js/api.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * API client for Model Inspector backend
3
+ */
4
+
5
+ const API = {
6
+ baseUrl: '', // Same origin
7
+
8
+ /**
9
+ * Inspect a model by HuggingFace model ID
10
+ * @param {string} modelId - HuggingFace model ID
11
+ * @returns {Promise<{tree: Object, metadata: Object}>}
12
+ */
13
+ async inspectModel(modelId) {
14
+ const response = await fetch(`${this.baseUrl}/api/inspect`, {
15
+ method: 'POST',
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ },
19
+ body: JSON.stringify({ model_id: modelId }),
20
+ });
21
+
22
+ if (!response.ok) {
23
+ const error = await response.json();
24
+ const detail = error.detail || 'Failed to inspect model';
25
+
26
+ // Check for common auth/access errors
27
+ const isAuthError = detail.includes('401') ||
28
+ detail.includes('403') ||
29
+ detail.includes('gated') ||
30
+ detail.includes('access') ||
31
+ detail.includes('token') ||
32
+ detail.includes('authorization') ||
33
+ detail.includes('authenticate');
34
+
35
+ if (isAuthError) {
36
+ const err = new Error(detail);
37
+ err.isAuthError = true;
38
+ throw err;
39
+ }
40
+
41
+ throw new Error(detail);
42
+ }
43
+
44
+ return response.json();
45
+ },
46
+
47
+ /**
48
+ * Inspect a model by config object
49
+ * @param {Object} config - Model config.json object
50
+ * @returns {Promise<{tree: Object, metadata: Object}>}
51
+ */
52
+ async inspectConfig(config) {
53
+ const response = await fetch(`${this.baseUrl}/api/inspect`, {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({ config }),
59
+ });
60
+
61
+ if (!response.ok) {
62
+ const error = await response.json();
63
+ throw new Error(error.detail || 'Failed to inspect config');
64
+ }
65
+
66
+ return response.json();
67
+ },
68
+
69
+ /**
70
+ * Upload a config.json file
71
+ * @param {File} file - The config.json file
72
+ * @returns {Promise<{tree: Object, metadata: Object}>}
73
+ */
74
+ async uploadConfig(file) {
75
+ const formData = new FormData();
76
+ formData.append('file', file);
77
+
78
+ const response = await fetch(`${this.baseUrl}/api/upload`, {
79
+ method: 'POST',
80
+ body: formData,
81
+ });
82
+
83
+ if (!response.ok) {
84
+ const error = await response.json();
85
+ throw new Error(error.detail || 'Failed to upload config');
86
+ }
87
+
88
+ return response.json();
89
+ },
90
+
91
+ /**
92
+ * Format parameter count for display
93
+ * @param {number} count - Parameter count
94
+ * @returns {string} Formatted string
95
+ */
96
+ formatParams(count) {
97
+ if (count >= 1e12) {
98
+ return `${(count / 1e12).toFixed(2)}T`;
99
+ } else if (count >= 1e9) {
100
+ return `${(count / 1e9).toFixed(2)}B`;
101
+ } else if (count >= 1e6) {
102
+ return `${(count / 1e6).toFixed(2)}M`;
103
+ } else if (count >= 1e3) {
104
+ return `${(count / 1e3).toFixed(2)}K`;
105
+ }
106
+ return count.toLocaleString();
107
+ }
108
+ };
frontend/js/app.js ADDED
@@ -0,0 +1,710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LLM Scope - Multi-panel Model Inspector
3
+ */
4
+
5
+ (function() {
6
+ const panels = new Map();
7
+ let panelIdCounter = 0;
8
+ let zoomLocked = true;
9
+ let globalMaxParams = 0;
10
+ const loadingPanels = new Set(); // Track panels currently loading
11
+
12
+ const panelsContainer = document.getElementById('panels-container');
13
+ const panelTemplate = document.getElementById('panel-template');
14
+ const addPanelBtn = document.getElementById('add-panel-btn');
15
+ const exportMenu = document.getElementById('export-menu');
16
+ const compareBtn = document.getElementById('compare-btn');
17
+ const zoomLockCheckbox = document.getElementById('zoom-lock');
18
+ const legend = document.getElementById('legend');
19
+ const compareModal = document.getElementById('compare-modal');
20
+ const compareModalClose = document.getElementById('compare-modal-close');
21
+ const compareResults = document.getElementById('compare-results');
22
+
23
+ function init() {
24
+ addPanelBtn.addEventListener('click', () => addPanel());
25
+ compareBtn.addEventListener('click', handleCompare);
26
+ zoomLockCheckbox.addEventListener('change', handleZoomLockChange);
27
+
28
+ // Export options
29
+ exportMenu.querySelectorAll('.export-option').forEach(btn => {
30
+ btn.addEventListener('click', (e) => {
31
+ const format = e.target.dataset.format;
32
+ handleExport(format);
33
+ });
34
+ });
35
+
36
+ // Modal close
37
+ compareModalClose.addEventListener('click', closeCompareModal);
38
+ compareModal.querySelector('.modal-backdrop').addEventListener('click', closeCompareModal);
39
+
40
+ document.querySelectorAll('.quick-btn').forEach(btn => {
41
+ btn.addEventListener('click', () => {
42
+ const modelId = btn.dataset.model;
43
+ const emptyPanel = findEmptyPanel();
44
+ if (emptyPanel) {
45
+ loadModelInPanel(emptyPanel, modelId);
46
+ } else {
47
+ const panel = addPanel();
48
+ loadModelInPanel(panel.id, modelId);
49
+ }
50
+ });
51
+ });
52
+
53
+ const urlState = parseUrlState();
54
+ if (urlState.models.length > 0) {
55
+ urlState.models.forEach((modelId) => {
56
+ const panel = addPanel();
57
+ if (modelId) {
58
+ loadModelInPanel(panel.id, modelId);
59
+ }
60
+ });
61
+ zoomLocked = urlState.zoomLocked;
62
+ zoomLockCheckbox.checked = zoomLocked;
63
+ } else {
64
+ addPanel();
65
+ }
66
+
67
+ updateLegend();
68
+ }
69
+
70
+ function findEmptyPanel() {
71
+ for (const [panelId, panel] of panels) {
72
+ // Don't reuse panels that are loading or already have data
73
+ if (!panel.data && !loadingPanels.has(panelId)) return panelId;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function addPanel() {
79
+ const id = `panel-${panelIdCounter++}`;
80
+ const content = panelTemplate.content.cloneNode(true);
81
+ const panelEl = content.querySelector('.panel');
82
+ panelEl.dataset.panelId = id;
83
+
84
+ const vizContainer = panelEl.querySelector('.panel-viz');
85
+ vizContainer.id = `viz-${id}`;
86
+
87
+ panelsContainer.appendChild(content);
88
+
89
+ const panel = panelsContainer.querySelector(`[data-panel-id="${id}"]`);
90
+ const viz = new TreemapViz(`viz-${id}`);
91
+
92
+ panels.set(id, {
93
+ el: panel,
94
+ viz: viz,
95
+ data: null,
96
+ modelId: null
97
+ });
98
+
99
+ setupPanelEvents(id, panel);
100
+ updateCloseButtons();
101
+
102
+ return { id, el: panel };
103
+ }
104
+
105
+ function setupPanelEvents(panelId, panelEl) {
106
+ const input = panelEl.querySelector('.panel-input');
107
+ const goBtn = panelEl.querySelector('.panel-go-btn');
108
+ const closeBtn = panelEl.querySelector('.panel-close-btn');
109
+
110
+ goBtn.addEventListener('click', () => {
111
+ const modelId = input.value.trim();
112
+ if (modelId) loadModelInPanel(panelId, modelId);
113
+ });
114
+
115
+ input.addEventListener('keypress', (e) => {
116
+ if (e.key === 'Enter') {
117
+ const modelId = input.value.trim();
118
+ if (modelId) loadModelInPanel(panelId, modelId);
119
+ }
120
+ });
121
+
122
+ closeBtn.addEventListener('click', () => removePanel(panelId));
123
+ }
124
+
125
+ function removePanel(panelId) {
126
+ if (panels.size <= 1) return;
127
+
128
+ const panel = panels.get(panelId);
129
+ if (panel) {
130
+ panel.viz.destroy();
131
+ panel.el.remove();
132
+ panels.delete(panelId);
133
+ }
134
+
135
+ updateCloseButtons();
136
+ updateGlobalScale();
137
+ updateUrlState();
138
+ }
139
+
140
+ function updateCloseButtons() {
141
+ const canClose = panels.size > 1;
142
+ panels.forEach(panel => {
143
+ const closeBtn = panel.el.querySelector('.panel-close-btn');
144
+ closeBtn.style.visibility = canClose ? 'visible' : 'hidden';
145
+ });
146
+ }
147
+
148
+ async function loadModelInPanel(panelId, modelId) {
149
+ const panel = panels.get(panelId);
150
+ if (!panel) return;
151
+
152
+ const input = panel.el.querySelector('.panel-input');
153
+ const goBtn = panel.el.querySelector('.panel-go-btn');
154
+ const btnText = goBtn.querySelector('.btn-text');
155
+ const btnLoader = goBtn.querySelector('.btn-loader');
156
+ const infoEl = panel.el.querySelector('.panel-info');
157
+ const errorEl = panel.el.querySelector('.panel-error');
158
+ const emptyEl = panel.el.querySelector('.panel-empty');
159
+
160
+ input.value = modelId;
161
+ loadingPanels.add(panelId);
162
+
163
+ goBtn.disabled = true;
164
+ btnText.classList.add('hidden');
165
+ btnLoader.classList.remove('hidden');
166
+ errorEl.classList.add('hidden');
167
+ infoEl.classList.add('hidden');
168
+
169
+ // Remove any existing hint
170
+ const existingHint = panel.el.querySelector('.error-hint');
171
+ if (existingHint) existingHint.remove();
172
+
173
+ // Show loading status in canvas
174
+ const statusMessages = [
175
+ `Fetching config for ${modelId}...`,
176
+ 'Downloading model configuration...',
177
+ 'Building architecture graph...',
178
+ 'Calculating parameter counts...',
179
+ 'Rendering visualization...'
180
+ ];
181
+ let statusIndex = 0;
182
+
183
+ emptyEl.innerHTML = `
184
+ <div class="loading-status">
185
+ <svg class="loading-spinner" viewBox="0 0 24 24">
186
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="30 70"/>
187
+ </svg>
188
+ <p class="status-text">${statusMessages[0]}</p>
189
+ </div>
190
+ `;
191
+ emptyEl.classList.remove('hidden');
192
+
193
+ // Cycle through status messages
194
+ const statusInterval = setInterval(() => {
195
+ statusIndex = (statusIndex + 1) % statusMessages.length;
196
+ const statusText = emptyEl.querySelector('.status-text');
197
+ if (statusText) {
198
+ statusText.textContent = statusMessages[statusIndex];
199
+ }
200
+ }, 2000);
201
+
202
+ try {
203
+ const result = await API.inspectModel(modelId);
204
+
205
+ panel.data = result;
206
+ panel.modelId = modelId;
207
+
208
+ const nameEl = infoEl.querySelector('.panel-model-name');
209
+ const paramsEl = infoEl.querySelector('.panel-params');
210
+ const typeEl = infoEl.querySelector('.panel-type');
211
+
212
+ nameEl.textContent = result.metadata.model_id || modelId;
213
+ paramsEl.textContent = result.metadata.formatted_params;
214
+ typeEl.textContent = result.metadata.model_type.toUpperCase();
215
+ infoEl.classList.remove('hidden');
216
+
217
+ emptyEl.classList.add('hidden');
218
+
219
+ updateGlobalScale();
220
+ updateUrlState();
221
+ updateLegend();
222
+
223
+ } catch (error) {
224
+ // Restore empty state
225
+ emptyEl.innerHTML = `
226
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
227
+ <circle cx="10" cy="10" r="7"/>
228
+ <line x1="15" y1="15" x2="21" y2="21" stroke-width="2" stroke-linecap="round"/>
229
+ </svg>
230
+ <p>Enter a model ID to inspect</p>
231
+ `;
232
+
233
+ errorEl.textContent = error.message;
234
+ errorEl.classList.remove('hidden');
235
+
236
+ // Add auth hint if it's an auth error
237
+ if (error.isAuthError) {
238
+ const hint = document.createElement('div');
239
+ hint.className = 'error-hint';
240
+ hint.innerHTML = `
241
+ This model may require authentication. Make sure you're logged in to HuggingFace:<br>
242
+ <code>huggingface-cli login</code> or set <code>HF_TOKEN</code> environment variable.
243
+ <br><br>
244
+ For gated models, you may also need to accept the model's license on the HuggingFace website.
245
+ `;
246
+ errorEl.after(hint);
247
+ }
248
+ } finally {
249
+ clearInterval(statusInterval);
250
+ loadingPanels.delete(panelId);
251
+ goBtn.disabled = false;
252
+ btnText.classList.remove('hidden');
253
+ btnLoader.classList.add('hidden');
254
+ }
255
+ }
256
+
257
+ function updateGlobalScale() {
258
+ if (zoomLocked) {
259
+ globalMaxParams = 0;
260
+ panels.forEach(panel => {
261
+ if (panel.data) {
262
+ globalMaxParams = Math.max(globalMaxParams, panel.data.pipeline.params);
263
+ }
264
+ });
265
+
266
+ panels.forEach(panel => {
267
+ if (panel.data) {
268
+ panel.viz.setData(panel.data.pipeline, globalMaxParams);
269
+ }
270
+ });
271
+ } else {
272
+ globalMaxParams = 0;
273
+ panels.forEach(panel => {
274
+ if (panel.data) {
275
+ panel.viz.setData(panel.data.pipeline, null);
276
+ }
277
+ });
278
+ }
279
+ }
280
+
281
+ function handleZoomLockChange() {
282
+ zoomLocked = zoomLockCheckbox.checked;
283
+ updateGlobalScale();
284
+ updateUrlState();
285
+ }
286
+
287
+ function handleExport(format = 'svg') {
288
+ const loadedPanels = [];
289
+ panels.forEach(panel => {
290
+ if (panel.data && panel.modelId) {
291
+ loadedPanels.push(panel);
292
+ }
293
+ });
294
+
295
+ if (loadedPanels.length === 0) return;
296
+
297
+ const panel = loadedPanels[0];
298
+ const blob = panel.viz.exportSVG(panel.modelId);
299
+ const filename = `${panel.modelId.replace(/\//g, '-')}-architecture`;
300
+
301
+ if (format === 'svg') {
302
+ const url = URL.createObjectURL(blob);
303
+ const a = document.createElement('a');
304
+ a.href = url;
305
+ a.download = `${filename}.svg`;
306
+ document.body.appendChild(a);
307
+ a.click();
308
+ document.body.removeChild(a);
309
+ URL.revokeObjectURL(url);
310
+ } else if (format === 'png') {
311
+ // Convert SVG to PNG
312
+ const svgText = URL.createObjectURL(blob);
313
+ const img = new Image();
314
+
315
+ img.onload = () => {
316
+ const scale = 2; // Higher resolution
317
+ const canvas = document.createElement('canvas');
318
+ canvas.width = img.width * scale;
319
+ canvas.height = img.height * scale;
320
+
321
+ const ctx = canvas.getContext('2d');
322
+ ctx.scale(scale, scale);
323
+ ctx.fillStyle = '#0a0a0b'; // Background color
324
+ ctx.fillRect(0, 0, img.width, img.height);
325
+ ctx.drawImage(img, 0, 0);
326
+
327
+ canvas.toBlob((pngBlob) => {
328
+ const url = URL.createObjectURL(pngBlob);
329
+ const a = document.createElement('a');
330
+ a.href = url;
331
+ a.download = `${filename}.png`;
332
+ document.body.appendChild(a);
333
+ a.click();
334
+ document.body.removeChild(a);
335
+ URL.revokeObjectURL(url);
336
+ }, 'image/png');
337
+
338
+ URL.revokeObjectURL(svgText);
339
+ };
340
+
341
+ img.src = svgText;
342
+ }
343
+ }
344
+
345
+ function handleCompare() {
346
+ const loadedPanels = [];
347
+ panels.forEach(panel => {
348
+ if (panel.data && panel.modelId) {
349
+ loadedPanels.push(panel);
350
+ }
351
+ });
352
+
353
+ if (loadedPanels.length < 2) {
354
+ compareResults.innerHTML = '<p class="compare-empty">Load at least 2 models to compare architectures</p>';
355
+ } else {
356
+ const comparison = compareArchitectures(loadedPanels[0], loadedPanels[1]);
357
+ renderComparison(comparison, loadedPanels[0], loadedPanels[1]);
358
+ }
359
+
360
+ compareModal.classList.remove('hidden');
361
+ }
362
+
363
+ function closeCompareModal() {
364
+ compareModal.classList.add('hidden');
365
+ }
366
+
367
+ /**
368
+ * Compare two model architectures
369
+ */
370
+ function compareArchitectures(panelA, panelB) {
371
+ const a = panelA.data.pipeline;
372
+ const b = panelB.data.pipeline;
373
+
374
+ const result = {
375
+ modelA: { name: panelA.modelId, params: a.params },
376
+ modelB: { name: panelB.modelId, params: b.params },
377
+ paramRatio: a.params > 0 ? b.params / a.params : 0,
378
+ similarities: [],
379
+ differences: [],
380
+ onlyInA: [],
381
+ onlyInB: [],
382
+ similarity: 0
383
+ };
384
+
385
+ // Flatten steps for comparison
386
+ const stepsA = flattenSteps(a.steps || []);
387
+ const stepsB = flattenSteps(b.steps || []);
388
+
389
+ // Compare by type
390
+ const typesA = new Map();
391
+ const typesB = new Map();
392
+
393
+ stepsA.forEach(s => {
394
+ const key = s.type + ':' + s.name;
395
+ typesA.set(key, s);
396
+ });
397
+
398
+ stepsB.forEach(s => {
399
+ const key = s.type + ':' + s.name;
400
+ typesB.set(key, s);
401
+ });
402
+
403
+ // Find common and different
404
+ let matchScore = 0;
405
+ const totalSteps = Math.max(typesA.size, typesB.size);
406
+
407
+ typesA.forEach((stepA, key) => {
408
+ if (typesB.has(key)) {
409
+ const stepB = typesB.get(key);
410
+ matchScore++;
411
+
412
+ // Check if params are similar
413
+ const paramDiff = stepA.params > 0 ? Math.abs(stepA.params - stepB.params) / stepA.params : 0;
414
+
415
+ if (paramDiff < 0.05) {
416
+ result.similarities.push({
417
+ name: stepA.name,
418
+ type: stepA.type,
419
+ paramsA: stepA.params,
420
+ paramsB: stepB.params,
421
+ shapeA: stepA.shape,
422
+ shapeB: stepB.shape
423
+ });
424
+ } else {
425
+ result.differences.push({
426
+ name: stepA.name,
427
+ type: stepA.type,
428
+ paramsA: stepA.params,
429
+ paramsB: stepB.params,
430
+ shapeA: stepA.shape,
431
+ shapeB: stepB.shape,
432
+ paramDiff: paramDiff
433
+ });
434
+ }
435
+ } else {
436
+ result.onlyInA.push({
437
+ name: stepA.name,
438
+ type: stepA.type,
439
+ params: stepA.params,
440
+ shape: stepA.shape
441
+ });
442
+ }
443
+ });
444
+
445
+ typesB.forEach((stepB, key) => {
446
+ if (!typesA.has(key)) {
447
+ result.onlyInB.push({
448
+ name: stepB.name,
449
+ type: stepB.type,
450
+ params: stepB.params,
451
+ shape: stepB.shape
452
+ });
453
+ }
454
+ });
455
+
456
+ // Calculate similarity
457
+ result.similarity = totalSteps > 0 ? (matchScore / totalSteps) * 100 : 0;
458
+
459
+ // Compare layer counts
460
+ const layersA = stepsA.find(s => s.type === 'layers');
461
+ const layersB = stepsB.find(s => s.type === 'layers');
462
+
463
+ if (layersA && layersB) {
464
+ result.layerComparison = {
465
+ countA: layersA.count || 1,
466
+ countB: layersB.count || 1,
467
+ paramsA: layersA.params,
468
+ paramsB: layersB.params,
469
+ shapeA: layersA.shape,
470
+ shapeB: layersB.shape
471
+ };
472
+ }
473
+
474
+ return result;
475
+ }
476
+
477
+ /**
478
+ * Flatten nested steps for comparison
479
+ */
480
+ function flattenSteps(steps, result = []) {
481
+ for (const step of steps) {
482
+ result.push(step);
483
+ if (step.substeps) {
484
+ flattenSteps(step.substeps, result);
485
+ }
486
+ if (step.branches) {
487
+ for (const branch of step.branches) {
488
+ result.push(branch);
489
+ if (branch.substeps) {
490
+ flattenSteps(branch.substeps, result);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ return result;
496
+ }
497
+
498
+ /**
499
+ * Render comparison results
500
+ */
501
+ function renderComparison(comparison, panelA, panelB) {
502
+ const fmt = API.formatParams;
503
+
504
+ let html = `
505
+ <div class="compare-summary">
506
+ <div class="compare-model">
507
+ <div class="compare-model-name">${comparison.modelA.name}</div>
508
+ <div class="compare-model-params">${fmt(comparison.modelA.params)}</div>
509
+ </div>
510
+ <div class="compare-model">
511
+ <div class="compare-model-name">${comparison.modelB.name}</div>
512
+ <div class="compare-model-params">${fmt(comparison.modelB.params)}</div>
513
+ </div>
514
+ </div>
515
+
516
+ <div class="compare-similarity">
517
+ <span class="similarity-label">Structural Similarity</span>
518
+ <div class="similarity-bar">
519
+ <div class="similarity-fill" style="width: ${comparison.similarity}%"></div>
520
+ </div>
521
+ <span class="similarity-value">${comparison.similarity.toFixed(0)}%</span>
522
+ </div>
523
+ `;
524
+
525
+ // Layer comparison
526
+ if (comparison.layerComparison) {
527
+ const lc = comparison.layerComparison;
528
+ html += `
529
+ <div class="compare-section">
530
+ <div class="compare-section-title">Transformer Layers</div>
531
+ <ul class="compare-list">
532
+ <li>
533
+ <span class="item-name">Layer Count</span>
534
+ <span class="item-diff">
535
+ <span class="model-a">${lc.countA}</span>
536
+ <span>/</span>
537
+ <span class="model-b">${lc.countB}</span>
538
+ </span>
539
+ </li>
540
+ <li>
541
+ <span class="item-name">Total Params</span>
542
+ <span class="item-diff">
543
+ <span class="model-a">${fmt(lc.paramsA)}</span>
544
+ <span>/</span>
545
+ <span class="model-b">${fmt(lc.paramsB)}</span>
546
+ </span>
547
+ </li>
548
+ ${lc.shapeA || lc.shapeB ? `
549
+ <li>
550
+ <span class="item-name">Dimensions</span>
551
+ <span class="item-diff">
552
+ <span class="model-a">${lc.shapeA || '-'}</span>
553
+ <span>/</span>
554
+ <span class="model-b">${lc.shapeB || '-'}</span>
555
+ </span>
556
+ </li>
557
+ ` : ''}
558
+ </ul>
559
+ </div>
560
+ `;
561
+ }
562
+
563
+ // Similarities
564
+ if (comparison.similarities.length > 0) {
565
+ html += `
566
+ <div class="compare-section">
567
+ <div class="compare-section-title">
568
+ Similar Components
569
+ <span class="compare-badge same">${comparison.similarities.length}</span>
570
+ </div>
571
+ <ul class="compare-list">
572
+ ${comparison.similarities.slice(0, 10).map(s => `
573
+ <li>
574
+ <span class="item-name">${s.name}</span>
575
+ <span class="item-detail">${s.type}</span>
576
+ </li>
577
+ `).join('')}
578
+ ${comparison.similarities.length > 10 ? `<li><span class="item-detail">...and ${comparison.similarities.length - 10} more</span></li>` : ''}
579
+ </ul>
580
+ </div>
581
+ `;
582
+ }
583
+
584
+ // Differences
585
+ if (comparison.differences.length > 0) {
586
+ html += `
587
+ <div class="compare-section">
588
+ <div class="compare-section-title">
589
+ Different Sizes
590
+ <span class="compare-badge diff">${comparison.differences.length}</span>
591
+ </div>
592
+ <ul class="compare-list">
593
+ ${comparison.differences.slice(0, 10).map(d => `
594
+ <li>
595
+ <span class="item-name">${d.name}</span>
596
+ <span class="item-diff">
597
+ <span class="model-a">${fmt(d.paramsA)}</span>
598
+ <span>/</span>
599
+ <span class="model-b">${fmt(d.paramsB)}</span>
600
+ </span>
601
+ </li>
602
+ `).join('')}
603
+ ${comparison.differences.length > 10 ? `<li><span class="item-detail">...and ${comparison.differences.length - 10} more</span></li>` : ''}
604
+ </ul>
605
+ </div>
606
+ `;
607
+ }
608
+
609
+ // Only in A
610
+ if (comparison.onlyInA.length > 0) {
611
+ html += `
612
+ <div class="compare-section">
613
+ <div class="compare-section-title">
614
+ Only in ${comparison.modelA.name.split('/').pop()}
615
+ <span class="compare-badge removed">${comparison.onlyInA.length}</span>
616
+ </div>
617
+ <ul class="compare-list">
618
+ ${comparison.onlyInA.map(s => `
619
+ <li>
620
+ <span class="item-name">${s.name}</span>
621
+ <span class="item-detail">${s.type} - ${fmt(s.params)}</span>
622
+ </li>
623
+ `).join('')}
624
+ </ul>
625
+ </div>
626
+ `;
627
+ }
628
+
629
+ // Only in B
630
+ if (comparison.onlyInB.length > 0) {
631
+ html += `
632
+ <div class="compare-section">
633
+ <div class="compare-section-title">
634
+ Only in ${comparison.modelB.name.split('/').pop()}
635
+ <span class="compare-badge added">${comparison.onlyInB.length}</span>
636
+ </div>
637
+ <ul class="compare-list">
638
+ ${comparison.onlyInB.map(s => `
639
+ <li>
640
+ <span class="item-name">${s.name}</span>
641
+ <span class="item-detail">${s.type} - ${fmt(s.params)}</span>
642
+ </li>
643
+ `).join('')}
644
+ </ul>
645
+ </div>
646
+ `;
647
+ }
648
+
649
+ compareResults.innerHTML = html;
650
+ }
651
+
652
+ function updateLegend() {
653
+ const allTypes = new Set();
654
+ const colors = {};
655
+
656
+ panels.forEach(panel => {
657
+ if (panel.data && panel.viz) {
658
+ const items = panel.viz.getLegendItems();
659
+ items.forEach(item => {
660
+ allTypes.add(item.type);
661
+ colors[item.type] = item.color;
662
+ });
663
+ }
664
+ });
665
+
666
+ legend.innerHTML = '';
667
+ allTypes.forEach(type => {
668
+ const el = document.createElement('div');
669
+ el.className = 'legend-item';
670
+ el.innerHTML = `
671
+ <div class="legend-color" style="background-color: ${colors[type]}"></div>
672
+ <span class="legend-label">${type}</span>
673
+ `;
674
+ legend.appendChild(el);
675
+ });
676
+ }
677
+
678
+ function parseUrlState() {
679
+ const hash = window.location.hash.slice(1);
680
+ if (!hash) return { models: [], zoomLocked: true };
681
+
682
+ try {
683
+ const params = new URLSearchParams(hash);
684
+ const models = (params.get('models') || '').split(',').filter(Boolean);
685
+ const zoomLocked = params.get('lock') !== '0';
686
+ return { models, zoomLocked };
687
+ } catch {
688
+ return { models: [], zoomLocked: true };
689
+ }
690
+ }
691
+
692
+ function updateUrlState() {
693
+ const models = [];
694
+ panels.forEach(panel => {
695
+ models.push(panel.modelId || '');
696
+ });
697
+
698
+ const params = new URLSearchParams();
699
+ params.set('models', models.join(','));
700
+ params.set('lock', zoomLocked ? '1' : '0');
701
+
702
+ window.history.replaceState(null, '', `#${params.toString()}`);
703
+ }
704
+
705
+ if (document.readyState === 'loading') {
706
+ document.addEventListener('DOMContentLoaded', init);
707
+ } else {
708
+ init();
709
+ }
710
+ })();
frontend/js/treemap.js ADDED
@@ -0,0 +1,638 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LLM Scope - Pipeline Visualization
3
+ * Professional, minimalist architecture diagrams
4
+ */
5
+
6
+ class ModelTreeViz {
7
+ constructor(containerId) {
8
+ this.containerId = containerId;
9
+ this.container = document.getElementById(containerId);
10
+ this.pipelineData = null;
11
+ this.totalParams = 0;
12
+ this.globalMaxParams = null;
13
+
14
+ // Layout
15
+ this.baseNodeHeight = 48;
16
+ this.minNodeHeight = 36;
17
+ this.maxNodeHeight = 72;
18
+ this.nodeGap = 12;
19
+ this.containerPadding = 14;
20
+ this.containerGap = 8;
21
+ this.padding = 24;
22
+ this.branchGap = 24;
23
+
24
+ // Width
25
+ this.minNodeWidth = 110;
26
+ this.maxNodeWidth = 240;
27
+
28
+ this.transform = d3.zoomIdentity;
29
+
30
+ // Professional muted color palette
31
+ this.colors = {
32
+ embedding: '#d97706', // amber
33
+ attention: '#7c3aed', // violet
34
+ mlp: '#059669', // emerald
35
+ norm: '#0891b2', // cyan
36
+ head: '#db2777', // pink
37
+ layers: '#4f46e5', // indigo
38
+ encoder: '#6366f1', // indigo lighter
39
+ linear: '#6b7280', // gray
40
+ module: '#52525b', // zinc
41
+ model: '#3f3f46', // zinc darker
42
+ block: '#52525b',
43
+ pooler: '#0d9488', // teal
44
+ parallel: '#3f3f46',
45
+ default: '#52525b'
46
+ };
47
+
48
+ this._init();
49
+ }
50
+
51
+ _init() {
52
+ this.svg = d3.select(`#${this.containerId}`)
53
+ .append('svg')
54
+ .attr('width', '100%')
55
+ .attr('height', '100%');
56
+
57
+ this.zoom = d3.zoom()
58
+ .scaleExtent([0.2, 3])
59
+ .on('zoom', (event) => {
60
+ this.transform = event.transform;
61
+ this.g.attr('transform', event.transform);
62
+ });
63
+
64
+ this.svg.call(this.zoom);
65
+
66
+ this.g = this.svg.append('g');
67
+ this.containersGroup = this.g.append('g').attr('class', 'containers');
68
+ this.arrowsGroup = this.g.append('g').attr('class', 'arrows');
69
+ this.nodesGroup = this.g.append('g').attr('class', 'nodes');
70
+
71
+ // Arrow marker
72
+ this.svg.append('defs').append('marker')
73
+ .attr('id', `arrow-${this.containerId}`)
74
+ .attr('viewBox', '0 -4 8 8')
75
+ .attr('refX', 7)
76
+ .attr('refY', 0)
77
+ .attr('markerWidth', 5)
78
+ .attr('markerHeight', 5)
79
+ .attr('orient', 'auto')
80
+ .append('path')
81
+ .attr('d', 'M0,-4L8,0L0,4')
82
+ .attr('fill', '#52525b');
83
+
84
+ this.resizeObserver = new ResizeObserver(() => this._onResize());
85
+ this.resizeObserver.observe(this.container);
86
+ }
87
+
88
+ _onResize() {
89
+ if (this.pipelineData) {
90
+ this._render();
91
+ }
92
+ }
93
+
94
+ getColor(type) {
95
+ return this.colors[type] || this.colors.default;
96
+ }
97
+
98
+ _getNodeWidth(params) {
99
+ const refParams = this.globalMaxParams || this.totalParams;
100
+ if (refParams === 0 || params === 0) return this.minNodeWidth;
101
+ const ratio = params / refParams;
102
+ const scale = Math.sqrt(ratio);
103
+ return this.minNodeWidth + (this.maxNodeWidth - this.minNodeWidth) * scale;
104
+ }
105
+
106
+ _getNodeHeight(params) {
107
+ const refParams = this.globalMaxParams || this.totalParams;
108
+ if (refParams === 0 || params === 0) return this.minNodeHeight;
109
+ const ratio = params / refParams;
110
+ const scale = Math.pow(ratio, 0.25);
111
+ return this.minNodeHeight + (this.maxNodeHeight - this.minNodeHeight) * scale;
112
+ }
113
+
114
+ setData(data, globalMaxParams = null) {
115
+ this.pipelineData = data;
116
+ this.totalParams = data.params || 0;
117
+ this.globalMaxParams = globalMaxParams;
118
+ this._render();
119
+ }
120
+
121
+ _calcStepWidth(step) {
122
+ const nodeWidth = this._getNodeWidth(step.params || 0);
123
+
124
+ if (step.type === 'parallel' && step.branches) {
125
+ let totalWidth = 0;
126
+ for (const branch of step.branches) {
127
+ totalWidth += this._calcStepWidth(branch);
128
+ }
129
+ totalWidth += (step.branches.length - 1) * this.branchGap;
130
+ return Math.max(nodeWidth, totalWidth);
131
+ }
132
+
133
+ if (step.substeps && step._collapsed === false) {
134
+ let maxChildWidth = 0;
135
+ for (const sub of step.substeps) {
136
+ maxChildWidth = Math.max(maxChildWidth, this._calcStepWidth(sub));
137
+ }
138
+ return Math.max(nodeWidth, maxChildWidth + this.containerPadding * 2);
139
+ }
140
+
141
+ return nodeWidth;
142
+ }
143
+
144
+ _layoutPipeline(data) {
145
+ const nodes = [];
146
+ const arrows = [];
147
+ const containers = [];
148
+ const centerX = 350;
149
+
150
+ const layoutSteps = (steps, startY, parentCenterX) => {
151
+ if (!steps || steps.length === 0) {
152
+ return { endY: startY, lastNodes: [] };
153
+ }
154
+
155
+ let y = startY;
156
+ let prevNodes = [];
157
+
158
+ for (let i = 0; i < steps.length; i++) {
159
+ const step = steps[i];
160
+
161
+ if (step.type === 'parallel' && step.branches && step.branches.length > 1) {
162
+ const branchResults = [];
163
+ const branchWidths = step.branches.map(b => this._calcStepWidth(b));
164
+ const totalWidth = branchWidths.reduce((a, b) => a + b, 0) + (step.branches.length - 1) * this.branchGap;
165
+
166
+ let branchX = parentCenterX - totalWidth / 2;
167
+ const branchStartY = y;
168
+
169
+ for (let bi = 0; bi < step.branches.length; bi++) {
170
+ const branch = step.branches[bi];
171
+ const branchWidth = branchWidths[bi];
172
+ const branchCenterX = branchX + branchWidth / 2;
173
+
174
+ const result = layoutSteps([branch], branchStartY, branchCenterX);
175
+ branchResults.push({
176
+ result,
177
+ centerX: branchCenterX,
178
+ firstNode: nodes.find(n => n.data === branch)
179
+ });
180
+
181
+ branchX += branchWidth + this.branchGap;
182
+ }
183
+
184
+ for (const prev of prevNodes) {
185
+ for (const br of branchResults) {
186
+ if (br.firstNode) {
187
+ arrows.push({ source: prev, target: br.firstNode });
188
+ }
189
+ }
190
+ }
191
+
192
+ let maxEndY = y;
193
+ let allLastNodes = [];
194
+ for (const br of branchResults) {
195
+ maxEndY = Math.max(maxEndY, br.result.endY);
196
+ if (br.result.lastNodes) {
197
+ allLastNodes.push(...br.result.lastNodes);
198
+ }
199
+ }
200
+
201
+ y = maxEndY;
202
+ prevNodes = allLastNodes;
203
+ continue;
204
+ }
205
+
206
+ const nodeWidth = this._getNodeWidth(step.params || 0);
207
+ const nodeHeight = this._getNodeHeight(step.params || 0);
208
+ const hasSubsteps = !!(step.substeps && step.substeps.length > 0);
209
+ const isExpanded = hasSubsteps && step._collapsed === false;
210
+ const x = parentCenterX - nodeWidth / 2;
211
+
212
+ const node = {
213
+ data: step,
214
+ x: x,
215
+ y: y,
216
+ width: nodeWidth,
217
+ height: nodeHeight,
218
+ hasSubsteps: hasSubsteps,
219
+ collapsed: !isExpanded
220
+ };
221
+
222
+ nodes.push(node);
223
+
224
+ for (const prev of prevNodes) {
225
+ arrows.push({ source: prev, target: node });
226
+ }
227
+
228
+ y += nodeHeight;
229
+
230
+ if (isExpanded) {
231
+ const containerStartY = y + this.containerGap;
232
+ const childrenMaxWidth = Math.max(...step.substeps.map(s => this._calcStepWidth(s)));
233
+ const containerWidth = Math.max(nodeWidth, childrenMaxWidth) + this.containerPadding * 2;
234
+ const containerX = parentCenterX - containerWidth / 2;
235
+
236
+ const childResult = layoutSteps(
237
+ step.substeps,
238
+ containerStartY + this.containerPadding,
239
+ parentCenterX
240
+ );
241
+
242
+ const containerEndY = childResult.endY + this.containerPadding - this.nodeGap;
243
+
244
+ containers.push({
245
+ x: containerX,
246
+ y: containerStartY,
247
+ width: containerWidth,
248
+ height: containerEndY - containerStartY,
249
+ color: this.getColor(step.type)
250
+ });
251
+
252
+ const firstChild = nodes.find(n => n.data === step.substeps[0]);
253
+ if (firstChild) {
254
+ arrows.push({ source: node, target: firstChild });
255
+ }
256
+
257
+ y = containerEndY + this.containerGap;
258
+ prevNodes = [{
259
+ isContainerBottom: true,
260
+ containerBottomY: containerEndY,
261
+ centerX: parentCenterX
262
+ }];
263
+ } else {
264
+ y += this.nodeGap;
265
+ prevNodes = [node];
266
+ }
267
+ }
268
+
269
+ return { endY: y, lastNodes: prevNodes };
270
+ };
271
+
272
+ layoutSteps(data.steps || [], this.padding, centerX);
273
+
274
+ return { nodes, arrows, containers };
275
+ }
276
+
277
+ _render() {
278
+ if (!this.pipelineData) return;
279
+
280
+ const { nodes, arrows, containers } = this._layoutPipeline(this.pipelineData);
281
+ const arrowId = `arrow-${this.containerId}`;
282
+
283
+ // Containers
284
+ this.containersGroup.selectAll('.container-box').remove();
285
+ containers.forEach(container => {
286
+ this.containersGroup.append('rect')
287
+ .attr('class', 'container-box')
288
+ .attr('x', container.x)
289
+ .attr('y', container.y)
290
+ .attr('width', container.width)
291
+ .attr('height', container.height)
292
+ .attr('rx', 6)
293
+ .attr('ry', 6)
294
+ .attr('fill', 'rgba(24, 24, 27, 0.6)')
295
+ .attr('stroke', container.color)
296
+ .attr('stroke-width', 1)
297
+ .attr('stroke-opacity', 0.3);
298
+ });
299
+
300
+ // Arrows
301
+ this.arrowsGroup.selectAll('.arrow').remove();
302
+ arrows.forEach(arrow => {
303
+ let sx, sy;
304
+ if (arrow.source.isContainerBottom) {
305
+ sx = arrow.source.centerX;
306
+ sy = arrow.source.containerBottomY;
307
+ } else {
308
+ sx = arrow.source.x + arrow.source.width / 2;
309
+ sy = arrow.source.y + arrow.source.height;
310
+ }
311
+
312
+ const tx = arrow.target.x + arrow.target.width / 2;
313
+ const ty = arrow.target.y;
314
+
315
+ if (Math.abs(sx - tx) > 5) {
316
+ const midY = (sy + ty) / 2;
317
+ this.arrowsGroup.append('path')
318
+ .attr('class', 'arrow')
319
+ .attr('d', `M${sx},${sy} C${sx},${midY} ${tx},${midY} ${tx},${ty - 3}`)
320
+ .attr('fill', 'none')
321
+ .attr('stroke', '#3f3f46')
322
+ .attr('stroke-width', 1.5)
323
+ .attr('marker-end', `url(#${arrowId})`);
324
+ } else {
325
+ this.arrowsGroup.append('line')
326
+ .attr('class', 'arrow')
327
+ .attr('x1', sx)
328
+ .attr('y1', sy)
329
+ .attr('x2', tx)
330
+ .attr('y2', ty - 3)
331
+ .attr('stroke', '#3f3f46')
332
+ .attr('stroke-width', 1.5)
333
+ .attr('marker-end', `url(#${arrowId})`);
334
+ }
335
+ });
336
+
337
+ // Nodes
338
+ const nodeGroups = this.nodesGroup.selectAll('.node')
339
+ .data(nodes, (d, i) => d.data.name + '-' + i)
340
+ .join('g')
341
+ .attr('class', 'node')
342
+ .attr('transform', d => `translate(${d.x}, ${d.y})`)
343
+ .style('cursor', d => d.hasSubsteps ? 'pointer' : 'default');
344
+
345
+ nodeGroups.selectAll('rect')
346
+ .data(d => [d])
347
+ .join('rect')
348
+ .attr('width', d => d.width)
349
+ .attr('height', d => d.height)
350
+ .attr('rx', 6)
351
+ .attr('ry', 6)
352
+ .attr('fill', d => this.getColor(d.data.type))
353
+ .attr('stroke', d => d.hasSubsteps && d.collapsed ? 'rgba(255,255,255,0.25)' : 'none')
354
+ .attr('stroke-width', 1)
355
+ .attr('stroke-dasharray', d => d.hasSubsteps && d.collapsed ? '3,2' : 'none');
356
+
357
+ // Name
358
+ nodeGroups.selectAll('.node-name')
359
+ .data(d => [d])
360
+ .join('text')
361
+ .attr('class', 'node-name')
362
+ .attr('x', d => d.width / 2)
363
+ .attr('y', d => d.data.shape ? 13 : (d.height / 2 - 3))
364
+ .attr('text-anchor', 'middle')
365
+ .attr('fill', 'white')
366
+ .attr('font-size', '10px')
367
+ .attr('font-weight', '600')
368
+ .attr('font-family', '-apple-system, BlinkMacSystemFont, sans-serif')
369
+ .text(d => {
370
+ let name = d.data.name;
371
+ if (d.data.count) name += ` (${d.data.count}x)`;
372
+ return name;
373
+ })
374
+ .each(function(d) {
375
+ const textEl = d3.select(this);
376
+ let text = textEl.text();
377
+ const maxWidth = d.width - 20;
378
+ while (this.getComputedTextLength() > maxWidth && text.length > 0) {
379
+ text = text.slice(0, -1);
380
+ textEl.text(text + '...');
381
+ }
382
+ });
383
+
384
+ // Shape
385
+ nodeGroups.selectAll('.node-shape')
386
+ .data(d => d.data.shape ? [d] : [])
387
+ .join('text')
388
+ .attr('class', 'node-shape')
389
+ .attr('x', d => d.width / 2)
390
+ .attr('y', d => d.height / 2)
391
+ .attr('text-anchor', 'middle')
392
+ .attr('fill', 'rgba(255,255,255,0.7)')
393
+ .attr('font-size', '8px')
394
+ .attr('font-family', 'SF Mono, monospace')
395
+ .text(d => d.data.shape)
396
+ .each(function(d) {
397
+ const textEl = d3.select(this);
398
+ let text = textEl.text();
399
+ const maxWidth = d.width - 14;
400
+ while (this.getComputedTextLength() > maxWidth && text.length > 0) {
401
+ text = text.slice(0, -1);
402
+ textEl.text(text + '...');
403
+ }
404
+ });
405
+
406
+ // Params
407
+ nodeGroups.selectAll('.node-params')
408
+ .data(d => [d])
409
+ .join('text')
410
+ .attr('class', 'node-params')
411
+ .attr('x', d => d.width / 2)
412
+ .attr('y', d => d.data.shape ? d.height - 7 : (d.height / 2 + 10))
413
+ .attr('text-anchor', 'middle')
414
+ .attr('fill', 'rgba(255,255,255,0.5)')
415
+ .attr('font-size', '9px')
416
+ .attr('font-family', 'SF Mono, monospace')
417
+ .text(d => API.formatParams(d.data.params || 0));
418
+
419
+ // Expand indicator
420
+ nodeGroups.selectAll('.expand-indicator')
421
+ .data(d => d.hasSubsteps ? [d] : [])
422
+ .join('text')
423
+ .attr('class', 'expand-indicator')
424
+ .attr('x', d => d.width - 10)
425
+ .attr('y', 12)
426
+ .attr('fill', 'rgba(255,255,255,0.5)')
427
+ .attr('font-size', '8px')
428
+ .text(d => d.collapsed ? '+' : '-');
429
+
430
+ nodeGroups
431
+ .on('click', (event, d) => this._handleClick(event, d))
432
+ .on('mouseenter', (event, d) => this._handleMouseEnter(event, d))
433
+ .on('mouseleave', (event, d) => this._handleMouseLeave(event, d));
434
+
435
+ this._fitView(nodes, containers);
436
+ }
437
+
438
+ _fitView(nodes, containers) {
439
+ if (nodes.length === 0) return;
440
+
441
+ const rect = this.container.getBoundingClientRect();
442
+ const padding = 32;
443
+
444
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
445
+
446
+ for (const node of nodes) {
447
+ minX = Math.min(minX, node.x);
448
+ minY = Math.min(minY, node.y);
449
+ maxX = Math.max(maxX, node.x + node.width);
450
+ maxY = Math.max(maxY, node.y + node.height);
451
+ }
452
+
453
+ for (const container of containers) {
454
+ minX = Math.min(minX, container.x);
455
+ minY = Math.min(minY, container.y);
456
+ maxX = Math.max(maxX, container.x + container.width);
457
+ maxY = Math.max(maxY, container.y + container.height);
458
+ }
459
+
460
+ const contentWidth = maxX - minX + padding * 2;
461
+ const contentHeight = maxY - minY + padding * 2;
462
+
463
+ const scaleX = rect.width / contentWidth;
464
+ const scaleY = rect.height / contentHeight;
465
+ const scale = Math.min(scaleX, scaleY, 1.5);
466
+
467
+ const translateX = (rect.width - contentWidth * scale) / 2 - minX * scale + padding * scale;
468
+ const translateY = (rect.height - contentHeight * scale) / 2 - minY * scale + padding * scale;
469
+
470
+ this.svg.transition().duration(250).call(
471
+ this.zoom.transform,
472
+ d3.zoomIdentity.translate(translateX, translateY).scale(scale)
473
+ );
474
+ }
475
+
476
+ _handleClick(event, d) {
477
+ event.stopPropagation();
478
+ if (d.hasSubsteps) {
479
+ d.data._collapsed = d.data._collapsed === false ? true : false;
480
+ this._render();
481
+ }
482
+ }
483
+
484
+ _handleMouseEnter(event, d) {
485
+ this._showTooltip(event, d);
486
+ }
487
+
488
+ _handleMouseLeave(event, d) {
489
+ this._hideTooltip();
490
+ }
491
+
492
+ _showTooltip(event, d) {
493
+ const tooltip = document.getElementById('tooltip');
494
+ const params = d.data.params || 0;
495
+ const refParams = this.globalMaxParams || this.totalParams;
496
+ const percent = refParams > 0 ? ((params / refParams) * 100).toFixed(1) : '0';
497
+
498
+ let extra = '';
499
+ if (d.data.shape) {
500
+ extra += `<div class="tooltip-row"><span class="tooltip-label">Shape</span><span class="tooltip-value">${d.data.shape}</span></div>`;
501
+ }
502
+ if (d.data.count) {
503
+ extra += `<div class="tooltip-row"><span class="tooltip-label">Layers</span><span class="tooltip-value">${d.data.count}</span></div>`;
504
+ }
505
+ if (d.data.substeps && d.data.substeps.length > 0) {
506
+ extra += `<div class="tooltip-row"><span class="tooltip-label">Components</span><span class="tooltip-value">${d.data.substeps.length}</span></div>`;
507
+ }
508
+
509
+ tooltip.innerHTML = `
510
+ <div class="tooltip-title">${d.data.name}</div>
511
+ <div class="tooltip-content">
512
+ <div class="tooltip-row"><span class="tooltip-label">Parameters</span><span class="tooltip-value">${API.formatParams(params)}</span></div>
513
+ <div class="tooltip-row"><span class="tooltip-label">Proportion</span><span class="tooltip-value">${percent}%</span></div>
514
+ <div class="tooltip-row"><span class="tooltip-label">Type</span><span class="tooltip-value">${d.data.type}</span></div>
515
+ ${extra}
516
+ </div>
517
+ `;
518
+
519
+ tooltip.classList.remove('hidden');
520
+
521
+ const pad = 10;
522
+ let x = event.clientX + pad;
523
+ let y = event.clientY + pad;
524
+
525
+ const tooltipRect = tooltip.getBoundingClientRect();
526
+ if (x + tooltipRect.width > window.innerWidth) x = event.clientX - tooltipRect.width - pad;
527
+ if (y + tooltipRect.height > window.innerHeight) y = event.clientY - tooltipRect.height - pad;
528
+
529
+ tooltip.style.left = `${x}px`;
530
+ tooltip.style.top = `${y}px`;
531
+ }
532
+
533
+ _hideTooltip() {
534
+ document.getElementById('tooltip').classList.add('hidden');
535
+ }
536
+
537
+ getLegendItems() {
538
+ if (!this.pipelineData || !this.pipelineData.steps) return [];
539
+
540
+ const types = new Set();
541
+ const collect = (steps) => {
542
+ if (!steps) return;
543
+ for (const step of steps) {
544
+ if (step.type) types.add(step.type);
545
+ if (step.substeps) collect(step.substeps);
546
+ if (step.branches) {
547
+ for (const branch of step.branches) {
548
+ if (branch.type) types.add(branch.type);
549
+ if (branch.substeps) collect(branch.substeps);
550
+ }
551
+ }
552
+ }
553
+ };
554
+ collect(this.pipelineData.steps);
555
+
556
+ return Array.from(types).map(type => ({ type, color: this.getColor(type) }));
557
+ }
558
+
559
+ /**
560
+ * Export SVG with watermark
561
+ */
562
+ exportSVG(modelName) {
563
+ const svgElement = this.svg.node();
564
+ const clone = svgElement.cloneNode(true);
565
+
566
+ // Get bounds
567
+ const rect = this.container.getBoundingClientRect();
568
+ clone.setAttribute('width', rect.width);
569
+ clone.setAttribute('height', rect.height);
570
+ clone.setAttribute('viewBox', `0 0 ${rect.width} ${rect.height}`);
571
+
572
+ // Add background
573
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
574
+ bg.setAttribute('width', '100%');
575
+ bg.setAttribute('height', '100%');
576
+ bg.setAttribute('fill', '#0a0a0b');
577
+ clone.insertBefore(bg, clone.firstChild);
578
+
579
+ // Add watermark
580
+ const watermark = document.createElementNS('http://www.w3.org/2000/svg', 'text');
581
+ watermark.setAttribute('x', rect.width - 10);
582
+ watermark.setAttribute('y', rect.height - 10);
583
+ watermark.setAttribute('text-anchor', 'end');
584
+ watermark.setAttribute('fill', '#3f3f46');
585
+ watermark.setAttribute('font-size', '11');
586
+ watermark.setAttribute('font-family', '-apple-system, sans-serif');
587
+ watermark.textContent = 'omarkamali.com/llmscope';
588
+ clone.appendChild(watermark);
589
+
590
+ // Add model name
591
+ if (modelName) {
592
+ const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
593
+ title.setAttribute('x', '10');
594
+ title.setAttribute('y', '20');
595
+ title.setAttribute('fill', '#fafafa');
596
+ title.setAttribute('font-size', '12');
597
+ title.setAttribute('font-weight', '600');
598
+ title.setAttribute('font-family', '-apple-system, sans-serif');
599
+ title.textContent = modelName;
600
+ clone.appendChild(title);
601
+ }
602
+
603
+ const serializer = new XMLSerializer();
604
+ const svgString = serializer.serializeToString(clone);
605
+ const blob = new Blob([svgString], { type: 'image/svg+xml' });
606
+
607
+ return blob;
608
+ }
609
+
610
+ resetZoom() {
611
+ if (this.pipelineData) {
612
+ const expandAll = (steps) => {
613
+ if (!steps) return;
614
+ for (const step of steps) {
615
+ step._collapsed = false;
616
+ if (step.substeps) expandAll(step.substeps);
617
+ if (step.branches) {
618
+ for (const branch of step.branches) {
619
+ branch._collapsed = false;
620
+ if (branch.substeps) expandAll(branch.substeps);
621
+ }
622
+ }
623
+ }
624
+ };
625
+ if (this.pipelineData.steps) {
626
+ expandAll(this.pipelineData.steps);
627
+ }
628
+ this._render();
629
+ }
630
+ }
631
+
632
+ destroy() {
633
+ if (this.resizeObserver) this.resizeObserver.disconnect();
634
+ this.svg.remove();
635
+ }
636
+ }
637
+
638
+ const TreemapViz = ModelTreeViz;