Coverage for transformer_lens/tools/model_registry/verify_models.py: 29%

731 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-21 19:27 +0000

1"""Batch model verification tool for the TransformerLens model registry. 

2 

3Iterates through supported models, estimates memory requirements, runs benchmarks 

4phase-by-phase, and updates the registry with status, phase scores, and notes. 

5 

6Usage: 

7 python -m transformer_lens.tools.model_registry.verify_models [options] 

8 

9Examples: 

10 # Dry run to see what would be tested 

11 python -m transformer_lens.tools.model_registry.verify_models --dry-run 

12 

13 # Verify top 10 models per architecture on CPU 

14 python -m transformer_lens.tools.model_registry.verify_models --device cpu 

15 

16 # Verify only GPT2 models, limit to 3 

17 python -m transformer_lens.tools.model_registry.verify_models --architectures GPT2LMHeadModel --limit 3 

18 

19 # Resume from a previous interrupted run 

20 python -m transformer_lens.tools.model_registry.verify_models --resume 

21 

22 # Re-verify already-tested models for a specific architecture 

23 python -m transformer_lens.tools.model_registry.verify_models --reverify --architectures Olmo2ForCausalLM 

24""" 

25 

26import argparse 

27import gc 

28import json 

29import logging 

30import re 

31import signal 

32import time 

33from dataclasses import dataclass, field 

34from datetime import datetime 

35from pathlib import Path 

36from typing import Optional 

37 

38from transformer_lens.benchmarks.text_quality_profiles import ( 

39 P4_SCORING_VERSION, 

40 p4_pass_threshold, 

41) 

42from transformer_lens.utilities.heterogeneous_config import het_safe_view 

43 

44# Exit code used for graceful interrupts (Ctrl+C). The wrapper script 

45# recognises this and stops without marking the in-flight model as failed. 

46_EXIT_GRACEFUL_INTERRUPT = 42 

47 

48# Module-level flag set by the SIGINT handler so the main loop can stop 

49# between models without corrupting state. 

50_interrupt_requested = False 

51 

52from . import REMOTE_CODE_MODEL_PREFIXES 

53from .registry_io import ( 

54 MODALITY_PHASES, 

55 QUANTIZED_NOTE, 

56 STATUS_FAILED, 

57 STATUS_PROVISIONAL, 

58 STATUS_SKIPPED, 

59 STATUS_UNVERIFIED, 

60 STATUS_VERIFIED, 

61 TEXT_PHASES, 

62 add_verification_record, 

63 extract_phase_scores, 

64 is_incompatible_quantized, 

65 load_supported_models_raw, 

66 pass_status, 

67 required_quant_library_for_model, 

68 update_model_status, 

69) 

70 

71logger = logging.getLogger(__name__) 

72 

73# Data directory for registry files 

74_DATA_DIR = Path(__file__).parent / "data" 

75_CHECKPOINT_PATH = _DATA_DIR / "verification_checkpoint.json" 

76 

77 

78def _handle_sigint(signum, frame): # noqa: ARG001 

79 """Handle Ctrl+C by setting a flag instead of raising immediately. 

80 

81 The main verification loop checks this flag between models so it can 

82 save the checkpoint cleanly and exit without marking the current model 

83 as failed. 

84 """ 

85 global _interrupt_requested # noqa: PLW0603 

86 if _interrupt_requested: 

87 # Second Ctrl+C — force exit immediately 

88 print("\nForce quit.") 

89 raise SystemExit(1) 

90 _interrupt_requested = True 

91 print("\n\nInterrupt received — finishing current model before stopping.") 

92 print("(Press Ctrl+C again to force quit immediately.)\n") 

93 

94 

95# Pattern matching HuggingFace API tokens (hf_ followed by 20+ alphanumeric chars) 

96_HF_TOKEN_RE = re.compile(r"hf_[A-Za-z0-9]{20,}") 

97 

98 

99def _sanitize_note(note: Optional[str]) -> Optional[str]: 

100 """Sanitize a note string to remove sensitive information. 

101 

102 Strips HuggingFace tokens and replaces verbose gated-repo error messages 

103 with a concise summary. 

104 """ 

105 if not note: 

106 return note 

107 # Replace any HF tokens that leaked into the message 

108 note = _HF_TOKEN_RE.sub("HF_TOKEN", note) 

109 # Replace verbose gated-repo 401 errors with a clean summary 

110 if "gated repo" in note: 

111 url_match = re.search(r"https://huggingface\.co/([^\s.]+)", note) 

112 model_ref = url_match.group(1) if url_match else "unknown" 

113 return f"Config unavailable: Gated repo ({model_ref})" 

114 return note 

115 

116 

117def _phases_to_run(arch: str, phases: list[int]) -> list[int]: 

118 """Restrict requested phases to those the adapter supports. 

119 

120 An adapter's ``applicable_phases`` declares which text phases (1-4) it covers. Phases 

121 7/8/9 are gated separately by ``is_multimodal``/``is_audio``/``is_visual_model`` in 

122 ``main_benchmark._phase_enabled`` (the mirror of this gate), so they are never filtered 

123 out here. An empty result means none of the requested phases apply to this architecture 

124 (SSM / recurrent families run all four). 

125 """ 

126 from transformer_lens.factories.architecture_adapter_factory import ( 

127 SUPPORTED_ARCHITECTURES, 

128 ) 

129 

130 applicable = getattr(SUPPORTED_ARCHITECTURES.get(arch), "applicable_phases", list(TEXT_PHASES)) 

131 return [p for p in phases if p in applicable or p in MODALITY_PHASES] 

132 

133 

134def _full_and_core_phases(arch: str) -> tuple[set[int], set[int]]: 

135 """``(full verification set, core subset)`` for this architecture -- the single 

136 source for both default phase selection and the status-writing decision.""" 

137 from transformer_lens.utilities.architectures import AUDIO_TEXT_ARCHITECTURES 

138 

139 kind = classify_architecture(arch) 

140 if kind == "audio": 

141 return {1, 8}, {1, 8} 

142 if kind == "vision": 

143 # Vision encoders have no tokenizer and no text tower: Phases 2/3 compare 

144 # text logits/loss, Phase 4 needs text generation, and Phase 7 covers 

145 # vision+text multimodal models, not these. Phase 1 (HF parity) plus 

146 # Phase 9 (pixel forward/cache/stability) are the whole story. 

147 return {1, 9}, {1, 9} 

148 if kind == "multimodal": 

149 return {1, 2, 3, 4, 7}, {1, 4, 7} 

150 if arch in AUDIO_TEXT_ARCHITECTURES: 

151 # Phase 8 (audio-conditioned forward) out of the core set so a partial {1,4} 

152 # run still verifies; a full run records and gates it via _check_phase_scores. 

153 return {1, 2, 3, 4, 8}, {1, 4} 

154 return {1, 2, 3, 4}, {1, 4} 

155 

156 

157def _default_phases_for_architecture(arch: str) -> list[int]: 

158 """Phases to run when the caller names none — a full verification.""" 

159 return sorted(_full_and_core_phases(arch)[0]) 

160 

161 

162def _get_current_model_status(model_id: str, arch_id: str) -> int: 

163 """Look up a model's current status in the registry. 

164 

165 Returns STATUS_UNVERIFIED (0) if the model is not found. 

166 """ 

167 data = load_supported_models_raw() 

168 for entry in data.get("models", []): 

169 if not isinstance(entry, dict): 

170 continue 

171 if entry.get("model_id") == model_id and entry.get("architecture_id") == arch_id: 

172 return entry.get("status", STATUS_UNVERIFIED) 

173 return STATUS_UNVERIFIED 

174 

175 

176@dataclass 

177class ModelCandidate: 

178 """A model selected for verification.""" 

179 

180 model_id: str 

181 architecture_id: str 

182 estimated_params: Optional[int] = None 

183 estimated_memory_gb: Optional[float] = None 

184 

185 

186@dataclass 

187class VerificationProgress: 

188 """Tracks progress across a verification run.""" 

189 

190 tested: list[str] = field(default_factory=list) 

191 skipped: list[str] = field(default_factory=list) 

192 failed: list[str] = field(default_factory=list) 

193 verified: list[str] = field(default_factory=list) 

194 # Structural-only (--no-hf-reference) passes; kept out of the verified tally. 

195 provisional: list[str] = field(default_factory=list) 

196 start_time: Optional[str] = None 

197 

198 def to_dict(self) -> dict: 

199 return { 

200 "tested": self.tested, 

201 "skipped": self.skipped, 

202 "failed": self.failed, 

203 "verified": self.verified, 

204 "provisional": self.provisional, 

205 "start_time": self.start_time, 

206 } 

207 

208 @classmethod 

209 def from_dict(cls, data: dict) -> "VerificationProgress": 

210 return cls( 

211 tested=data.get("tested", []), 

212 skipped=data.get("skipped", []), 

213 failed=data.get("failed", []), 

214 verified=data.get("verified", []), 

215 provisional=data.get("provisional", []), 

216 start_time=data.get("start_time"), 

217 ) 

218 

219 

220def published_param_count(model_id: str) -> Optional[int]: 

221 """Exact parameter count from the hub's safetensors metadata, or None. 

222 

223 Metadata only -- no weights are fetched. Preferred over the config formula 

224 below, which assumes every layer carries full attention and an MLP and so 

225 over-counts a hybrid Mamba/attention stack roughly fourfold 

226 (NVIDIA-Nemotron-Nano-9B-v2: 36.6B estimated against 8.89B published, enough 

227 to skip the model as too large for memory it does not need). 

228 """ 

229 from transformer_lens.utilities.hf_utils import get_hf_token 

230 

231 try: 

232 from huggingface_hub import HfApi 

233 

234 info = HfApi().model_info(model_id, expand=["safetensors"], token=get_hf_token()) 

235 except Exception: 

236 # Unpublished metadata, a gated repo or a network blip: fall back rather 

237 # than fail, since the config formula needs no hub metadata. 

238 return None 

239 total = getattr(info.safetensors, "total", None) if info.safetensors else None 

240 return int(total) if total else None 

241 

242 

243def estimate_model_params(model_id: str) -> int: 

244 """Parameter count for this model: published metadata first, else a config estimate. 

245 

246 Fetches only metadata and the config JSON (~KB), never weights. 

247 

248 Args: 

249 model_id: HuggingFace model ID 

250 

251 Returns: 

252 Number of parameters — exact when the hub publishes it, else estimated 

253 from config dimensions using the standard TransformerLens 

254 parameter-count formula. 

255 

256 Raises: 

257 Exception: If config cannot be fetched or parsed 

258 """ 

259 published = published_param_count(model_id) 

260 if published: 

261 return published 

262 

263 trust_remote_code = any(model_id.startswith(prefix) for prefix in REMOTE_CODE_MODEL_PREFIXES) 

264 from transformer_lens.utilities.hf_utils import ( 

265 autoconfig_with_remote_post_init_compat, 

266 get_hf_token, 

267 ) 

268 

269 config = autoconfig_with_remote_post_init_compat( 

270 model_id, trust_remote_code=trust_remote_code, token=get_hf_token() 

271 ) 

272 

273 # For multimodal models (LLaVA, Gemma3 multimodal), the language model config 

274 # is nested under text_config. Fall through to the top-level config otherwise. 

275 lang_config = getattr(config, "text_config", config) 

276 

277 # Encoder-decoder models (e.g. T5Gemma) nest dimensions under decoder/encoder 

278 # subconfigs rather than the top level; prefer the decoder for the estimate. 

279 if not (hasattr(lang_config, "hidden_size") or hasattr(lang_config, "d_model")): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 for _sub in ("decoder", "encoder"): 

281 _subcfg = getattr(config, _sub, None) 

282 if _subcfg is not None and ( 

283 hasattr(_subcfg, "hidden_size") or hasattr(_subcfg, "d_model") 

284 ): 

285 lang_config = _subcfg 

286 break 

287 

288 # Heterogeneous configs (transformers>=5.15 Gemma 4) raise on global reads of 

289 # per-layer fields like head_dim; the view resolves them to majority values. 

290 lang_config = het_safe_view(lang_config) 

291 

292 # Extract dimensions from config (different models use different attribute names) 

293 d_model = ( 

294 getattr(lang_config, "hidden_size", None) 

295 or getattr(lang_config, "d_model", None) 

296 or getattr(lang_config, "model_dim", None) # OpenELM 

297 or 0 

298 ) 

299 n_heads_raw = ( 

300 getattr(lang_config, "num_attention_heads", None) 

301 or getattr(lang_config, "n_head", None) 

302 or getattr(lang_config, "num_query_heads", None) # OpenELM (may be per-layer list) 

303 or getattr(lang_config, "num_heads", None) # Mamba-2 SSM heads 

304 or 0 

305 ) 

306 # OpenELM uses per-layer lists for heads; take the max for estimation 

307 n_heads = max(n_heads_raw) if isinstance(n_heads_raw, (list, tuple)) else n_heads_raw 

308 n_layers = ( 

309 getattr(lang_config, "num_hidden_layers", None) 

310 or getattr(lang_config, "n_layer", None) 

311 or getattr(lang_config, "num_transformer_layers", None) # OpenELM 

312 or 0 

313 ) 

314 d_mlp = ( 

315 getattr(lang_config, "intermediate_size", None) 

316 or getattr(lang_config, "d_inner", None) 

317 or getattr(lang_config, "n_inner", None) 

318 or getattr(lang_config, "ffn_dim", None) # OPT 

319 or getattr(lang_config, "d_ff", None) # T5 

320 ) 

321 # Gemma 3n exposes a per-layer intermediate_size list (uniform in all released 

322 # checkpoints); collapse to max for the scalar param estimate. 

323 if isinstance(d_mlp, (list, tuple)): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 d_mlp = max(d_mlp) if d_mlp else None 

325 # OpenELM uses per-layer ffn_multipliers instead of a fixed intermediate_size 

326 if not d_mlp and d_model: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 ffn_multipliers = getattr(lang_config, "ffn_multipliers", None) 

328 if isinstance(ffn_multipliers, (list, tuple)): 

329 d_mlp = int(max(ffn_multipliers) * d_model) 

330 else: 

331 # Many architectures (GPT-2, Bloom, GPT-Neo, GPT-J) leave d_mlp/n_inner 

332 # as None and default to 4 * hidden_size internally. 

333 d_mlp = 4 * d_model 

334 d_vocab = getattr(lang_config, "vocab_size", None) or 0 

335 

336 if d_model == 0 or n_layers == 0: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 raise ValueError(f"Could not extract model dimensions from config for {model_id}") 

338 

339 # Attention-less architectures (Mamba SSMs) have no heads. Use nominal 

340 # values so the estimate doesn't attribute phantom attention params. 

341 is_attention_less = n_heads == 0 

342 if is_attention_less: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 n_heads = 1 

344 d_head = d_model 

345 else: 

346 d_head = getattr(lang_config, "head_dim", None) or (d_model // n_heads) 

347 

348 # Attention parameters: W_Q, W_K, W_V, W_O per layer (skipped for SSMs) 

349 if is_attention_less: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 n_params = 0 

351 else: 

352 n_params = n_layers * (d_model * d_head * n_heads * 4) 

353 

354 # MLP parameters (if present) 

355 if d_mlp is not None and d_mlp > 0: 355 ↛ 398line 355 didn't jump to line 398 because the condition on line 355 was always true

356 # Check for gated MLP (LLaMA, Gemma, Mistral, Qwen, T5 gated-gelu, etc.) 

357 has_gate = getattr(lang_config, "is_gated_act", False) or ( 

358 hasattr(lang_config, "intermediate_size") 

359 and ( 

360 getattr(lang_config, "hidden_act", None) in ("silu", "gelu", "swiglu") 

361 or getattr(lang_config, "model_type", None) 

362 in ( 

363 "llama", 

364 "gemma", 

365 "gemma2", 

366 "gemma3", 

367 "mistral", 

368 "mixtral", 

369 "qwen2", 

370 "qwen3", 

371 "qwen3_moe", 

372 "phi3", 

373 "stablelm", 

374 ) 

375 ) 

376 ) 

377 mlp_multiplier = 3 if has_gate else 2 

378 n_params += n_layers * (d_model * d_mlp * mlp_multiplier) 

379 

380 # MoE expert scaling 

381 num_experts = ( 

382 getattr(lang_config, "num_local_experts", None) 

383 or getattr(lang_config, "num_experts", None) 

384 or getattr(lang_config, "n_routed_experts", None) # DeepSeek-V2/V3 

385 ) 

386 if num_experts and num_experts > 1: 386 ↛ 389line 386 didn't jump to line 389 because the condition on line 386 was never true

387 # Qwen3MoE and similar store per-expert hidden size in moe_intermediate_size; 

388 # intermediate_size refers to a dense fallback MLP that we don't use here. 

389 moe_d_mlp = getattr(lang_config, "moe_intermediate_size", None) or d_mlp 

390 # MLP params scale with num_experts; add gate params per expert 

391 mlp_per_layer = d_model * moe_d_mlp * mlp_multiplier 

392 moe_per_layer = (mlp_per_layer + d_model) * num_experts 

393 # Replace the non-MoE MLP contribution 

394 n_params -= n_layers * (d_model * d_mlp * mlp_multiplier) 

395 n_params += n_layers * moe_per_layer 

396 

397 # Embedding parameters (not in the block-param formula but relevant for memory) 

398 n_params += d_vocab * d_model 

399 

400 return n_params 

401 

402 

403def estimate_benchmark_memory_gb( 

404 n_params: int, 

405 dtype: str = "float32", 

406 phases: Optional[list[int]] = None, 

407 use_hf_reference: bool = True, 

408 device: str = "cpu", 

409) -> float: 

410 """Estimate peak memory needed for benchmark suite. 

411 

412 Phases run sequentially, so peak memory is the maximum of any single phase, 

413 not the sum. The multiplier represents how many model copies exist at peak: 

414 

415 Phase 1 (HF ref on): HF ref + Bridge → 2.0x peak 

416 Phase 1 (HF ref off): Bridge only → 1.0x peak 

417 Phase 2: Bridge only (runtime self-checks) → 1.0x model + overhead 

418 Phase 3: Bridge + weight-processing state-dict transient → 2.0x model + overhead 

419 Phase 4: Bridge + GPT-2 scorer (~500MB) → ~1.0x model + 0.5 GB 

420 

421 Args: 

422 n_params: Number of model parameters 

423 dtype: Data type for memory calculation 

424 phases: Which phases will be run (None = all phases) 

425 use_hf_reference: Whether Phase 1 loads an HF reference alongside the 

426 Bridge. Mirrors the ``--no-hf-reference`` CLI flag. 

427 

428 Returns: 

429 Estimated peak memory in GB 

430 """ 

431 bytes_per_param = {"float32": 4, "float16": 2, "bfloat16": 2} 

432 bpp = bytes_per_param.get(dtype, 4) 

433 model_size_gb = n_params * bpp / (1024**3) 

434 

435 # Phase-4 judge overhead: measured 2.33 GB RSS loading Qwen2.5-0.5B fp32 

436 # on CPU (494M params). Kept slightly above the measurement; over-counting 

437 # is the safe direction. 

438 # The CPU-pinned judge never occupies accelerator memory; charging it to 

439 # a cuda budget produces spurious VRAM skips. 

440 judge_overhead_gb = 2.5 if device == "cpu" else 0.0 

441 

442 # Activation/framework overhead as a fraction of model size 

443 overhead_fraction = 0.2 

444 

445 # Determine peak memory across all requested phases 

446 phase_peaks = [] 

447 

448 if phases is None: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 phases = list(TEXT_PHASES) 

450 

451 for p in phases: 

452 if p == 1: 

453 # HF ref + Bridge (2 copies) or Bridge alone 

454 multiplier = 2.0 if use_hf_reference else 1.0 

455 phase_peaks.append(model_size_gb * multiplier * (1 + overhead_fraction)) 

456 elif p == 2: 456 ↛ 458line 456 didn't jump to line 458 because the condition on line 456 was never true

457 # Bridge only (runtime self-checks + saved-HF equivalence) 

458 phase_peaks.append(model_size_gb * 1.0 * (1 + overhead_fraction)) 

459 elif p == 3: 459 ↛ 461line 459 didn't jump to line 461 because the condition on line 459 was never true

460 # Bridge + the full state-dict copy materialized during weight processing 

461 phase_peaks.append(model_size_gb * 2.0 * (1 + overhead_fraction)) 

462 elif p == 4: 462 ↛ 451line 462 didn't jump to line 451 because the condition on line 462 was always true

463 # Bridge + judge 

464 phase_peaks.append(model_size_gb * (1 + overhead_fraction) + judge_overhead_gb) 

465 

466 return max(phase_peaks) if phase_peaks else model_size_gb 

467 

468 

469def get_available_memory_gb(device: str) -> float: 

470 """Detect available memory on the target device. 

471 

472 Args: 

473 device: "cpu" or "cuda" 

474 

475 Returns: 

476 Available memory in GB 

477 """ 

478 if device.startswith("cuda"): 

479 try: 

480 import torch 

481 

482 if torch.cuda.is_available(): 

483 device_idx = 0 

484 if ":" in device: 

485 device_idx = int(device.split(":")[1]) 

486 props = torch.cuda.get_device_properties(device_idx) 

487 return props.total_memory / (1024**3) 

488 except Exception: 

489 pass 

490 return 8.0 # Conservative default for GPU 

491 

492 # CPU: use psutil if available, else conservative default 

493 try: 

494 import psutil 

495 

496 return psutil.virtual_memory().available / (1024**3) 

497 except ImportError: 

498 return 16.0 # Conservative default for CPU 

499 

500 

501def select_models_for_verification( 

502 per_arch: int = 10, 

503 architectures: Optional[list[str]] = None, 

504 limit: Optional[int] = None, 

505 resume_progress: Optional[VerificationProgress] = None, 

506 retry_failed: bool = False, 

507 reverify: bool = False, 

508) -> list[ModelCandidate]: 

509 """Select models for verification from the registry. 

510 

511 Loads supported_models.json (already sorted by downloads). 

512 Takes the top N unverified models per architecture. 

513 

514 Args: 

515 per_arch: Maximum models to verify per architecture 

516 architectures: Filter to specific architectures (None = all) 

517 limit: Total model cap (None = no cap) 

518 resume_progress: If resuming, skip already-tested models 

519 retry_failed: If True, include previously failed models for re-testing 

520 reverify: If True, ignore previous status and re-test all matching models 

521 

522 Returns: 

523 List of ModelCandidate objects to verify 

524 """ 

525 already_tested: set[str] = set() 

526 if resume_progress and not reverify: 

527 already_tested = set(resume_progress.tested) 

528 if retry_failed: 

529 # Remove failed models from already_tested so they get re-selected 

530 failed_set = set(resume_progress.failed) 

531 already_tested -= failed_set 

532 

533 data = load_supported_models_raw() 

534 models = data.get("models", []) 

535 

536 # Group by architecture 

537 by_arch: dict[str, list[dict]] = {} 

538 for model in models: 

539 arch = model["architecture_id"] 

540 by_arch.setdefault(arch, []).append(model) 

541 

542 # Determine which architectures to scan 

543 if architectures: 

544 arch_ids = architectures 

545 else: 

546 arch_ids = sorted(by_arch.keys()) 

547 

548 candidates: list[ModelCandidate] = [] 

549 

550 for arch in arch_ids: 

551 arch_models = by_arch.get(arch, []) 

552 count = 0 

553 

554 for model in arch_models: 

555 model_id = model["model_id"] 

556 

557 # Skip already-verified or already-tested models 

558 if not reverify: 

559 model_status = model.get("status", 0) 

560 if model_status == STATUS_VERIFIED or model_status == STATUS_SKIPPED: 

561 continue 

562 if model_status == STATUS_FAILED and not retry_failed: 

563 continue 

564 if model_id in already_tested: 

565 continue 

566 

567 # Check per-arch limit 

568 if count >= per_arch: 

569 break 

570 

571 count += 1 

572 candidates.append(ModelCandidate(model_id=model_id, architecture_id=arch)) 

573 

574 # Check total limit 

575 if limit and len(candidates) >= limit: 

576 return candidates 

577 

578 return candidates 

579 

580 

581# Phase-score extraction and pass-status logic live in registry_io 

582# (extract_phase_scores / pass_status) — the shared home for both 

583# registry-writing paths, this module and main_benchmark.update_model_registry. 

584 

585 

586def _extract_prompt_profile(results: list) -> Optional[str]: 

587 """Effective Phase-4 prompt profile from the benchmark details, or None 

588 when no Phase-4 result exists. The default "continuation" is reported so 

589 the registry write can clear a stale non-default key.""" 

590 for result in results: 

591 if result.phase == 4 and result.details: 

592 profile = result.details.get("prompt_profile") 

593 if isinstance(profile, str): 593 ↛ 590line 593 didn't jump to line 590 because the condition on line 593 was always true

594 return profile 

595 return None 

596 

597 

598# Per-phase minimum score thresholds (0-100). 

599# Phase 1: Core correctness (bridge vs HF) — must pass everything. 

600# Phase 2: Hook/cache/gradient tests — most should pass. 

601# Phase 3: Weight processing tests — most should pass. 

602# Phase 4: Text quality — inherently fuzzy, keep lenient. 

603_MIN_PHASE_SCORES: dict[int, float] = { 

604 1: 100.0, 

605 2: 75.0, 

606 3: 75.0, 

607 # Phase 4 floor == the benchmark pass line; a gap between them lets a 

608 # failing score carry a clean "completed" note. 

609 4: p4_pass_threshold(), 

610 7: 75.0, 

611 8: 75.0, 

612 9: 75.0, 

613} 

614_DEFAULT_MIN_PHASE_SCORE = 50.0 

615 

616from transformer_lens.utilities.architectures import classify_architecture 

617 

618# Tests that MUST pass for a phase to be considered passing, regardless of 

619# the overall percentage score. If any required test fails, the phase fails 

620# even if the score is above the minimum threshold. 

621_REQUIRED_PHASE_TESTS: dict[int, list[str]] = { 

622 2: ["logits_equivalence", "loss_equivalence"], 

623 3: ["logits_equivalence", "loss_equivalence"], 

624 7: ["multimodal_forward"], 

625 8: ["audio_forward", "audio_text_forward"], 

626 9: ["vision_forward", "vision_cache"], 

627} 

628 

629# Failure text for a modality phase that produced no score — either absent from 

630# phase_scores (all tests skipped) or explicitly NULL. 

631_MODALITY_NULL_MESSAGES: dict[int, str] = { 

632 7: "P7=NULL (multimodal tests skipped — processor unavailable)", 

633 8: "P8=NULL (audio tests skipped — no results)", 

634 9: "P9=NULL (vision tests skipped — no results)", 

635} 

636 

637 

638def _measured_nothing(phase_scores: dict) -> bool: 

639 """True when no phase produced a score, so the run verified nothing. 

640 

641 An adapter's ``applicable_phases`` can prune the requested phases to empty. 

642 That leaves ``required_phases`` empty as well, so no score check fires and a 

643 run that measured nothing would otherwise be recorded as VERIFIED. 

644 

645 ``is not None`` rather than truthiness: 0.0 is a real score. 

646 """ 

647 return not any(score is not None for score in phase_scores.values()) 

648 

649 

650def _check_phase_scores( 

651 phase_scores: dict[int, Optional[float]], 

652 all_results: list, 

653 required_phases: Optional[set[int]] = None, 

654) -> Optional[str]: 

655 """Check phase scores against per-phase minimum thresholds and required tests. 

656 

657 A phase fails if: 

658 1. Its overall score is below the minimum threshold, OR 

659 2. Any of its required tests (per _REQUIRED_PHASE_TESTS) failed. 

660 

661 Phase 4 (text quality) is excluded — it is a quality metric, not a 

662 correctness check. Low text quality is surfaced in the verification 

663 note via _build_verified_note() but never causes a model to fail. 

664 

665 Args: 

666 phase_scores: Per-phase scores; a phase whose tests all skipped is 

667 absent entirely (``extract_phase_scores`` omits empty phases). 

668 all_results: Benchmark results, used to name the failing tests. 

669 required_phases: Phases this architecture must produce a score for — 

670 the core set from ``_full_and_core_phases``. A required modality 

671 phase that is absent counts as NULL, not as a pass. 

672 

673 Returns an error message if any phase fails, or None if all phases pass. 

674 The message includes the names of failed tests. 

675 """ 

676 from transformer_lens.benchmarks.utils import BenchmarkSeverity 

677 

678 failing_phases: list[str] = [] 

679 

680 # A required modality phase whose tests all skipped never reaches phase_scores. 

681 for phase in sorted((required_phases or set()) - set(phase_scores)): 

682 if phase in _MODALITY_NULL_MESSAGES: 682 ↛ 681line 682 didn't jump to line 681 because the condition on line 682 was always true

683 failing_phases.append(_MODALITY_NULL_MESSAGES[phase]) 

684 

685 for phase, score in sorted(phase_scores.items()): 

686 if score is None: 686 ↛ 690line 686 didn't jump to line 690 because the condition on line 686 was never true

687 # Phase 7 (multimodal), 8 (audio), or 9 (vision) with a NULL score 

688 # means the modality tests never ran. This is a verification 

689 # failure, not something to silently skip. 

690 if phase in _MODALITY_NULL_MESSAGES: 

691 failing_phases.append(_MODALITY_NULL_MESSAGES[phase]) 

692 continue 

693 

694 # Phase 4 is a quality metric, not a pass/fail check — skip it here. 

695 # Low text quality is reported in the note by _build_verified_note(). 

696 if phase == 4: 

697 continue 

698 

699 # Check 1: overall score threshold 

700 threshold = _MIN_PHASE_SCORES.get(phase, _DEFAULT_MIN_PHASE_SCORE) 

701 if score < threshold: 

702 failed_tests = [ 

703 r.name 

704 for r in all_results 

705 if r.phase == phase and not r.passed and r.severity != BenchmarkSeverity.SKIPPED 

706 ] 

707 tests_str = ", ".join(failed_tests) if failed_tests else "unknown" 

708 failing_phases.append(f"P{phase}={score}% < {threshold}% (failed: {tests_str})") 

709 continue # Already failing; no need to also check required tests 

710 

711 # Check 2: required tests must pass 

712 required_tests = _REQUIRED_PHASE_TESTS.get(phase, []) 

713 if required_tests: 

714 failed_required = [ 

715 r.name 

716 for r in all_results 

717 if r.phase == phase 

718 and r.name in required_tests 

719 and not r.passed 

720 and r.severity != BenchmarkSeverity.SKIPPED 

721 ] 

722 if failed_required: 722 ↛ 723line 722 didn't jump to line 723 because the condition on line 722 was never true

723 tests_str = ", ".join(failed_required) 

724 failing_phases.append(f"P{phase}={score}% but required tests failed: {tests_str}") 

725 

726 if failing_phases: 

727 return f"Below threshold: {'; '.join(failing_phases)}" 

728 return None 

729 

730 

731def _build_verified_note( 

732 phase_scores: dict[int, Optional[float]], 

733 all_results: list, 

734) -> str: 

735 """Build a verification note summarizing phase scores. 

736 

737 Phase 4 (text quality) is excluded from the score summary since it's a 

738 quality metric, not a pass/fail comparison. It only contributes a "low 

739 text quality" flag when below threshold. 

740 """ 

741 from transformer_lens.benchmarks.utils import BenchmarkSeverity 

742 

743 issue_parts: list[str] = [] 

744 low_text_quality = False 

745 

746 for phase in sorted(phase_scores): 

747 score = phase_scores[phase] 

748 if score is None: 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true

749 continue 

750 # Phase 4 is a quality score, not a pass/fail comparison — don't 

751 # include it in the normal score summary. 

752 if phase == 4: 

753 threshold = _MIN_PHASE_SCORES.get(4, _DEFAULT_MIN_PHASE_SCORE) 

754 if score < threshold: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 low_text_quality = True 

756 continue 

757 

758 if score < 100.0: 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true

759 failed_tests = [ 

760 r.name 

761 for r in all_results 

762 if r.phase == phase and not r.passed and r.severity != BenchmarkSeverity.SKIPPED 

763 ] 

764 if failed_tests: 

765 issue_parts.append(f"P{phase}={score}% (failed: {', '.join(failed_tests)})") 

766 else: 

767 issue_parts.append(f"P{phase}={score}%") 

768 

769 p4_uncovered = next( 

770 ( 

771 r.message 

772 for r in all_results 

773 if r.phase == 4 

774 and r.severity == BenchmarkSeverity.SKIPPED 

775 and r.message.startswith("P4 skipped:") 

776 ), 

777 None, 

778 ) 

779 suffix = "" 

780 if p4_uncovered: 780 ↛ 782line 780 didn't jump to line 782 because the condition on line 780 was never true

781 # Keep the gap visible in the registry until prompt coverage is added. 

782 reason = p4_uncovered.split("—")[0].replace("P4 skipped:", "").strip() 

783 suffix = f"; P4 skipped (uncovered: {reason} — file a coverage issue)" 

784 

785 if issue_parts and low_text_quality: 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 return ( 

787 f"Full verification completed with issues, low text quality: {'; '.join(issue_parts)}" 

788 + suffix 

789 ) 

790 if issue_parts: 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true

791 return f"Full verification completed with issues: {'; '.join(issue_parts)}" + suffix 

792 if low_text_quality: 792 ↛ 793line 792 didn't jump to line 793 because the condition on line 792 was never true

793 return "Full verification completed with issues, low text quality" + suffix 

794 return "Full verification completed" + suffix 

795 

796 

797def _preserved_issue_suffix(model_id: str, eff_phases) -> str: 

798 """Sub-100 scores from phases not re-run this pass stay visible in the 

799 note; a partial pass must not overwrite tracked residue.""" 

800 from transformer_lens.tools.model_registry.registry_io import ( 

801 load_supported_models_raw, 

802 ) 

803 

804 try: 

805 entry = next( 

806 ( 

807 m 

808 for m in load_supported_models_raw().get("models", []) 

809 if m.get("model_id") == model_id 

810 ), 

811 None, 

812 ) 

813 except OSError: 

814 return "" 

815 if entry is None: 815 ↛ 816line 815 didn't jump to line 816 because the condition on line 815 was never true

816 return "" 

817 residue = [] 

818 for phase in (2, 3, 7, 8, 9): 

819 if phase in (eff_phases or []): 

820 continue 

821 score = entry.get(f"phase{phase}_score") 

822 if score is not None and score < 100.0: 

823 residue.append(f"P{phase}={score}%") 

824 if not residue: 

825 return "" 

826 return f" (prior issues retained: {', '.join(residue)})" 

827 

828 

829def _p1_only_core_note(p4_score, all_results: list) -> str: 

830 """Note for a core run where P1 passed but P4 did not contribute a pass. 

831 

832 A skipped P4 is a coverage gap, not a quality failure — the stale 

833 (possibly old-scale) score must not be relabeled "poor".""" 

834 from transformer_lens.benchmarks.utils import BenchmarkSeverity 

835 

836 p4_skip_msg = next( 

837 ( 

838 r.message 

839 for r in all_results 

840 if r.phase == 4 

841 and r.severity == BenchmarkSeverity.SKIPPED 

842 and r.message.startswith("P4 skipped:") 

843 ), 

844 None, 

845 ) 

846 if p4_skip_msg is not None: 

847 reason = p4_skip_msg.split("—")[0].replace("P4 skipped:", "").strip() 

848 return f"Core verification passed; P4 skipped ({reason})" 

849 if p4_score is None: 

850 return "Core verification passed, but text quality benchmark errored. Needs review" 

851 return f"Core verification passed, but text quality poor (P4={p4_score}). Needs review" 

852 

853 

854def _clear_hf_cache(quiet: bool = False) -> None: 

855 """Remove downloaded model weights from the HuggingFace cache to free disk.""" 

856 from pathlib import Path 

857 

858 cache_dir = Path.home() / ".cache" / "huggingface" / "hub" 

859 if not cache_dir.exists(): 859 ↛ 860line 859 didn't jump to line 860 because the condition on line 859 was never true

860 return 

861 

862 from transformer_lens.benchmarks.text_quality import JUDGE_MODEL_ID 

863 

864 # The pinned Phase-4 judge is needed by every run; deleting it here would 

865 # force a re-download per family. 

866 judge_dir = "models--" + JUDGE_MODEL_ID.replace("/", "--") 

867 

868 freed = 0 

869 for blobs_dir in cache_dir.glob("models--*/blobs"): 

870 if blobs_dir.parent.name == judge_dir: 

871 continue 

872 for blob in blobs_dir.iterdir(): 

873 try: 

874 size = blob.stat().st_size 

875 blob.unlink() 

876 freed += size 

877 except OSError: 

878 pass 

879 

880 if not quiet and freed > 0: 880 ↛ 881line 880 didn't jump to line 881 because the condition on line 880 was never true

881 print(f" Cleared {freed / (1024**3):.1f} GB from HuggingFace cache") 

882 

883 

884def _save_checkpoint(progress: VerificationProgress) -> None: 

885 """Save verification progress to checkpoint file.""" 

886 with open(_CHECKPOINT_PATH, "w") as f: 

887 json.dump(progress.to_dict(), f, indent=2) 

888 f.write("\n") 

889 

890 

891def _skip_model( 

892 model_id: str, arch: str, note: str, progress: VerificationProgress, quiet: bool 

893) -> None: 

894 """Record a model as skipped with ``note``, preserving an existing verified or provisional 

895 status, and checkpoint. Callers ``continue`` the loop afterwards. 

896 """ 

897 if not quiet: 

898 print(f" SKIP: {note}") 

899 if _get_current_model_status(model_id, arch) not in (STATUS_VERIFIED, STATUS_PROVISIONAL): 

900 update_model_status(model_id, arch, STATUS_SKIPPED, note=note, sanitize_fn=_sanitize_note) 

901 elif not quiet: 

902 print(" (preserving existing verified/provisional status)") 

903 progress.skipped.append(model_id) 

904 _save_checkpoint(progress) 

905 

906 

907def _load_checkpoint() -> Optional[VerificationProgress]: 

908 """Load verification progress from checkpoint file.""" 

909 if not _CHECKPOINT_PATH.exists(): 

910 return None 

911 try: 

912 with open(_CHECKPOINT_PATH) as f: 

913 data = json.load(f) 

914 return VerificationProgress.from_dict(data) 

915 except (json.JSONDecodeError, KeyError): 

916 return None 

917 

918 

919def verify_models( 

920 candidates: list[ModelCandidate], 

921 device: str = "cpu", 

922 max_memory_gb: Optional[float] = None, 

923 dtype: str = "float32", 

924 use_hf_reference: bool = True, 

925 phases: Optional[list[int]] = None, 

926 quiet: bool = False, 

927 progress: Optional[VerificationProgress] = None, 

928) -> VerificationProgress: 

929 """Run verification benchmarks on a list of model candidates. 

930 

931 Args: 

932 candidates: Models to verify 

933 device: Device for benchmarks 

934 max_memory_gb: Memory limit (auto-detected if None) 

935 dtype: Dtype for memory estimation 

936 use_hf_reference: Whether to compare against HuggingFace model 

937 phases: Which benchmark phases to run 

938 quiet: Suppress verbose output 

939 progress: Existing progress for resume 

940 

941 Returns: 

942 VerificationProgress with results 

943 """ 

944 from transformer_lens.benchmarks.main_benchmark import run_benchmark_suite 

945 

946 if progress is None: 

947 progress = VerificationProgress(start_time=datetime.now().isoformat()) 

948 

949 if max_memory_gb is None: 

950 max_memory_gb = get_available_memory_gb(device) 

951 if not quiet: 

952 print(f"Auto-detected available memory: {max_memory_gb:.1f} GB") 

953 

954 # phases stays None = full verification for the model. 

955 

956 # Pre-load the Phase-4 judge so it persists across all models in the batch 

957 # instead of being loaded and destroyed for each one. 

958 _judge_model = None 

959 _judge_tokenizer = None 

960 if phases is None or 4 in phases: 

961 try: 

962 from transformer_lens.benchmarks.text_quality import ( 

963 JUDGE_MODEL_ID, 

964 JUDGE_REVISION, 

965 load_judge, 

966 ) 

967 

968 _judge_model, _judge_tokenizer = load_judge() 

969 if not quiet: 

970 print(f"Pre-loaded Phase 4 judge {JUDGE_MODEL_ID}@{JUDGE_REVISION[:8]}") 

971 except Exception as e: 

972 if not quiet: 

973 print(f"Warning: Could not pre-load Phase 4 judge: {e}") 

974 print(" Phase 4 will load its own judge per model.") 

975 

976 total = len(candidates) 

977 for i, candidate in enumerate(candidates, 1): 

978 # Check for graceful interrupt between models 

979 if _interrupt_requested: 

980 if not quiet: 

981 print( 

982 f"\nStopping gracefully. Progress saved " 

983 f"({len(progress.verified)} verified, " 

984 f"{len(progress.provisional)} provisional)." 

985 ) 

986 _save_checkpoint(progress) 

987 raise SystemExit(_EXIT_GRACEFUL_INTERRUPT) 

988 

989 model_id = candidate.model_id 

990 arch = candidate.architecture_id 

991 

992 if not quiet: 

993 print(f"\n{'='*70}") 

994 print(f"[{i}/{total}] {model_id} ({arch})") 

995 print(f"{'='*70}") 

996 

997 progress.tested.append(model_id) 

998 

999 # Step 0: Skip formats with no HF loader path (GGUF / MLX / FP4 / FP8). 

1000 if is_incompatible_quantized(model_id): 

1001 _skip_model(model_id, arch, QUANTIZED_NOTE, progress, quiet) 

1002 continue 

1003 

1004 # Step 0a: skip HF-loadable quantized models when their loader lib is missing. 

1005 required_lib = required_quant_library_for_model(model_id) 

1006 if required_lib is not None: 

1007 import importlib.util 

1008 

1009 if importlib.util.find_spec(required_lib) is None: 

1010 note = f"Skipped: {required_lib} not installed (required to load this quantized format)" 

1011 _skip_model(model_id, arch, note, progress, quiet) 

1012 continue 

1013 

1014 # Step 0b: Check adapter-level phase applicability. applicable_phases=[] 

1015 # means no phase applies (a genuinely unsupported architecture — rare); 

1016 # SSM / recurrent families run the full [1, 2, 3, 4]. 

1017 from transformer_lens.factories.architecture_adapter_factory import ( 

1018 SUPPORTED_ARCHITECTURES, 

1019 ) 

1020 

1021 adapter_cls = SUPPORTED_ARCHITECTURES.get(arch) 

1022 eff_phases = phases if phases is not None else _default_phases_for_architecture(arch) 

1023 phases_to_run = _phases_to_run(arch, eff_phases) 

1024 if adapter_cls is not None and not phases_to_run: 

1025 applicable = getattr(adapter_cls, "applicable_phases", list(TEXT_PHASES)) 

1026 note = ( 

1027 f"Architecture {arch} has applicable_phases={applicable}; " 

1028 f"verify_models coverage is deferred. Verification lives " 

1029 f"in integration tests." 

1030 ) 

1031 _skip_model(model_id, arch, note, progress, quiet) 

1032 continue 

1033 

1034 # Step 1: Estimate parameters 

1035 try: 

1036 n_params = estimate_model_params(model_id) 

1037 candidate.estimated_params = n_params 

1038 if not quiet: 

1039 print(f" Estimated parameters: {n_params:,}") 

1040 except Exception as e: 

1041 _skip_model(model_id, arch, f"Config unavailable: {str(e)[:200]}", progress, quiet) 

1042 continue 

1043 

1044 # Step 2: Check memory 

1045 estimated_mem = estimate_benchmark_memory_gb( 

1046 n_params, dtype, phases=phases_to_run, use_hf_reference=use_hf_reference, device=device 

1047 ) 

1048 candidate.estimated_memory_gb = estimated_mem 

1049 if not quiet: 

1050 print( 

1051 f" Estimated benchmark memory: {estimated_mem:.1f} GB (limit: {max_memory_gb:.1f} GB)" 

1052 ) 

1053 

1054 if estimated_mem > max_memory_gb: 

1055 note = f"Estimated {estimated_mem:.1f} GB exceeds {max_memory_gb:.1f} GB limit" 

1056 _skip_model(model_id, arch, note, progress, quiet) 

1057 continue 

1058 

1059 # Step 3: Run benchmarks (all phases in a single call to share models) 

1060 all_results: list = [] 

1061 error_msg: Optional[str] = None 

1062 

1063 needs_remote_code = any( 

1064 model_id.startswith(prefix) for prefix in REMOTE_CODE_MODEL_PREFIXES 

1065 ) 

1066 

1067 # Convert string dtype to torch.dtype for benchmark suite 

1068 import torch 

1069 

1070 _dtype_map = { 

1071 "float32": torch.float32, 

1072 "float16": torch.float16, 

1073 "bfloat16": torch.bfloat16, 

1074 } 

1075 torch_dtype = _dtype_map[dtype] 

1076 

1077 from transformer_lens.benchmarks.text_quality_profiles import resolve_profile 

1078 from transformer_lens.tools.model_registry.registry_io import ( 

1079 registry_prompt_profile, 

1080 ) 

1081 

1082 resolved_profile = str(resolve_profile(model_id, arch, registry_prompt_profile(model_id))) 

1083 if not quiet: 

1084 print(f" Prompt profile: {resolved_profile}") 

1085 print(f" Running phases {phases} in a single benchmark call...") 

1086 try: 

1087 all_results = run_benchmark_suite( 

1088 model_id, 

1089 device=device, 

1090 dtype=torch_dtype, 

1091 use_hf_reference=use_hf_reference, 

1092 verbose=not quiet, 

1093 phases=phases_to_run, 

1094 trust_remote_code=needs_remote_code, 

1095 judge_model=_judge_model, 

1096 judge_tokenizer=_judge_tokenizer, 

1097 prompt_profile=resolved_profile, 

1098 ) 

1099 except Exception as e: 

1100 error_msg = str(e) 

1101 if not quiet: 

1102 print(f" Benchmark failed: {error_msg[:200]}") 

1103 

1104 phase_scores = extract_phase_scores(all_results) 

1105 

1106 if not error_msg: 

1107 # Only require the core phases this run actually requested, so a 

1108 # partial run (e.g. --phases 1 2) isn't failed for a missing P7. 

1109 _, core_for_arch = _full_and_core_phases(arch) 

1110 score_error = _check_phase_scores( 

1111 phase_scores, all_results, required_phases=core_for_arch & set(eff_phases) 

1112 ) 

1113 if score_error: 

1114 error_msg = score_error 

1115 

1116 if error_msg: 

1117 is_oom = "out of memory" in error_msg.lower() or "oom" in error_msg.lower() 

1118 if is_oom: 

1119 note = "OOM during benchmark" 

1120 else: 

1121 # Include the specific error from failed results (e.g., tokenizer 

1122 # errors, load failures) so the note explains WHY it failed. 

1123 root_errors = [r.message for r in all_results if not r.passed and r.message] 

1124 if root_errors: 

1125 # Deduplicate and use first unique error as the detail 

1126 unique_errors = list(dict.fromkeys(root_errors)) 

1127 detail = unique_errors[0][:150] 

1128 note = f"{error_msg[:100]} — {detail}" 

1129 else: 

1130 note = error_msg[:200] 

1131 final_status = STATUS_FAILED 

1132 else: 

1133 note = _build_verified_note(phase_scores, all_results) 

1134 final_status = STATUS_VERIFIED 

1135 

1136 # When running a partial phase set (e.g., --phases 4 for backfill), 

1137 # only update the phase scores that were run. Don't change the 

1138 # model's overall status or note — those reflect the full 

1139 # verification and should only be set by a complete run. 

1140 kind = classify_architecture(arch) 

1141 is_multimodal = kind == "multimodal" 

1142 is_audio = kind == "audio" 

1143 is_vision = kind == "vision" 

1144 full_phases, core_required = _full_and_core_phases(arch) 

1145 is_partial_run = set(eff_phases) != full_phases 

1146 

1147 if is_partial_run and phase_scores: 

1148 # Only write scores for phases that were actually requested. 

1149 # Bridge load failures can produce Phase 1-tagged error results 

1150 # even during Phase 4-only runs — don't let those corrupt 

1151 # existing scores for unrequested phases. 

1152 filtered_scores = {p: s for p, s in phase_scores.items() if p in eff_phases} 

1153 if filtered_scores: 

1154 if not quiet: 

1155 score_parts = [f"P{p}={s}%" for p, s in sorted(filtered_scores.items())] 

1156 print(f" Partial phase update: {', '.join(score_parts)}") 

1157 

1158 # Core verification: P1+P4 for text-only, P1+P4+P7 for 

1159 # multimodal, P9 for vision. 

1160 is_core_verification = set(eff_phases) >= core_required 

1161 partial_status = None 

1162 partial_note = None 

1163 

1164 if is_core_verification: 

1165 p1 = filtered_scores.get(1) 

1166 p4 = filtered_scores.get(4) 

1167 p1_pass = p1 is not None and p1 >= _MIN_PHASE_SCORES.get( 

1168 1, _DEFAULT_MIN_PHASE_SCORE 

1169 ) 

1170 p4_pass = p4 is not None and p4 >= _MIN_PHASE_SCORES.get( 

1171 4, _DEFAULT_MIN_PHASE_SCORE 

1172 ) 

1173 

1174 # For multimodal, Phase 7 is required. A score below 75% 

1175 # or a missing score (NULL — processor unavailable) both 

1176 # count as failures. 

1177 p7_pass = True 

1178 if is_multimodal: 

1179 p7 = filtered_scores.get(7) 

1180 if p7 is not None: 

1181 p7_pass = p7 >= _MIN_PHASE_SCORES.get(7, _DEFAULT_MIN_PHASE_SCORE) 

1182 else: 

1183 p7_pass = False 

1184 

1185 # For audio models, Phase 8 is required; Phase 4 is not applicable 

1186 p8_pass = True 

1187 if is_audio: 

1188 p4_pass = True # Audio models skip text quality 

1189 p8 = filtered_scores.get(8) 

1190 if p8 is not None: 

1191 p8_pass = p8 >= _MIN_PHASE_SCORES.get(8, _DEFAULT_MIN_PHASE_SCORE) 

1192 else: 

1193 p8_pass = False 

1194 

1195 # For vision models, Phase 9 is required; text phases 1/4 

1196 # are not applicable (no text tower). 

1197 p9_pass = True 

1198 if is_vision: 

1199 p1_pass = True 

1200 p4_pass = True 

1201 p9 = filtered_scores.get(9) 

1202 if p9 is not None: 

1203 p9_pass = p9 >= _MIN_PHASE_SCORES.get(9, _DEFAULT_MIN_PHASE_SCORE) 

1204 else: 

1205 p9_pass = False 

1206 

1207 if p1_pass and p4_pass and p7_pass and p8_pass and p9_pass: 

1208 partial_status = STATUS_VERIFIED 

1209 partial_note = "Core verification completed" + _preserved_issue_suffix( 

1210 model_id, eff_phases 

1211 ) 

1212 elif p1_pass and p4_pass and not p7_pass: 

1213 p7_score = filtered_scores.get(7) 

1214 if p7_score is None: 

1215 partial_status = STATUS_FAILED 

1216 partial_note = ( 

1217 "Core verification failed: multimodal tests skipped " 

1218 "(processor unavailable)" 

1219 ) 

1220 else: 

1221 partial_status = STATUS_FAILED 

1222 partial_note = ( 

1223 f"Core verification failed: multimodal tests " 

1224 f"scored {p7_score}% (requires >= " 

1225 f"{_MIN_PHASE_SCORES.get(7, _DEFAULT_MIN_PHASE_SCORE):g}%)" 

1226 ) 

1227 elif p1_pass and p4_pass and not p9_pass: 

1228 p9_score = filtered_scores.get(9) 

1229 partial_status = STATUS_FAILED 

1230 if p9_score is None: 

1231 partial_note = ( 

1232 "Core verification failed: vision tests skipped (no results)" 

1233 ) 

1234 else: 

1235 partial_note = ( 

1236 f"Core verification failed: vision tests " 

1237 f"scored {p9_score}% (requires >= " 

1238 f"{_MIN_PHASE_SCORES.get(9, _DEFAULT_MIN_PHASE_SCORE):g}%)" 

1239 ) 

1240 elif p1_pass: 

1241 partial_status = STATUS_VERIFIED 

1242 partial_note = _p1_only_core_note(p4, all_results) 

1243 else: 

1244 # P1 failed — build a descriptive failure note 

1245 partial_status = STATUS_FAILED 

1246 if error_msg: 

1247 partial_note = f"CORE FAILED: {error_msg[:200]}" 

1248 else: 

1249 # Score-based failure — include details 

1250 from transformer_lens.benchmarks.utils import ( 

1251 BenchmarkSeverity, 

1252 ) 

1253 

1254 failed_tests = [ 

1255 r.name 

1256 for r in all_results 

1257 if r.phase == 1 

1258 and not r.passed 

1259 and r.severity != BenchmarkSeverity.SKIPPED 

1260 ] 

1261 tests_str = ", ".join(failed_tests) if failed_tests else "unknown" 

1262 partial_note = f"CORE FAILED: P1={p1}% (failed: {tests_str})" 

1263 

1264 # A structural-only core pass (--no-hf-reference) is provisional, 

1265 # never numerically compared to HF, so it must not count as verified. 

1266 if partial_status == STATUS_VERIFIED and not use_hf_reference: 

1267 partial_status = STATUS_PROVISIONAL 

1268 partial_note = f"Structural only (no HF reference): {partial_note}" 

1269 

1270 if not quiet: 

1271 print(f" {partial_note}") 

1272 

1273 update_model_status( 

1274 model_id, 

1275 arch, 

1276 status=partial_status, 

1277 phase_scores=filtered_scores, 

1278 note=partial_note, 

1279 prompt_profile=_extract_prompt_profile(all_results), 

1280 ) 

1281 # A provisional run was not numerically verified; do not write a 

1282 # verification-history record (VerificationHistory.is_verified() 

1283 # treats any record as verified — a second "counts as verified" path). 

1284 if partial_status != STATUS_PROVISIONAL: 

1285 add_verification_record( 

1286 model_id, 

1287 arch, 

1288 notes=partial_note, 

1289 sanitize_fn=_sanitize_note, 

1290 prompt_profile=_extract_prompt_profile(all_results), 

1291 p4_scoring_version=(P4_SCORING_VERSION if 4 in filtered_scores else None), 

1292 ) 

1293 if partial_status == STATUS_FAILED: 

1294 progress.failed.append(model_id) 

1295 elif partial_status == STATUS_PROVISIONAL: 

1296 progress.provisional.append(model_id) 

1297 elif partial_status is None: 

1298 # Scores were written but the status deliberately was not: 

1299 # this phase set cannot establish core verification for 

1300 # this architecture class. Reporting it as verified is how 

1301 # a model ends up with all-pass scores and status 0. 

1302 if not quiet: 

1303 print( 

1304 f" Scores updated, status unchanged — core verification for " 

1305 f"{arch} requires phases {sorted(core_required)}, " 

1306 f"this run had {sorted(eff_phases)}." 

1307 ) 

1308 progress.skipped.append(model_id) 

1309 else: 

1310 progress.verified.append(model_id) 

1311 else: 

1312 if not quiet: 

1313 print(f" No results for requested phases {eff_phases} — skipping update") 

1314 progress.skipped.append(model_id) 

1315 elif final_status == STATUS_VERIFIED and _measured_nothing(phase_scores): 

1316 if not quiet: 

1317 print( 

1318 f" No phase produced a score (requested {eff_phases}) — " 

1319 f"status left unchanged" 

1320 ) 

1321 progress.skipped.append(model_id) 

1322 elif final_status == STATUS_VERIFIED: 

1323 # A passing run is VERIFIED only if it was numerically compared to an 

1324 # HF reference; a --no-hf-reference (structural-only) pass is PROVISIONAL. 

1325 written_status = pass_status(use_hf_reference) 

1326 is_provisional = written_status == STATUS_PROVISIONAL 

1327 if is_provisional: 

1328 note = f"Structural only (no HF reference): {note}" 

1329 if not quiet: 

1330 label = "PROVISIONAL" if is_provisional else "VERIFIED" 

1331 print( 

1332 f" {label}: P1={phase_scores.get(1)}%, " 

1333 f"P2={phase_scores.get(2)}%, P3={phase_scores.get(3)}%, " 

1334 f"P4={phase_scores.get(4)}%, P7={phase_scores.get(7)}%, " 

1335 f"P8={phase_scores.get(8)}%, P9={phase_scores.get(9)}%" 

1336 ) 

1337 update_model_status( 

1338 model_id, 

1339 arch, 

1340 written_status, 

1341 phase_scores=phase_scores, 

1342 note=note, 

1343 prompt_profile=_extract_prompt_profile(all_results), 

1344 ) 

1345 # Provisional runs are not numerically verified — no history record 

1346 # (is_verified() would otherwise report them as verified). 

1347 if not is_provisional: 

1348 add_verification_record( 

1349 model_id, 

1350 arch, 

1351 notes=note, 

1352 prompt_profile=_extract_prompt_profile(all_results), 

1353 p4_scoring_version=(P4_SCORING_VERSION if 4 in phase_scores else None), 

1354 ) 

1355 if is_provisional: 

1356 progress.provisional.append(model_id) 

1357 else: 

1358 progress.verified.append(model_id) 

1359 else: 

1360 if not quiet: 

1361 print(f" FAILED: {note}") 

1362 if any(v is not None for v in phase_scores.values()): 

1363 print( 

1364 f" Partial scores saved: P1={phase_scores.get(1)}%, " 

1365 f"P2={phase_scores.get(2)}%, P3={phase_scores.get(3)}%, " 

1366 f"P4={phase_scores.get(4)}%, P7={phase_scores.get(7)}%, " 

1367 f"P8={phase_scores.get(8)}%, P9={phase_scores.get(9)}%" 

1368 ) 

1369 update_model_status( 

1370 model_id, 

1371 arch, 

1372 STATUS_FAILED, 

1373 note=note, 

1374 phase_scores=phase_scores, 

1375 sanitize_fn=_sanitize_note, 

1376 prompt_profile=_extract_prompt_profile(all_results), 

1377 ) 

1378 add_verification_record( 

1379 model_id, 

1380 arch, 

1381 notes=note, 

1382 sanitize_fn=_sanitize_note, 

1383 prompt_profile=_extract_prompt_profile(all_results), 

1384 p4_scoring_version=(P4_SCORING_VERSION if 4 in phase_scores else None), 

1385 ) 

1386 progress.failed.append(model_id) 

1387 

1388 # Post-model cleanup 

1389 gc.collect() 

1390 try: 

1391 import torch 

1392 

1393 if torch.cuda.is_available(): 

1394 torch.cuda.empty_cache() 

1395 torch.cuda.synchronize() 

1396 if device == "mps" and hasattr(torch, "mps") and torch.backends.mps.is_available(): 

1397 torch.mps.synchronize() 

1398 torch.mps.empty_cache() 

1399 

1400 # Log MPS memory state for debugging long runs 

1401 if device == "mps" and not quiet and hasattr(torch.mps, "current_allocated_memory"): 

1402 alloc_mb = torch.mps.current_allocated_memory() / (1024 * 1024) 

1403 driver_mb = torch.mps.driver_allocated_memory() / (1024 * 1024) 

1404 print(f" MPS memory: {alloc_mb:.0f} MB allocated, " f"{driver_mb:.0f} MB driver") 

1405 except ImportError: 

1406 pass 

1407 

1408 # Brief pause to let the OS and MPS reclaim memory between models 

1409 if device in ("mps", "cuda"): 

1410 time.sleep(3) 

1411 

1412 # Periodically clear the HuggingFace cache to prevent disk exhaustion 

1413 if i % 50 == 0: 

1414 _clear_hf_cache(quiet) 

1415 

1416 _save_checkpoint(progress) 

1417 

1418 # Clean up pre-loaded scoring model 

1419 if _judge_model is not None: 

1420 del _judge_model 

1421 del _judge_tokenizer 

1422 gc.collect() 

1423 

1424 return progress 

1425 

1426 

1427def _print_dry_run( 

1428 candidates: list[ModelCandidate], 

1429 dtype: str, 

1430 max_memory_gb: float, 

1431 phases: Optional[list[int]] = None, 

1432 use_hf_reference: bool = True, 

1433 device: str = "cpu", 

1434) -> None: 

1435 """Print what would be tested in a dry run.""" 

1436 print(f"\nDry run: {len(candidates)} models would be tested") 

1437 print(f"Memory limit: {max_memory_gb:.1f} GB | Dtype: {dtype}") 

1438 print() 

1439 

1440 # Group by architecture 

1441 by_arch: dict[str, list[ModelCandidate]] = {} 

1442 for c in candidates: 

1443 by_arch.setdefault(c.architecture_id, []).append(c) 

1444 

1445 skippable = 0 

1446 testable = 0 

1447 

1448 for arch in sorted(by_arch.keys()): 

1449 models = by_arch[arch] 

1450 # Same per-architecture default as the real run, so the dry run's 

1451 # memory estimates cover the phases that will actually execute. 

1452 eff_phases = phases if phases is not None else _default_phases_for_architecture(arch) 

1453 phases_to_run = _phases_to_run(arch, eff_phases) 

1454 print(f" {arch} ({len(models)} models):") 

1455 for c in models: 

1456 try: 

1457 n_params = estimate_model_params(c.model_id) 

1458 mem = estimate_benchmark_memory_gb( 

1459 n_params, 

1460 dtype, 

1461 phases=phases_to_run, 

1462 use_hf_reference=use_hf_reference, 

1463 device=device, 

1464 ) 

1465 status = "OK" if mem <= max_memory_gb else "SKIP (too large)" 

1466 if mem > max_memory_gb: 

1467 skippable += 1 

1468 else: 

1469 testable += 1 

1470 print(f" {c.model_id}: ~{n_params/1e6:.0f}M params, ~{mem:.1f} GB [{status}]") 

1471 except Exception as e: 

1472 skippable += 1 

1473 print(f" {c.model_id}: config error ({e})") 

1474 print() 

1475 

1476 print(f"Summary: {testable} testable, {skippable} would be skipped") 

1477 

1478 

1479def _print_summary(progress: VerificationProgress) -> None: 

1480 """Print a summary of the verification run.""" 

1481 total = len(progress.tested) 

1482 print(f"\n{'='*70}") 

1483 print("Verification Summary") 

1484 print(f"{'='*70}") 

1485 print(f" Total tested: {total}") 

1486 print(f" Verified: {len(progress.verified)}") 

1487 print(f" Provisional: {len(progress.provisional)}") 

1488 print(f" Skipped: {len(progress.skipped)}") 

1489 print(f" Failed: {len(progress.failed)}") 

1490 

1491 if progress.verified: 

1492 print(f"\n Verified models:") 

1493 for m in progress.verified: 

1494 print(f" - {m}") 

1495 

1496 if progress.provisional: 

1497 print(f"\n Provisional models (structural only, no HF reference):") 

1498 for m in progress.provisional: 

1499 print(f" - {m}") 

1500 

1501 if progress.failed: 

1502 print(f"\n Failed models:") 

1503 for m in progress.failed: 

1504 print(f" - {m}") 

1505 

1506 if progress.skipped: 

1507 print(f"\n Skipped models:") 

1508 for m in progress.skipped[:20]: 

1509 print(f" - {m}") 

1510 if len(progress.skipped) > 20: 

1511 print(f" ... and {len(progress.skipped) - 20} more") 

1512 

1513 

1514def main() -> None: 

1515 """CLI entry point for batch model verification.""" 

1516 parser = argparse.ArgumentParser( 

1517 description="Batch verify models in the TransformerLens registry", 

1518 formatter_class=argparse.RawDescriptionHelpFormatter, 

1519 epilog=""" 

1520Examples: 

1521 %(prog)s --dry-run Show what would be tested 

1522 %(prog)s --limit 3 Test 3 models total 

1523 %(prog)s --architectures GPT2LMHeadModel --per-arch 5 

1524 %(prog)s --device cuda --max-memory 24 

1525 %(prog)s --resume Resume from checkpoint 

1526 %(prog)s --reverify --architectures Olmo2ForCausalLM Re-verify already-tested models 

1527 %(prog)s --model google/gemma-2b Verify a single model by ID 

1528 """, 

1529 ) 

1530 parser.add_argument( 

1531 "--per-arch", 

1532 type=int, 

1533 default=10, 

1534 help="Max models to verify per architecture (default: 10)", 

1535 ) 

1536 parser.add_argument( 

1537 "--device", 

1538 type=str, 

1539 default="cpu", 

1540 help="Device for benchmarks (default: cpu)", 

1541 ) 

1542 parser.add_argument( 

1543 "--max-memory", 

1544 type=float, 

1545 default=None, 

1546 help="Memory limit in GB (default: auto-detect)", 

1547 ) 

1548 parser.add_argument( 

1549 "--architectures", 

1550 nargs="+", 

1551 default=None, 

1552 help="Filter to specific architectures", 

1553 ) 

1554 parser.add_argument( 

1555 "--limit", 

1556 type=int, 

1557 default=None, 

1558 help="Total model cap", 

1559 ) 

1560 parser.add_argument( 

1561 "--resume", 

1562 action="store_true", 

1563 help="Resume from checkpoint", 

1564 ) 

1565 parser.add_argument( 

1566 "--dry-run", 

1567 action="store_true", 

1568 help="Show what would be tested without running benchmarks", 

1569 ) 

1570 parser.add_argument( 

1571 "--no-hf-reference", 

1572 action="store_true", 

1573 help=( 

1574 "Skip HuggingFace reference comparison (Phase 1 is structural-only). " 

1575 "A passing run is recorded as PROVISIONAL, not verified — re-run without " 

1576 "this flag for a real HF-compared verification." 

1577 ), 

1578 ) 

1579 parser.add_argument( 

1580 "--phases", 

1581 nargs="+", 

1582 type=int, 

1583 default=None, 

1584 help=( 

1585 "Which benchmark phases to run (default: a full verification for each " 

1586 "model's architecture — 1 2 3 4 for text, 1 2 3 4 7 for multimodal, " 

1587 "1 8 for audio, 1 9 for vision)" 

1588 ), 

1589 ) 

1590 parser.add_argument( 

1591 "--dtype", 

1592 type=str, 

1593 default="float32", 

1594 choices=["float32", "float16", "bfloat16"], 

1595 help="Dtype for memory estimation (default: float32)", 

1596 ) 

1597 parser.add_argument( 

1598 "--quiet", 

1599 action="store_true", 

1600 help="Suppress verbose output", 

1601 ) 

1602 parser.add_argument( 

1603 "--retry-failed", 

1604 action="store_true", 

1605 help="Re-run previously failed models instead of skipping them", 

1606 ) 

1607 parser.add_argument( 

1608 "--reverify", 

1609 action="store_true", 

1610 help="Re-run verification for already-verified/skipped/failed models. " 

1611 "Ignores previous status and re-tests matching models from scratch.", 

1612 ) 

1613 parser.add_argument( 

1614 "--model", 

1615 type=str, 

1616 nargs="+", 

1617 default=None, 

1618 help="Verify one or more models by HuggingFace model ID. " 

1619 "Looks up architecture from the registry automatically.", 

1620 ) 

1621 

1622 args = parser.parse_args() 

1623 

1624 # Setup logging 

1625 logging.basicConfig( 

1626 level=logging.WARNING if args.quiet else logging.INFO, 

1627 format="%(asctime)s [%(levelname)s] %(message)s", 

1628 ) 

1629 

1630 # Auto-detect memory 

1631 max_memory_gb = args.max_memory 

1632 if max_memory_gb is None: 

1633 max_memory_gb = get_available_memory_gb(args.device) 

1634 

1635 # Load checkpoint if resuming 

1636 progress = None 

1637 if args.resume: 

1638 progress = _load_checkpoint() 

1639 if progress: 

1640 print(f"Resuming from checkpoint: {len(progress.tested)} models already tested") 

1641 else: 

1642 print("No checkpoint found, starting fresh") 

1643 

1644 # If retrying failed, clean them from checkpoint and reset status in registry 

1645 if args.retry_failed and progress and not args.dry_run: 

1646 failed_set = set(progress.failed) 

1647 if failed_set: 

1648 # Reset status in supported_models.json 

1649 registry_data = load_supported_models_raw() 

1650 for entry in registry_data.get("models", []): 

1651 if entry["model_id"] in failed_set and entry.get("status") == STATUS_FAILED: 

1652 update_model_status( 

1653 entry["model_id"], 

1654 entry["architecture_id"], 

1655 STATUS_UNVERIFIED, 

1656 ) 

1657 # Clean checkpoint 

1658 progress.tested = [m for m in progress.tested if m not in failed_set] 

1659 progress.failed = [] 

1660 _save_checkpoint(progress) 

1661 print(f" Cleared {len(failed_set)} failed models for retry") 

1662 

1663 # Select models — either --model list or the normal batch selection 

1664 if args.model: 

1665 # Look up architecture for each model from the registry 

1666 registry_data = load_supported_models_raw() 

1667 candidates = [] 

1668 for model_id in args.model: 

1669 arch_id = None 

1670 for entry in registry_data.get("models", []): 

1671 if entry["model_id"] == model_id: 

1672 arch_id = entry["architecture_id"] 

1673 break 

1674 if arch_id is None: 

1675 print(f"Model '{model_id}' not found in supported_models.json, skipping") 

1676 continue 

1677 candidates.append(ModelCandidate(model_id=model_id, architecture_id=arch_id)) 

1678 if not candidates: 

1679 print("No valid models found in registry") 

1680 return 

1681 print(f"Model list mode: {len(candidates)} model(s)") 

1682 else: 

1683 candidates = select_models_for_verification( 

1684 per_arch=args.per_arch, 

1685 architectures=args.architectures, 

1686 limit=args.limit, 

1687 resume_progress=progress, 

1688 retry_failed=args.retry_failed, 

1689 reverify=args.reverify, 

1690 ) 

1691 

1692 if not candidates: 

1693 print("No models to verify (all matching models already tested)") 

1694 return 

1695 

1696 print(f"Selected {len(candidates)} models for verification") 

1697 

1698 # Dry run 

1699 if args.dry_run: 

1700 _print_dry_run( 

1701 candidates, 

1702 args.dtype, 

1703 max_memory_gb, 

1704 phases=args.phases, 

1705 use_hf_reference=not args.no_hf_reference, 

1706 device=args.device, 

1707 ) 

1708 return 

1709 

1710 # Install graceful interrupt handler (Ctrl+C stops between models) 

1711 signal.signal(signal.SIGINT, _handle_sigint) 

1712 

1713 # Run verification 

1714 start = time.time() 

1715 progress = verify_models( 

1716 candidates, 

1717 device=args.device, 

1718 max_memory_gb=max_memory_gb, 

1719 dtype=args.dtype, 

1720 use_hf_reference=not args.no_hf_reference, 

1721 phases=args.phases, 

1722 quiet=args.quiet, 

1723 progress=progress, 

1724 ) 

1725 elapsed = time.time() - start 

1726 

1727 _print_summary(progress) 

1728 print(f"\nTotal time: {elapsed:.1f}s") 

1729 

1730 # Clean up checkpoint on successful completion 

1731 if _CHECKPOINT_PATH.exists(): 

1732 _CHECKPOINT_PATH.unlink() 

1733 print("Checkpoint cleared (run complete)") 

1734 

1735 

1736if __name__ == "__main__": 1736 ↛ 1737line 1736 didn't jump to line 1737 because the condition on line 1736 was never true

1737 main()