wop commited on
Commit
50d08dd
·
verified ·
1 Parent(s): 37489c5

7-2026 upgrade: script.py

Browse files
Files changed (1) hide show
  1. script.py +991 -728
script.py CHANGED
@@ -1,728 +1,991 @@
1
- #!/usr/bin/env python3
2
- """
3
- BenchLabs universal evaluation script.
4
-
5
- One script, every BenchLabs benchmark. Downloads the datasets straight from the
6
- Hugging Face Hub, runs your model, and prints an in-depth report with
7
- category / subcategory breakdowns -- in the exact shape the
8
- BenchLabs-Leaderboard `models.json` expects.
9
-
10
- Benchmarks covered
11
- bench-effortless-6-2026 generative, exact-match (tier 1)
12
- bench-easy-6-2026 generative, hybrid category-aware (tier 2)
13
- bench-mid-6-2026 multiple-choice, log-likelihood (tier 3)
14
- bench-AGI skipped -- scoring pipeline under maintenance
15
-
16
- Install
17
- pip install torch transformers
18
- pip install sentence-transformers # optional: better semantic scoring on Easy
19
- pip install accelerate # optional: faster / multi-GPU loading
20
-
21
- Run
22
- python script.py --model Qwen/Qwen2.5-0.5B
23
- python script.py --model Qwen/Qwen2.5-1.5B-Instruct --benchmarks easy,mid
24
- python script.py --model ./my-local-checkpoint --device cuda --batch-size 16
25
- python script.py --model Qwen/Qwen2.5-0.5B --limit 10 # quick smoke test
26
- python script.py --model Qwen/Qwen2.5-0.5B --leaderboard # print models.json entry
27
-
28
- Outputs (under --output-dir, default benchlabs_results/<model>/)
29
- results.json full report: every benchmark, category, subcategory, sample counts
30
- samples_<id>.csv per-sample predictions and scores for each benchmark
31
- leaderboard.json ready-to-paste `models.json` entry for the leaderboard PR
32
-
33
- Scoring conventions
34
- Effortless exact match after normalization (strip, lowercase, drop punctuation).
35
- Easy hybrid category-aware scoring, identical to the official
36
- benchmark.ipynb: strict categories are binary exact-match, soft
37
- categories get semantic similarity, hybrid categories get fuzzy
38
- string similarity. Plain exact-match is also reported.
39
- Mid lm-eval style log-likelihood over the `target_scores` candidates:
40
- acc = argmax raw log-likelihood is the 1.0 answer
41
- acc_norm = argmax log-likelihood / byte-length of the answer
42
- soft_score / soft_score_norm = target_scores value of the picked
43
- answer (partial credit on distractors with non-zero scores)
44
- Headline score = soft_score_norm, matching the leaderboard.
45
- Multiple-choice prompt format: "Q: {input}\nA:" with candidates " {choice}".
46
-
47
- Reasoning / CoT models
48
- <think>...</think> blocks are stripped before answer extraction: only the
49
- text after the final </think> is scored. Raise --max-new-tokens (2048+) so
50
- the model can finish thinking -- the default 32 is sized for direct-answer
51
- models. A generation cut off mid-think (unclosed <think>) scores as an
52
- empty answer. Mid is scored by log-likelihood over the answer choices with
53
- no generation at all, so thinking never happens there.
54
-
55
- Bench Labs - Simple, Reliable, Open sourced
56
- """
57
-
58
- from __future__ import annotations
59
-
60
- import argparse
61
- import csv
62
- import hashlib
63
- import json
64
- import math
65
- import os
66
- import re
67
- import sys
68
- import urllib.request
69
- from collections import defaultdict
70
- from dataclasses import dataclass, field
71
- from difflib import SequenceMatcher
72
- from pathlib import Path
73
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
74
-
75
- # --------------------------------------------------------------------------- #
76
- # Benchmark registry
77
- # --------------------------------------------------------------------------- #
78
-
79
- HUB_BASE = "https://huggingface.co/datasets/bench-labs/{id}/resolve/main/eval.jsonl"
80
-
81
- BENCHMARKS: Dict[str, Dict[str, Any]] = {
82
- "effortless": {
83
- "id": "bench-effortless-6-2026",
84
- "tier": 1,
85
- "kind": "generative",
86
- "metric": "exact_match",
87
- "description": "Sanity-layer QA: unambiguous single-answer questions.",
88
- },
89
- "easy": {
90
- "id": "bench-easy-6-2026",
91
- "tier": 2,
92
- "kind": "generative",
93
- "metric": "hybrid_score",
94
- "description": "Easy-tier QA with hybrid category-aware scoring.",
95
- },
96
- "mid": {
97
- "id": "bench-mid-6-2026",
98
- "tier": 3,
99
- "kind": "multiple_choice",
100
- "metric": "soft_score_norm",
101
- "description": "Mid-tier multiple-choice QA via log-likelihood.",
102
- },
103
- "agi": {
104
- "id": "bench-AGI",
105
- "tier": 4,
106
- "kind": "rank_order",
107
- "metric": "rank_order",
108
- "description": "Hard open-ended questions, panel-graded rank order.",
109
- "unavailable": "Scoring pipeline under maintenance -- see the dataset README.",
110
- },
111
- }
112
-
113
- # Easy-tier category routing, identical to the official benchmark.ipynb.
114
- STRICT_CATEGORIES = {
115
- "Math-arithmetic", "Math-pattern",
116
- "Logic-deduction", "Logic-pattern", "Logic-consistency",
117
- "Knowledge-basic", "Pattern-matching",
118
- }
119
- SOFT_CATEGORIES = {
120
- "Commonsense-simulation", "Commonsense-causality", "Commonsense-reasoning",
121
- "Language-comprehension", "Knowledge-definitions",
122
- }
123
- HYBRID_CATEGORIES = {
124
- "Language-structure", "Language-transformation",
125
- }
126
-
127
- SYSTEM_PROMPT = "You are a precise assistant. Give only the final answer, without explanation."
128
- MC_PROMPT = "Q: {input}\nA:"
129
-
130
- ANSWER_PREFIXES = re.compile(
131
- r"^(the answer is|answer\s*[:=]|final answer\s*[:=]?|it is|it's)\s*", re.IGNORECASE
132
- )
133
-
134
- # Reasoning-model tags. THINK_CLOSE also matches a bare closing tag: some chat
135
- # templates open <think> inside the prompt, so the generation contains only
136
- # the reasoning and a </think>.
137
- THINK_CLOSE = re.compile(r"</think(?:ing)?>\s*", re.IGNORECASE)
138
- THINK_OPEN = re.compile(r"<think(?:ing)?>.*", re.IGNORECASE | re.DOTALL)
139
-
140
- # --------------------------------------------------------------------------- #
141
- # Text normalization and scoring
142
- # --------------------------------------------------------------------------- #
143
-
144
- def normalize(text: str) -> str:
145
- text = str(text).strip().lower()
146
- text = re.sub(r"[\u201c\u201d\"'`]", "", text)
147
- text = text.replace("\u2019", "'")
148
- text = re.sub(r"[\.\,\!\?\:\;\(\)\[\]\{\}]", "", text)
149
- text = re.sub(r"\s+", " ", text)
150
- return text.strip()
151
-
152
-
153
- def extract_answer(text: str) -> str:
154
- """First line of the generation after any <think> block, minus boilerplate prefixes.
155
-
156
- Only text after the final </think> is scored. An unclosed <think> means the
157
- generation ran out of budget mid-reasoning, so there is no answer to extract.
158
- """
159
- text = str(text)
160
- parts = THINK_CLOSE.split(text)
161
- if len(parts) > 1:
162
- text = parts[-1]
163
- else:
164
- text = THINK_OPEN.sub("", text)
165
- text = text.strip()
166
- if "\n" in text:
167
- text = text.split("\n", 1)[0]
168
- text = ANSWER_PREFIXES.sub("", text.strip())
169
- return text.strip()
170
-
171
-
172
- def strict_score(pred: str, gold: str) -> float:
173
- return 1.0 if normalize(pred) == normalize(gold) else 0.0
174
-
175
-
176
- def fuzzy_score(pred: str, gold: str) -> float:
177
- p, g = normalize(pred), normalize(gold)
178
- if p == g:
179
- return 1.0
180
- return max(0.0, min(1.0, SequenceMatcher(None, p, g).ratio()))
181
-
182
-
183
- class SemanticScorer:
184
- """Sentence-embedding similarity with a fuzzy-string fallback."""
185
-
186
- def __init__(self) -> None:
187
- self._embedder = None
188
- try:
189
- from sentence_transformers import SentenceTransformer # type: ignore
190
- self._embedder = SentenceTransformer("all-MiniLM-L6-v2")
191
- except Exception:
192
- self._embedder = None
193
-
194
- @property
195
- def backend(self) -> str:
196
- return "sentence-transformers/all-MiniLM-L6-v2" if self._embedder else "difflib-fallback"
197
-
198
- def score(self, pred: str, gold: str) -> float:
199
- p, g = normalize(pred), normalize(gold)
200
- if p == g:
201
- return 1.0
202
- if self._embedder is not None:
203
- try:
204
- import numpy as np
205
- pv, gv = self._embedder.encode([p, g], normalize_embeddings=True)
206
- cos = float(np.dot(pv, gv))
207
- return max(0.0, min(1.0, (cos + 1.0) / 2.0))
208
- except Exception:
209
- pass
210
- return fuzzy_score(pred, gold)
211
-
212
-
213
- def easy_hybrid_score(category: str, pred: str, gold: str, semantic: SemanticScorer) -> float:
214
- if category in STRICT_CATEGORIES:
215
- return strict_score(pred, gold)
216
- if category in SOFT_CATEGORIES:
217
- return semantic.score(pred, gold)
218
- if category in HYBRID_CATEGORIES:
219
- return fuzzy_score(pred, gold)
220
- return fuzzy_score(pred, gold) # unknown categories: fuzzy, never hard-fail
221
-
222
-
223
- # --------------------------------------------------------------------------- #
224
- # Dataset loading (no `datasets` dependency -- each benchmark is one eval.jsonl)
225
- # --------------------------------------------------------------------------- #
226
-
227
- def cache_dir() -> Path:
228
- return Path(os.environ.get("BENCHLABS_CACHE", Path.home() / ".cache" / "benchlabs"))
229
-
230
-
231
- def load_benchmark_rows(bench_id: str, refresh: bool = False) -> List[dict]:
232
- path = cache_dir() / f"{bench_id}.jsonl"
233
- if refresh or not path.exists():
234
- url = HUB_BASE.format(id=bench_id)
235
- print(f" downloading {url}")
236
- path.parent.mkdir(parents=True, exist_ok=True)
237
- req = urllib.request.Request(url)
238
- token = os.environ.get("HF_TOKEN")
239
- if token:
240
- req.add_header("Authorization", f"Bearer {token}")
241
- with urllib.request.urlopen(req) as resp:
242
- path.write_bytes(resp.read())
243
- rows = []
244
- with path.open(encoding="utf-8") as f:
245
- for line in f:
246
- line = line.strip()
247
- if line:
248
- rows.append(json.loads(line))
249
- return rows
250
-
251
-
252
- def split_category(cat: str) -> Tuple[str, Optional[str]]:
253
- """'Commonsense-causality' -> ('Commonsense', 'causality'); 'Math' -> ('Math', None)."""
254
- if "-" in cat:
255
- top, sub = cat.split("-", 1)
256
- return top, sub
257
- return cat, None
258
-
259
-
260
- # --------------------------------------------------------------------------- #
261
- # Model backend (lazy torch/transformers import)
262
- # --------------------------------------------------------------------------- #
263
-
264
- class HFModel:
265
- """Thin wrapper: batched greedy generation + batched log-likelihood scoring."""
266
-
267
- def __init__(self, name: str, device: str, dtype: str, trust_remote_code: bool,
268
- use_chat_template: bool) -> None:
269
- try:
270
- import torch
271
- from transformers import AutoModelForCausalLM, AutoTokenizer
272
- except ImportError as e:
273
- sys.exit(f"Missing dependency ({e.name}). Install with: pip install torch transformers")
274
-
275
- self.torch = torch
276
- if device == "auto":
277
- if torch.cuda.is_available():
278
- device = "cuda"
279
- elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
280
- device = "mps"
281
- else:
282
- device = "cpu"
283
- self.device = device
284
-
285
- if dtype == "auto":
286
- torch_dtype = torch.bfloat16 if device == "cuda" and torch.cuda.is_bf16_supported() \
287
- else (torch.float16 if device in ("cuda", "mps") else torch.float32)
288
- else:
289
- torch_dtype = {"float16": torch.float16, "fp16": torch.float16,
290
- "bfloat16": torch.bfloat16, "bf16": torch.bfloat16,
291
- "float32": torch.float32, "fp32": torch.float32}[dtype.lower()]
292
-
293
- print(f"Loading model: {name} (device={device}, dtype={torch_dtype})")
294
- self.tokenizer = AutoTokenizer.from_pretrained(name, trust_remote_code=trust_remote_code)
295
- if self.tokenizer.pad_token_id is None:
296
- self.tokenizer.pad_token = self.tokenizer.eos_token
297
- self.model = AutoModelForCausalLM.from_pretrained(
298
- name, torch_dtype=torch_dtype, trust_remote_code=trust_remote_code,
299
- ).to(device)
300
- self.model.eval()
301
- self.use_chat = use_chat_template and self.tokenizer.chat_template is not None
302
- print(f" chat template: {'yes' if self.use_chat else 'no (plain QA prompt)'}")
303
-
304
- # -- resolved model provenance ------------------------------------ #
305
- # The weights already carry their commit: from_pretrained records the
306
- # snapshot it actually loaded in config._commit_hash, no second Hub
307
- # lookup. Asking the Hub afterwards can pin a different commit if the
308
- # branch moved between load and lookup, so _commit_hash is primary.
309
- self.resolved_revision: Optional[str] = getattr(self.model.config, "_commit_hash", None)
310
- if self.resolved_revision is None and "/" in name and not Path(name).exists():
311
- # Fallback for transformers versions that don't record it. Local
312
- # checkpoints stay None, which is honest: they have no hub revision.
313
- try:
314
- from huggingface_hub import HfApi
315
- self.resolved_revision = HfApi().model_info(name).sha
316
- except Exception:
317
- pass
318
-
319
- # -- generation -------------------------------------------------------- #
320
-
321
- def _format_prompt(self, question: str) -> str:
322
- if self.use_chat:
323
- return self.tokenizer.apply_chat_template(
324
- [{"role": "system", "content": SYSTEM_PROMPT},
325
- {"role": "user", "content": question}],
326
- tokenize=False, add_generation_prompt=True,
327
- )
328
- return f"Question: {question}\nAnswer:"
329
-
330
- def generate(self, questions: Sequence[str], batch_size: int, max_new_tokens: int,
331
- progress: str = "") -> List[str]:
332
- torch = self.torch
333
- tok = self.tokenizer
334
- preds: List[str] = []
335
- old_side = tok.padding_side
336
- tok.padding_side = "left"
337
- try:
338
- with torch.no_grad():
339
- for start in range(0, len(questions), batch_size):
340
- chunk = questions[start:start + batch_size]
341
- prompts = [self._format_prompt(q) for q in chunk]
342
- inputs = tok(prompts, return_tensors="pt", padding=True,
343
- truncation=True).to(self.device)
344
- out = self.model.generate(
345
- **inputs, max_new_tokens=max_new_tokens, do_sample=False,
346
- pad_token_id=tok.pad_token_id,
347
- )
348
- gen = out[:, inputs["input_ids"].shape[1]:]
349
- preds.extend(tok.decode(g, skip_special_tokens=True).strip() for g in gen)
350
- _progress(progress, len(preds), len(questions))
351
- finally:
352
- tok.padding_side = old_side
353
- return preds
354
-
355
- # -- log-likelihood ---------------------------------------------------- #
356
-
357
- def loglikelihoods(self, pairs: Sequence[Tuple[str, str]], batch_size: int,
358
- progress: str = "") -> List[float]:
359
- """Sum of log-probs of `continuation` given `context` for each pair."""
360
- torch = self.torch
361
- tok = self.tokenizer
362
- encoded = []
363
- for ctx, cont in pairs:
364
- ctx_ids = tok.encode(ctx)
365
- full_ids = tok.encode(ctx + cont)
366
- n_cont = len(full_ids) - len(ctx_ids)
367
- if n_cont <= 0: # tokenizer merged across the boundary; re-split manually
368
- cont_ids = tok.encode(cont, add_special_tokens=False)
369
- full_ids = ctx_ids + cont_ids
370
- n_cont = len(cont_ids)
371
- encoded.append((full_ids, n_cont))
372
-
373
- results: List[float] = []
374
- with torch.no_grad():
375
- for start in range(0, len(encoded), batch_size):
376
- chunk = encoded[start:start + batch_size]
377
- maxlen = max(len(ids) for ids, _ in chunk)
378
- pad_id = tok.pad_token_id
379
- input_ids = torch.full((len(chunk), maxlen), pad_id, dtype=torch.long)
380
- attn = torch.zeros((len(chunk), maxlen), dtype=torch.long)
381
- for i, (ids, _) in enumerate(chunk):
382
- input_ids[i, :len(ids)] = torch.tensor(ids)
383
- attn[i, :len(ids)] = 1
384
- input_ids, attn = input_ids.to(self.device), attn.to(self.device)
385
- logits = self.model(input_ids=input_ids, attention_mask=attn).logits
386
- logprobs = torch.log_softmax(logits.float(), dim=-1)
387
- for i, (ids, n_cont) in enumerate(chunk):
388
- total = 0.0
389
- for pos in range(len(ids) - n_cont, len(ids)):
390
- total += logprobs[i, pos - 1, ids[pos]].item()
391
- results.append(total)
392
- _progress(progress, len(results), len(pairs))
393
- return results
394
-
395
-
396
- def _progress(label: str, done: int, total: int) -> None:
397
- if label:
398
- print(f"\r {label}: {done}/{total}", end="", flush=True)
399
- if done >= total:
400
- print()
401
-
402
-
403
- # --------------------------------------------------------------------------- #
404
- # Aggregation
405
- # --------------------------------------------------------------------------- #
406
-
407
- @dataclass
408
- class Sample:
409
- idx: int
410
- category: str
411
- question: str
412
- gold: str
413
- pred: str
414
- scores: Dict[str, float] = field(default_factory=dict)
415
-
416
-
417
- def mean(xs: Sequence[float]) -> float:
418
- return sum(xs) / len(xs) if xs else 0.0
419
-
420
-
421
- def stderr_of(xs: Sequence[float]) -> float:
422
- if len(xs) < 2:
423
- return 0.0
424
- m = mean(xs)
425
- var = sum((x - m) ** 2 for x in xs) / (len(xs) - 1)
426
- return math.sqrt(var / len(xs))
427
-
428
-
429
- def aggregate(samples: List[Sample], metrics: Sequence[str]) -> Dict[str, Any]:
430
- """Overall + per-category + per-subcategory rollups for each metric."""
431
- by_cat: Dict[str, List[Sample]] = defaultdict(list)
432
- by_top: Dict[str, List[Sample]] = defaultdict(list)
433
- for s in samples:
434
- by_cat[s.category].append(s)
435
- by_top[split_category(s.category)[0]].append(s)
436
-
437
- def block(rows: List[Sample]) -> Dict[str, Any]:
438
- out: Dict[str, Any] = {"n": len(rows)}
439
- for m in metrics:
440
- vals = [s.scores[m] for s in rows]
441
- out[m] = round(mean(vals), 4)
442
- return out
443
-
444
- return {
445
- "overall": {**block(samples),
446
- "stderr": round(stderr_of([s.scores[metrics[-1]] for s in samples]), 4)},
447
- "categories": {cat: block(rows) for cat, rows in sorted(by_cat.items())},
448
- "category_groups": {top: block(rows) for top, rows in sorted(by_top.items())},
449
- "macro_avg": {m: round(mean([mean([s.scores[m] for s in rows])
450
- for rows in by_cat.values()]), 4) for m in metrics},
451
- }
452
-
453
-
454
- # --------------------------------------------------------------------------- #
455
- # Benchmark runners
456
- # --------------------------------------------------------------------------- #
457
-
458
- def run_generative(key: str, rows: List[dict], model: HFModel, args) -> Tuple[List[Sample], Dict]:
459
- bench = BENCHMARKS[key]
460
- questions = [str(r["question"]) for r in rows]
461
- raw_preds = model.generate(questions, args.batch_size, args.max_new_tokens,
462
- progress=f"{bench['id']} generate")
463
-
464
- semantic = SemanticScorer() if key == "easy" else None
465
- if semantic:
466
- print(f" semantic scorer: {semantic.backend}")
467
-
468
- samples: List[Sample] = []
469
- for i, (row, raw) in enumerate(zip(rows, raw_preds)):
470
- pred = extract_answer(raw)
471
- gold = str(row["answer"])
472
- cat = str(row["category"])
473
- scores = {"exact_match": strict_score(pred, gold)}
474
- if key == "easy":
475
- scores["hybrid_score"] = easy_hybrid_score(cat, pred, gold, semantic)
476
- samples.append(Sample(i, cat, str(row["question"]), gold, pred, scores))
477
-
478
- metrics = ["exact_match"] + (["hybrid_score"] if key == "easy" else [])
479
- return samples, aggregate(samples, metrics)
480
-
481
-
482
- def run_multiple_choice(key: str, rows: List[dict], model: HFModel, args) -> Tuple[List[Sample], Dict]:
483
- bench = BENCHMARKS[key]
484
- pairs: List[Tuple[str, str]] = []
485
- index: List[Tuple[int, List[str]]] = []
486
- for i, row in enumerate(rows):
487
- choices = list(row["target_scores"].keys())
488
- ctx = MC_PROMPT.format(input=row["input"])
489
- for c in choices:
490
- pairs.append((ctx, f" {c}"))
491
- index.append((i, choices))
492
-
493
- lls = model.loglikelihoods(pairs, args.batch_size, progress=f"{bench['id']} loglikelihood")
494
-
495
- samples: List[Sample] = []
496
- pos = 0
497
- for i, choices in index:
498
- row = rows[i]
499
- tgt = row["target_scores"]
500
- chunk = lls[pos:pos + len(choices)]
501
- pos += len(choices)
502
- norm = [ll / max(1, len(c.encode("utf-8"))) for ll, c in zip(chunk, choices)]
503
- pick_raw = choices[max(range(len(choices)), key=lambda j: chunk[j])]
504
- pick_norm = choices[max(range(len(choices)), key=lambda j: norm[j])]
505
- gold = max(tgt, key=tgt.get)
506
- samples.append(Sample(
507
- i, str(row["category"]), str(row["input"]), gold, pick_norm,
508
- scores={
509
- "acc": 1.0 if tgt.get(pick_raw) == 1 else 0.0,
510
- "acc_norm": 1.0 if tgt.get(pick_norm) == 1 else 0.0,
511
- "soft_score": float(tgt.get(pick_raw, 0.0)),
512
- "soft_score_norm": float(tgt.get(pick_norm, 0.0)),
513
- },
514
- ))
515
- return samples, aggregate(samples, ["acc", "acc_norm", "soft_score", "soft_score_norm"])
516
-
517
-
518
- # --------------------------------------------------------------------------- #
519
- # Reporting
520
- # --------------------------------------------------------------------------- #
521
-
522
- def print_report(bench_key: str, agg: Dict[str, Any]) -> None:
523
- bench = BENCHMARKS[bench_key]
524
- headline = bench["metric"]
525
- overall = agg["overall"]
526
- print(f"\n=== {bench['id']} (tier {bench['tier']}) ===")
527
- print(f" headline [{headline}]: {overall[headline]:.4f} "
528
- f"(n={overall['n']}, stderr={overall['stderr']:.4f})")
529
- others = [m for m in overall if m not in ("n", "stderr", headline)]
530
- if others:
531
- print(" also: " + " ".join(f"{m}={overall[m]:.4f}" for m in others))
532
- print(f" macro avg [{headline}]: {agg['macro_avg'][headline]:.4f}")
533
-
534
- print(f" {'category':<28}{'n':>4} {headline}")
535
- current_top = None
536
- for cat, stats in agg["categories"].items():
537
- top, sub = split_category(cat)
538
- if top != current_top:
539
- group = agg["category_groups"][top]
540
- print(f" {top:<28}{group['n']:>4} {group[headline]:.3f}")
541
- current_top = top
542
- if sub is not None:
543
- print(f" - {sub:<24}{stats['n']:>4} {stats[headline]:.3f}")
544
-
545
-
546
- def leaderboard_entry(model_name: str, results: Dict[str, Any],
547
- model_revision: Optional[str]) -> Dict[str, Any]:
548
- """A ready-to-paste entry for the leaderboard's models.json `models` array."""
549
- runs: Dict[str, Any] = {}
550
- for key, bench in BENCHMARKS.items():
551
- bid = bench["id"]
552
- if key not in results:
553
- runs[bid] = {"score": None, "n": None, "notes": "Not yet evaluated on this tier."}
554
- continue
555
- agg = results[key]["aggregate"]
556
- overall = agg["overall"]
557
- entry: Dict[str, Any] = {"score": overall[bench["metric"]], "n": overall["n"]}
558
- if bench["kind"] == "multiple_choice":
559
- entry.update({m: overall[m] for m in ("acc", "acc_norm", "soft_score", "soft_score_norm")})
560
- entry["stderr"] = overall["stderr"]
561
- entry["categories"] = {
562
- cat: {"n": s["n"], "acc": s["acc"], "acc_norm": s["acc_norm"]}
563
- for cat, s in agg["categories"].items()
564
- }
565
- else:
566
- entry["notes"] = ("Exact-match, normalized." if bench["metric"] == "exact_match"
567
- else "Hybrid category-aware scoring (strict / flexible / semantic).")
568
- entry["categories"] = {
569
- cat: {"n": s["n"], bench["metric"]: s[bench["metric"]]}
570
- for cat, s in agg["categories"].items()
571
- }
572
- runs[bid] = entry
573
-
574
- slug = re.sub(r"[^a-z0-9.]+", "-", model_name.lower()).strip("-")
575
- return {
576
- "id": slug.split("/")[-1] if "/" in slug else slug,
577
- "name": model_name,
578
- "org": model_name.split("/")[0] if "/" in model_name else "",
579
- "params_b": None,
580
- "license": None,
581
- "architecture": None,
582
- "url": f"https://huggingface.co/{model_name}" if "/" in model_name else None,
583
- "model_revision": model_revision,
584
- "script_sha256": script_sha256(),
585
- "runs": runs,
586
- }
587
-
588
-
589
- def script_sha256() -> str:
590
- """SHA-256 of this file's own bytes.
591
-
592
- Written for content, not label: it lets a maintainer re-run the pinned
593
- copy of this script and compare hashes, rather than trusting a static
594
- version string that an edited copy would still print unchanged.
595
- """
596
- return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
597
-
598
-
599
- def save_outputs(out_dir: Path, model_name: str, results: Dict[str, Any], args,
600
- model_revision: Optional[str]) -> None:
601
- out_dir.mkdir(parents=True, exist_ok=True)
602
-
603
- report = {
604
- "model": model_name,
605
- "model_revision": model_revision,
606
- "script_sha256": script_sha256(),
607
- "config": {
608
- "device": args.device, "dtype": args.dtype, "batch_size": args.batch_size,
609
- "max_new_tokens": args.max_new_tokens, "limit": args.limit,
610
- "chat_template": not args.no_chat_template, "seed": "greedy/deterministic",
611
- },
612
- "benchmarks": {
613
- BENCHMARKS[k]["id"]: {"metric": BENCHMARKS[k]["metric"], **v["aggregate"]}
614
- for k, v in results.items()
615
- },
616
- }
617
- (out_dir / "results.json").write_text(json.dumps(report, indent=2, ensure_ascii=False),
618
- encoding="utf-8")
619
-
620
- for key, res in results.items():
621
- path = out_dir / f"samples_{BENCHMARKS[key]['id']}.csv"
622
- with path.open("w", newline="", encoding="utf-8") as f:
623
- w = csv.writer(f)
624
- metric_names = list(res["samples"][0].scores.keys()) if res["samples"] else []
625
- w.writerow(["idx", "category", *metric_names, "question", "gold", "pred"])
626
- for s in res["samples"]:
627
- w.writerow([s.idx, s.category, *[f"{s.scores[m]:.4f}" for m in metric_names],
628
- s.question, s.gold, s.pred])
629
-
630
- entry = leaderboard_entry(model_name, results, model_revision)
631
- (out_dir / "leaderboard.json").write_text(json.dumps(entry, indent=2, ensure_ascii=False),
632
- encoding="utf-8")
633
- print(f"\nSaved: {out_dir / 'results.json'}")
634
- print(f"Saved: {out_dir / 'leaderboard.json'} (paste into models.json `models` array)")
635
- for key in results:
636
- print(f"Saved: {out_dir / ('samples_' + BENCHMARKS[key]['id'] + '.csv')}")
637
-
638
-
639
- # --------------------------------------------------------------------------- #
640
- # Main
641
- # --------------------------------------------------------------------------- #
642
-
643
- def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
644
- p = argparse.ArgumentParser(
645
- description="Universal BenchLabs evaluator -- one script, every benchmark.",
646
- formatter_class=argparse.RawDescriptionHelpFormatter,
647
- epilog="Example: python script.py --model Qwen/Qwen2.5-0.5B",
648
- )
649
- p.add_argument("--model", required=True, help="HF model id or local checkpoint path")
650
- p.add_argument("--benchmarks", default="all",
651
- help="comma-separated: effortless,easy,mid (default: all available)")
652
- p.add_argument("--device", default="auto", help="auto | cuda | cpu | mps")
653
- p.add_argument("--dtype", default="auto", help="auto | float16 | bfloat16 | float32")
654
- p.add_argument("--batch-size", type=int, default=8)
655
- p.add_argument("--max-new-tokens", type=int, default=32,
656
- help="generation budget; raise to 2048+ for reasoning models "
657
- "that emit <think> blocks (default: 32)")
658
- p.add_argument("--limit", type=int, default=None, help="cap rows per benchmark (smoke test)")
659
- p.add_argument("--output-dir", default=None,
660
- help="default: benchlabs_results/<model-name>")
661
- p.add_argument("--no-chat-template", action="store_true",
662
- help="force plain 'Question:/Answer:' prompting even for instruct models")
663
- p.add_argument("--trust-remote-code", action="store_true")
664
- p.add_argument("--refresh-data", action="store_true", help="re-download datasets")
665
- p.add_argument("--leaderboard", action="store_true",
666
- help="also print the models.json entry to stdout")
667
- return p.parse_args(argv)
668
-
669
-
670
- def resolve_benchmarks(spec: str) -> List[str]:
671
- if spec.strip().lower() == "all":
672
- keys = [k for k, b in BENCHMARKS.items() if "unavailable" not in b]
673
- else:
674
- keys = [s.strip().lower() for s in spec.split(",") if s.strip()]
675
- unknown = [k for k in keys if k not in BENCHMARKS]
676
- if unknown:
677
- sys.exit(f"Unknown benchmark(s): {unknown}. Choose from: {list(BENCHMARKS)}")
678
- for k in list(keys):
679
- if "unavailable" in BENCHMARKS[k]:
680
- print(f"Skipping {BENCHMARKS[k]['id']}: {BENCHMARKS[k]['unavailable']}")
681
- keys.remove(k)
682
- return keys
683
-
684
-
685
- def main(argv: Optional[Sequence[str]] = None, model_factory=None) -> int:
686
- args = parse_args(argv)
687
- keys = resolve_benchmarks(args.benchmarks)
688
- if not keys:
689
- sys.exit("No runnable benchmarks selected.")
690
-
691
- print("Loading datasets...")
692
- data: Dict[str, List[dict]] = {}
693
- for k in keys:
694
- rows = load_benchmark_rows(BENCHMARKS[k]["id"], refresh=args.refresh_data)
695
- if args.limit:
696
- rows = rows[:args.limit]
697
- data[k] = rows
698
- print(f" {BENCHMARKS[k]['id']}: {len(rows)} rows")
699
-
700
- factory = model_factory or (lambda: HFModel(
701
- args.model, args.device, args.dtype, args.trust_remote_code,
702
- use_chat_template=not args.no_chat_template))
703
- model = factory()
704
-
705
- results: Dict[str, Any] = {}
706
- for k in keys:
707
- bench = BENCHMARKS[k]
708
- print(f"\nRunning {bench['id']} ({bench['kind']}, {len(data[k])} rows)...")
709
- if bench["kind"] == "generative":
710
- samples, agg = run_generative(k, data[k], model, args)
711
- else:
712
- samples, agg = run_multiple_choice(k, data[k], model, args)
713
- results[k] = {"samples": samples, "aggregate": agg}
714
- print_report(k, agg)
715
-
716
- out_dir = Path(args.output_dir) if args.output_dir else \
717
- Path("benchlabs_results") / re.sub(r"[^A-Za-z0-9._-]+", "_", args.model)
718
- save_outputs(out_dir, args.model, results, args, model.resolved_revision)
719
-
720
- if args.leaderboard:
721
- print("\n=== leaderboard entry (models.json) ===")
722
- print(json.dumps(leaderboard_entry(args.model, results, model.resolved_revision),
723
- indent=2, ensure_ascii=False))
724
- return 0
725
-
726
-
727
- if __name__ == "__main__":
728
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ BenchLabs universal evaluation script.
4
+
5
+ One script, every BenchLabs benchmark. Downloads the datasets straight from the
6
+ Hugging Face Hub, runs your model, and prints an in-depth report with
7
+ category / subcategory breakdowns -- in the exact shape the
8
+ BenchLabs-Leaderboard `models.json` expects.
9
+
10
+ Benchmarks covered
11
+ bench-effortless-7-2026 dual-mode: generative + log-likelihood (tier 1, latest)
12
+ bench-easy-7-2026 dual-mode: generative + log-likelihood (tier 2, latest)
13
+ bench-mid-7-2026 dual-mode: generative + log-likelihood (tier 3, latest)
14
+ bench-effortless-6-2026 generative, exact-match (tier 1, legacy)
15
+ bench-easy-6-2026 generative, hybrid category-aware (tier 2, legacy)
16
+ bench-mid-6-2026 multiple-choice, log-likelihood (tier 3, legacy)
17
+ bench-AGI skipped -- scoring pipeline under maintenance
18
+
19
+ Dual-mode (7-2026, schema v2)
20
+ Every item carries a gold answer + aliases AND target_scores choices, so each
21
+ benchmark is scored BOTH ways in one run:
22
+ generative exact_match (alias-aware) + hybrid_score routed per item by
23
+ its `gen_scoring` field (strict / semantic / fuzzy)
24
+ loglikelihood lm-eval style over choices: acc, acc_norm, soft_score,
25
+ soft_score_norm, with per-choice log-probs recorded
26
+ Headline metric stays tier-conventional: exact_match (effortless),
27
+ hybrid_score (easy), soft_score_norm (mid). Per-item detail -- raw
28
+ generation, extracted answer, per-choice log-probs raw and per-byte -- is
29
+ written to samples_<id>.jsonl next to the usual CSV.
30
+
31
+ Install
32
+ pip install torch transformers
33
+ pip install sentence-transformers # optional: better semantic scoring on Easy
34
+ pip install accelerate # optional: faster / multi-GPU loading
35
+
36
+ Run
37
+ python script.py --model Qwen/Qwen2.5-0.5B
38
+ python script.py --model Qwen/Qwen2.5-1.5B-Instruct --benchmarks easy,mid
39
+ python script.py --model ./my-local-checkpoint --device cuda --batch-size 16
40
+ python script.py --model Qwen/Qwen2.5-0.5B --limit 10 # quick smoke test
41
+ python script.py --model Qwen/Qwen2.5-0.5B --leaderboard # print models.json entry
42
+
43
+ Outputs (under --output-dir, default benchlabs_results/<model>/)
44
+ results.json full report: every benchmark, category, subcategory, sample counts
45
+ samples_<id>.csv per-sample predictions and scores for each benchmark
46
+ leaderboard.json ready-to-paste `models.json` entry for the leaderboard PR
47
+
48
+ Scoring conventions
49
+ Effortless exact match after normalization (strip, lowercase, drop punctuation).
50
+ Easy hybrid category-aware scoring, identical to the official
51
+ benchmark.ipynb: strict categories are binary exact-match, soft
52
+ categories get semantic similarity, hybrid categories get fuzzy
53
+ string similarity. Plain exact-match is also reported.
54
+ Mid lm-eval style log-likelihood over the `target_scores` candidates:
55
+ acc = argmax raw log-likelihood is the 1.0 answer
56
+ acc_norm = argmax log-likelihood / byte-length of the answer
57
+ soft_score / soft_score_norm = target_scores value of the picked
58
+ answer (partial credit on distractors with non-zero scores)
59
+ Headline score = soft_score_norm, matching the leaderboard.
60
+ Multiple-choice prompt format: "Q: {input}\nA:" with candidates " {choice}".
61
+
62
+ Reasoning / CoT models
63
+ <think>...</think> blocks are stripped before answer extraction: only the
64
+ text after the final </think> is scored. Raise --max-new-tokens (2048+) so
65
+ the model can finish thinking -- the default 32 is sized for direct-answer
66
+ models. A generation cut off mid-think (unclosed <think>) scores as an
67
+ empty answer. Mid is scored by log-likelihood over the answer choices with
68
+ no generation at all, so thinking never happens there.
69
+ Known limit: a model that reasons in plain prose with NO tags slips the
70
+ strip -- its first prose line is what gets scored. The scorer trusts the
71
+ tag convention; script_sha256 pins which scorer said so.
72
+
73
+ Reproducibility
74
+ --revision pins the exact model commit to evaluate. Every run records
75
+ model_revision (the snapshot actually loaded) + script_sha256 (the exact
76
+ scorer bytes); re-running the pinned script with --revision <recorded sha>
77
+ reproduces the run even if the model's main branch moved since.
78
+
79
+ Bench Labs - Simple, Reliable, Open sourced
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import argparse
85
+ import csv
86
+ import hashlib
87
+ import json
88
+ import math
89
+ import os
90
+ import re
91
+ import sys
92
+ import urllib.request
93
+ from collections import defaultdict
94
+ from dataclasses import dataclass, field
95
+ from difflib import SequenceMatcher
96
+ from pathlib import Path
97
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
98
+
99
+ # --------------------------------------------------------------------------- #
100
+ # Benchmark registry
101
+ # --------------------------------------------------------------------------- #
102
+
103
+ HUB_BASE = "https://huggingface.co/datasets/bench-labs/{id}/resolve/main/eval.jsonl"
104
+
105
+ BENCHMARKS: Dict[str, Dict[str, Any]] = {
106
+ "effortless7": {
107
+ "id": "bench-effortless-7-2026",
108
+ "tier": 1,
109
+ "kind": "dual",
110
+ "metric": "exact_match",
111
+ "generation": "7-2026",
112
+ "description": "Sanity-layer QA, dual-mode (generative + log-likelihood).",
113
+ },
114
+ "easy7": {
115
+ "id": "bench-easy-7-2026",
116
+ "tier": 2,
117
+ "kind": "dual",
118
+ "metric": "hybrid_score",
119
+ "generation": "7-2026",
120
+ "description": "Easy-tier QA, dual-mode with per-item scorer routing.",
121
+ },
122
+ "mid7": {
123
+ "id": "bench-mid-7-2026",
124
+ "tier": 3,
125
+ "kind": "dual",
126
+ "metric": "soft_score_norm",
127
+ "generation": "7-2026",
128
+ "description": "Mid-tier QA, dual-mode (headline: log-likelihood soft_score_norm).",
129
+ },
130
+ "effortless": {
131
+ "id": "bench-effortless-6-2026",
132
+ "tier": 1,
133
+ "kind": "generative",
134
+ "metric": "exact_match",
135
+ "generation": "6-2026",
136
+ "description": "Sanity-layer QA: unambiguous single-answer questions.",
137
+ },
138
+ "easy": {
139
+ "id": "bench-easy-6-2026",
140
+ "tier": 2,
141
+ "kind": "generative",
142
+ "metric": "hybrid_score",
143
+ "generation": "6-2026",
144
+ "description": "Easy-tier QA with hybrid category-aware scoring.",
145
+ },
146
+ "mid": {
147
+ "id": "bench-mid-6-2026",
148
+ "tier": 3,
149
+ "kind": "multiple_choice",
150
+ "metric": "soft_score_norm",
151
+ "generation": "6-2026",
152
+ "description": "Mid-tier multiple-choice QA via log-likelihood.",
153
+ },
154
+ "agi": {
155
+ "id": "bench-AGI",
156
+ "tier": 4,
157
+ "kind": "rank_order",
158
+ "metric": "rank_order",
159
+ "generation": "6-2026",
160
+ "description": "Hard open-ended questions, panel-graded rank order.",
161
+ "unavailable": "Scoring pipeline under maintenance -- see the dataset README.",
162
+ },
163
+ }
164
+
165
+ DUAL_METRICS_GEN = ("exact_match", "hybrid_score")
166
+ DUAL_METRICS_LL = ("acc", "acc_norm", "soft_score", "soft_score_norm")
167
+
168
+ # Easy-tier category routing, identical to the official benchmark.ipynb.
169
+ STRICT_CATEGORIES = {
170
+ "Math-arithmetic", "Math-pattern",
171
+ "Logic-deduction", "Logic-pattern", "Logic-consistency",
172
+ "Knowledge-basic", "Pattern-matching",
173
+ }
174
+ SOFT_CATEGORIES = {
175
+ "Commonsense-simulation", "Commonsense-causality", "Commonsense-reasoning",
176
+ "Language-comprehension", "Knowledge-definitions",
177
+ }
178
+ HYBRID_CATEGORIES = {
179
+ "Language-structure", "Language-transformation",
180
+ }
181
+
182
+ SYSTEM_PROMPT = "You are a precise assistant. Give only the final answer, without explanation."
183
+ MC_PROMPT = "Q: {input}\nA:"
184
+
185
+ ANSWER_PREFIXES = re.compile(
186
+ r"^(the answer is|answer\s*[:=]|final answer\s*[:=]?|it is|it's)\s*", re.IGNORECASE
187
+ )
188
+
189
+ # Reasoning-model tags. THINK_CLOSE also matches a bare closing tag: some chat
190
+ # templates open <think> inside the prompt, so the generation contains only
191
+ # the reasoning and a </think>.
192
+ THINK_CLOSE = re.compile(r"</think(?:ing)?>\s*", re.IGNORECASE)
193
+ THINK_OPEN = re.compile(r"<think(?:ing)?>.*", re.IGNORECASE | re.DOTALL)
194
+
195
+ # --------------------------------------------------------------------------- #
196
+ # Text normalization and scoring
197
+ # --------------------------------------------------------------------------- #
198
+
199
+ def normalize(text: str) -> str:
200
+ text = str(text).strip().lower()
201
+ text = re.sub(r"[\u201c\u201d\"'`]", "", text)
202
+ text = text.replace("\u2019", "'")
203
+ text = re.sub(r"[\.\,\!\?\:\;\(\)\[\]\{\}]", "", text)
204
+ text = re.sub(r"\s+", " ", text)
205
+ return text.strip()
206
+
207
+
208
+ def extract_answer(text: str) -> str:
209
+ """First line of the generation after any <think> block, minus boilerplate prefixes.
210
+
211
+ Only text after the final </think> is scored. An unclosed <think> means the
212
+ generation ran out of budget mid-reasoning, so there is no answer to extract.
213
+ """
214
+ text = str(text)
215
+ parts = THINK_CLOSE.split(text)
216
+ if len(parts) > 1:
217
+ text = parts[-1]
218
+ else:
219
+ text = THINK_OPEN.sub("", text)
220
+ text = text.strip()
221
+ if "\n" in text:
222
+ text = text.split("\n", 1)[0]
223
+ text = ANSWER_PREFIXES.sub("", text.strip())
224
+ return text.strip()
225
+
226
+
227
+ def strict_score(pred: str, gold: str) -> float:
228
+ return 1.0 if normalize(pred) == normalize(gold) else 0.0
229
+
230
+
231
+ def fuzzy_score(pred: str, gold: str) -> float:
232
+ p, g = normalize(pred), normalize(gold)
233
+ if p == g:
234
+ return 1.0
235
+ return max(0.0, min(1.0, SequenceMatcher(None, p, g).ratio()))
236
+
237
+
238
+ class SemanticScorer:
239
+ """Sentence-embedding similarity with a fuzzy-string fallback."""
240
+
241
+ def __init__(self) -> None:
242
+ self._embedder = None
243
+ try:
244
+ from sentence_transformers import SentenceTransformer # type: ignore
245
+ self._embedder = SentenceTransformer("all-MiniLM-L6-v2")
246
+ except Exception:
247
+ self._embedder = None
248
+
249
+ @property
250
+ def backend(self) -> str:
251
+ return "sentence-transformers/all-MiniLM-L6-v2" if self._embedder else "difflib-fallback"
252
+
253
+ def score(self, pred: str, gold: str) -> float:
254
+ p, g = normalize(pred), normalize(gold)
255
+ if p == g:
256
+ return 1.0
257
+ if self._embedder is not None:
258
+ try:
259
+ import numpy as np
260
+ pv, gv = self._embedder.encode([p, g], normalize_embeddings=True)
261
+ cos = float(np.dot(pv, gv))
262
+ return max(0.0, min(1.0, (cos + 1.0) / 2.0))
263
+ except Exception:
264
+ pass
265
+ return fuzzy_score(pred, gold)
266
+
267
+
268
+ def easy_hybrid_score(category: str, pred: str, gold: str, semantic: SemanticScorer) -> float:
269
+ if category in STRICT_CATEGORIES:
270
+ return strict_score(pred, gold)
271
+ if category in SOFT_CATEGORIES:
272
+ return semantic.score(pred, gold)
273
+ if category in HYBRID_CATEGORIES:
274
+ return fuzzy_score(pred, gold)
275
+ return fuzzy_score(pred, gold) # unknown categories: fuzzy, never hard-fail
276
+
277
+
278
+ # -- v2 (7-2026) scoring: alias-aware, routed per item by `gen_scoring` ------ #
279
+
280
+ def strict_score_multi(pred: str, golds: Sequence[str]) -> float:
281
+ return 1.0 if any(normalize(pred) == normalize(g) for g in golds) else 0.0
282
+
283
+
284
+ def routed_score(gen_scoring: str, pred: str, golds: Sequence[str],
285
+ semantic: SemanticScorer) -> float:
286
+ """v2 generation-mode score: routing comes from the item, not category tables."""
287
+ if gen_scoring == "strict":
288
+ return strict_score_multi(pred, golds)
289
+ if gen_scoring == "semantic":
290
+ return max(semantic.score(pred, g) for g in golds)
291
+ return max(fuzzy_score(pred, g) for g in golds) # "fuzzy"
292
+
293
+
294
+ # --------------------------------------------------------------------------- #
295
+ # Dataset loading (no `datasets` dependency -- each benchmark is one eval.jsonl)
296
+ # --------------------------------------------------------------------------- #
297
+
298
+ def cache_dir() -> Path:
299
+ return Path(os.environ.get("BENCHLABS_CACHE", Path.home() / ".cache" / "benchlabs"))
300
+
301
+
302
+ def load_benchmark_rows(bench_id: str, refresh: bool = False) -> List[dict]:
303
+ path = cache_dir() / f"{bench_id}.jsonl"
304
+ if refresh or not path.exists():
305
+ url = HUB_BASE.format(id=bench_id)
306
+ print(f" downloading {url}")
307
+ path.parent.mkdir(parents=True, exist_ok=True)
308
+ req = urllib.request.Request(url)
309
+ token = os.environ.get("HF_TOKEN")
310
+ if token:
311
+ req.add_header("Authorization", f"Bearer {token}")
312
+ with urllib.request.urlopen(req) as resp:
313
+ path.write_bytes(resp.read())
314
+ rows = []
315
+ with path.open(encoding="utf-8") as f:
316
+ for line in f:
317
+ line = line.strip()
318
+ if line:
319
+ rows.append(json.loads(line))
320
+ return rows
321
+
322
+
323
+ def split_category(cat: str) -> Tuple[str, Optional[str]]:
324
+ """'Commonsense-causality' -> ('Commonsense', 'causality'); 'Math' -> ('Math', None)."""
325
+ if "-" in cat:
326
+ top, sub = cat.split("-", 1)
327
+ return top, sub
328
+ return cat, None
329
+
330
+
331
+ # --------------------------------------------------------------------------- #
332
+ # Model backend (lazy torch/transformers import)
333
+ # --------------------------------------------------------------------------- #
334
+
335
+ class HFModel:
336
+ """Thin wrapper: batched greedy generation + batched log-likelihood scoring."""
337
+
338
+ def __init__(self, name: str, device: str, dtype: str, trust_remote_code: bool,
339
+ use_chat_template: bool, revision: Optional[str] = None) -> None:
340
+ try:
341
+ import torch
342
+ from transformers import AutoModelForCausalLM, AutoTokenizer
343
+ except ImportError as e:
344
+ sys.exit(f"Missing dependency ({e.name}). Install with: pip install torch transformers")
345
+
346
+ self.torch = torch
347
+ if device == "auto":
348
+ if torch.cuda.is_available():
349
+ device = "cuda"
350
+ elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
351
+ device = "mps"
352
+ else:
353
+ device = "cpu"
354
+ self.device = device
355
+
356
+ if dtype == "auto":
357
+ torch_dtype = torch.bfloat16 if device == "cuda" and torch.cuda.is_bf16_supported() \
358
+ else (torch.float16 if device in ("cuda", "mps") else torch.float32)
359
+ else:
360
+ torch_dtype = {"float16": torch.float16, "fp16": torch.float16,
361
+ "bfloat16": torch.bfloat16, "bf16": torch.bfloat16,
362
+ "float32": torch.float32, "fp32": torch.float32}[dtype.lower()]
363
+
364
+ pin = f", revision={revision}" if revision else ""
365
+ print(f"Loading model: {name} (device={device}, dtype={torch_dtype}{pin})")
366
+ self.tokenizer = AutoTokenizer.from_pretrained(name, trust_remote_code=trust_remote_code,
367
+ revision=revision)
368
+ if self.tokenizer.pad_token_id is None:
369
+ self.tokenizer.pad_token = self.tokenizer.eos_token
370
+ self.model = AutoModelForCausalLM.from_pretrained(
371
+ name, torch_dtype=torch_dtype, trust_remote_code=trust_remote_code,
372
+ revision=revision,
373
+ ).to(device)
374
+ self.model.eval()
375
+ self.use_chat = use_chat_template and self.tokenizer.chat_template is not None
376
+ print(f" chat template: {'yes' if self.use_chat else 'no (plain QA prompt)'}")
377
+
378
+ # -- resolved model provenance ------------------------------------ #
379
+ # The weights already carry their commit: from_pretrained records the
380
+ # snapshot it actually loaded in config._commit_hash, no second Hub
381
+ # lookup. Asking the Hub afterwards can pin a different commit if the
382
+ # branch moved between load and lookup, so _commit_hash is primary.
383
+ self.resolved_revision: Optional[str] = getattr(self.model.config, "_commit_hash", None)
384
+ if self.resolved_revision is None and "/" in name and not Path(name).exists():
385
+ # Fallback for transformers versions that don't record it. Local
386
+ # checkpoints stay None, which is honest: they have no hub revision.
387
+ try:
388
+ from huggingface_hub import HfApi
389
+ self.resolved_revision = HfApi().model_info(name).sha
390
+ except Exception:
391
+ pass
392
+
393
+ # -- generation -------------------------------------------------------- #
394
+
395
+ def _format_prompt(self, question: str) -> str:
396
+ if self.use_chat:
397
+ return self.tokenizer.apply_chat_template(
398
+ [{"role": "system", "content": SYSTEM_PROMPT},
399
+ {"role": "user", "content": question}],
400
+ tokenize=False, add_generation_prompt=True,
401
+ )
402
+ return f"Question: {question}\nAnswer:"
403
+
404
+ def generate(self, questions: Sequence[str], batch_size: int, max_new_tokens: int,
405
+ progress: str = "") -> List[str]:
406
+ torch = self.torch
407
+ tok = self.tokenizer
408
+ preds: List[str] = []
409
+ old_side = tok.padding_side
410
+ tok.padding_side = "left"
411
+ try:
412
+ with torch.no_grad():
413
+ for start in range(0, len(questions), batch_size):
414
+ chunk = questions[start:start + batch_size]
415
+ prompts = [self._format_prompt(q) for q in chunk]
416
+ inputs = tok(prompts, return_tensors="pt", padding=True,
417
+ truncation=True).to(self.device)
418
+ out = self.model.generate(
419
+ **inputs, max_new_tokens=max_new_tokens, do_sample=False,
420
+ pad_token_id=tok.pad_token_id,
421
+ )
422
+ gen = out[:, inputs["input_ids"].shape[1]:]
423
+ preds.extend(tok.decode(g, skip_special_tokens=True).strip() for g in gen)
424
+ _progress(progress, len(preds), len(questions))
425
+ finally:
426
+ tok.padding_side = old_side
427
+ return preds
428
+
429
+ # -- log-likelihood ---------------------------------------------------- #
430
+
431
+ def loglikelihoods(self, pairs: Sequence[Tuple[str, str]], batch_size: int,
432
+ progress: str = "") -> List[float]:
433
+ """Sum of log-probs of `continuation` given `context` for each pair."""
434
+ torch = self.torch
435
+ tok = self.tokenizer
436
+ encoded = []
437
+ for ctx, cont in pairs:
438
+ ctx_ids = tok.encode(ctx)
439
+ full_ids = tok.encode(ctx + cont)
440
+ n_cont = len(full_ids) - len(ctx_ids)
441
+ if n_cont <= 0: # tokenizer merged across the boundary; re-split manually
442
+ cont_ids = tok.encode(cont, add_special_tokens=False)
443
+ full_ids = ctx_ids + cont_ids
444
+ n_cont = len(cont_ids)
445
+ encoded.append((full_ids, n_cont))
446
+
447
+ results: List[float] = []
448
+ with torch.no_grad():
449
+ for start in range(0, len(encoded), batch_size):
450
+ chunk = encoded[start:start + batch_size]
451
+ maxlen = max(len(ids) for ids, _ in chunk)
452
+ pad_id = tok.pad_token_id
453
+ input_ids = torch.full((len(chunk), maxlen), pad_id, dtype=torch.long)
454
+ attn = torch.zeros((len(chunk), maxlen), dtype=torch.long)
455
+ for i, (ids, _) in enumerate(chunk):
456
+ input_ids[i, :len(ids)] = torch.tensor(ids)
457
+ attn[i, :len(ids)] = 1
458
+ input_ids, attn = input_ids.to(self.device), attn.to(self.device)
459
+ logits = self.model(input_ids=input_ids, attention_mask=attn).logits
460
+ logprobs = torch.log_softmax(logits.float(), dim=-1)
461
+ for i, (ids, n_cont) in enumerate(chunk):
462
+ total = 0.0
463
+ for pos in range(len(ids) - n_cont, len(ids)):
464
+ total += logprobs[i, pos - 1, ids[pos]].item()
465
+ results.append(total)
466
+ _progress(progress, len(results), len(pairs))
467
+ return results
468
+
469
+
470
+ _progress_t0: Dict[str, float] = {}
471
+
472
+
473
+ def _progress(label: str, done: int, total: int) -> None:
474
+ if not label:
475
+ return
476
+ import time
477
+ t0 = _progress_t0.setdefault(label, time.monotonic())
478
+ elapsed = time.monotonic() - t0
479
+ eta = ""
480
+ if 0 < done < total and elapsed > 2:
481
+ remain = elapsed / done * (total - done)
482
+ eta = f" · {int(remain // 60)}m{int(remain % 60):02d}s left"
483
+ print(f"\r {label}: {done}/{total} ({100 * done // max(1, total)}%){eta} ",
484
+ end="", flush=True)
485
+ if done >= total:
486
+ _progress_t0.pop(label, None)
487
+ print(f"\r {label}: {total}/{total} done in {int(elapsed // 60)}m{int(elapsed % 60):02d}s")
488
+
489
+
490
+ # --------------------------------------------------------------------------- #
491
+ # Aggregation
492
+ # --------------------------------------------------------------------------- #
493
+
494
+ @dataclass
495
+ class Sample:
496
+ idx: int
497
+ category: str
498
+ question: str
499
+ gold: str
500
+ pred: str
501
+ scores: Dict[str, float] = field(default_factory=dict)
502
+ detail: Optional[Dict[str, Any]] = None # dual-mode per-item record (samples_<id>.jsonl)
503
+
504
+
505
+ def mean(xs: Sequence[float]) -> float:
506
+ return sum(xs) / len(xs) if xs else 0.0
507
+
508
+
509
+ def stderr_of(xs: Sequence[float]) -> float:
510
+ if len(xs) < 2:
511
+ return 0.0
512
+ m = mean(xs)
513
+ var = sum((x - m) ** 2 for x in xs) / (len(xs) - 1)
514
+ return math.sqrt(var / len(xs))
515
+
516
+
517
+ def aggregate(samples: List[Sample], metrics: Sequence[str]) -> Dict[str, Any]:
518
+ """Overall + per-category + per-subcategory rollups for each metric."""
519
+ by_cat: Dict[str, List[Sample]] = defaultdict(list)
520
+ by_top: Dict[str, List[Sample]] = defaultdict(list)
521
+ for s in samples:
522
+ by_cat[s.category].append(s)
523
+ by_top[split_category(s.category)[0]].append(s)
524
+
525
+ def block(rows: List[Sample]) -> Dict[str, Any]:
526
+ out: Dict[str, Any] = {"n": len(rows)}
527
+ for m in metrics:
528
+ vals = [s.scores[m] for s in rows]
529
+ out[m] = round(mean(vals), 4)
530
+ return out
531
+
532
+ return {
533
+ "overall": {**block(samples),
534
+ "stderr": round(stderr_of([s.scores[metrics[-1]] for s in samples]), 4)},
535
+ "categories": {cat: block(rows) for cat, rows in sorted(by_cat.items())},
536
+ "category_groups": {top: block(rows) for top, rows in sorted(by_top.items())},
537
+ "macro_avg": {m: round(mean([mean([s.scores[m] for s in rows])
538
+ for rows in by_cat.values()]), 4) for m in metrics},
539
+ }
540
+
541
+
542
+ # --------------------------------------------------------------------------- #
543
+ # Benchmark runners
544
+ # --------------------------------------------------------------------------- #
545
+
546
+ def run_generative(key: str, rows: List[dict], model: HFModel, args) -> Tuple[List[Sample], Dict]:
547
+ bench = BENCHMARKS[key]
548
+ questions = [str(r["question"]) for r in rows]
549
+ raw_preds = model.generate(questions, args.batch_size, args.max_new_tokens,
550
+ progress=f"{bench['id']} generate")
551
+
552
+ semantic = SemanticScorer() if key == "easy" else None
553
+ if semantic:
554
+ print(f" semantic scorer: {semantic.backend}")
555
+
556
+ samples: List[Sample] = []
557
+ for i, (row, raw) in enumerate(zip(rows, raw_preds)):
558
+ pred = extract_answer(raw)
559
+ gold = str(row["answer"])
560
+ cat = str(row["category"])
561
+ scores = {"exact_match": strict_score(pred, gold)}
562
+ if key == "easy":
563
+ scores["hybrid_score"] = easy_hybrid_score(cat, pred, gold, semantic)
564
+ samples.append(Sample(i, cat, str(row["question"]), gold, pred, scores))
565
+
566
+ metrics = ["exact_match"] + (["hybrid_score"] if key == "easy" else [])
567
+ return samples, aggregate(samples, metrics)
568
+
569
+
570
+ def run_multiple_choice(key: str, rows: List[dict], model: HFModel, args) -> Tuple[List[Sample], Dict]:
571
+ bench = BENCHMARKS[key]
572
+ pairs: List[Tuple[str, str]] = []
573
+ index: List[Tuple[int, List[str]]] = []
574
+ for i, row in enumerate(rows):
575
+ choices = list(row["target_scores"].keys())
576
+ ctx = MC_PROMPT.format(input=row["input"])
577
+ for c in choices:
578
+ pairs.append((ctx, f" {c}"))
579
+ index.append((i, choices))
580
+
581
+ lls = model.loglikelihoods(pairs, args.batch_size, progress=f"{bench['id']} loglikelihood")
582
+
583
+ samples: List[Sample] = []
584
+ pos = 0
585
+ for i, choices in index:
586
+ row = rows[i]
587
+ tgt = row["target_scores"]
588
+ chunk = lls[pos:pos + len(choices)]
589
+ pos += len(choices)
590
+ norm = [ll / max(1, len(c.encode("utf-8"))) for ll, c in zip(chunk, choices)]
591
+ pick_raw = choices[max(range(len(choices)), key=lambda j: chunk[j])]
592
+ pick_norm = choices[max(range(len(choices)), key=lambda j: norm[j])]
593
+ gold = max(tgt, key=tgt.get)
594
+ samples.append(Sample(
595
+ i, str(row["category"]), str(row["input"]), gold, pick_norm,
596
+ scores={
597
+ "acc": 1.0 if tgt.get(pick_raw) == 1 else 0.0,
598
+ "acc_norm": 1.0 if tgt.get(pick_norm) == 1 else 0.0,
599
+ "soft_score": float(tgt.get(pick_raw, 0.0)),
600
+ "soft_score_norm": float(tgt.get(pick_norm, 0.0)),
601
+ },
602
+ ))
603
+ return samples, aggregate(samples, ["acc", "acc_norm", "soft_score", "soft_score_norm"])
604
+
605
+
606
+ def run_dual(key: str, rows: List[dict], model: HFModel, args) -> Tuple[List[Sample], Dict]:
607
+ """v2 (7-2026) benchmarks: run BOTH modes over every item.
608
+
609
+ Generative: greedy generation, alias-aware exact match + hybrid_score
610
+ routed per item by its `gen_scoring` field.
611
+ Log-likelihood: lm-eval style over `target_scores` choices, per-choice
612
+ log-probs (raw and per-byte) recorded in the sample detail.
613
+ """
614
+ bench = BENCHMARKS[key]
615
+
616
+ # -- generative pass ---------------------------------------------------- #
617
+ questions = [str(r["question"]) for r in rows]
618
+ raw_preds = model.generate(questions, args.batch_size, args.max_new_tokens,
619
+ progress=f"{bench['id']} generate")
620
+ semantic = SemanticScorer()
621
+ print(f" semantic scorer: {semantic.backend}")
622
+
623
+ # -- log-likelihood pass ------------------------------------------------ #
624
+ pairs: List[Tuple[str, str]] = []
625
+ index: List[List[str]] = []
626
+ for row in rows:
627
+ choices = list(row["target_scores"].keys())
628
+ ctx = MC_PROMPT.format(input=row["question"])
629
+ for c in choices:
630
+ pairs.append((ctx, f" {c}"))
631
+ index.append(choices)
632
+ lls = model.loglikelihoods(pairs, args.batch_size,
633
+ progress=f"{bench['id']} loglikelihood")
634
+
635
+ samples: List[Sample] = []
636
+ pos = 0
637
+ for i, (row, raw) in enumerate(zip(rows, raw_preds)):
638
+ gold = str(row["answer"])
639
+ aliases = [str(a) for a in row.get("answer_aliases", [])]
640
+ golds = [gold] + aliases
641
+ gen_scoring = str(row.get("gen_scoring", "strict"))
642
+ cat = str(row["category"])
643
+
644
+ pred = extract_answer(raw)
645
+ exact = strict_score_multi(pred, golds)
646
+ hybrid = routed_score(gen_scoring, pred, golds, semantic)
647
+
648
+ tgt = row["target_scores"]
649
+ choices = index[i]
650
+ chunk = lls[pos:pos + len(choices)]
651
+ pos += len(choices)
652
+ norm = [ll / max(1, len(c.encode("utf-8"))) for ll, c in zip(chunk, choices)]
653
+ pick_raw = choices[max(range(len(choices)), key=lambda j: chunk[j])]
654
+ pick_norm = choices[max(range(len(choices)), key=lambda j: norm[j])]
655
+
656
+ scores = {
657
+ "exact_match": exact,
658
+ "hybrid_score": hybrid,
659
+ "acc": 1.0 if tgt.get(pick_raw) == 1 else 0.0,
660
+ "acc_norm": 1.0 if tgt.get(pick_norm) == 1 else 0.0,
661
+ "soft_score": float(tgt.get(pick_raw, 0.0)),
662
+ "soft_score_norm": float(tgt.get(pick_norm, 0.0)),
663
+ }
664
+ detail = {
665
+ "id": row.get("id", i),
666
+ "category": cat,
667
+ "gold": gold,
668
+ "answer_aliases": aliases,
669
+ "gen_scoring": gen_scoring,
670
+ "preferred_mode": row.get("preferred_mode"),
671
+ "generative": {
672
+ "raw": raw,
673
+ "extracted": pred,
674
+ "exact_match": exact,
675
+ "hybrid_score": round(hybrid, 4),
676
+ },
677
+ "loglikelihood": {
678
+ "choices": {
679
+ c: {"logprob": round(ll, 4), "logprob_per_byte": round(nb, 5)}
680
+ for c, ll, nb in zip(choices, chunk, norm)
681
+ },
682
+ "pick_raw": pick_raw,
683
+ "pick_norm": pick_norm,
684
+ **{m: scores[m] for m in DUAL_METRICS_LL},
685
+ },
686
+ }
687
+ samples.append(Sample(i, cat, str(row["question"]), gold, pred, scores, detail))
688
+
689
+ # stderr is computed on metrics[-1]; keep the headline metric last.
690
+ metrics = [m for m in (*DUAL_METRICS_GEN, *DUAL_METRICS_LL) if m != bench["metric"]]
691
+ metrics.append(bench["metric"])
692
+ return samples, aggregate(samples, metrics)
693
+
694
+
695
+ # --------------------------------------------------------------------------- #
696
+ # Reporting
697
+ # --------------------------------------------------------------------------- #
698
+
699
+ def print_report(bench_key: str, agg: Dict[str, Any]) -> None:
700
+ bench = BENCHMARKS[bench_key]
701
+ headline = bench["metric"]
702
+ overall = agg["overall"]
703
+ print(f"\n=== {bench['id']} (tier {bench['tier']}) ===")
704
+ print(f" headline [{headline}]: {overall[headline]:.4f} "
705
+ f"(n={overall['n']}, stderr={overall['stderr']:.4f})")
706
+ others = [m for m in overall if m not in ("n", "stderr", headline)]
707
+ if others:
708
+ print(" also: " + " ".join(f"{m}={overall[m]:.4f}" for m in others))
709
+ print(f" macro avg [{headline}]: {agg['macro_avg'][headline]:.4f}")
710
+
711
+ print(f" {'category':<28}{'n':>4} {headline}")
712
+ current_top = None
713
+ for cat, stats in agg["categories"].items():
714
+ top, sub = split_category(cat)
715
+ if top != current_top:
716
+ group = agg["category_groups"][top]
717
+ print(f" {top:<28}{group['n']:>4} {group[headline]:.3f}")
718
+ current_top = top
719
+ if sub is not None:
720
+ print(f" - {sub:<24}{stats['n']:>4} {stats[headline]:.3f}")
721
+
722
+
723
+ def leaderboard_entry(model_name: str, results: Dict[str, Any],
724
+ model_revision: Optional[str]) -> Dict[str, Any]:
725
+ """A ready-to-paste entry for the leaderboard's models.json `models` array."""
726
+ runs: Dict[str, Any] = {}
727
+ for key, bench in BENCHMARKS.items():
728
+ bid = bench["id"]
729
+ if key not in results:
730
+ runs[bid] = {"score": None, "n": None, "notes": "Not yet evaluated on this tier."}
731
+ continue
732
+ agg = results[key]["aggregate"]
733
+ overall = agg["overall"]
734
+ entry: Dict[str, Any] = {"score": overall[bench["metric"]], "n": overall["n"]}
735
+ if bench["kind"] == "dual":
736
+ # v2: uniform shape -- run-level metrics{} split by mode, and every
737
+ # category block carries a generic "score" (the headline metric).
738
+ entry["stderr"] = overall["stderr"]
739
+ entry["metrics"] = {
740
+ "generative": {m: overall[m] for m in DUAL_METRICS_GEN},
741
+ "loglikelihood": {m: overall[m] for m in DUAL_METRICS_LL},
742
+ }
743
+ entry["categories"] = {
744
+ cat: {"n": s["n"], "score": s[bench["metric"]],
745
+ "exact_match": s["exact_match"], "acc_norm": s["acc_norm"]}
746
+ for cat, s in agg["categories"].items()
747
+ }
748
+ elif bench["kind"] == "multiple_choice":
749
+ entry.update({m: overall[m] for m in ("acc", "acc_norm", "soft_score", "soft_score_norm")})
750
+ entry["stderr"] = overall["stderr"]
751
+ entry["categories"] = {
752
+ cat: {"n": s["n"], "acc": s["acc"], "acc_norm": s["acc_norm"]}
753
+ for cat, s in agg["categories"].items()
754
+ }
755
+ else:
756
+ entry["notes"] = ("Exact-match, normalized." if bench["metric"] == "exact_match"
757
+ else "Hybrid category-aware scoring (strict / flexible / semantic).")
758
+ entry["categories"] = {
759
+ cat: {"n": s["n"], bench["metric"]: s[bench["metric"]]}
760
+ for cat, s in agg["categories"].items()
761
+ }
762
+ runs[bid] = entry
763
+
764
+ slug = re.sub(r"[^a-z0-9.]+", "-", model_name.lower()).strip("-")
765
+ return {
766
+ "id": slug.split("/")[-1] if "/" in slug else slug,
767
+ "name": model_name,
768
+ "org": model_name.split("/")[0] if "/" in model_name else "",
769
+ "params_b": None,
770
+ "license": None,
771
+ "architecture": None,
772
+ "url": f"https://huggingface.co/{model_name}" if "/" in model_name else None,
773
+ "model_revision": model_revision,
774
+ "script_sha256": script_sha256(),
775
+ "runs": runs,
776
+ }
777
+
778
+
779
+ MODELS_JSON_URL = ("https://huggingface.co/spaces/bench-labs/BenchLabs-Leaderboard/"
780
+ "resolve/main/models.json")
781
+
782
+
783
+ def merge_into_models_json(entry: Dict[str, Any], evaluated_bench_ids: List[str],
784
+ out_dir: Path) -> Optional[Path]:
785
+ """Fetch the live models.json and merge this run's entry into it.
786
+
787
+ The result is written to <out_dir>/models.json, ready to upload as-is --
788
+ no hand-pasting. Merge rules:
789
+ * matched by `id`: only the benchmarks evaluated THIS run are replaced;
790
+ scores from other tiers and hand-curated metadata (params_b, license,
791
+ architecture) are kept.
792
+ * unmatched: the entry is appended.
793
+ Returns the written path, or None if the live file could not be fetched.
794
+ """
795
+ try:
796
+ req = urllib.request.Request(MODELS_JSON_URL)
797
+ token = os.environ.get("HF_TOKEN")
798
+ if token:
799
+ req.add_header("Authorization", f"Bearer {token}")
800
+ with urllib.request.urlopen(req, timeout=30) as resp:
801
+ board = json.loads(resp.read().decode("utf-8"))
802
+ except Exception as e:
803
+ print(f" could not fetch live models.json ({e}); skipping auto-merge")
804
+ return None
805
+
806
+ existing = next((m for m in board.get("models", []) if m.get("id") == entry["id"]), None)
807
+ if existing is None:
808
+ board.setdefault("models", []).append(entry)
809
+ else:
810
+ for bid in evaluated_bench_ids:
811
+ existing.setdefault("runs", {})[bid] = entry["runs"][bid]
812
+ existing["model_revision"] = entry["model_revision"]
813
+ existing["script_sha256"] = entry["script_sha256"]
814
+ for meta in ("name", "org", "url"):
815
+ existing.setdefault(meta, entry[meta])
816
+
817
+ import datetime as _dt
818
+ board["updated"] = _dt.date.today().isoformat()
819
+
820
+ path = out_dir / "models.json"
821
+ path.write_text(json.dumps(board, indent=2, ensure_ascii=False), encoding="utf-8")
822
+ return path
823
+
824
+
825
+ def script_sha256() -> str:
826
+ """SHA-256 of this file's own bytes.
827
+
828
+ Written for content, not label: it lets a maintainer re-run the pinned
829
+ copy of this script and compare hashes, rather than trusting a static
830
+ version string that an edited copy would still print unchanged.
831
+ """
832
+ return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
833
+
834
+
835
+ def save_outputs(out_dir: Path, model_name: str, results: Dict[str, Any], args,
836
+ model_revision: Optional[str]) -> None:
837
+ out_dir.mkdir(parents=True, exist_ok=True)
838
+
839
+ report = {
840
+ "model": model_name,
841
+ "model_revision": model_revision,
842
+ "script_sha256": script_sha256(),
843
+ "config": {
844
+ "device": args.device, "dtype": args.dtype, "batch_size": args.batch_size,
845
+ "max_new_tokens": args.max_new_tokens, "limit": args.limit,
846
+ "chat_template": not args.no_chat_template, "seed": "greedy/deterministic",
847
+ "requested_revision": args.revision,
848
+ },
849
+ "benchmarks": {
850
+ BENCHMARKS[k]["id"]: {"metric": BENCHMARKS[k]["metric"], **v["aggregate"]}
851
+ for k, v in results.items()
852
+ },
853
+ }
854
+ (out_dir / "results.json").write_text(json.dumps(report, indent=2, ensure_ascii=False),
855
+ encoding="utf-8")
856
+
857
+ for key, res in results.items():
858
+ path = out_dir / f"samples_{BENCHMARKS[key]['id']}.csv"
859
+ with path.open("w", newline="", encoding="utf-8") as f:
860
+ w = csv.writer(f)
861
+ metric_names = list(res["samples"][0].scores.keys()) if res["samples"] else []
862
+ w.writerow(["idx", "category", *metric_names, "question", "gold", "pred"])
863
+ for s in res["samples"]:
864
+ w.writerow([s.idx, s.category, *[f"{s.scores[m]:.4f}" for m in metric_names],
865
+ s.question, s.gold, s.pred])
866
+
867
+ # dual-mode benchmarks additionally get a rich per-item JSONL: raw
868
+ # generation, extracted answer, per-choice log-probs, both metric families.
869
+ for key, res in results.items():
870
+ if BENCHMARKS[key]["kind"] != "dual":
871
+ continue
872
+ path = out_dir / f"samples_{BENCHMARKS[key]['id']}.jsonl"
873
+ with path.open("w", encoding="utf-8") as f:
874
+ for s in res["samples"]:
875
+ if s.detail is not None:
876
+ f.write(json.dumps(s.detail, ensure_ascii=False) + "\n")
877
+
878
+ entry = leaderboard_entry(model_name, results, model_revision)
879
+ (out_dir / "leaderboard.json").write_text(json.dumps(entry, indent=2, ensure_ascii=False),
880
+ encoding="utf-8")
881
+
882
+ evaluated = [BENCHMARKS[k]["id"] for k in results]
883
+ merged = merge_into_models_json(entry, evaluated, out_dir)
884
+
885
+ print(f"\nSaved: {out_dir / 'results.json'}")
886
+ print(f"Saved: {out_dir / 'leaderboard.json'} (single entry, for reference)")
887
+ if merged:
888
+ print(f"Saved: {merged} <- live leaderboard with this run merged in; "
889
+ f"upload this file to the Space as-is")
890
+ for key in results:
891
+ print(f"Saved: {out_dir / ('samples_' + BENCHMARKS[key]['id'] + '.csv')}")
892
+
893
+
894
+ # --------------------------------------------------------------------------- #
895
+ # Main
896
+ # --------------------------------------------------------------------------- #
897
+
898
+ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
899
+ p = argparse.ArgumentParser(
900
+ description="Universal BenchLabs evaluator -- one script, every benchmark.",
901
+ formatter_class=argparse.RawDescriptionHelpFormatter,
902
+ epilog="Example: python script.py --model Qwen/Qwen2.5-0.5B",
903
+ )
904
+ p.add_argument("--model", required=True, help="HF model id or local checkpoint path")
905
+ p.add_argument("--revision", default=None,
906
+ help="pin an exact model commit (SHA / tag / branch). A maintainer "
907
+ "re-running with the recorded model_revision + the pinned "
908
+ "script copy reproduces the run byte-for-byte even if the "
909
+ "model's main branch has moved since")
910
+ p.add_argument("--benchmarks", default="all",
911
+ help="comma-separated: effortless7,easy7,mid7 (latest) and/or "
912
+ "effortless,easy,mid (legacy 6-2026); default: all available")
913
+ p.add_argument("--device", default="auto", help="auto | cuda | cpu | mps")
914
+ p.add_argument("--dtype", default="auto", help="auto | float16 | bfloat16 | float32")
915
+ p.add_argument("--batch-size", type=int, default=8)
916
+ p.add_argument("--max-new-tokens", type=int, default=32,
917
+ help="generation budget; raise to 2048+ for reasoning models "
918
+ "that emit <think> blocks (default: 32)")
919
+ p.add_argument("--limit", type=int, default=None, help="cap rows per benchmark (smoke test)")
920
+ p.add_argument("--output-dir", default=None,
921
+ help="default: benchlabs_results/<model-name>")
922
+ p.add_argument("--no-chat-template", action="store_true",
923
+ help="force plain 'Question:/Answer:' prompting even for instruct models")
924
+ p.add_argument("--trust-remote-code", action="store_true")
925
+ p.add_argument("--refresh-data", action="store_true", help="re-download datasets")
926
+ p.add_argument("--leaderboard", action="store_true",
927
+ help="also print the models.json entry to stdout")
928
+ return p.parse_args(argv)
929
+
930
+
931
+ def resolve_benchmarks(spec: str) -> List[str]:
932
+ if spec.strip().lower() == "all":
933
+ keys = [k for k, b in BENCHMARKS.items() if "unavailable" not in b]
934
+ else:
935
+ keys = [s.strip().lower() for s in spec.split(",") if s.strip()]
936
+ unknown = [k for k in keys if k not in BENCHMARKS]
937
+ if unknown:
938
+ sys.exit(f"Unknown benchmark(s): {unknown}. Choose from: {list(BENCHMARKS)}")
939
+ for k in list(keys):
940
+ if "unavailable" in BENCHMARKS[k]:
941
+ print(f"Skipping {BENCHMARKS[k]['id']}: {BENCHMARKS[k]['unavailable']}")
942
+ keys.remove(k)
943
+ return keys
944
+
945
+
946
+ def main(argv: Optional[Sequence[str]] = None, model_factory=None) -> int:
947
+ args = parse_args(argv)
948
+ keys = resolve_benchmarks(args.benchmarks)
949
+ if not keys:
950
+ sys.exit("No runnable benchmarks selected.")
951
+
952
+ print("Loading datasets...")
953
+ data: Dict[str, List[dict]] = {}
954
+ for k in keys:
955
+ rows = load_benchmark_rows(BENCHMARKS[k]["id"], refresh=args.refresh_data)
956
+ if args.limit:
957
+ rows = rows[:args.limit]
958
+ data[k] = rows
959
+ print(f" {BENCHMARKS[k]['id']}: {len(rows)} rows")
960
+
961
+ factory = model_factory or (lambda: HFModel(
962
+ args.model, args.device, args.dtype, args.trust_remote_code,
963
+ use_chat_template=not args.no_chat_template, revision=args.revision))
964
+ model = factory()
965
+
966
+ results: Dict[str, Any] = {}
967
+ for k in keys:
968
+ bench = BENCHMARKS[k]
969
+ print(f"\nRunning {bench['id']} ({bench['kind']}, {len(data[k])} rows)...")
970
+ if bench["kind"] == "dual":
971
+ samples, agg = run_dual(k, data[k], model, args)
972
+ elif bench["kind"] == "generative":
973
+ samples, agg = run_generative(k, data[k], model, args)
974
+ else:
975
+ samples, agg = run_multiple_choice(k, data[k], model, args)
976
+ results[k] = {"samples": samples, "aggregate": agg}
977
+ print_report(k, agg)
978
+
979
+ out_dir = Path(args.output_dir) if args.output_dir else \
980
+ Path("benchlabs_results") / re.sub(r"[^A-Za-z0-9._-]+", "_", args.model)
981
+ save_outputs(out_dir, args.model, results, args, model.resolved_revision)
982
+
983
+ if args.leaderboard:
984
+ print("\n=== leaderboard entry (models.json) ===")
985
+ print(json.dumps(leaderboard_entry(args.model, results, model.resolved_revision),
986
+ indent=2, ensure_ascii=False))
987
+ return 0
988
+
989
+
990
+ if __name__ == "__main__":
991
+ raise SystemExit(main())