File size: 17,896 Bytes
81d60af
 
c8aec40
 
 
 
 
c1b6928
 
 
81d60af
 
 
 
 
 
f56c2df
81d60af
f56c2df
81d60af
 
 
 
 
 
 
 
 
 
 
 
 
f56c2df
 
 
 
 
 
 
 
c8aec40
f56c2df
81d60af
b342230
c8aec40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b342230
 
 
81d60af
 
 
 
 
c8aec40
81d60af
 
 
 
c8aec40
81d60af
c8aec40
 
 
 
 
f56c2df
 
c8aec40
 
 
81d60af
 
c8aec40
 
 
 
 
 
 
 
 
81d60af
 
c1b6928
f56c2df
 
c1b6928
c8aec40
81d60af
c8aec40
81d60af
 
 
c1b6928
c8aec40
81d60af
 
 
 
 
 
 
 
c8aec40
81d60af
c8aec40
81d60af
 
 
 
c8aec40
81d60af
c1b6928
 
 
c8aec40
81d60af
 
 
 
 
c8aec40
81d60af
 
c8aec40
 
81d60af
 
c1b6928
c8aec40
81d60af
 
 
c8aec40
 
c1b6928
 
 
 
 
 
 
 
c8aec40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f56c2df
c1b6928
81d60af
 
 
 
 
 
 
f56c2df
81d60af
f56c2df
81d60af
 
f56c2df
81d60af
 
 
c8aec40
 
81d60af
 
c1b6928
f56c2df
 
 
 
c1b6928
81d60af
 
 
f56c2df
c8aec40
f56c2df
81d60af
c8aec40
 
 
f56c2df
c8aec40
 
f56c2df
c8aec40
 
 
 
 
 
81d60af
 
c8aec40
 
 
 
f56c2df
c1b6928
81d60af
 
 
 
f56c2df
c1b6928
c8aec40
f56c2df
 
81d60af
 
 
 
 
c1b6928
81d60af
 
 
 
 
 
0105df7
 
 
 
 
 
 
81d60af
 
 
 
 
 
 
 
 
 
 
c8aec40
81d60af
 
 
c8aec40
81d60af
 
c1b6928
81d60af
 
 
 
f56c2df
c1b6928
c8aec40
f56c2df
 
 
c8aec40
f56c2df
 
c8aec40
 
 
 
 
f56c2df
81d60af
c8aec40
81d60af
 
 
c1b6928
c8aec40
 
 
 
 
 
 
 
c1b6928
f56c2df
 
81d60af
c8aec40
 
 
 
 
f56c2df
c8aec40
81d60af
 
 
c8aec40
 
 
 
 
 
 
 
 
 
81d60af
 
 
 
 
 
c8aec40
81d60af
 
b342230
c8aec40
81d60af
c8aec40
 
 
 
 
 
81d60af
 
 
 
 
c8aec40
81d60af
 
 
 
c8aec40
81d60af
 
b342230
 
c8aec40
b342230
 
 
c8aec40
b342230
 
c8aec40
 
 
 
 
 
 
 
 
 
 
b342230
 
 
81d60af
 
c8aec40
81d60af
 
 
c8aec40
81d60af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8aec40
 
 
 
 
c1b6928
c8aec40
81d60af
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# ui/tab_analyze.py
"""
Tab2: Analyze a single model
- Auto-infer structure via LayerProfile
- Filter layers by start_layer / end_layer (raw index)
- Compute all Wang's Five Laws metrics per head
- Write results to SQLite if admin token is valid (read-only for reviewers)

Fix: upsert_model / upsert_component are now written AFTER shard headers load
     successfully, preventing dirty DB entries from mistyped model names.
"""

import gradio as gr
import requests
import pandas as pd
import numpy as np
from datetime import datetime

from core.debug import dlog
from core.fetcher import (
    load_all_shard_headers,
    load_tensor_remote,
    get_file_url,
    check_quantization,
    http_error_msg,
)
from core.layer_profile import (
    scan_model_structure,
    extract_config_params,
)
from core.metrics import analyze_layer, summarize_records

from db.schema import init_db
from db.writer import (
    upsert_model,
    upsert_component,
    write_layer_records,
    update_model_summary,
    get_analyzed_layers,
    infer_layer_type,
    check_write_permission,
)

SIDEBAR_MD = """
### Recommended Models
`google/gemma-4-e2b`  
`google/gemma-4-e4b-it`  
`google/gemma-4-31b-it`  
`Qwen/Qwen2.5-14B-Instruct`  
`deepseek-ai/DeepSeek-R1-Distill-Qwen-14B`  
`meta-llama/Meta-Llama-3-8B`  

---

### Layer Index
- Layer index = **N** in `layers.{N}` of safetensors keys
- Raw index, **not re-numbered per component**
- Multi-modal models (e.g. Gemma-4):
  - `layers.0~11` may contain audio / vision / text layers
  - All components output separately, distinguished by prefix

### Example: Gemma-4-E2B
| Component | Layer Range |
|-----------|-------------|
| audio_tower | 0 ~ 11 |
| language_model | 0 ~ 34 |
| vision_tower | 0 ~ 15 |

### Example: Gemma-4-31B
| Component | Layer Range |
|-----------|-------------|
| language (local) | 0 ~ 59 |
| language (global) | 5, 11, 17 โ€ฆ 59 |
| vision_tower | 0 ~ 26 |

---

### Reviewer Note
Leave **Admin Write Token** empty to run the full analysis  
without writing to the database.  
All metrics are computed and displayed normally.
"""


def run_analysis(
    model_id:    str,
    hf_token:    str,
    start_layer: int,
    end_layer:   int,
    admin_token: str,
    progress=gr.Progress()
) -> tuple[str, pd.DataFrame]:

    if not model_id.strip():
        return "โŒ Please enter a model ID.", None

    token      = hf_token.strip() or None
    start_l    = int(start_layer)
    end_l      = int(end_layer)
    t_start    = datetime.utcnow()
    can_write  = check_write_permission(admin_token)

    log = [
        f"๐Ÿ” Analyzing: {model_id}  layers {start_l}~{end_l}\n"
        f"{'โ•'*80}\n"
        f"๐Ÿ’พ Database write: {'โœ… ENABLED (admin)' if can_write else '๐Ÿ”’ DISABLED (read-only mode)'}\n"
        f"{'โ•'*80}\n"
    ]

    if not can_write:
        log.append(
            "โ„น๏ธ  Running in read-only mode.\n"
            "   Analysis will run normally. Results displayed below but NOT saved to DB.\n"
            "   Reviewers: this is intentional โ€” full reproducibility without DB access.\n"
            f"{'โ”€'*80}\n"
        )

    all_records: list[dict] = []

    # โ”€โ”€ DB connection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    conn = init_db()

    # โ”€โ”€ Quantization check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    progress(0.02, desc="Checking quantization...")
    blocked, qmsg = check_quantization(model_id, token)
    log.append(f"[Quantization Check]\n{qmsg}\n{'โ”€'*80}\n")
    if blocked:
        return "".join(log), None

    # โ”€โ”€ config.json โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    progress(0.05, desc="Reading config...")
    config_params = {}
    try:
        r = requests.get(
            f"https://huggingface.co/{model_id}/resolve/main/config.json",
            headers={"Authorization": f"Bearer {token}"} if token else {},
            timeout=15
        )
        if r.status_code == 200:
            config_params = extract_config_params(r.json())
            log.append(
                f"๐Ÿ“‹ Config: model_type={config_params.get('model_type')}  "
                f"head_dim={config_params.get('head_dim')}\n"
                f"{'โ”€'*80}\n"
            )
    except Exception:
        log.append("โš ๏ธ  Could not read config.json\n")

    # โ”€โ”€ Load all shard headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    # NOTE: upsert_model is intentionally called AFTER this succeeds.
    # This prevents dirty DB entries when a model name is mistyped (404 here).
    progress(0.08, desc="Loading shard headers...")
    try:
        all_headers = load_all_shard_headers(model_id, token)
    except requests.exceptions.HTTPError as e:
        return http_error_msg(e, model_id), None
    except Exception as e:
        return "".join(log) + f"โŒ Failed to load headers: {e}\n", None

    log.append(
        f"๐Ÿ“ฆ Shards: {len(all_headers)}  "
        f"Total keys: {sum(len(h) for h,_ in all_headers.values())}\n"
    )

    # โ”€โ”€ Scan layer structure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    progress(0.12, desc="Scanning layer structure...")
    profiles = scan_model_structure(all_headers, config_params)

    if not profiles:
        return "".join(log) + "โš ๏ธ No Q/K/V layers found.\n", None

    # โ”€โ”€ Write model metadata (admin only) โ€” AFTER successful header load โ”€โ”€โ”€โ”€โ”€โ”€
    # Model name is now confirmed valid (HF returned real shard headers).
    if can_write:
        model_type = config_params.get("model_type", "unknown")
        upsert_model(conn, model_id, model_type=model_type)
        log.append(f"๐Ÿ’พ Model registered in DB: {model_id}\n")

    # โ”€โ”€ Write component metadata (admin only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if can_write:
        by_prefix: dict[str, list] = {}
        for (pfx, idx), prof in profiles.items():
            by_prefix.setdefault(pfx, []).append(prof)

        for pfx, profs in by_prefix.items():
            complete_profs = [p for p in profs if p.complete]
            if not complete_profs:
                continue
            head_dims  = [p.head_dim for p in complete_profs]
            has_shared = any(p.kv_shared for p in complete_profs)
            d_models   = [p.d_model for p in complete_profs if p.d_model > 0]
            upsert_component(
                conn          = conn,
                model_id      = model_id,
                prefix        = pfx,
                n_layers      = len(complete_profs),
                head_dim_min  = min(head_dims),
                head_dim_max  = max(head_dims),
                has_kv_shared = has_shared,
                has_global    = has_shared,
                d_model       = d_models[0] if d_models else 0,
            )

    # โ”€โ”€ Filter by layer range โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    filtered = {
        (pfx, idx): prof
        for (pfx, idx), prof in profiles.items()
        if start_l <= idx <= end_l and prof.complete
    }

    if not filtered:
        by_pfx_all: dict[str, list] = {}
        for (pfx, idx) in profiles:
            by_pfx_all.setdefault(pfx, []).append(idx)
        info = "\n".join(
            f"  '{p}': {sorted(v)}"
            for p, v in sorted(by_pfx_all.items())
        )
        return (
            "".join(log) +
            f"โš ๏ธ No complete layers found in range {start_l}~{end_l}.\n"
            f"Available layer indices:\n{info}\n", None
        )

    # โ”€โ”€ Resume check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    done_layers: dict[str, set] = {}
    for pfx in set(pfx for pfx, _ in filtered):
        done_layers[pfx] = get_analyzed_layers(conn, model_id, pfx)

    # โ”€โ”€ Print analysis plan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    by_pfx2: dict[str, list] = {}
    for (pfx, idx) in filtered:
        by_pfx2.setdefault(pfx, []).append(idx)

    log.append("๐Ÿ“ Analysis plan:\n")
    skipped_total = 0
    for pfx, idxs in sorted(by_pfx2.items()):
        done = done_layers.get(pfx, set())
        todo = [i for i in sorted(idxs) if i not in done]
        skip = [i for i in sorted(idxs) if i in done]
        skipped_total += len(skip)
        log.append(f"  [{pfx}]\n")
        log.append(f"    To analyze : {todo}\n")
        if skip:
            log.append(
                f"    Skipped (resume): {skip}\n"
                if can_write else
                f"    Already in DB   : {skip}  "
                f"(read-only: will re-compute but not save)\n"
            )
    log.append(f"{'โ•'*80}\n")

    if can_write and skipped_total > 0:
        log.append(
            f"โšก Resume: skipping {skipped_total} already-analyzed layers.\n"
        )

    # โ”€โ”€ Layer-by-layer analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    sorted_items = sorted(filtered.items(), key=lambda x: (x[0][0], x[0][1]))
    total = len(sorted_items)

    for i, ((pfx, idx), prof) in enumerate(sorted_items):

        # Resume skip (only in write mode)
        if can_write and idx in done_layers.get(pfx, set()):
            continue

        progress(
            0.15 + 0.80 * i / max(total, 1),
            desc=f"{pfx.split('.')[-2] if '.' in pfx else pfx} L{idx}..."
        )

        # โ”€โ”€ Load Q / K / V โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
        try:
            q_url = get_file_url(model_id, prof.q.shard)
            k_url = get_file_url(model_id, prof.k.shard)
            q_hdr, q_hs = all_headers[prof.q.shard]
            k_hdr, k_hs = all_headers[prof.k.shard]

            dlog(log,
                f"Layer {idx}:\n"
                f"  q: {prof.q.shard} โ†’ {prof.q.key}\n"
                f"  k: {prof.k.shard} โ†’ {prof.k.key}\n"
                f"  v: {prof.v.shard + ' โ†’ ' + prof.v.key if prof.v else 'K=V shared'}\n"
            )

            W_q = load_tensor_remote(q_url, prof.q.key, q_hdr, q_hs, token)
            W_k = load_tensor_remote(k_url, prof.k.key, k_hdr, k_hs, token)

            if prof.kv_shared:
                W_v = W_k.clone()
            else:
                v_url = get_file_url(model_id, prof.v.shard)
                v_hdr, v_hs = all_headers[prof.v.shard]
                W_v = load_tensor_remote(v_url, prof.v.key, v_hdr, v_hs, token)

        except Exception as e:
            log.append(f"[{pfx}] Layer {idx}: โŒ Load failed: {e}\n")
            continue

        if W_q is None or W_k is None or W_v is None:
            log.append(f"[{pfx}] Layer {idx}: โš ๏ธ Tensor is None\n")
            continue

        # โ”€โ”€ Compute Five Laws โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
        try:
            records, layer_log = analyze_layer(W_q, W_k, W_v, prof)
            all_records.extend(records)
            log.append(layer_log)

            # โ”€โ”€ Write to DB (admin only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
            if can_write and records:
                write_layer_records(conn, model_id, records)
                update_model_summary(conn, model_id, pfx)
                log.append(
                    f"  โœ… Saved to DB: {len(records)} records "
                    f"[{pfx}] Layer {idx}\n"
                )
            elif not can_write and records:
                log.append(
                    f"  ๐Ÿ“Š Computed: {len(records)} records "
                    f"[{pfx}] Layer {idx}  (read-only, not saved)\n"
                )

        except Exception as e:
            log.append(f"[{pfx}] Layer {idx}: โŒ Compute failed: {e}\n")
        finally:
            del W_q, W_k, W_v

    # โ”€โ”€ Update elapsed time (admin only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if can_write:
        elapsed = (datetime.utcnow() - t_start).total_seconds()
        conn.execute(
            "UPDATE models SET analyze_sec = ? WHERE model_id = ?",
            (elapsed, model_id)
        )
        conn.commit()

    # โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    elapsed = (datetime.utcnow() - t_start).total_seconds()

    if not all_records:
        msg = (
            "\nโšก All layers already in DB (resume mode). "
            "See Leaderboard or Database tab.\n"
            if can_write else
            "\nโš ๏ธ No records computed.\n"
        )
        return "".join(log) + msg, None

    summary = summarize_records(all_records, model_id)
    log.append(summary)
    log.append(
        f"\nโฑ๏ธ  Elapsed: {elapsed:.1f}s\n"
        f"{'โ•'*80}\n"
    )

    if not can_write:
        log.append(
            "๐Ÿ”’ Read-only mode: results above are NOT saved to the database.\n"
            "   To save, provide a valid Admin Write Token.\n"
        )

    df = pd.DataFrame(all_records)
    return "".join(log), df


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Tab2 UI
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def build_tab_analyze():
    with gr.Tab("๐Ÿ“Š Analyze"):
        gr.Markdown("""
        **Step 2: Select layer range and compute Wang's Five Laws metrics.**  
        Layer index = raw **N** in `layers.{N}` of safetensors keys.  
        K=V shared layers (e.g. Gemma-4 global layers) are handled automatically.  
        โšก **Resume supported**: already-analyzed layers are skipped automatically.

        > ็ฌฌไบŒๆญฅ๏ผš้€‰ๆ‹ฉๅฑ‚่Œƒๅ›ด๏ผŒ่ฎก็ฎ—็Ž‹ๆฐไบ”ๅฎšๅพ‹ๅ…จๆŒ‡ๆ ‡ใ€‚ๆ”ฏๆŒๆ–ญ็‚น็ปญไผ ๏ผŒๅทฒๅˆ†ๆžๅฑ‚่‡ชๅŠจ่ทณ่ฟ‡ใ€‚
        """)

        with gr.Row():
            with gr.Column(scale=3):
                model_id_input = gr.Textbox(
                    label="HuggingFace Model ID",
                    placeholder="google/gemma-4-e2b",
                    value="google/gemma-4-e2b"
                )
                token_input = gr.Textbox(
                    label="HF Access Token (leave empty for public models)",
                    type="password"
                )
                with gr.Row():
                    start_input = gr.Number(
                        label="Start Layer (inclusive)",
                        value=0, minimum=0, maximum=9999, precision=0
                    )
                    end_input = gr.Number(
                        label="End Layer (inclusive)",
                        value=5, minimum=0, maximum=9999, precision=0
                    )
                admin_token_input = gr.Textbox(
                    label="Admin Write Token",
                    placeholder="Leave empty to run analysis without saving to database",
                    type="password",
                    info=(
                        "Reviewers: leave empty. "
                        "Analysis runs fully โ€” results shown below but not saved to DB. "
                        "| ๅฎก็จฟไบบ่ฏท็•™็ฉบ๏ผŒๅˆ†ๆžๆญฃๅธธ่ฟ่กŒ๏ผŒ็ป“ๆžœไธๅ†™ๅ…ฅๆ•ฐๆฎๅบ“ใ€‚"
                    )
                )
                analyze_btn = gr.Button("๐Ÿš€ Start Analysis", variant="primary")

            with gr.Column(scale=1):
                gr.Markdown(SIDEBAR_MD)

        analyze_log = gr.Textbox(
            label="Analysis Log (per-head details)",
            lines=35, max_lines=300
        )
        analyze_table = gr.Dataframe(
            label="Per-head metrics (all Five Laws)",
            headers=[
                "prefix", "layer", "kv_head", "q_head", "kv_shared",
                "pearson_QK", "spearman_QK", "pearson_QV", "pearson_KV",
                "ssr_QK", "ssr_QV", "ssr_KV",
                "cosU_QK", "cosU_QV", "cosU_KV",
                "cosV_QK", "cosV_QV", "cosV_KV",
                "alpha_QK", "alpha_QV", "alpha_KV",
                "alpha_res_QK", "alpha_res_QV", "alpha_res_KV",
                "sigma_max_Q", "sigma_min_Q",
                "sigma_max_K", "sigma_min_K",
                "sigma_max_V", "sigma_min_V",
                "cond_Q", "cond_K", "cond_V",
                "head_dim", "d_model", "n_q_heads", "n_kv_heads",
            ]
        )

        analyze_btn.click(
            fn=run_analysis,
            inputs=[
                model_id_input,
                token_input,
                start_input,
                end_input,
                admin_token_input,
            ],
            outputs=[analyze_log, analyze_table]
        )

    return model_id_input, token_input