Coverage for transformer_lens/benchmarks/text_quality.py: 80%

327 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-01 16:23 +0000

1"""Text quality benchmark for TransformerBridge. 

2 

3Generates text the way a real user of the model would (its prompt profile: 

4chat template, translation source, code, own-language continuation — see 

5``text_quality_profiles``) and scores each output against a known-good 

6reference completion with one pinned multilingual judge. The score derives 

7from the perplexity ratio PPL_judge(generated)/PPL_judge(reference), which 

8cancels the judge's per-language handicap; a repetition penalty catches 

9degenerate loops (which the ratio alone rewards) and a length penalty 

10catches truncated output. 

11 

12Generation is seeded per prompt for reproducibility, and the judge is loaded 

13once (CPU/fp32 always, so scores do not depend on the verifying machine) and 

14reused across all prompts. 

15""" 

16 

17import gc 

18import math 

19from typing import Any, List, Optional, Tuple, Union 

20 

21import torch 

22from transformers import ( 

23 AutoModelForCausalLM, 

24 AutoTokenizer, 

25 PreTrainedModel, 

26 PreTrainedTokenizerBase, 

27) 

28 

29from transformer_lens.benchmarks.text_quality_profiles import ( 

30 CAPTION_REFERENCES, 

31 JUDGE_CONTEXT_KINDS, 

32 JUDGE_R_FAIL, 

33 LANG_ISO3, 

34 LANG_NAMES, 

35 MAX_NEW_TOKENS_BY_KIND, 

36 NLLB_CODES, 

37 PREPEND_BOS_BY_KIND, 

38 T5_PREFIX_ARCHITECTURES, 

39 TEMPERATURE_BY_KIND, 

40 ProfilePrompt, 

41 ProfileSpec, 

42 p4_pass_threshold, 

43 prompts_for, 

44) 

45from transformer_lens.benchmarks.utils import ( 

46 BenchmarkResult, 

47 BenchmarkSeverity, 

48 deterministic_rng, 

49) 

50from transformer_lens.model_bridge import TransformerBridge 

51 

52# The one judge every model is scored with, pinned by revision so a Hub update 

53# can never silently move every score. Selection + measurements live in 

54# scripts/text_quality_judge_bakeoff.py; separation is weakest in de/ru, so 

55# scores there carry wider error bars. 

56JUDGE_MODEL_ID = "Qwen/Qwen2.5-0.5B" 

57JUDGE_REVISION = "060db6499f32faf8b98477b0a26969ef7d8b9987" 

58 

59 

60def load_judge() -> Tuple[PreTrainedModel, PreTrainedTokenizerBase]: 

61 """Load the pinned judge on CPU in fp32 (machine-independent scores).""" 

62 tokenizer = AutoTokenizer.from_pretrained(JUDGE_MODEL_ID, revision=JUDGE_REVISION) 

63 model = AutoModelForCausalLM.from_pretrained( 

64 JUDGE_MODEL_ID, revision=JUDGE_REVISION, dtype=torch.float32 

65 ) 

66 torch.nn.Module.to(model, "cpu") 

67 model.eval() 

68 return model, tokenizer 

69 

70 

71def _judge_perplexity( 

72 text: str, 

73 context: str, 

74 tokenizer: Any, 

75 judge: Any, 

76) -> Tuple[float, Optional[str]]: 

77 """Judge perplexity of ``text``; ``context`` tokens are label-masked so only 

78 ``text`` is scored. Returns (ppl, error).""" 

79 try: 

80 # Tokenize the pieces separately: tokenizing the concatenated string 

81 # lets BPE merge across the boundary and shifts the label mask into 

82 # the scored text. 

83 text_ids = tokenizer(text, return_tensors="pt")["input_ids"] 

84 context_len = 0 

85 input_ids = text_ids 

86 if context: 

87 context_ids = tokenizer(context, return_tensors="pt")["input_ids"] 

88 context_len = context_ids.shape[1] 

89 input_ids = torch.cat([context_ids, text_ids], dim=1) 

90 

91 if text_ids.shape[1] < 2: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 return float("inf"), "Scored text too short (< 2 judge tokens)" 

93 

94 labels = input_ids.clone() 

95 if context_len: 

96 labels[0, :context_len] = -100 

97 

98 with torch.no_grad(): 

99 loss = judge(input_ids, labels=labels).loss.item() 

100 return math.exp(loss), None 

101 except Exception as e: 

102 return float("inf"), f"Perplexity computation failed: {str(e)}" 

103 

104 

105def _compute_repetition_penalty(text: str, ns: Tuple[int, ...] = (2, 3, 4)) -> float: 

106 """Minimum unique-n-gram ratio in [0, 1]; low values mean looping output. 

107 

108 Load-bearing under ratio scoring: a degenerate loop has LOW judge 

109 perplexity, so without this multiplier it would score 100. 

110 """ 

111 words = text.lower().split() 

112 # Scriptio continua (zh/ja): word n-grams are inert exactly where the 

113 # judge rewards loops most, and a single stray space would restore the 

114 # word path — so char n-grams whenever the text is CJK-dominated. 

115 compact = "".join(text.split()) 

116 if compact: 116 ↛ 120line 116 didn't jump to line 120 because the condition on line 116 was always true

117 cjk = sum(1 for c in compact if 0x3040 <= ord(c) <= 0x30FF or 0x4E00 <= ord(c) <= 0x9FFF) 

118 if cjk / len(compact) >= 0.3 and len(compact) >= 8: 

119 words = list(compact) 

120 if len(words) < 2: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 return 1.0 

122 

123 min_ratio = 1.0 

124 for n in ns: 

125 if len(words) < n: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 continue 

127 ngrams = [tuple(words[i : i + n]) for i in range(len(words) - n + 1)] 

128 if len(ngrams) == 0: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 continue 

130 unique_ratio = len(set(ngrams)) / len(ngrams) 

131 min_ratio = min(min_ratio, unique_ratio) 

132 

133 return min_ratio 

134 

135 

136def _ratio_to_score(ratio: float) -> float: 

137 """Map generated/reference perplexity ratio to 0-100. 

138 

139 score = 100 - 100*ln(ratio)/ln(R_FAIL), clamped: ratio<=1 (as good as the 

140 reference) scores 100, ratio=R_FAIL scores 0, and score 50 falls at 

141 sqrt(R_FAIL) — the geometric midpoint between reference quality and 

142 unambiguously broken output, which keeps the registry's phase-4 floor of 

143 50 principled. 

144 """ 

145 if ratio <= 0 or math.isinf(ratio) or math.isnan(ratio): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 return 0.0 

147 if ratio <= 1.0: 

148 return 100.0 

149 return max(0.0, min(100.0, 100.0 - 100.0 * math.log(ratio) / math.log(JUDGE_R_FAIL))) 

150 

151 

152_SCRIPT_RANGES: dict[str, Tuple[Tuple[int, int], ...]] = { 

153 "zh": ((0x4E00, 0x9FFF),), 

154 "ja": ((0x3040, 0x30FF), (0x4E00, 0x9FFF)), 

155 "ar": ((0x0600, 0x06FF),), 

156 "ru": ((0x0400, 0x04FF),), 

157 "hi": ((0x0900, 0x097F),), 

158} 

159 

160_LATIN_STOPWORDS: dict[str, frozenset] = { 

161 "en": frozenset("the and of to is that with for was are it in on".split()), 

162 "fr": frozenset( 

163 "le la les des une est que je pas dans de et il elle un en du au pour sur ne ce se".split() 

164 ), 

165 "es": frozenset("el los una es que no por con para como de en la y se del las".split()), 

166 "de": frozenset( 

167 "der die das und ist nicht ich ein eine mit den von zu im auf f\u00fcr sich".split() 

168 ), 

169 "it": frozenset("il la di che non per una sono del gli e in un le si con".split()), 

170 "nl": frozenset("de het een en is niet ik van dat met op voor aan zijn".split()), 

171 "pt": frozenset("o os uma de e que n\u00e3o para com por em um as dos da".split()), 

172} 

173 

174 

175def _wrong_language(text: str, lang: str) -> bool: 

176 """Conservatively true only when the text is clearly NOT in ``lang``. 

177 

178 Ratio scoring alone measures fluency, not language: fluent English output 

179 beats a short German reference and clamps to 100, so an untranslated echo 

180 would otherwise score perfectly. Non-Latin targets check script presence; 

181 Latin targets require zero expected-language stopwords while another 

182 covered language has several. 

183 """ 

184 if lang in ("code", ""): 

185 return False 

186 ranges = _SCRIPT_RANGES.get(lang) 

187 if ranges is not None: 

188 letters = [c for c in text if c.isalpha()] 

189 if not letters: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 return False 

191 in_script = sum(1 for c in letters if any(lo <= ord(c) <= hi for lo, hi in ranges)) 

192 return in_script / len(letters) < 0.3 

193 expected = _LATIN_STOPWORDS.get(lang) 

194 if expected is None: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

195 return False 

196 tokens = [w.strip(".,;:!?\"'()") for w in text.lower().split()] 

197 hits = {code: sum(1 for w in tokens if w in stops) for code, stops in _LATIN_STOPWORDS.items()} 

198 return hits[lang] == 0 and max(hits.values(), default=0) >= 3 

199 

200 

201def _length_penalty(gen_tokens: int, ref_tokens: int) -> float: 

202 """Penalize output far shorter OR far longer than its reference. 

203 

204 Neutral band [0.5x, 3x] of reference length. The old 25% floor never 

205 fired once in four validation sweeps — a contentless 13-token chat stub 

206 against a 41-token reference scored 93.6; at a 0.5x floor it drops below 

207 the pass line. The 3x cap is the second net for rambling output the 

208 repetition penalty misses.""" 

209 if ref_tokens <= 0: 

210 return 1.0 

211 under = gen_tokens / (0.5 * ref_tokens) 

212 over = (3.0 * ref_tokens) / max(gen_tokens, 1) 

213 return max(0.0, min(1.0, under, over)) 

214 

215 

216def _build_caption_test_images(n: int = 3) -> list: 

217 """A few distinct synthetic images so caption scoring averages over samples 

218 (content is unimportant — P4 only measures whether generation is grammatical).""" 

219 from PIL import Image, ImageDraw 

220 

221 specs = [ 

222 ( 

223 "white", 

224 [("rectangle", (30, 30, 110, 150), "blue"), ("ellipse", (120, 60, 200, 140), "green")], 

225 ), 

226 ("black", [("ellipse", (40, 40, 180, 180), "yellow")]), 

227 ( 

228 "skyblue", 

229 [ 

230 ("rectangle", (20, 120, 200, 200), "darkgreen"), 

231 ("ellipse", (140, 20, 200, 80), "orange"), 

232 ], 

233 ), 

234 ] 

235 images = [] 

236 for bg, shapes in specs[:n]: 

237 img = Image.new("RGB", (224, 224), color=bg) 

238 draw = ImageDraw.Draw(img) 

239 for kind, box, color in shapes: 

240 getattr(draw, kind)(box, fill=color) 

241 images.append(img) 

242 return images 

243 

244 

245def _generate_image_conditioned_captions( 

246 bridge: TransformerBridge, max_new_tokens: int 

247) -> List[Tuple[int, str]]: 

248 """Caption synthetic images for image-conditioned seq2seq (Florence-2 emits 

249 nothing text-only, so text-only P4 is uninformative); [] if no processor/PIL.""" 

250 processor = getattr(bridge, "processor", None) 

251 if processor is None: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 return [] 

253 try: 

254 images = _build_caption_test_images() 

255 except Exception: 

256 return [] 

257 

258 # Task captioners (Florence-2) map a task token to an internal prompt. 

259 # <DETAILED_CAPTION> yields a full grammatical sentence; plain <CAPTION> is 

260 # only 2-3 words (too short to score) and <MORE_DETAILED_CAPTION> tends to 

261 # loop on out-of-distribution synthetic images. 

262 is_task_captioner = hasattr(processor, "post_process_generation") 

263 task = "<DETAILED_CAPTION>" if is_task_captioner else "Describe this image in detail." 

264 

265 samples: List[Tuple[int, str]] = [] 

266 for i, image in enumerate(images): 

267 try: 

268 inputs = processor(text=task, images=image, return_tensors="pt") 

269 input_ids = inputs["input_ids"].to(bridge.cfg.device) 

270 extra = { 

271 k: (v.to(bridge.cfg.device) if hasattr(v, "to") else v) 

272 for k, v in inputs.items() 

273 if k != "input_ids" 

274 } 

275 out = bridge.generate( 

276 input_ids, max_new_tokens=max_new_tokens, return_type="tokens", **extra 

277 ) 

278 if isinstance(out, torch.Tensor): 278 ↛ 266line 278 didn't jump to line 266 because the condition on line 278 was always true

279 is_encoder_decoder = bool( 

280 getattr(getattr(bridge, "original_model", None), "config", None) 

281 and getattr(bridge.original_model.config, "is_encoder_decoder", False) 

282 ) 

283 # Decoder-only VLM output is prompt + continuation; scoring the 

284 # fluent prompt as caption text would inflate every sample. 

285 caption_ids = out[0] if is_encoder_decoder else out[0, input_ids.shape[-1] :] 

286 text = bridge.tokenizer.decode(caption_ids, skip_special_tokens=True).strip() 

287 if text: 287 ↛ 266line 287 didn't jump to line 266 because the condition on line 287 was always true

288 samples.append((i, text)) 

289 except Exception: 

290 continue 

291 return samples 

292 

293 

294def _architecture_id(bridge: Any) -> str: 

295 """First HF architecture name of the wrapped model, or ''.""" 

296 config = getattr(getattr(bridge, "original_model", None), "config", None) 

297 architectures = getattr(config, "architectures", None) or [] 

298 return architectures[0] if architectures else "" 

299 

300 

301def _resolve_lang_code(tokenizer, lang: str) -> Optional[str]: 

302 """The tokenizer's own code string for ``lang`` ("de" / "de_DE" / 

303 "deu_Latn"), or None. transformers 5.x NllbTokenizer exposes neither 

304 get_lang_id nor lang_code_to_id, so candidates are probed through the 

305 vocab as well.""" 

306 lang = lang.lower() 

307 lang_code_to_id = getattr(tokenizer, "lang_code_to_id", None) 

308 if isinstance(lang_code_to_id, dict): 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 if lang in lang_code_to_id: 

310 return lang 

311 iso3 = LANG_ISO3.get(lang, "") 

312 for code in lang_code_to_id: 

313 code_lower = code.lower() 

314 if code_lower.startswith(lang + "_") or (iso3 and code_lower.startswith(iso3 + "_")): 

315 return code 

316 # Vocab probing is only safe for DISTINCTIVE code forms ("deu_Latn"): a 

317 # bare ISO code collides with ordinary subwords (T5's "de", Marian's "en") 

318 # and would be injected as a forced decoder token. 

319 nllb = NLLB_CODES.get(lang) 

320 unk_id = getattr(tokenizer, "unk_token_id", None) 

321 convert = getattr(tokenizer, "convert_tokens_to_ids", None) 

322 if nllb and callable(convert): 

323 try: 

324 token_id = convert(nllb) 

325 except Exception: 

326 return None 

327 if isinstance(token_id, int) and token_id >= 0 and token_id != unk_id: 

328 return nllb 

329 return None 

330 

331 

332def _forced_bos_for_target(tokenizer, tgt_lang: str) -> Optional[int]: 

333 """Target-language decoder token for multilingual translators, or None.""" 

334 get_lang_id = getattr(tokenizer, "get_lang_id", None) 

335 if callable(get_lang_id): 

336 try: 

337 return int(get_lang_id(tgt_lang)) 

338 except Exception: 

339 return None 

340 code = _resolve_lang_code(tokenizer, tgt_lang) 

341 if code is None: 

342 return None 

343 lang_code_to_id = getattr(tokenizer, "lang_code_to_id", None) 

344 if isinstance(lang_code_to_id, dict) and code in lang_code_to_id: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 return int(lang_code_to_id[code]) 

346 try: 

347 token_id = tokenizer.convert_tokens_to_ids(code) 

348 except Exception: 

349 return None 

350 if ( 350 ↛ 356line 350 didn't jump to line 356 because the condition on line 350 was always true

351 isinstance(token_id, int) 

352 and token_id >= 0 

353 and token_id != getattr(tokenizer, "unk_token_id", None) 

354 ): 

355 return int(token_id) 

356 return None 

357 

358 

359def _build_model_input( 

360 bridge: Any, 

361 spec: ProfileSpec, 

362 prompt: ProfilePrompt, 

363 architecture_id: str, 

364) -> str: 

365 """Render one profile prompt into the text this model expects.""" 

366 if spec.kind == "chat": 

367 return bridge.tokenizer.apply_chat_template( 

368 [{"role": "user", "content": prompt.prompt}], 

369 add_generation_prompt=True, 

370 tokenize=False, 

371 ) 

372 if spec.kind == "task:translation" and architecture_id in T5_PREFIX_ARCHITECTURES: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 src_name = LANG_NAMES.get(spec.src or "en", "English") 

374 tgt_name = LANG_NAMES.get(spec.lang, "German") 

375 return f"translate {src_name} to {tgt_name}: {prompt.prompt}" 

376 if spec.kind == "task:summarization" and architecture_id in T5_PREFIX_ARCHITECTURES: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 return f"summarize: {prompt.prompt}" 

378 return prompt.prompt 

379 

380 

381def benchmark_text_quality( 

382 bridge: Any, 

383 profile: Union[str, ProfileSpec] = "continuation", 

384 *, 

385 max_new_tokens: Optional[int] = None, 

386 judge_model: Optional[Any] = None, 

387 judge_tokenizer: Optional[Any] = None, 

388 model_name: Optional[str] = None, 

389) -> BenchmarkResult: 

390 """Benchmark text generation quality with profile prompts and reference-ratio scoring. 

391 

392 Generates from the model's prompt-profile prompts through the real user 

393 path (``bridge.generate``), then scores each output against the prompt's 

394 reference completion via the pinned judge's perplexity ratio, with 

395 repetition and length penalties. 

396 """ 

397 if model_name is not None and model_name.lower() == JUDGE_MODEL_ID.lower(): 

398 # Ratio scoring against the judge's own perplexity is self-grading. 

399 return BenchmarkResult( 

400 name="text_quality", 

401 severity=BenchmarkSeverity.SKIPPED, 

402 message=f"P4 skipped: {model_name} is the pinned judge — cannot self-score", 

403 ) 

404 _loaded_locally = False 

405 tokenizer = judge_tokenizer 

406 try: 

407 spec = ProfileSpec.parse(profile) if isinstance(profile, str) else profile 

408 

409 # Diffusion LMs produce text through their native sampler; scoring that 

410 # text is as meaningful as scoring autoregressive output. 

411 from transformer_lens.benchmarks.generation import resolve_text_generator 

412 

413 generator = resolve_text_generator(bridge) 

414 if generator is None: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 return BenchmarkResult( 

416 name="text_quality", 

417 severity=BenchmarkSeverity.INFO, 

418 message="Skipped: architecture supports no text generation", 

419 ) 

420 

421 is_encoder_decoder = bool( 

422 getattr(getattr(bridge, "original_model", None), "config", None) 

423 and getattr(bridge.original_model.config, "is_encoder_decoder", False) 

424 ) 

425 is_multimodal = bool(getattr(getattr(bridge, "cfg", None), "is_multimodal", False)) 

426 

427 # Effective-profile adjustments. Image-conditioned seq2seq (Florence-2) 

428 # emits a bare EOS for text-only prompts — caption real images instead. 

429 # A chat profile without a chat template downgrades to continuation; 

430 # never the other direction (base models may ship templates). 

431 adjustment = "" 

432 if is_encoder_decoder and is_multimodal: 

433 spec = ProfileSpec("caption") 

434 elif spec.kind == "chat": 

435 if getattr(bridge.tokenizer, "chat_template", None) is None: 

436 spec = ProfileSpec("continuation", spec.lang) 

437 adjustment = "chat profile downgraded: tokenizer has no chat template" 

438 else: 

439 try: 

440 bridge.tokenizer.apply_chat_template( 

441 [{"role": "user", "content": "probe"}], 

442 add_generation_prompt=True, 

443 tokenize=False, 

444 ) 

445 except Exception as template_error: 

446 spec = ProfileSpec("continuation", spec.lang) 

447 adjustment = f"chat profile downgraded: template raised {template_error!r}" 

448 

449 denoise_style = "mask" if getattr(bridge.tokenizer, "mask_token", None) else "t5" 

450 profile_prompts = prompts_for(spec, denoise_style=denoise_style) 

451 if profile_prompts is None: 

452 return BenchmarkResult( 

453 name="text_quality", 

454 severity=BenchmarkSeverity.SKIPPED, 

455 message=( 

456 f"P4 skipped: no prompts for profile '{spec}' — file a " 

457 "TransformerLens issue to add coverage in " 

458 "benchmarks/text_quality_profiles.py" 

459 ), 

460 ) 

461 

462 if max_new_tokens is None: 462 ↛ 465line 462 didn't jump to line 465 because the condition on line 462 was always true

463 max_new_tokens = MAX_NEW_TOKENS_BY_KIND.get(spec.kind, 50) 

464 

465 architecture_id = _architecture_id(bridge) 

466 forced_bos: Optional[int] = None 

467 if spec.kind == "task:translation": 

468 src_lang_attr = getattr(bridge.tokenizer, "src_lang", None) 

469 if src_lang_attr is not None and spec.src: 

470 src_code = _resolve_lang_code(bridge.tokenizer, spec.src) 

471 if src_code is not None: 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true

472 try: 

473 bridge.tokenizer.src_lang = src_code 

474 except Exception: 

475 pass 

476 forced_bos = _forced_bos_for_target(bridge.tokenizer, spec.lang) 

477 

478 # Generate: (profile_prompt, generated_text) pairs. Token-level slicing — 

479 # generate() decodes with skip_special_tokens, so the prompt string is 

480 # not reliably a prefix of the output string (chat templates). 

481 generations: List[Tuple[ProfilePrompt, str]] = [] 

482 primary_generated = "" 

483 if spec.kind == "caption": 

484 with deterministic_rng(): 

485 captions = _generate_image_conditioned_captions(bridge, max_new_tokens) 

486 if not captions: 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true

487 return BenchmarkResult( 

488 name="text_quality", 

489 severity=BenchmarkSeverity.SKIPPED, 

490 message="Skipped: image-conditioned model; image processor/PIL unavailable", 

491 ) 

492 generations = [ 

493 (ProfilePrompt(prompt="", reference=CAPTION_REFERENCES[i]), text) 

494 for i, text in captions 

495 if i < len(CAPTION_REFERENCES) 

496 ] 

497 primary_generated = captions[0][1] 

498 else: 

499 prepend_bos = PREPEND_BOS_BY_KIND.get(spec.kind) 

500 # Native diffusion samplers take neither return_type nor forced_bos; 

501 # bound-method identity can't detect them (new object per access). 

502 is_autoregressive = getattr(bridge.adapter, "supports_generation", True) 

503 for prompt in profile_prompts: 

504 model_input = _build_model_input(bridge, spec, prompt, architecture_id) 

505 if is_encoder_decoder: 

506 # Encoder input follows the tokenizer's own recipe (lang 

507 # token + trailing </s>); to_tokens' BOS policy corrupts it 

508 # (m2m100 loops on a stray <s>). 

509 prompt_ids = bridge.tokenizer(model_input, return_tensors="pt")["input_ids"].to( 

510 bridge.cfg.device 

511 ) 

512 else: 

513 prompt_ids = bridge.to_tokens(model_input, prepend_bos=prepend_bos) 

514 gen_kwargs: dict = { 

515 "max_new_tokens": max_new_tokens, 

516 "temperature": TEMPERATURE_BY_KIND.get(spec.kind, 0.7), 

517 } 

518 if is_autoregressive: 518 ↛ 524line 518 didn't jump to line 524 because the condition on line 518 was always true

519 gen_kwargs["return_type"] = "tokens" 

520 if forced_bos is not None: 

521 gen_kwargs["forced_bos_token_id"] = forced_bos 

522 # Seeded per prompt so each sample stream is independent of the 

523 # previous prompt's length. 

524 with deterministic_rng(): 

525 out = generator(prompt_ids, **gen_kwargs) 

526 if not isinstance(out, torch.Tensor): 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 continue 

528 generated_ids = out[0, 1:] if is_encoder_decoder else out[0, prompt_ids.shape[-1] :] 

529 generated = bridge.tokenizer.decode(generated_ids, skip_special_tokens=True) 

530 if spec.kind == "task:denoise" and denoise_style == "t5": 

531 # Splice the fill back so both ratio sides are full 

532 # sentences (bare fragments judge in the thousands). An 

533 # EMPTY fill must stay empty or a dead model inherits the 

534 # near-reference sentence and a free 100. 

535 if generated.strip(): 

536 generated = prompt.prompt.replace("<extra_id_0>", generated.strip()) 

537 # Empty output is a scored failure (0), not a dropped sample — 

538 # dropping it would average only over the prompts that worked. 

539 generations.append((prompt, generated)) 

540 if not primary_generated: 

541 primary_generated = generated 

542 

543 if len(generations) == 0: 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true

544 return BenchmarkResult( 

545 name="text_quality", 

546 severity=BenchmarkSeverity.DANGER, 

547 message="Generation produced no scoreable output for any prompt", 

548 passed=False, 

549 ) 

550 

551 if judge_model is None or tokenizer is None: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 judge_model, tokenizer = load_judge() 

553 _loaded_locally = True 

554 

555 # Judge context per kind is JUDGE_CONTEXT_KINDS' call. Translation is 

556 # scored jointly: per-sentence judge perplexity on the short pivots is 

557 # unstable (measured spread 4.8-3497), so the samples concatenate into 

558 # one gen/ref pair. 

559 # Captured pre-merge so translation keeps its per-sentence texts. 

560 all_generated_texts = [text for _, text in generations] 

561 

562 if spec.kind == "task:translation" and len(generations) > 1: 

563 joiner = "" if spec.lang in ("zh", "ja") else " " 

564 joint = ProfilePrompt( 

565 prompt="", 

566 reference=joiner.join(g[0].reference for g in generations), 

567 lang=spec.lang, 

568 ) 

569 generations = [(joint, joiner.join(g[1] for g in generations))] 

570 

571 sample_lang = spec.lang if spec.kind != "caption" else "en" 

572 per_prompt_scores = [] 

573 per_prompt_ratios = [] 

574 per_prompt_penalties = [] 

575 prompt_details_parts = [] 

576 

577 for prompt, generated in generations: 

578 context = prompt.prompt if spec.kind in JUDGE_CONTEXT_KINDS else "" 

579 

580 gen_token_count = len(tokenizer(generated)["input_ids"]) if generated.strip() else 0 

581 if gen_token_count < 2: 

582 # Empty or one-token output is a scored failure, not a dropped 

583 # sample (Florence-style bare EOS, dead generation). 

584 per_prompt_scores.append(0.0) 

585 per_prompt_ratios.append(float("inf")) 

586 per_prompt_penalties.append(0.0) 

587 prompt_details_parts.append("score=0.0 (output < 2 tokens)") 

588 continue 

589 check_lang = prompt.lang if spec.kind != "task:translation" else sample_lang 

590 if _wrong_language(generated, check_lang): 

591 # Fluency-only ratio scoring would rate untranslated or 

592 # wrong-language output above the reference; hard zero. 

593 per_prompt_scores.append(0.0) 

594 per_prompt_ratios.append(float("inf")) 

595 per_prompt_penalties.append(0.0) 

596 prompt_details_parts.append(f"score=0.0 (output not in '{check_lang}')") 

597 continue 

598 

599 gen_ppl, gen_err = _judge_perplexity(generated, context, tokenizer, judge_model) 

600 ref_ppl, ref_err = _judge_perplexity(prompt.reference, context, tokenizer, judge_model) 

601 if gen_err is not None: 601 ↛ 603line 601 didn't jump to line 603 because the condition on line 601 was never true

602 # The model's own output was unjudgeable — scored failure. 

603 per_prompt_scores.append(0.0) 

604 per_prompt_ratios.append(float("inf")) 

605 per_prompt_penalties.append(0.0) 

606 prompt_details_parts.append(f"score=0.0 ({gen_err})") 

607 continue 

608 if ref_err is not None: 608 ↛ 611line 608 didn't jump to line 611 because the condition on line 608 was never true

609 # Our reference failed to judge — a data problem, not the 

610 # model's; exclude the sample and say so. 

611 prompt_details_parts.append(f"excluded (reference: {ref_err})") 

612 continue 

613 

614 ratio = gen_ppl / ref_ppl if ref_ppl > 0 else float("inf") 

615 rep_penalty = _compute_repetition_penalty(generated) 

616 ref_token_count = len(tokenizer(prompt.reference)["input_ids"]) 

617 len_penalty = _length_penalty(gen_token_count, ref_token_count) 

618 adjusted_score = _ratio_to_score(ratio) * rep_penalty * len_penalty 

619 

620 per_prompt_scores.append(adjusted_score) 

621 per_prompt_ratios.append(ratio) 

622 per_prompt_penalties.append(rep_penalty) 

623 prompt_details_parts.append( 

624 f"ratio={ratio:.2f} ppl={gen_ppl:.1f} ref_ppl={ref_ppl:.1f} " 

625 f"rep={rep_penalty:.2f} len={len_penalty:.2f} score={adjusted_score:.1f}" 

626 ) 

627 

628 if len(per_prompt_scores) == 0: 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

629 return BenchmarkResult( 

630 name="text_quality", 

631 severity=BenchmarkSeverity.ERROR, 

632 message="Scoring failed for all prompts", 

633 details={"generated_text": primary_generated}, 

634 passed=False, 

635 ) 

636 

637 avg_score = sum(per_prompt_scores) / len(per_prompt_scores) 

638 finite_ratios = [r for r in per_prompt_ratios if math.isfinite(r)] 

639 avg_ratio = sum(finite_ratios) / len(finite_ratios) if finite_ratios else float("inf") 

640 avg_rep_penalty = sum(per_prompt_penalties) / len(per_prompt_penalties) 

641 

642 pass_threshold = p4_pass_threshold() 

643 details = { 

644 "score": round(avg_score, 1), 

645 "prompt_profile": str(spec), 

646 "judge_model": JUDGE_MODEL_ID, 

647 "judge_revision": JUDGE_REVISION, 

648 "avg_ratio": round(avg_ratio, 3) if math.isfinite(avg_ratio) else "inf", 

649 "avg_repetition_penalty": round(avg_rep_penalty, 2), 

650 "num_prompts": len(per_prompt_scores), 

651 "per_prompt": " | ".join(prompt_details_parts), 

652 "max_new_tokens": max_new_tokens, 

653 "generated_text": primary_generated, 

654 "generated_texts": all_generated_texts, 

655 } 

656 if adjustment: 

657 details["profile_adjustment"] = adjustment 

658 

659 if avg_score >= pass_threshold: 

660 return BenchmarkResult( 

661 name="text_quality", 

662 severity=BenchmarkSeverity.INFO, 

663 message=( 

664 f"Text quality score: {avg_score:.1f}/100 " 

665 f"(profile {spec}, {len(per_prompt_scores)} prompts)" 

666 ), 

667 details=details, 

668 ) 

669 elif avg_score >= pass_threshold / 2: 

670 return BenchmarkResult( 

671 name="text_quality", 

672 severity=BenchmarkSeverity.WARNING, 

673 message=( 

674 f"Text quality score: {avg_score:.1f}/100 " 

675 f"(below {pass_threshold:.0f}, profile {spec})" 

676 ), 

677 details=details, 

678 passed=False, 

679 ) 

680 else: 

681 return BenchmarkResult( 

682 name="text_quality", 

683 severity=BenchmarkSeverity.DANGER, 

684 message=( 

685 f"Text quality score: {avg_score:.1f}/100 " 

686 f"(profile {spec}) — generated text may be incoherent" 

687 ), 

688 details=details, 

689 passed=False, 

690 ) 

691 

692 except Exception as e: 

693 return BenchmarkResult( 

694 name="text_quality", 

695 severity=BenchmarkSeverity.ERROR, 

696 message=f"Text quality benchmark failed: {str(e)}", 

697 passed=False, 

698 ) 

699 

700 finally: 

701 if _loaded_locally: 

702 if judge_model is not None: 

703 del judge_model 

704 if tokenizer is not None: 

705 del tokenizer 

706 gc.collect()