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

137 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-01 16:23 +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 pathlib import Path 

12from typing import Callable, Optional 

13 

14from transformer_lens.benchmarks.text_quality_profiles import ( 

15 P4_SCORING_VERSION, 

16 is_default_profile, 

17) 

18 

19from .verification import VerificationHistory, VerificationRecord 

20 

21logger = logging.getLogger(__name__) 

22 

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

24_SUPPORTED_MODELS_PATH = _DATA_DIR / "supported_models.json" 

25_VERIFICATION_HISTORY_PATH = _DATA_DIR / "verification_history.json" 

26 

27# Status codes 

28STATUS_UNVERIFIED = 0 

29STATUS_VERIFIED = 1 

30STATUS_SKIPPED = 2 

31STATUS_FAILED = 3 

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

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

34# real result but deliberately NOT counted as verified. 

35STATUS_PROVISIONAL = 4 

36 

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

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

39_HF_LOADABLE_QUANT_PATTERNS = [ 

40 "-awq", 

41 "_awq", 

42 "-AWQ", 

43 "_AWQ", 

44 "-gptq", 

45 "_gptq", 

46 "-GPTQ", 

47 "_GPTQ", 

48 "GPTQ", 

49 "-bnb-", 

50 "_bnb_", 

51 "bnb-4bit", 

52 "bnb-8bit", 

53 "-4bit", 

54 "_4bit", 

55 "-8bit", 

56 "_8bit", 

57 "-int4", 

58 "_int4", 

59 "-int8", 

60 "_int8", 

61 "-w4a16", 

62 "-w8a8", 

63 "-W4A16", 

64 "-W8A8", 

65 ".w4a16", 

66 ".W4A16", 

67 "-hqq", 

68 "_hqq", 

69 "-HQQ", 

70 "_HQQ", 

71 "-3bit", 

72 "_3bit", 

73 "-2bit", 

74 "_2bit", 

75 "-5bit", 

76 "-6bit", 

77 "-oQ", 

78 "_oQ", 

79 "-quantized.", 

80 "_Quantized", 

81 "-Quantized", 

82] 

83 

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

85_INCOMPATIBLE_QUANT_PATTERNS = [ 

86 "-gguf", 

87 "_gguf", 

88 "-GGUF", 

89 "_GGUF", 

90 "mlx-community/", 

91 "-mlx", 

92 "-MLX", 

93 "_mlx", 

94 "_MLX", 

95 ".mlx", 

96 ".MLX", 

97 "-fp8", 

98 "_fp8", 

99 "-FP8", 

100 "_FP8", 

101 "-nvfp4", 

102 "_nvfp4", 

103 "-NVFP4", 

104 "_NVFP4", 

105 "-mxfp4", 

106 "_mxfp4", 

107 "-MXFP4", 

108 "_MXFP4", 

109] 

110 

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

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

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

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

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

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

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

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

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

120] 

121 

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

123 

124 

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

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

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

128 

129 

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

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

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

133 

134 

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

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

137 for patterns, library in _QUANT_LIBRARY_BY_PATTERN: 

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

139 return library 

140 return None 

141 

142 

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

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

145 return is_incompatible_quantized(model_id) 

146 

147 

148def load_supported_models_raw() -> dict: 

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

150 with open(_SUPPORTED_MODELS_PATH) as f: 

151 return json.load(f) 

152 

153 

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

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

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

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

158 f.write("\n") 

159 

160 

161def load_verification_history() -> VerificationHistory: 

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

163 if _VERIFICATION_HISTORY_PATH.exists(): 

164 with open(_VERIFICATION_HISTORY_PATH) as f: 

165 data = json.load(f) 

166 return VerificationHistory.from_dict(data) 

167 return VerificationHistory() 

168 

169 

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

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

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

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

174 f.write("\n") 

175 

176 

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

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

179 try: 

180 import transformer_lens 

181 

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

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

184 return str(version) 

185 # The package exports no __version__; installed-distribution 

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

187 from importlib.metadata import version as dist_version 

188 

189 return dist_version("transformer-lens") 

190 except Exception: 

191 return None 

192 

193 

194def update_model_status( 

195 model_id: str, 

196 arch_id: str, 

197 status: Optional[int] = None, 

198 note: Optional[str] = None, 

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

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

201 prompt_profile: Optional[str] = None, 

202) -> bool: 

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

204 

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

206 STATUS_PROVISIONAL, a new entry is appended. 

207 

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

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

210 

211 Args: 

212 model_id: The model to update 

213 arch_id: Architecture of the model 

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

215 note: Optional note for skip/fail reason 

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

217 sanitize_fn: Optional callable to sanitize note strings 

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

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

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

221 Phase-4 result) leaves it untouched. 

222 

223 Returns: 

224 True if entry was found/created and updated 

225 """ 

226 if phase_scores is None: 

227 phase_scores = {} 

228 

229 if sanitize_fn and note: 

230 note = sanitize_fn(note) 

231 

232 data = load_supported_models_raw() 

233 updated = False 

234 

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

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

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

238 entry["status"] = status 

239 entry["verified_date"] = ( 

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

241 ) 

242 entry["note"] = note 

243 elif note is not None: 

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

245 entry["note"] = note 

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

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

248 entry["note"] = None 

249 for phase_num in (1, 2, 3, 4, 7, 8, 9): 

250 key = f"phase{phase_num}_score" 

251 if phase_num in phase_scores: 

252 entry[key] = phase_scores[phase_num] 

253 elif key not in entry: 

254 entry[key] = None 

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

256 entry.pop("prompt_profile", None) 

257 elif prompt_profile is not None: 

258 entry["prompt_profile"] = prompt_profile 

259 if 4 in phase_scores: 

260 entry["p4_scoring_version"] = P4_SCORING_VERSION 

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

262 _KEY_ORDER = [ 

263 "architecture_id", 

264 "model_id", 

265 "status", 

266 "verified_date", 

267 "metadata", 

268 "note", 

269 "prompt_profile", 

270 "p4_scoring_version", 

271 "phase1_score", 

272 "phase2_score", 

273 "phase3_score", 

274 "phase4_score", 

275 "phase7_score", 

276 "phase8_score", 

277 "phase9_score", 

278 ] 

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

280 for k in entry: 

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

282 reordered[k] = entry[k] 

283 entry.clear() 

284 entry.update(reordered) 

285 updated = True 

286 break 

287 

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

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

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

291 # are not, so they still fall through. 

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

293 { 

294 "model_id": model_id, 

295 "architecture_id": arch_id, 

296 "status": status, 

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

298 "metadata": None, 

299 "note": note, 

300 "phase1_score": phase_scores.get(1), 

301 "phase2_score": phase_scores.get(2), 

302 "phase3_score": phase_scores.get(3), 

303 "phase4_score": phase_scores.get(4), 

304 "phase7_score": phase_scores.get(7), 

305 "phase8_score": phase_scores.get(8), 

306 "phase9_score": phase_scores.get(9), 

307 } 

308 ) 

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

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

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

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

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

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

315 if extras: 

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

317 items = list(new_entry.items()) 

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

319 for offset, pair in enumerate(extras): 

320 items.insert(idx + offset, pair) 

321 new_entry.clear() 

322 new_entry.update(items) 

323 updated = True 

324 

325 if updated: 

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

327 data["total_verified"] = sum(1 for m in models if m.get("status", 0) == STATUS_VERIFIED) 

328 data["total_provisional"] = sum( 

329 1 for m in models if m.get("status", 0) == STATUS_PROVISIONAL 

330 ) 

331 data["total_models"] = len(models) 

332 data["total_architectures"] = len(set(m["architecture_id"] for m in models)) 

333 save_supported_models_raw(data) 

334 

335 return updated 

336 

337 

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

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

340 rewrites the registry between models.""" 

341 try: 

342 data = load_supported_models_raw() 

343 except Exception: 

344 return None 

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

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

347 profile = entry.get("prompt_profile") 

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

349 return None 

350 

351 

352def add_verification_record( 

353 model_id: str, 

354 arch_id: str, 

355 notes: Optional[str] = None, 

356 verified_by: str = "verify_models", 

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

358 prompt_profile: Optional[str] = None, 

359 p4_scoring_version: Optional[int] = None, 

360) -> None: 

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

362 

363 Uses the VerificationRecord dataclass properly instead of raw dict 

364 manipulation. 

365 

366 Args: 

367 model_id: The verified model 

368 arch_id: Architecture type 

369 notes: Optional verification notes 

370 verified_by: Who/what performed the verification 

371 sanitize_fn: Optional callable to sanitize note strings 

372 """ 

373 if sanitize_fn and notes: 

374 notes = sanitize_fn(notes) 

375 

376 record = VerificationRecord( 

377 model_id=model_id, 

378 architecture_id=arch_id, 

379 verified_date=date.today(), 

380 verified_by=verified_by, 

381 transformerlens_version=_get_tl_version(), 

382 notes=notes, 

383 prompt_profile=prompt_profile, 

384 p4_scoring_version=p4_scoring_version, 

385 ) 

386 

387 history = load_verification_history() 

388 history.add_record(record) 

389 save_verification_history(history)