Alex W. commited on
Commit
c8aec40
·
1 Parent(s): 6f797b1

| 文件 | 改动 |

Browse files

|------|------|
| `db/writer.py` | 末尾追加 `check_write_permission()`,其余不变 |
| `ui/tab_analyze.py` | 完整重写:加 `admin_token` 参数,所有写库操作加 `can_write` 判断,日志改英文 |

Files changed (2) hide show
  1. db/writer.py +29 -1
  2. ui/tab_analyze.py +203 -148
db/writer.py CHANGED
@@ -10,6 +10,7 @@ import sqlite3
10
  import numpy as np
11
  from datetime import datetime
12
  from db.schema import get_connection, init_db
 
13
 
14
 
15
  # ─────────────────────────────────────────────
@@ -372,4 +373,31 @@ def update_model_summary(
372
  summary
373
  )
374
 
375
- conn.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  import numpy as np
11
  from datetime import datetime
12
  from db.schema import get_connection, init_db
13
+ import os
14
 
15
 
16
  # ─────────────────────────────────────────────
 
373
  summary
374
  )
375
 
376
+ conn.commit()
377
+
378
+ # 在 db/writer.py 末尾追加
379
+
380
+
381
+
382
+ # ─────────────────────────────────────────────
383
+ # 写入权限验证
384
+ # ─────────────────────────────────────────────
385
+
386
+ def check_write_permission(admin_token: str) -> bool:
387
+ """
388
+ 验证管理员写入权限。
389
+
390
+ 原理:
391
+ - WRITE_TOKEN 存储在 HF Space Secrets(加密,不进入 git repo)
392
+ - 运行时由 HF 注入为环境变量
393
+ - 只在服务端比对,不返回给前端
394
+
395
+ 返回:
396
+ - True = 有写入权限
397
+ - False = 只读模式(分析可以跑,结果不写库)
398
+ """
399
+ server_token = os.environ.get("WRITE_TOKEN", "")
400
+ if not server_token:
401
+ # 服务端未配置 WRITE_TOKEN → 拒绝所有写入
402
+ return False
403
+ return admin_token.strip() == server_token
ui/tab_analyze.py CHANGED
@@ -1,10 +1,10 @@
1
  # ui/tab_analyze.py
2
  """
3
- Tab2:分析单个模型
4
- - 使用 LayerProfile 自动推断结构
5
- - start_layer / end_layer 按原始层号过滤
6
- - 逐头计算五定律全指标
7
- - 结果写入 SQLite(断点续传,以 prefix+layer 为粒度)
8
  """
9
 
10
  import gradio as gr
@@ -34,39 +34,48 @@ from db.writer import (
34
  write_layer_records,
35
  update_model_summary,
36
  get_analyzed_layers,
37
- is_layer_complete,
38
  infer_layer_type,
 
39
  )
40
 
41
  SIDEBAR_MD = """
42
- ### 推荐模型
43
- google/gemma-4-e2b
44
- google/gemma-4-e4b-it
45
- google/gemma-4-31b-it
46
- Qwen/Qwen2.5-14B-Instruct
47
- deepseek-ai/DeepSeek-R1-Distill-Qwen-14B
48
- meta-llama/Meta-Llama-3-8B
49
-
50
- ### 层号说明
51
- - 层号 = safetensors key 中 `layers.{N}` 的 **N**
52
- - **不按组件重排**,原始值直接输出
53
- - 混合模态模型(如 Gemma-4):
54
- - `layers.0~11` 同时含 audio/vision/text
55
- - 全部输出,按前缀区分组件
56
-
57
- ### 示例:Gemma-4-E2B
58
- | 组件 | 层范围 |
59
- |------|--------|
60
- | audio_tower | 0~11 |
61
- | language_model | 0~34 |
62
- | vision_tower | 0~15 |
63
-
64
- ### 示例:Gemma-4-31B
65
- | 组件 | 层范围 |
66
- |------|--------|
67
- | language(局部层) | 0~59 |
68
- | language(全局层) | 5,11,17...59 |
69
- | vision_tower | 0~26 |
 
 
 
 
 
 
 
 
 
70
  """
71
 
72
 
@@ -75,37 +84,49 @@ def run_analysis(
75
  hf_token: str,
76
  start_layer: int,
77
  end_layer: int,
 
78
  progress=gr.Progress()
79
  ) -> tuple[str, pd.DataFrame]:
80
 
81
  if not model_id.strip():
82
- return "❌ 请输入模型 ID", None
83
 
84
- token = hf_token.strip() or None
85
- start_l = int(start_layer)
86
- end_l = int(end_layer)
87
- t_start = datetime.utcnow()
 
88
 
89
  log = [
90
- f"🔍 分析:{model_id} {start_l}~{end_l}\n"
 
 
91
  f"{'═'*80}\n"
92
  ]
 
 
 
 
 
 
 
 
 
93
  all_records: list[dict] = []
94
 
95
- # ── 初始化数据库连接 ──────────────────────────
96
  conn = init_db()
97
 
98
- # ── 量化检测 ─────────────────────────────────
99
- progress(0.02, desc="量化检测...")
100
  blocked, qmsg = check_quantization(model_id, token)
101
- log.append(f"【量化检测】\n{qmsg}\n{'─'*80}\n")
102
  if blocked:
103
  return "".join(log), None
104
 
105
- # ── config.json ───────────────────────────────
106
- progress(0.05, desc="读取 config...")
107
  config_params = {}
108
- config_raw = {}
109
  try:
110
  r = requests.get(
111
  f"https://huggingface.co/{model_id}/resolve/main/config.json",
@@ -113,70 +134,67 @@ def run_analysis(
113
  timeout=15
114
  )
115
  if r.status_code == 200:
116
- config_raw = r.json()
117
- config_params = extract_config_params(config_raw)
118
  log.append(
119
- f"📋 config:model_type={config_params.get('model_type')} "
120
  f"head_dim={config_params.get('head_dim')}\n"
121
  f"{'─'*80}\n"
122
  )
123
  except Exception:
124
- log.append("⚠️ 无法读取 config.json\n")
125
 
126
- # ── 写入模型元数据 ────────────────────────────
127
- model_type = config_params.get("model_type", "unknown")
128
- upsert_model(conn, model_id, model_type=model_type)
 
129
 
130
- # ── 读取所有 shard headers ────────────────────
131
- progress(0.08, desc="读取 shard headers...")
132
  try:
133
  all_headers = load_all_shard_headers(model_id, token)
134
  except requests.exceptions.HTTPError as e:
135
  return http_error_msg(e, model_id), None
136
  except Exception as e:
137
- return "".join(log) + f"❌ 读取失败:{e}\n", None
138
 
139
  log.append(
140
- f"📦 shard 数:{len(all_headers)} "
141
- f" key:{sum(len(h) for h,_ in all_headers.values())}\n"
142
  )
143
 
144
- # ── 扫描层结构 ────────────────────────────────
145
- progress(0.12, desc="扫描层结构...")
146
  profiles = scan_model_structure(all_headers, config_params)
147
 
148
  if not profiles:
149
- return "".join(log) + "⚠️ 未发现任何 Q/K/V \n", None
150
-
151
- # ── 按组件写入 components ──────────────────
152
- # 按 prefix 分组,统计组件信息
153
- by_prefix: dict[str, list] = {}
154
- for (pfx, idx), prof in profiles.items():
155
- by_prefix.setdefault(pfx, []).append(prof)
156
-
157
- for pfx, profs in by_prefix.items():
158
- complete_profs = [p for p in profs if p.complete]
159
- if not complete_profs:
160
- continue
161
-
162
- head_dims = [p.head_dim for p in complete_profs]
163
- has_shared = any(p.kv_shared for p in complete_profs)
164
- has_global = has_shared # kv_shared=True → global 层
165
- d_models = [p.d_model for p in complete_profs if p.d_model > 0]
166
-
167
- upsert_component(
168
- conn = conn,
169
- model_id = model_id,
170
- prefix = pfx,
171
- n_layers = len(complete_profs),
172
- head_dim_min = min(head_dims),
173
- head_dim_max = max(head_dims),
174
- has_kv_shared= has_shared,
175
- has_global = has_global,
176
- d_model = d_models[0] if d_models else 0,
177
- )
178
 
179
- # ── 按原始层号过滤 ────────────────────────────
180
  filtered = {
181
  (pfx, idx): prof
182
  for (pfx, idx), prof in profiles.items()
@@ -193,46 +211,51 @@ def run_analysis(
193
  )
194
  return (
195
  "".join(log) +
196
- f"⚠️ {start_l}~{end_l} 内无完整层\n"
197
- f"实际层号:\n{info}\n", None
198
  )
199
 
200
- # ── 断点续传:查询已完成层 ────────────────────
201
- # 按 prefix 分别查询
202
  done_layers: dict[str, set] = {}
203
  for pfx in set(pfx for pfx, _ in filtered):
204
  done_layers[pfx] = get_analyzed_layers(conn, model_id, pfx)
205
 
206
- # 打印将分析的层(含断点续传状态)
207
  by_pfx2: dict[str, list] = {}
208
  for (pfx, idx) in filtered:
209
  by_pfx2.setdefault(pfx, []).append(idx)
210
 
211
- log.append("📐 将分析:\n")
212
  skipped_total = 0
213
  for pfx, idxs in sorted(by_pfx2.items()):
214
- done = done_layers.get(pfx, set())
215
- todo = [i for i in sorted(idxs) if i not in done]
216
- skip = [i for i in sorted(idxs) if i in done]
217
  skipped_total += len(skip)
218
- log.append(f" '{pfx}'\n")
219
- log.append(f" 待分析:{todo}\n")
220
  if skip:
221
- log.append(f" 已跳过(断点续传):{skip}\n")
 
 
 
 
 
222
  log.append(f"{'═'*80}\n")
223
 
224
- if skipped_total > 0:
225
- log.append(f"⚡ 断点续传:跳过 {skipped_total} 层(已有数据)\n")
 
 
226
 
227
- # ── 逐层分析 ─────────────────────────────────
228
  sorted_items = sorted(filtered.items(), key=lambda x: (x[0][0], x[0][1]))
229
  total = len(sorted_items)
230
 
231
  for i, ((pfx, idx), prof) in enumerate(sorted_items):
232
 
233
- # 断点续传:该层已完成则跳过
234
- if idx in done_layers.get(pfx, set()):
235
- # 从数据库读取已有记录加入 all_records(用于最终展示)
236
  continue
237
 
238
  progress(
@@ -240,11 +263,10 @@ def run_analysis(
240
  desc=f"{pfx.split('.')[-2] if '.' in pfx else pfx} L{idx}..."
241
  )
242
 
243
- # ── 加载 Q/K/V ────────────────────────────
244
  try:
245
  q_url = get_file_url(model_id, prof.q.shard)
246
  k_url = get_file_url(model_id, prof.k.shard)
247
-
248
  q_hdr, q_hs = all_headers[prof.q.shard]
249
  k_hdr, k_hs = all_headers[prof.k.shard]
250
 
@@ -253,9 +275,6 @@ def run_analysis(
253
  f" q: {prof.q.shard} → {prof.q.key}\n"
254
  f" k: {prof.k.shard} → {prof.k.key}\n"
255
  f" v: {prof.v.shard + ' → ' + prof.v.key if prof.v else 'K=V shared'}\n"
256
- f" k_header_size={k_hs}\n"
257
- f" k_offsets={k_hdr[prof.k.key]['data_offsets']}\n"
258
- f" k_abs_start={8 + k_hs + k_hdr[prof.k.key]['data_offsets'][0]}"
259
  )
260
 
261
  W_q = load_tensor_remote(q_url, prof.q.key, q_hdr, q_hs, token)
@@ -269,102 +288,132 @@ def run_analysis(
269
  W_v = load_tensor_remote(v_url, prof.v.key, v_hdr, v_hs, token)
270
 
271
  except Exception as e:
272
- log.append(f"[{pfx}] Layer {idx}: ❌ 加载失败:{e}\n")
273
  continue
274
 
275
  if W_q is None or W_k is None or W_v is None:
276
- log.append(f"[{pfx}] Layer {idx}: ⚠️ tensor None\n")
277
  continue
278
 
279
- # ── 计算五定律 ────────────────────────────
280
  try:
281
  records, layer_log = analyze_layer(W_q, W_k, W_v, prof)
282
  all_records.extend(records)
283
  log.append(layer_log)
284
 
285
- # ── 写入数据库 ────────────────────────
286
- if records:
287
  write_layer_records(conn, model_id, records)
288
- # 每层写完立刻更新 summary(支持中途查看排行榜)
289
  update_model_summary(conn, model_id, pfx)
290
  log.append(
291
- f" ✅ 已写库:{len(records)} 条记录 "
292
  f"[{pfx}] Layer {idx}\n"
293
  )
 
 
 
 
 
294
 
295
  except Exception as e:
296
- log.append(f"[{pfx}] Layer {idx}: ❌ 计算失败:{e}\n")
297
  finally:
298
  del W_q, W_k, W_v
299
 
300
- # ── 更新分析耗时 ──────────────────────────────
 
 
 
 
 
 
 
 
 
301
  elapsed = (datetime.utcnow() - t_start).total_seconds()
302
- conn.execute(
303
- "UPDATE models SET analyze_sec = ? WHERE model_id = ?",
304
- (elapsed, model_id)
305
- )
306
- conn.commit()
307
 
308
- # ── 汇总 ─────────────────────────────────────
309
  if not all_records:
310
- # 可能全部是断点续传跳过的
311
- log.append(
312
- "\n⚡ 所有层均已完成(断点续传),"
313
- "请到「排行榜」或「数据库」Tab 查看结果\n"
 
314
  )
315
- return "".join(log), None
316
 
317
  summary = summarize_records(all_records, model_id)
318
  log.append(summary)
319
- log.append(f"\n⏱️ 本次耗时:{elapsed:.1f} 秒\n")
 
 
 
 
 
 
 
 
 
320
 
321
  df = pd.DataFrame(all_records)
322
  return "".join(log), df
323
 
324
 
325
  # ─────────────────────────────────────────────
326
- # Tab2 UI 组件
327
  # ─────────────────────────────────────────────
328
 
329
  def build_tab_analyze():
330
- with gr.Tab("📊 分析"):
331
  gr.Markdown("""
332
- **第二步:选择层范围,计算王氏五定律全指标**
333
- 层号 = safetensors key `layers.{N}` 的原始 N,K=V 共享层自动处理。
334
- **支持断点续传**:已分析的层自动跳过,随时中断随时继续。
 
 
 
335
  """)
336
 
337
  with gr.Row():
338
  with gr.Column(scale=3):
339
  model_id_input = gr.Textbox(
340
- label="HuggingFace 模型 ID",
341
  placeholder="google/gemma-4-e2b",
342
  value="google/gemma-4-e2b"
343
  )
344
  token_input = gr.Textbox(
345
- label="HF Access Token(公开模型可留空)",
346
  type="password"
347
  )
348
  with gr.Row():
349
  start_input = gr.Number(
350
- label="起始层号(含)",
351
  value=0, minimum=0, maximum=9999, precision=0
352
  )
353
  end_input = gr.Number(
354
- label="结束层号(含)",
355
  value=5, minimum=0, maximum=9999, precision=0
356
  )
357
- analyze_btn = gr.Button("🚀 开始分析", variant="primary")
 
 
 
 
 
 
 
 
 
 
358
 
359
  with gr.Column(scale=1):
360
  gr.Markdown(SIDEBAR_MD)
361
 
362
  analyze_log = gr.Textbox(
363
- label="分析日志(逐头详情)",
364
  lines=35, max_lines=300
365
  )
366
  analyze_table = gr.Dataframe(
367
- label="逐头全指标结果表",
368
  headers=[
369
  "prefix", "layer", "kv_head", "q_head", "kv_shared",
370
  "pearson_QK", "spearman_QK", "pearson_QV", "pearson_KV",
@@ -383,7 +432,13 @@ def build_tab_analyze():
383
 
384
  analyze_btn.click(
385
  fn=run_analysis,
386
- inputs=[model_id_input, token_input, start_input, end_input],
 
 
 
 
 
 
387
  outputs=[analyze_log, analyze_table]
388
  )
389
 
 
1
  # ui/tab_analyze.py
2
  """
3
+ Tab2: Analyze a single model
4
+ - Auto-infer structure via LayerProfile
5
+ - Filter layers by start_layer / end_layer (raw index)
6
+ - Compute all Wang's Five Laws metrics per head
7
+ - Write results to SQLite if admin token is valid (read-only for reviewers)
8
  """
9
 
10
  import gradio as gr
 
34
  write_layer_records,
35
  update_model_summary,
36
  get_analyzed_layers,
 
37
  infer_layer_type,
38
+ check_write_permission,
39
  )
40
 
41
  SIDEBAR_MD = """
42
+ ### Recommended Models
43
+ `google/gemma-4-e2b`
44
+ `google/gemma-4-e4b-it`
45
+ `google/gemma-4-31b-it`
46
+ `Qwen/Qwen2.5-14B-Instruct`
47
+ `deepseek-ai/DeepSeek-R1-Distill-Qwen-14B`
48
+ `meta-llama/Meta-Llama-3-8B`
49
+
50
+ ---
51
+
52
+ ### Layer Index
53
+ - Layer index = **N** in `layers.{N}` of safetensors keys
54
+ - Raw index, **not re-numbered per component**
55
+ - Multi-modal models (e.g. Gemma-4):
56
+ - `layers.0~11` may contain audio / vision / text layers
57
+ - All components output separately, distinguished by prefix
58
+
59
+ ### Example: Gemma-4-E2B
60
+ | Component | Layer Range |
61
+ |-----------|-------------|
62
+ | audio_tower | 0 ~ 11 |
63
+ | language_model | 0 ~ 34 |
64
+ | vision_tower | 0 ~ 15 |
65
+
66
+ ### Example: Gemma-4-31B
67
+ | Component | Layer Range |
68
+ |-----------|-------------|
69
+ | language (local) | 0 ~ 59 |
70
+ | language (global) | 5, 11, 17 … 59 |
71
+ | vision_tower | 0 ~ 26 |
72
+
73
+ ---
74
+
75
+ ### Reviewer Note
76
+ Leave **Admin Write Token** empty to run the full analysis
77
+ without writing to the database.
78
+ All metrics are computed and displayed normally.
79
  """
80
 
81
 
 
84
  hf_token: str,
85
  start_layer: int,
86
  end_layer: int,
87
+ admin_token: str,
88
  progress=gr.Progress()
89
  ) -> tuple[str, pd.DataFrame]:
90
 
91
  if not model_id.strip():
92
+ return "❌ Please enter a model ID.", None
93
 
94
+ token = hf_token.strip() or None
95
+ start_l = int(start_layer)
96
+ end_l = int(end_layer)
97
+ t_start = datetime.utcnow()
98
+ can_write = check_write_permission(admin_token)
99
 
100
  log = [
101
+ f"🔍 Analyzing: {model_id} layers {start_l}~{end_l}\n"
102
+ f"{'═'*80}\n"
103
+ f"💾 Database write: {'✅ ENABLED (admin)' if can_write else '🔒 DISABLED (read-only mode)'}\n"
104
  f"{'═'*80}\n"
105
  ]
106
+
107
+ if not can_write:
108
+ log.append(
109
+ "ℹ️ Running in read-only mode.\n"
110
+ " Analysis will run normally. Results displayed below but NOT saved to DB.\n"
111
+ " Reviewers: this is intentional — full reproducibility without DB access.\n"
112
+ f"{'─'*80}\n"
113
+ )
114
+
115
  all_records: list[dict] = []
116
 
117
+ # ── DB connection (needed for resume check even in read-only) ──
118
  conn = init_db()
119
 
120
+ # ── Quantization check ────────────────────────────────────────
121
+ progress(0.02, desc="Checking quantization...")
122
  blocked, qmsg = check_quantization(model_id, token)
123
+ log.append(f"[Quantization Check]\n{qmsg}\n{'─'*80}\n")
124
  if blocked:
125
  return "".join(log), None
126
 
127
+ # ── config.json ───────────────────────────────────────────────
128
+ progress(0.05, desc="Reading config...")
129
  config_params = {}
 
130
  try:
131
  r = requests.get(
132
  f"https://huggingface.co/{model_id}/resolve/main/config.json",
 
134
  timeout=15
135
  )
136
  if r.status_code == 200:
137
+ config_params = extract_config_params(r.json())
 
138
  log.append(
139
+ f"📋 Config: model_type={config_params.get('model_type')} "
140
  f"head_dim={config_params.get('head_dim')}\n"
141
  f"{'─'*80}\n"
142
  )
143
  except Exception:
144
+ log.append("⚠️ Could not read config.json\n")
145
 
146
+ # ── Write model metadata (admin only) ────────────────────────
147
+ if can_write:
148
+ model_type = config_params.get("model_type", "unknown")
149
+ upsert_model(conn, model_id, model_type=model_type)
150
 
151
+ # ── Load all shard headers ────────────────────────────────────
152
+ progress(0.08, desc="Loading shard headers...")
153
  try:
154
  all_headers = load_all_shard_headers(model_id, token)
155
  except requests.exceptions.HTTPError as e:
156
  return http_error_msg(e, model_id), None
157
  except Exception as e:
158
+ return "".join(log) + f"❌ Failed to load headers: {e}\n", None
159
 
160
  log.append(
161
+ f"📦 Shards: {len(all_headers)} "
162
+ f"Total keys: {sum(len(h) for h,_ in all_headers.values())}\n"
163
  )
164
 
165
+ # ── Scan layer structure ──────────────────────────────────────
166
+ progress(0.12, desc="Scanning layer structure...")
167
  profiles = scan_model_structure(all_headers, config_params)
168
 
169
  if not profiles:
170
+ return "".join(log) + "⚠️ No Q/K/V layers found.\n", None
171
+
172
+ # ── Write component metadata (admin only) ────────────────────
173
+ if can_write:
174
+ by_prefix: dict[str, list] = {}
175
+ for (pfx, idx), prof in profiles.items():
176
+ by_prefix.setdefault(pfx, []).append(prof)
177
+
178
+ for pfx, profs in by_prefix.items():
179
+ complete_profs = [p for p in profs if p.complete]
180
+ if not complete_profs:
181
+ continue
182
+ head_dims = [p.head_dim for p in complete_profs]
183
+ has_shared = any(p.kv_shared for p in complete_profs)
184
+ d_models = [p.d_model for p in complete_profs if p.d_model > 0]
185
+ upsert_component(
186
+ conn = conn,
187
+ model_id = model_id,
188
+ prefix = pfx,
189
+ n_layers = len(complete_profs),
190
+ head_dim_min = min(head_dims),
191
+ head_dim_max = max(head_dims),
192
+ has_kv_shared = has_shared,
193
+ has_global = has_shared,
194
+ d_model = d_models[0] if d_models else 0,
195
+ )
 
 
 
196
 
197
+ # ── Filter by layer range ─────────────────────────────────────
198
  filtered = {
199
  (pfx, idx): prof
200
  for (pfx, idx), prof in profiles.items()
 
211
  )
212
  return (
213
  "".join(log) +
214
+ f"⚠️ No complete layers found in range {start_l}~{end_l}.\n"
215
+ f"Available layer indices:\n{info}\n", None
216
  )
217
 
218
+ # ── Resume check (always query DB, write only if can_write) ──
 
219
  done_layers: dict[str, set] = {}
220
  for pfx in set(pfx for pfx, _ in filtered):
221
  done_layers[pfx] = get_analyzed_layers(conn, model_id, pfx)
222
 
223
+ # ── Print analysis plan ───────────────────────────────────────
224
  by_pfx2: dict[str, list] = {}
225
  for (pfx, idx) in filtered:
226
  by_pfx2.setdefault(pfx, []).append(idx)
227
 
228
+ log.append("📐 Analysis plan:\n")
229
  skipped_total = 0
230
  for pfx, idxs in sorted(by_pfx2.items()):
231
+ done = done_layers.get(pfx, set())
232
+ todo = [i for i in sorted(idxs) if i not in done]
233
+ skip = [i for i in sorted(idxs) if i in done]
234
  skipped_total += len(skip)
235
+ log.append(f" [{pfx}]\n")
236
+ log.append(f" To analyze : {todo}\n")
237
  if skip:
238
+ log.append(
239
+ f" Skipped (resume): {skip}\n"
240
+ if can_write else
241
+ f" Already in DB : {skip} "
242
+ f"(read-only: will re-compute but not save)\n"
243
+ )
244
  log.append(f"{'═'*80}\n")
245
 
246
+ if can_write and skipped_total > 0:
247
+ log.append(
248
+ f"⚡ Resume: skipping {skipped_total} already-analyzed layers.\n"
249
+ )
250
 
251
+ # ── Layer-by-layer analysis ───────────────────────────────────
252
  sorted_items = sorted(filtered.items(), key=lambda x: (x[0][0], x[0][1]))
253
  total = len(sorted_items)
254
 
255
  for i, ((pfx, idx), prof) in enumerate(sorted_items):
256
 
257
+ # Resume skip (only in write mode — reviewers always re-compute)
258
+ if can_write and idx in done_layers.get(pfx, set()):
 
259
  continue
260
 
261
  progress(
 
263
  desc=f"{pfx.split('.')[-2] if '.' in pfx else pfx} L{idx}..."
264
  )
265
 
266
+ # ── Load Q / K / V ────────────────────────────────────────
267
  try:
268
  q_url = get_file_url(model_id, prof.q.shard)
269
  k_url = get_file_url(model_id, prof.k.shard)
 
270
  q_hdr, q_hs = all_headers[prof.q.shard]
271
  k_hdr, k_hs = all_headers[prof.k.shard]
272
 
 
275
  f" q: {prof.q.shard} → {prof.q.key}\n"
276
  f" k: {prof.k.shard} → {prof.k.key}\n"
277
  f" v: {prof.v.shard + ' → ' + prof.v.key if prof.v else 'K=V shared'}\n"
 
 
 
278
  )
279
 
280
  W_q = load_tensor_remote(q_url, prof.q.key, q_hdr, q_hs, token)
 
288
  W_v = load_tensor_remote(v_url, prof.v.key, v_hdr, v_hs, token)
289
 
290
  except Exception as e:
291
+ log.append(f"[{pfx}] Layer {idx}: ❌ Load failed: {e}\n")
292
  continue
293
 
294
  if W_q is None or W_k is None or W_v is None:
295
+ log.append(f"[{pfx}] Layer {idx}: ⚠️ Tensor is None\n")
296
  continue
297
 
298
+ # ── Compute Five Laws ─────────────────────────────────────
299
  try:
300
  records, layer_log = analyze_layer(W_q, W_k, W_v, prof)
301
  all_records.extend(records)
302
  log.append(layer_log)
303
 
304
+ # ── Write to DB (admin only) ──────────────────────────
305
+ if can_write and records:
306
  write_layer_records(conn, model_id, records)
 
307
  update_model_summary(conn, model_id, pfx)
308
  log.append(
309
+ f" ✅ Saved to DB: {len(records)} records "
310
  f"[{pfx}] Layer {idx}\n"
311
  )
312
+ elif not can_write and records:
313
+ log.append(
314
+ f" 📊 Computed: {len(records)} records "
315
+ f"[{pfx}] Layer {idx} (read-only, not saved)\n"
316
+ )
317
 
318
  except Exception as e:
319
+ log.append(f"[{pfx}] Layer {idx}: ❌ Compute failed: {e}\n")
320
  finally:
321
  del W_q, W_k, W_v
322
 
323
+ # ── Update elapsed time (admin only) ─────────────────────────
324
+ if can_write:
325
+ elapsed = (datetime.utcnow() - t_start).total_seconds()
326
+ conn.execute(
327
+ "UPDATE models SET analyze_sec = ? WHERE model_id = ?",
328
+ (elapsed, model_id)
329
+ )
330
+ conn.commit()
331
+
332
+ # ── Summary ───────────────────────────────────────────────────
333
  elapsed = (datetime.utcnow() - t_start).total_seconds()
 
 
 
 
 
334
 
 
335
  if not all_records:
336
+ msg = (
337
+ "\n⚡ All layers already in DB (resume mode). "
338
+ "See Leaderboard or Database tab.\n"
339
+ if can_write else
340
+ "\n⚠️ No records computed.\n"
341
  )
342
+ return "".join(log) + msg, None
343
 
344
  summary = summarize_records(all_records, model_id)
345
  log.append(summary)
346
+ log.append(
347
+ f"\n⏱️ Elapsed: {elapsed:.1f}s\n"
348
+ f"{'═'*80}\n"
349
+ )
350
+
351
+ if not can_write:
352
+ log.append(
353
+ "🔒 Read-only mode: results above are NOT saved to the database.\n"
354
+ " To save, provide a valid Admin Write Token.\n"
355
+ )
356
 
357
  df = pd.DataFrame(all_records)
358
  return "".join(log), df
359
 
360
 
361
  # ─────────────────────────────────────────────
362
+ # Tab2 UI
363
  # ─────────────────────────────────────────────
364
 
365
  def build_tab_analyze():
366
+ with gr.Tab("📊 Analyze"):
367
  gr.Markdown("""
368
+ **Step 2: Select layer range and compute Wang's Five Laws metrics.**
369
+ Layer index = raw **N** in `layers.{N}` of safetensors keys.
370
+ K=V shared layers (e.g. Gemma-4 global layers) are handled automatically.
371
+ ⚡ **Resume supported**: already-analyzed layers are skipped automatically.
372
+
373
+ > 第二步:选择层范围,计算王氏五定律全指标。支持断点续传,已分析层自动跳过。
374
  """)
375
 
376
  with gr.Row():
377
  with gr.Column(scale=3):
378
  model_id_input = gr.Textbox(
379
+ label="HuggingFace Model ID",
380
  placeholder="google/gemma-4-e2b",
381
  value="google/gemma-4-e2b"
382
  )
383
  token_input = gr.Textbox(
384
+ label="HF Access Token (leave empty for public models)",
385
  type="password"
386
  )
387
  with gr.Row():
388
  start_input = gr.Number(
389
+ label="Start Layer (inclusive)",
390
  value=0, minimum=0, maximum=9999, precision=0
391
  )
392
  end_input = gr.Number(
393
+ label="End Layer (inclusive)",
394
  value=5, minimum=0, maximum=9999, precision=0
395
  )
396
+ admin_token_input = gr.Textbox(
397
+ label="Admin Write Token",
398
+ placeholder="Leave empty to run analysis without saving to database",
399
+ type="password",
400
+ info=(
401
+ "Reviewers: leave empty. "
402
+ "Analysis runs fully — results shown below but not saved to DB. "
403
+ "| 审稿人请留空,分析正常运行,结果不写入数据库。"
404
+ )
405
+ )
406
+ analyze_btn = gr.Button("🚀 Start Analysis", variant="primary")
407
 
408
  with gr.Column(scale=1):
409
  gr.Markdown(SIDEBAR_MD)
410
 
411
  analyze_log = gr.Textbox(
412
+ label="Analysis Log (per-head details)",
413
  lines=35, max_lines=300
414
  )
415
  analyze_table = gr.Dataframe(
416
+ label="Per-head metrics (all Five Laws)",
417
  headers=[
418
  "prefix", "layer", "kv_head", "q_head", "kv_shared",
419
  "pearson_QK", "spearman_QK", "pearson_QV", "pearson_KV",
 
432
 
433
  analyze_btn.click(
434
  fn=run_analysis,
435
+ inputs=[
436
+ model_id_input,
437
+ token_input,
438
+ start_input,
439
+ end_input,
440
+ admin_token_input, # ← 新增
441
+ ],
442
  outputs=[analyze_log, analyze_table]
443
  )
444