Coverage for transformer_lens/tools/model_registry/registry_io.py: 88%

168 statements  

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

1"""Shared I/O functions for reading and writing model registry data files. 

2 

3Consolidates the load-modify-save pattern used by verify_models.py and 

4main_benchmark.py into a single module that properly uses the 

5VerificationRecord/VerificationHistory dataclasses. 

6""" 

7 

8import json 

9import logging 

10from datetime import date 

11from functools import lru_cache 

12from pathlib import Path 

13from typing import Callable, Optional 

14 

15from .verification import VerificationHistory, VerificationRecord 

16 

17logger = logging.getLogger(__name__) 

18 

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

20_SUPPORTED_MODELS_PATH = _DATA_DIR / "supported_models.json" 

21_VERIFICATION_HISTORY_PATH = _DATA_DIR / "verification_history.json" 

22_MODEL_ALIASES_PATH = _DATA_DIR / "model_aliases.json" 

23 

24# Status codes 

25STATUS_UNVERIFIED = 0 

26STATUS_VERIFIED = 1 

27STATUS_SKIPPED = 2 

28STATUS_FAILED = 3 

29# Structural-only pass (--no-hf-reference): Phase 1 ran without an HF reference, 

30# so the forward was never numerically compared to HuggingFace. Recorded as a 

31# real result but deliberately NOT counted as verified. 

32STATUS_PROVISIONAL = 4 

33 

34# Human-readable labels for docs display. STATUS_SKIPPED deliberately renders as 

35# "Unverified": a skip (memory/tooling) is not a verification outcome. 

36STATUS_LABELS: dict[int, str] = { 

37 STATUS_UNVERIFIED: "Unverified", 

38 STATUS_VERIFIED: "Verified", 

39 STATUS_SKIPPED: "Unverified", 

40 STATUS_FAILED: "Failed", 

41 STATUS_PROVISIONAL: "Provisional", 

42} 

43 

44# Registry phase-score columns: text 1-4, multimodal 7, audio 8, vision 9. 

45# PHASES is derived so the full column set can never drift from the two groups. 

46TEXT_PHASES: tuple[int, ...] = (1, 2, 3, 4) 

47MODALITY_PHASES: tuple[int, ...] = (7, 8, 9) 

48PHASES: tuple[int, ...] = TEXT_PHASES + MODALITY_PHASES 

49 

50# HF-loadable quantization formats. Admitted to the registry; verification gates 

51# on `required_quant_library_for_model()` at run time. 

52_HF_LOADABLE_QUANT_PATTERNS = [ 

53 "-awq", 

54 "_awq", 

55 "-AWQ", 

56 "_AWQ", 

57 "-gptq", 

58 "_gptq", 

59 "-GPTQ", 

60 "_GPTQ", 

61 "GPTQ", 

62 "-bnb-", 

63 "_bnb_", 

64 "bnb-4bit", 

65 "bnb-8bit", 

66 "-4bit", 

67 "_4bit", 

68 "-8bit", 

69 "_8bit", 

70 "-int4", 

71 "_int4", 

72 "-int8", 

73 "_int8", 

74 "-w4a16", 

75 "-w8a8", 

76 "-W4A16", 

77 "-W8A8", 

78 ".w4a16", 

79 ".W4A16", 

80 "-hqq", 

81 "_hqq", 

82 "-HQQ", 

83 "_HQQ", 

84 "-3bit", 

85 "_3bit", 

86 "-2bit", 

87 "_2bit", 

88 "-5bit", 

89 "-6bit", 

90 "-oQ", 

91 "_oQ", 

92 "-quantized.", 

93 "_Quantized", 

94 "-Quantized", 

95] 

96 

97# Formats that need a non-HF loader (GGUF→llama.cpp, MLX→Apple, FP4/FP8→NVIDIA). 

98_INCOMPATIBLE_QUANT_PATTERNS = [ 

99 "-gguf", 

100 "_gguf", 

101 "-GGUF", 

102 "_GGUF", 

103 "mlx-community/", 

104 "-mlx", 

105 "-MLX", 

106 "_mlx", 

107 "_MLX", 

108 ".mlx", 

109 ".MLX", 

110 "-fp8", 

111 "_fp8", 

112 "-FP8", 

113 "_FP8", 

114 "-nvfp4", 

115 "_nvfp4", 

116 "-NVFP4", 

117 "_NVFP4", 

118 "-mxfp4", 

119 "_mxfp4", 

120 "-MXFP4", 

121 "_MXFP4", 

122] 

123 

124# Values are Python import names, not PyPI package names. Order matters: explicit 

125# format markers must precede generic bit-width markers (HQQ-4bit IDs match both). 

126_QUANT_LIBRARY_BY_PATTERN: list[tuple[tuple[str, ...], str]] = [ 

127 (("-hqq", "_hqq", "-HQQ", "_HQQ"), "hqq"), 

128 (("-gptq", "_gptq", "-GPTQ", "_GPTQ", "GPTQ"), "auto_gptq"), 

129 (("-awq", "_awq", "-AWQ", "_AWQ"), "awq"), 

130 (("-w4a16", "-w8a8", "-W4A16", "-W8A8", ".w4a16", ".W4A16"), "auto_gptq"), 

131 (("-bnb-", "_bnb_", "bnb-4bit", "bnb-8bit"), "bitsandbytes"), 

132 (("-4bit", "_4bit", "-8bit", "_8bit", "-int4", "_int4", "-int8", "_int8"), "bitsandbytes"), 

133] 

134 

135QUANTIZED_NOTE = "Quantized format not loadable by HF transformers" 

136 

137 

138def is_incompatible_quantized(model_id: str) -> bool: 

139 """True for quantization formats the bridge can't ingest (GGUF, MLX, FP4/FP8).""" 

140 return any(pat in model_id for pat in _INCOMPATIBLE_QUANT_PATTERNS) 

141 

142 

143def is_hf_loadable_quantized(model_id: str) -> bool: 

144 """True for quantizations loadable by HF transformers + a quant library.""" 

145 return any(pat in model_id for pat in _HF_LOADABLE_QUANT_PATTERNS) 

146 

147 

148def required_quant_library_for_model(model_id: str) -> Optional[str]: 

149 """Return the Python import name needed to load this model, or None if unquantized.""" 

150 for patterns, library in _QUANT_LIBRARY_BY_PATTERN: 

151 if any(pat in model_id for pat in patterns): 

152 return library 

153 return None 

154 

155 

156def is_quantized_model(model_id: str) -> bool: 

157 """Alias for ``is_incompatible_quantized`` — kept for back-compat with existing call sites.""" 

158 return is_incompatible_quantized(model_id) 

159 

160 

161@lru_cache(maxsize=1) 

162def load_model_aliases() -> dict[str, list[str]]: 

163 """Load the canonical alias table: official HF model name -> deprecated short aliases.""" 

164 with open(_MODEL_ALIASES_PATH) as f: 

165 return json.load(f)["aliases"] 

166 

167 

168def resolve_model_alias(model_name: str) -> Optional[str]: 

169 """Return the official HF name if ``model_name`` is a deprecated alias, else None.""" 

170 for official_name, aliases in load_model_aliases().items(): 

171 if model_name in aliases: 

172 return official_name 

173 return None 

174 

175 

176def load_supported_models_raw() -> dict: 

177 """Load supported_models.json as a raw dict.""" 

178 with open(_SUPPORTED_MODELS_PATH) as f: 

179 return json.load(f) 

180 

181 

182def save_supported_models_raw(data: dict) -> None: 

183 """Save raw dict back to supported_models.json.""" 

184 with open(_SUPPORTED_MODELS_PATH, "w") as f: 

185 json.dump(data, f, indent=2) 

186 f.write("\n") 

187 

188 

189def load_verification_history() -> VerificationHistory: 

190 """Load verification_history.json into a VerificationHistory dataclass.""" 

191 if _VERIFICATION_HISTORY_PATH.exists(): 

192 with open(_VERIFICATION_HISTORY_PATH) as f: 

193 data = json.load(f) 

194 return VerificationHistory.from_dict(data) 

195 return VerificationHistory() 

196 

197 

198def save_verification_history(history: VerificationHistory) -> None: 

199 """Save VerificationHistory dataclass to verification_history.json.""" 

200 with open(_VERIFICATION_HISTORY_PATH, "w") as f: 

201 json.dump(history.to_dict(), f, indent=2) 

202 f.write("\n") 

203 

204 

205def _get_tl_version() -> Optional[str]: 

206 """Get the current TransformerLens version, or None.""" 

207 try: 

208 import transformer_lens 

209 

210 version = getattr(transformer_lens, "__version__", None) 

211 if version: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 return str(version) 

213 # The package exports no __version__; installed-distribution 

214 # metadata is the fallback (dev installs record 0.0.0). 

215 from importlib.metadata import version as dist_version 

216 

217 return dist_version("transformer-lens") 

218 except Exception: 

219 return None 

220 

221 

222def pass_status(use_hf_reference: bool) -> int: 

223 """Status for a passing run: VERIFIED with an HF reference, else PROVISIONAL 

224 (a --no-hf-reference structural-only pass is recorded but not counted verified).""" 

225 return STATUS_VERIFIED if use_hf_reference else STATUS_PROVISIONAL 

226 

227 

228def extract_phase_scores(results: list) -> dict[int, Optional[float]]: 

229 """Extract phase scores from benchmark results. 

230 

231 Shared home for both registry-writing paths (verify_models and 

232 main_benchmark.update_model_registry) so they cannot drift. 

233 

234 Args: 

235 results: List of BenchmarkResult objects 

236 

237 Returns: 

238 Dict mapping phase number to score (0-100) or None 

239 """ 

240 from transformer_lens.benchmarks.utils import BenchmarkSeverity 

241 

242 phase_results: dict[int, list[bool]] = {phase: [] for phase in PHASES} 

243 for result in results: 

244 if result.phase in phase_results and result.severity != BenchmarkSeverity.SKIPPED: 

245 phase_results[result.phase].append(result.passed) 

246 

247 scores: dict[int, Optional[float]] = {} 

248 for phase, passed_list in phase_results.items(): 

249 if passed_list: 

250 scores[phase] = round(sum(passed_list) / len(passed_list) * 100, 1) 

251 # Omit phases with no results — they weren't run, so their 

252 # existing registry scores should be preserved. 

253 

254 # Phase 4 (text quality): store the actual 0-100 quality score from the 

255 # benchmark details instead of a binary pass/fail percentage. 

256 if 4 in scores: 

257 for result in results: 257 ↛ 262line 257 didn't jump to line 262 because the loop on line 257 didn't complete

258 if result.phase == 4 and result.details and "score" in result.details: 

259 scores[4] = round(result.details["score"], 1) 

260 break 

261 

262 return scores 

263 

264 

265def recompute_registry_totals(models: list[dict]) -> dict: 

266 """Header totals for supported_models.json, recomputed from the models list. 

267 

268 Shared by both writers (``update_model_status`` here and hf_scraper's report 

269 builder) so the counting rules cannot drift. 

270 """ 

271 return { 

272 "total_architectures": len({m["architecture_id"] for m in models}), 

273 "total_models": len(models), 

274 "total_verified": sum(1 for m in models if m.get("status", 0) == STATUS_VERIFIED), 

275 "total_provisional": sum(1 for m in models if m.get("status", 0) == STATUS_PROVISIONAL), 

276 } 

277 

278 

279def update_model_status( 

280 model_id: str, 

281 arch_id: str, 

282 status: Optional[int] = None, 

283 note: Optional[str] = None, 

284 phase_scores: Optional[dict[int, Optional[float]]] = None, 

285 sanitize_fn: Optional[Callable[[Optional[str]], Optional[str]]] = None, 

286 prompt_profile: Optional[str] = None, 

287) -> bool: 

288 """Update a single model entry in supported_models.json. 

289 

290 If the model is not found in the registry and status is STATUS_VERIFIED or 

291 STATUS_PROVISIONAL, a new entry is appended. 

292 

293 When status is None (partial-phase update), only the provided phase_scores 

294 are updated — status, note, and other scores are preserved. 

295 

296 Args: 

297 model_id: The model to update 

298 arch_id: Architecture of the model 

299 status: New status code (0-4), or None for score-only updates 

300 note: Optional note for skip/fail reason 

301 phase_scores: Phase score dict {1: float, 2: float, 3: float, 4: float} 

302 sanitize_fn: Optional callable to sanitize note strings 

303 prompt_profile: Phase-4 prompt profile actually used (e.g. 

304 "task:translation@en-de"). Sparse: the default "continuation" 

305 removes the key (clearing a stale non-default value), None (no 

306 Phase-4 result) leaves it untouched. 

307 

308 Returns: 

309 True if entry was found/created and updated 

310 """ 

311 # Deferred: benchmarks imports model_bridge, and this module loads during 

312 # `import transformer_lens` (via supported_models) — importing it at module 

313 # scope closes an import cycle back through the bridge's HF source. 

314 from transformer_lens.benchmarks.text_quality_profiles import ( 

315 P4_SCORING_VERSION, 

316 is_default_profile, 

317 ) 

318 

319 if phase_scores is None: 

320 phase_scores = {} 

321 

322 if sanitize_fn and note: 

323 note = sanitize_fn(note) 

324 

325 data = load_supported_models_raw() 

326 updated = False 

327 

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

329 if entry["model_id"] == model_id and entry["architecture_id"] == arch_id: 

330 if status is not None: 330 ↛ 336line 330 didn't jump to line 336 because the condition on line 330 was always true

331 entry["status"] = status 

332 entry["verified_date"] = ( 

333 date.today().isoformat() if status != STATUS_UNVERIFIED else None 

334 ) 

335 entry["note"] = note 

336 elif note is not None: 

337 # Score-only update with an explicit note — overwrite stale notes 

338 entry["note"] = note 

339 elif phase_scores and "exceeds" in (entry.get("note") or "").lower(): 

340 # Writing real scores clears a stale memory-skip note 

341 entry["note"] = None 

342 for phase_num in PHASES: 

343 key = f"phase{phase_num}_score" 

344 if phase_num in phase_scores: 

345 entry[key] = phase_scores[phase_num] 

346 elif key not in entry: 

347 entry[key] = None 

348 if prompt_profile is not None and is_default_profile(prompt_profile): 

349 entry.pop("prompt_profile", None) 

350 elif prompt_profile is not None: 

351 entry["prompt_profile"] = prompt_profile 

352 if 4 in phase_scores: 

353 entry["p4_scoring_version"] = P4_SCORING_VERSION 

354 # Reorder keys so phase scores are always in numerical order 

355 _KEY_ORDER = [ 

356 "architecture_id", 

357 "model_id", 

358 "status", 

359 "verified_date", 

360 "metadata", 

361 "note", 

362 "prompt_profile", 

363 "p4_scoring_version", 

364 *[f"phase{p}_score" for p in PHASES], 

365 ] 

366 reordered = {k: entry[k] for k in _KEY_ORDER if k in entry} 

367 for k in entry: 

368 if k not in reordered: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 reordered[k] = entry[k] 

370 entry.clear() 

371 entry.update(reordered) 

372 updated = True 

373 break 

374 

375 if not updated and status in (STATUS_VERIFIED, STATUS_PROVISIONAL): 

376 # Model not in registry -- add it. A structural-only (provisional) pass 

377 # is a real result worth recording; skipped/failed on a missing model 

378 # are not, so they still fall through. 

379 data.get("models", []).append( 

380 { 

381 "model_id": model_id, 

382 "architecture_id": arch_id, 

383 "status": status, 

384 "verified_date": date.today().isoformat(), 

385 "metadata": None, 

386 "note": note, 

387 **{f"phase{p}_score": phase_scores.get(p) for p in PHASES}, 

388 } 

389 ) 

390 new_entry = data["models"][-1] 

391 extras: list[tuple[str, object]] = [] 

392 if prompt_profile is not None and not is_default_profile(prompt_profile): 

393 extras.append(("prompt_profile", prompt_profile)) 

394 if phase_scores.get(4) is not None: 

395 extras.append(("p4_scoring_version", P4_SCORING_VERSION)) 

396 if extras: 

397 # Keep key position consistent with _KEY_ORDER (after "note"). 

398 items = list(new_entry.items()) 

399 idx = [k for k, _ in items].index("note") + 1 

400 for offset, pair in enumerate(extras): 

401 items.insert(idx + offset, pair) 

402 new_entry.clear() 

403 new_entry.update(items) 

404 updated = True 

405 

406 if updated: 

407 data.update(recompute_registry_totals(data.get("models", []))) 

408 save_supported_models_raw(data) 

409 

410 return updated 

411 

412 

413def registry_prompt_profile(model_id: str) -> Optional[str]: 

414 """Stored prompt_profile for a model, or None. Uncached read: the sweep 

415 rewrites the registry between models.""" 

416 try: 

417 data = load_supported_models_raw() 

418 except Exception: 

419 return None 

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

421 if entry.get("model_id") == model_id: 

422 profile = entry.get("prompt_profile") 

423 return profile if isinstance(profile, str) else None 

424 return None 

425 

426 

427def add_verification_record( 

428 model_id: str, 

429 arch_id: str, 

430 notes: Optional[str] = None, 

431 verified_by: str = "verify_models", 

432 sanitize_fn: Optional[Callable[[Optional[str]], Optional[str]]] = None, 

433 prompt_profile: Optional[str] = None, 

434 p4_scoring_version: Optional[int] = None, 

435) -> None: 

436 """Append a VerificationRecord to verification_history.json. 

437 

438 Uses the VerificationRecord dataclass properly instead of raw dict 

439 manipulation. 

440 

441 Args: 

442 model_id: The verified model 

443 arch_id: Architecture type 

444 notes: Optional verification notes 

445 verified_by: Who/what performed the verification 

446 sanitize_fn: Optional callable to sanitize note strings 

447 """ 

448 if sanitize_fn and notes: 

449 notes = sanitize_fn(notes) 

450 

451 record = VerificationRecord( 

452 model_id=model_id, 

453 architecture_id=arch_id, 

454 verified_date=date.today(), 

455 verified_by=verified_by, 

456 transformerlens_version=_get_tl_version(), 

457 notes=notes, 

458 prompt_profile=prompt_profile, 

459 p4_scoring_version=p4_scoring_version, 

460 ) 

461 

462 history = load_verification_history() 

463 history.add_record(record) 

464 save_verification_history(history)