davanstrien HF Staff commited on
Commit
87677df
·
verified ·
1 Parent(s): a7a14ae

Sync src/ to 86e2b67 (viewer XSS fix + judge hardening)

Browse files
src/ocr_bench/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (302 Bytes). View file
 
src/ocr_bench/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (295 Bytes). View file
 
src/ocr_bench/__pycache__/backends.cpython-311.pyc ADDED
Binary file (13.4 kB). View file
 
src/ocr_bench/__pycache__/cli.cpython-311.pyc ADDED
Binary file (33.7 kB). View file
 
src/ocr_bench/__pycache__/dataset.cpython-311.pyc ADDED
Binary file (13.7 kB). View file
 
src/ocr_bench/__pycache__/elo.cpython-311.pyc ADDED
Binary file (16 kB). View file
 
src/ocr_bench/__pycache__/elo.cpython-314.pyc ADDED
Binary file (16.1 kB). View file
 
src/ocr_bench/__pycache__/judge.cpython-311.pyc ADDED
Binary file (13.5 kB). View file
 
src/ocr_bench/__pycache__/judge.cpython-314.pyc ADDED
Binary file (12.6 kB). View file
 
src/ocr_bench/__pycache__/publish.cpython-311.pyc ADDED
Binary file (14 kB). View file
 
src/ocr_bench/__pycache__/run.cpython-311.pyc ADDED
Binary file (9.67 kB). View file
 
src/ocr_bench/__pycache__/validate.cpython-311.pyc ADDED
Binary file (15.9 kB). View file
 
src/ocr_bench/__pycache__/viewer.cpython-311.pyc ADDED
Binary file (10.6 kB). View file
 
src/ocr_bench/__pycache__/web.cpython-311.pyc ADDED
Binary file (23.9 kB). View file
 
src/ocr_bench/backends.py CHANGED
@@ -5,19 +5,57 @@ from __future__ import annotations
5
  import abc
6
  from collections import Counter
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
8
  from typing import Any
9
 
10
  import stamina
11
  import structlog
12
  from huggingface_hub import InferenceClient
13
- from openai import OpenAI
14
 
15
  from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
16
 
17
  logger = structlog.get_logger()
18
 
19
- # Retry on these exception types with exponential backoff + jitter.
20
- _RETRYABLE = (Exception,)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  class JudgeBackend(abc.ABC):
@@ -38,7 +76,14 @@ class JudgeBackend(abc.ABC):
38
  or an empty dict on failure.
39
  """
40
  if self.concurrency <= 1 or len(comparisons) <= 1:
41
- return [self._call_single(comp) for comp in comparisons]
 
 
 
 
 
 
 
42
 
43
  # Concurrent execution preserving order
44
  results: list[dict[str, str]] = [{}] * len(comparisons)
@@ -69,11 +114,11 @@ class InferenceProviderJudge(JudgeBackend):
69
  self.name = f"{provider + ':' if provider else ''}{model}"
70
  self.model = model
71
  self.max_tokens = max_tokens
72
- self.client = InferenceClient(model=model, provider=provider) # type: ignore[invalid-argument-type]
73
 
74
- @stamina.retry(on=_RETRYABLE, attempts=6)
75
  def _call_single(self, comp: Comparison) -> dict[str, str]:
76
- response = self.client.chat_completion( # type: ignore[no-matching-overload]
77
  messages=comp.messages,
78
  max_tokens=self.max_tokens,
79
  temperature=0.0,
@@ -108,11 +153,11 @@ class OpenAICompatibleJudge(JudgeBackend):
108
  self.concurrency = concurrency
109
  self.client = OpenAI(base_url=base_url, api_key=api_key)
110
 
111
- @stamina.retry(on=_RETRYABLE, attempts=3)
112
  def _call_single(self, comp: Comparison) -> dict[str, str]:
113
  response = self.client.chat.completions.create(
114
  model=self.model,
115
- messages=comp.messages, # type: ignore[invalid-argument-type]
116
  max_tokens=self.max_tokens,
117
  temperature=self.temperature,
118
  extra_body=self.extra_body,
@@ -128,7 +173,7 @@ class OpenAICompatibleJudge(JudgeBackend):
128
  # Spec parsing
129
  # ---------------------------------------------------------------------------
130
 
131
- DEFAULT_JUDGE = "novita:moonshotai/Kimi-K2.5"
132
 
133
 
134
  def parse_judge_spec(
@@ -165,7 +210,7 @@ def parse_judge_spec(
165
  model=model_name,
166
  api_key=token,
167
  max_tokens=max_tokens,
168
- temperature=0.7,
169
  extra_body={"chat_template_kwargs": {"enable_thinking": False}},
170
  concurrency=concurrency,
171
  )
@@ -207,26 +252,31 @@ def aggregate_jury_votes(
207
  if not all_results:
208
  return []
209
 
210
- n_comparisons = len(all_results[0])
211
- n_judges = len(all_results)
212
  aggregated: list[dict[str, Any]] = []
213
 
214
- for i in range(n_comparisons):
 
 
215
  votes: list[str] = []
216
  reasons: list[str] = []
217
- for j in range(n_judges):
218
- result = all_results[j][i] if i < len(all_results[j]) else {}
219
  winner = result.get("winner", "")
220
  if winner:
221
  votes.append(winner)
222
- reasons.append(f"{judge_names[j]}: {result.get('reason', '')}")
223
 
224
  if not votes:
225
  aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
226
  continue
227
 
228
  counter = Counter(votes)
229
- majority_winner, majority_count = counter.most_common(1)[0]
 
 
 
 
 
 
230
  agreement = f"{majority_count}/{len(votes)}"
231
 
232
  aggregated.append({
 
5
  import abc
6
  from collections import Counter
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from itertools import zip_longest
9
  from typing import Any
10
 
11
  import stamina
12
  import structlog
13
  from huggingface_hub import InferenceClient
14
+ from openai import APIConnectionError, OpenAI
15
 
16
  from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
17
 
18
  logger = structlog.get_logger()
19
 
20
+ # Number of attempts for a transient judge call (connection/timeout/429/5xx).
21
+ _RETRY_ATTEMPTS = 5
22
+
23
+ # Transport-level failures worth retrying — no HTTP status (connection reset,
24
+ # DNS failure, read timeout, server hang-up mid-response). `APIConnectionError`
25
+ # also covers the openai client's `APITimeoutError` (a subclass). HTTP errors are
26
+ # handled by status code below.
27
+ _TRANSPORT_ERRORS: tuple[type[BaseException], ...] = (APIConnectionError,)
28
+ try: # requests is always present via huggingface_hub; guard defensively
29
+ from requests.exceptions import ConnectionError as _ReqConnectionError
30
+ from requests.exceptions import Timeout as _ReqTimeout
31
+
32
+ _TRANSPORT_ERRORS += (_ReqConnectionError, _ReqTimeout)
33
+ except Exception: # pragma: no cover
34
+ pass
35
+ try: # httpx backs huggingface_hub's InferenceClient — the default judge path.
36
+ # Providers (e.g. Novita) disconnect on long runs, raising httpx
37
+ # TransportError subclasses (RemoteProtocolError, ConnectError, timeouts).
38
+ from httpx import TransportError as _HttpxTransportError
39
+
40
+ _TRANSPORT_ERRORS += (_HttpxTransportError,)
41
+ except Exception: # pragma: no cover
42
+ pass
43
+
44
+
45
+ def _is_retryable(exc: Exception) -> bool:
46
+ """Retry only *transient* judge failures.
47
+
48
+ Retries connection/timeout errors and HTTP 429 (rate limit) or 5xx
49
+ (server) responses. Fatal errors — a bad/expired token, a typo'd model
50
+ id, or any other 4xx — are NOT retried, so they surface immediately
51
+ instead of after several pointless backoffs.
52
+ """
53
+ status = getattr(exc, "status_code", None)
54
+ if status is None:
55
+ status = getattr(getattr(exc, "response", None), "status_code", None)
56
+ if status is not None:
57
+ return status == 429 or status >= 500
58
+ return isinstance(exc, _TRANSPORT_ERRORS)
59
 
60
 
61
  class JudgeBackend(abc.ABC):
 
76
  or an empty dict on failure.
77
  """
78
  if self.concurrency <= 1 or len(comparisons) <= 1:
79
+ results = []
80
+ for i, comp in enumerate(comparisons):
81
+ try:
82
+ results.append(self._call_single(comp))
83
+ except Exception as exc:
84
+ logger.warning("judge_call_failed", idx=i, error=str(exc))
85
+ results.append({})
86
+ return results
87
 
88
  # Concurrent execution preserving order
89
  results: list[dict[str, str]] = [{}] * len(comparisons)
 
114
  self.name = f"{provider + ':' if provider else ''}{model}"
115
  self.model = model
116
  self.max_tokens = max_tokens
117
+ self.client = InferenceClient(model=model, provider=provider) # ty: ignore[invalid-argument-type]
118
 
119
+ @stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
120
  def _call_single(self, comp: Comparison) -> dict[str, str]:
121
+ response = self.client.chat_completion( # ty: ignore[no-matching-overload]
122
  messages=comp.messages,
123
  max_tokens=self.max_tokens,
124
  temperature=0.0,
 
153
  self.concurrency = concurrency
154
  self.client = OpenAI(base_url=base_url, api_key=api_key)
155
 
156
+ @stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
157
  def _call_single(self, comp: Comparison) -> dict[str, str]:
158
  response = self.client.chat.completions.create(
159
  model=self.model,
160
+ messages=comp.messages, # ty: ignore[invalid-argument-type]
161
  max_tokens=self.max_tokens,
162
  temperature=self.temperature,
163
  extra_body=self.extra_body,
 
173
  # Spec parsing
174
  # ---------------------------------------------------------------------------
175
 
176
+ DEFAULT_JUDGE = "novita:Qwen/Qwen3.5-35B-A3B"
177
 
178
 
179
  def parse_judge_spec(
 
210
  model=model_name,
211
  api_key=token,
212
  max_tokens=max_tokens,
213
+ temperature=0.0,
214
  extra_body={"chat_template_kwargs": {"enable_thinking": False}},
215
  concurrency=concurrency,
216
  )
 
252
  if not all_results:
253
  return []
254
 
 
 
255
  aggregated: list[dict[str, Any]] = []
256
 
257
+ # Transpose judge-major results to comparison-major; a judge that
258
+ # returned a short list pads out with failures.
259
+ for judge_results in zip_longest(*all_results, fillvalue={}):
260
  votes: list[str] = []
261
  reasons: list[str] = []
262
+ for name, result in zip(judge_names, judge_results):
 
263
  winner = result.get("winner", "")
264
  if winner:
265
  votes.append(winner)
266
+ reasons.append(f"{name}: {result.get('reason', '')}")
267
 
268
  if not votes:
269
  aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
270
  continue
271
 
272
  counter = Counter(votes)
273
+ top = counter.most_common(2)
274
+ majority_winner, majority_count = top[0]
275
+ if len(top) > 1 and top[1][1] == majority_count:
276
+ # No strict majority (1-1, 2-2, three-way split, ...): record a
277
+ # tie rather than letting Counter's insertion order side with
278
+ # whichever judge happens to be listed first.
279
+ majority_winner = "tie"
280
  agreement = f"{majority_count}/{len(votes)}"
281
 
282
  aggregated.append({
src/ocr_bench/cli.py CHANGED
@@ -6,6 +6,7 @@ import argparse
6
  import sys
7
 
8
  import structlog
 
9
  from rich.console import Console
10
  from rich.table import Table
11
 
@@ -87,6 +88,15 @@ def build_parser() -> argparse.ArgumentParser:
87
  action="store_true",
88
  help="Don't publish results (default: publish to {dataset}-results)",
89
  )
 
 
 
 
 
 
 
 
 
90
  judge.add_argument(
91
  "--full-rejudge",
92
  action="store_true",
@@ -132,14 +142,26 @@ def build_parser() -> argparse.ArgumentParser:
132
  view.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)")
133
  view.add_argument("--output", default=None, help="Path to save annotations JSON")
134
 
 
 
 
 
 
 
 
 
135
  return parser
136
 
137
 
138
  def print_leaderboard(board: Leaderboard) -> None:
139
  """Print leaderboard as a Rich table."""
 
 
 
140
  table = Table(title="OCR Model Leaderboard")
141
  table.add_column("Rank", style="bold")
142
  table.add_column("Model")
 
143
  has_ci = bool(board.elo_ci)
144
  if has_ci:
145
  table.add_column("ELO (95% CI)", justify="right")
@@ -161,6 +183,7 @@ def print_leaderboard(board: Leaderboard) -> None:
161
  table.add_row(
162
  str(rank),
163
  model,
 
164
  elo_str,
165
  str(board.wins[model]),
166
  str(board.losses[model]),
@@ -177,7 +200,9 @@ def _convert_results(
177
  """Convert judged comparisons + aggregated outputs into ComparisonResult list."""
178
  results: list[ComparisonResult] = []
179
  for comp, result in zip(comparisons, aggregated):
180
- if not result:
 
 
181
  continue
182
  results.append(
183
  ComparisonResult(
@@ -420,6 +445,7 @@ def cmd_judge(args: argparse.Namespace) -> None:
420
  board,
421
  metadata,
422
  existing_metadata=existing_meta_rows,
 
423
  )
424
  console.print(f"\nResults published to [bold]{results_repo}[/bold]")
425
  return
@@ -451,7 +477,13 @@ def cmd_judge(args: argparse.Namespace) -> None:
451
  valid_comparisons=len(new_results),
452
  from_prs=from_prs,
453
  )
454
- publish_results(results_repo, board, metadata, existing_metadata=existing_meta_rows)
 
 
 
 
 
 
455
  console.print(f"\nResults published to [bold]{results_repo}[/bold]")
456
 
457
 
@@ -476,7 +508,8 @@ def cmd_run(args: argparse.Namespace) -> None:
476
  for slug in sorted(MODEL_REGISTRY):
477
  cfg = MODEL_REGISTRY[slug]
478
  default = " (default)" if slug in DEFAULT_MODELS else ""
479
- table.add_row(slug + default, cfg.model_id, cfg.size, cfg.default_flavor)
 
480
 
481
  console.print(table)
482
  console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
@@ -515,6 +548,10 @@ def cmd_run(args: argparse.Namespace) -> None:
515
  console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
516
  console.print(f" Flavor: {flavor}")
517
  console.print(f" Timeout: {args.timeout}")
 
 
 
 
518
  console.print(f" Script: {cfg.script}")
519
  console.print(f" Args: {' '.join(script_args)}")
520
  console.print()
@@ -569,6 +606,47 @@ def cmd_view(args: argparse.Namespace) -> None:
569
  uvicorn.run(app, host=args.host, port=args.port)
570
 
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  def main() -> None:
573
  parser = build_parser()
574
  args = parser.parse_args()
@@ -584,6 +662,15 @@ def main() -> None:
584
  cmd_run(args)
585
  elif args.command == "view":
586
  cmd_view(args)
 
 
587
  except DatasetError as exc:
588
  console.print(f"[red]Error:[/red] {exc}")
589
  sys.exit(1)
 
 
 
 
 
 
 
 
6
  import sys
7
 
8
  import structlog
9
+ from openai import OpenAIError
10
  from rich.console import Console
11
  from rich.table import Table
12
 
 
88
  action="store_true",
89
  help="Don't publish results (default: publish to {dataset}-results)",
90
  )
91
+ judge.add_argument(
92
+ "--license",
93
+ default=None,
94
+ help=(
95
+ "License tag for the published results dataset card, e.g. cc0-1.0 "
96
+ "(default: none — the results embed source-derived text, so only "
97
+ "the publisher knows the right license)"
98
+ ),
99
+ )
100
  judge.add_argument(
101
  "--full-rejudge",
102
  action="store_true",
 
142
  view.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)")
143
  view.add_argument("--output", default=None, help="Path to save annotations JSON")
144
 
145
+ # --- publish subcommand ---
146
+ publish = sub.add_parser("publish", help="Deploy results viewer as a Hugging Face Space")
147
+ publish.add_argument("results", help="HF results dataset repo id to view in the Space")
148
+ publish.add_argument(
149
+ "--space", default=None, help="Space repo id (default: {results}-viewer)"
150
+ )
151
+ publish.add_argument("--private", action="store_true", help="Make the Space private")
152
+
153
  return parser
154
 
155
 
156
  def print_leaderboard(board: Leaderboard) -> None:
157
  """Print leaderboard as a Rich table."""
158
+ from ocr_bench.publish import _get_model_sizes
159
+
160
+ sizes = _get_model_sizes()
161
  table = Table(title="OCR Model Leaderboard")
162
  table.add_column("Rank", style="bold")
163
  table.add_column("Model")
164
+ table.add_column("Params", justify="right")
165
  has_ci = bool(board.elo_ci)
166
  if has_ci:
167
  table.add_column("ELO (95% CI)", justify="right")
 
183
  table.add_row(
184
  str(rank),
185
  model,
186
+ sizes.get(model, ""),
187
  elo_str,
188
  str(board.wins[model]),
189
  str(board.losses[model]),
 
200
  """Convert judged comparisons + aggregated outputs into ComparisonResult list."""
201
  results: list[ComparisonResult] = []
202
  for comp, result in zip(comparisons, aggregated):
203
+ # Skip failures: empty dict (single judge failed) and 0/0 "ties"
204
+ # (every judge in a jury failed) — neither is a real verdict.
205
+ if not result or result.get("agreement") == "0/0":
206
  continue
207
  results.append(
208
  ComparisonResult(
 
445
  board,
446
  metadata,
447
  existing_metadata=existing_meta_rows,
448
+ license_id=args.license,
449
  )
450
  console.print(f"\nResults published to [bold]{results_repo}[/bold]")
451
  return
 
477
  valid_comparisons=len(new_results),
478
  from_prs=from_prs,
479
  )
480
+ publish_results(
481
+ results_repo,
482
+ board,
483
+ metadata,
484
+ existing_metadata=existing_meta_rows,
485
+ license_id=args.license,
486
+ )
487
  console.print(f"\nResults published to [bold]{results_repo}[/bold]")
488
 
489
 
 
508
  for slug in sorted(MODEL_REGISTRY):
509
  cfg = MODEL_REGISTRY[slug]
510
  default = " (default)" if slug in DEFAULT_MODELS else ""
511
+ gpu = cfg.default_flavor + (" (image-mode)" if cfg.image else "")
512
+ table.add_row(slug + default, cfg.model_id, cfg.size, gpu)
513
 
514
  console.print(table)
515
  console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
 
548
  console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
549
  console.print(f" Flavor: {flavor}")
550
  console.print(f" Timeout: {args.timeout}")
551
+ if cfg.image:
552
+ console.print(f" Image: {cfg.image}")
553
+ console.print(f" Python: {cfg.python}")
554
+ console.print(f" Env: {cfg.env}")
555
  console.print(f" Script: {cfg.script}")
556
  console.print(f" Args: {' '.join(script_args)}")
557
  console.print()
 
606
  uvicorn.run(app, host=args.host, port=args.port)
607
 
608
 
609
+ SPACE_TEMPLATE = "davanstrien/ocr-bench-space-template"
610
+
611
+
612
+ def cmd_publish(args: argparse.Namespace) -> None:
613
+ """Deploy results viewer as a Hugging Face Space."""
614
+ from huggingface_hub import HfApi, SpaceHardware
615
+
616
+ api = HfApi()
617
+ results = args.results
618
+ space_id = args.space or f"{results}-viewer"
619
+
620
+ console.print(f"Deploying viewer for [bold]{results}[/bold] to [bold]{space_id}[/bold]...")
621
+
622
+ api.duplicate_space(
623
+ from_id=SPACE_TEMPLATE,
624
+ to_id=space_id,
625
+ private=args.private if args.private else None,
626
+ hardware=SpaceHardware.CPU_BASIC,
627
+ exist_ok=True,
628
+ variables=[{"key": "REPOS", "value": results}],
629
+ )
630
+
631
+ api.add_space_variable(repo_id=space_id, key="REPOS", value=results)
632
+
633
+ # Update Space metadata to link to results dataset
634
+ try:
635
+ from huggingface_hub import metadata_update
636
+
637
+ metadata_update(
638
+ space_id,
639
+ {"datasets": [results], "tags": ["ocr-bench"]},
640
+ repo_type="space",
641
+ overwrite=True,
642
+ )
643
+ except Exception as exc:
644
+ logger.warning("space_metadata_update_failed", error=str(exc))
645
+
646
+ url = f"https://huggingface.co/spaces/{space_id}"
647
+ console.print(f"[green]Space published![/green] {url}")
648
+
649
+
650
  def main() -> None:
651
  parser = build_parser()
652
  args = parser.parse_args()
 
662
  cmd_run(args)
663
  elif args.command == "view":
664
  cmd_view(args)
665
+ elif args.command == "publish":
666
+ cmd_publish(args)
667
  except DatasetError as exc:
668
  console.print(f"[red]Error:[/red] {exc}")
669
  sys.exit(1)
670
+ except (OpenAIError, OSError) as exc:
671
+ # Judge or Hub request failed (bad/expired token, unknown model id,
672
+ # rate limit, provider/network outage) — every requests/HfHubHTTPError
673
+ # subclasses OSError — or another OS-level error. Fail with a clean
674
+ # message instead of dumping a traceback on the user.
675
+ console.print(f"[red]Error:[/red] {exc}")
676
+ sys.exit(1)
src/ocr_bench/dataset.py CHANGED
@@ -211,11 +211,46 @@ def load_config_dataset(
211
  if unified is None:
212
  raise DatasetError("No configs loaded successfully")
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  return unified, ocr_columns
215
 
216
 
217
  def _extract_model_id(ds: Dataset, config: str) -> str:
218
- """Extract model_id from inference_info in first row, falling back to config name."""
 
 
 
 
219
  if "inference_info" not in ds.column_names:
220
  return config
221
  try:
@@ -223,7 +258,7 @@ def _extract_model_id(ds: Dataset, config: str) -> str:
223
  if info_raw:
224
  info = json.loads(info_raw)
225
  if isinstance(info, list):
226
- info = info[0]
227
  return info.get("model_id", info.get("model_name", config))
228
  except (json.JSONDecodeError, TypeError, KeyError, IndexError):
229
  pass
 
211
  if unified is None:
212
  raise DatasetError("No configs loaded successfully")
213
 
214
+ # Disambiguate configs that resolve to the same model_id (mirrors the flat
215
+ # discover_ocr_columns path). Downstream the ELO keys models by these values,
216
+ # so two configs of the same model run with different settings (e.g.
217
+ # `nuextract3` vs `nuextract3-rep`) would silently collapse into one
218
+ # leaderboard row. On collision, label by config name; keep the bare
219
+ # model_id when unique.
220
+ model_counts: dict[str, int] = {}
221
+ for model_id in ocr_columns.values():
222
+ model_counts[model_id] = model_counts.get(model_id, 0) + 1
223
+ duplicates = sorted(mid for mid, n in model_counts.items() if n > 1)
224
+ if duplicates:
225
+ # Capture the colliding config → model_id mapping before relabeling so
226
+ # the warning is actionable (which configs/model_ids collided, in which
227
+ # repo) when running multiple datasets/config sweeps.
228
+ collided = {
229
+ config: model_id
230
+ for config, model_id in ocr_columns.items()
231
+ if model_counts[model_id] > 1
232
+ }
233
+ for config, model_id in list(ocr_columns.items()):
234
+ if model_counts[model_id] > 1:
235
+ short = model_id.split("/")[-1] if "/" in model_id else model_id
236
+ ocr_columns[config] = f"{short} ({config})"
237
+ logger.warning(
238
+ "duplicate_model_ids",
239
+ repo_id=repo_id,
240
+ model_ids=duplicates,
241
+ collided_configs=collided,
242
+ note="configs sharing a model_id were labelled by config name to keep them distinct",
243
+ )
244
+
245
  return unified, ocr_columns
246
 
247
 
248
  def _extract_model_id(ds: Dataset, config: str) -> str:
249
+ """Extract model_id from inference_info in first row, falling back to config name.
250
+
251
+ Takes the *last* entry in the inference_info list, since OCR scripts append
252
+ new entries — the last one is the model that actually produced this config.
253
+ """
254
  if "inference_info" not in ds.column_names:
255
  return config
256
  try:
 
258
  if info_raw:
259
  info = json.loads(info_raw)
260
  if isinstance(info, list):
261
+ info = info[-1]
262
  return info.get("model_id", info.get("model_name", config))
263
  except (json.JSONDecodeError, TypeError, KeyError, IndexError):
264
  pass
src/ocr_bench/elo.py CHANGED
@@ -282,13 +282,24 @@ def compute_elo(
282
  board.ties[r.model_a] += 1
283
  board.ties[r.model_b] += 1
284
 
 
 
 
 
 
 
 
 
 
 
 
285
  board.comparison_log.append(
286
  {
287
  "sample_idx": r.sample_idx,
288
  "model_a": r.model_a,
289
  "model_b": r.model_b,
290
  "winner": winner,
291
- "reason": r.reason,
292
  "agreement": r.agreement,
293
  "text_a": r.text_a,
294
  "text_b": r.text_b,
 
282
  board.ties[r.model_a] += 1
283
  board.ties[r.model_b] += 1
284
 
285
+ # Canonicalise the reason text so A/B references match model_a/model_b
286
+ reason = r.reason
287
+ if r.swapped and reason:
288
+ # Swap "Output A"↔"Output B" (and bare A/B) so the stored reason
289
+ # uses A/B consistently with model_a/model_b ordering.
290
+ reason = (
291
+ reason.replace("Output A", "Output __X__")
292
+ .replace("Output B", "Output A")
293
+ .replace("Output __X__", "Output B")
294
+ )
295
+
296
  board.comparison_log.append(
297
  {
298
  "sample_idx": r.sample_idx,
299
  "model_a": r.model_a,
300
  "model_b": r.model_b,
301
  "winner": winner,
302
+ "reason": reason,
303
  "agreement": r.agreement,
304
  "text_a": r.text_a,
305
  "text_b": r.text_b,
src/ocr_bench/judge.py CHANGED
@@ -40,8 +40,10 @@ stamps, handwritten notes. Missing any section of text is a significant penalty.
40
  4. Reading order: Text flows naturally as a human would read the document.
41
 
42
  5. Formatting: Clean structure. Ignore bounding box tags like <|ref|> <|det|> \
43
- if present. Do NOT prefer fancier markdown formatting plain accurate text is \
44
- better than nicely formatted but incomplete text.
 
 
45
 
46
  If both outputs capture the same text with similar accuracy, respond with "tie". \
47
  Only pick a winner when there is a clear quality difference.
@@ -273,15 +275,27 @@ def parse_judge_output(text: str) -> dict[str, str]:
273
  """
274
  text = text.strip()
275
  if text.startswith("```"):
276
- text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
 
 
277
  try:
278
  result = json.loads(text)
279
- winner = result.get("winner", "tie").upper().strip()
280
- if winner == "TIE":
281
- winner = "tie"
282
- if winner not in ("A", "B", "tie"):
283
- winner = "tie"
284
- return {"winner": winner, "reason": result.get("reason", "")}
285
  except json.JSONDecodeError:
286
  logger.warning("Failed to parse judge output: %s", text[:200])
287
  return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  4. Reading order: Text flows naturally as a human would read the document.
41
 
42
  5. Formatting: Clean structure. Ignore bounding box tags like <|ref|> <|det|> \
43
+ if present. Markdown formatting markers (#, **, *, etc.) are neutral do not \
44
+ penalise or reward their presence. Judge only on the actual text content, not \
45
+ on whether it is wrapped in markup. Plain text and markdown-formatted text that \
46
+ contain the same words are equivalent.
47
 
48
  If both outputs capture the same text with similar accuracy, respond with "tie". \
49
  Only pick a winner when there is a clear quality difference.
 
275
  """
276
  text = text.strip()
277
  if text.startswith("```"):
278
+ # A truncated response can be a bare opening fence with no body
279
+ parts = text.split("\n", 1)
280
+ text = parts[1].rsplit("```", 1)[0].strip() if len(parts) == 2 else ""
281
  try:
282
  result = json.loads(text)
 
 
 
 
 
 
283
  except json.JSONDecodeError:
284
  logger.warning("Failed to parse judge output: %s", text[:200])
285
  return {}
286
+ if not isinstance(result, dict):
287
+ logger.warning("Judge output is not a JSON object: %s", text[:200])
288
+ return {}
289
+ winner = result.get("winner", "tie")
290
+ if not isinstance(winner, str):
291
+ logger.warning("Judge output has non-string winner: %s", text[:200])
292
+ return {}
293
+ winner = winner.upper().strip()
294
+ if winner == "TIE":
295
+ winner = "tie"
296
+ if winner not in ("A", "B", "tie"):
297
+ winner = "tie"
298
+ reason = result.get("reason", "")
299
+ if not isinstance(reason, str):
300
+ reason = json.dumps(reason)
301
+ return {"winner": winner, "reason": reason}
src/ocr_bench/publish.py CHANGED
@@ -11,6 +11,7 @@ from datasets import Dataset, load_dataset
11
  from huggingface_hub import HfApi
12
 
13
  from ocr_bench.elo import ComparisonResult, Leaderboard
 
14
 
15
  logger = structlog.get_logger()
16
 
@@ -79,14 +80,21 @@ def load_existing_metadata(repo_id: str) -> list[dict]:
79
  return []
80
 
81
 
 
 
 
 
 
82
  def build_leaderboard_rows(board: Leaderboard) -> list[dict]:
83
  """Convert a Leaderboard into rows suitable for a Hub dataset."""
 
84
  rows = []
85
  for model, elo in board.ranked:
86
  total = board.wins[model] + board.losses[model] + board.ties[model]
87
  row = {
88
  "model": model,
89
  "elo": round(elo),
 
90
  "wins": board.wins[model],
91
  "losses": board.losses[model],
92
  "ties": board.ties[model],
@@ -119,6 +127,7 @@ def publish_results(
119
  board: Leaderboard,
120
  metadata: EvalMetadata,
121
  existing_metadata: list[dict] | None = None,
 
122
  ) -> None:
123
  """Push evaluation results to Hub as a dataset with multiple configs.
124
 
@@ -151,7 +160,7 @@ def publish_results(
151
  logger.info("published_metadata", repo=repo_id, n=len(all_meta))
152
 
153
  # README — auto-generated dataset card with leaderboard
154
- readme = _build_readme(repo_id, rows, board, metadata)
155
  api = HfApi()
156
  api.upload_file(
157
  path_or_fileobj=readme.encode(),
@@ -167,6 +176,7 @@ def _build_readme(
167
  rows: list[dict],
168
  board: Leaderboard,
169
  metadata: EvalMetadata,
 
170
  ) -> str:
171
  """Build a dataset card README with the leaderboard table."""
172
  has_ci = bool(board.elo_ci)
@@ -179,12 +189,19 @@ def _build_readme(
179
  judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
180
  n_comparisons = len(board.comparison_log)
181
 
182
- lines = [
183
- "---",
184
- "license: mit",
 
 
 
 
 
185
  "tags:",
186
  " - ocr-bench",
187
  " - leaderboard",
 
 
188
  "configs:",
189
  " - config_name: default",
190
  " data_files:",
@@ -215,25 +232,27 @@ def _build_readme(
215
 
216
  # Table header
217
  if has_ci:
218
- lines.append("| Rank | Model | ELO | 95% CI | Wins | Losses | Ties | Win% |")
219
- lines.append("|------|-------|-----|--------|------|--------|------|------|")
220
  else:
221
- lines.append("| Rank | Model | ELO | Wins | Losses | Ties | Win% |")
222
- lines.append("|------|-------|-----|------|--------|------|------|")
223
 
224
  for rank, row in enumerate(rows, 1):
225
- model = row["model"]
 
226
  elo = row["elo"]
 
227
  if has_ci and "elo_low" in row:
228
  ci = f"{row['elo_low']}\u2013{row['elo_high']}"
229
  lines.append(
230
- f"| {rank} | {model} | {elo} | {ci} "
231
  f"| {row['wins']} | {row['losses']} | {row['ties']} "
232
  f"| {row['win_pct']}% |"
233
  )
234
  else:
235
  lines.append(
236
- f"| {rank} | {model} | {elo} "
237
  f"| {row['wins']} | {row['losses']} | {row['ties']} "
238
  f"| {row['win_pct']}% |"
239
  )
 
11
  from huggingface_hub import HfApi
12
 
13
  from ocr_bench.elo import ComparisonResult, Leaderboard
14
+ from ocr_bench.run import MODEL_REGISTRY
15
 
16
  logger = structlog.get_logger()
17
 
 
80
  return []
81
 
82
 
83
+ def _get_model_sizes() -> dict[str, str]:
84
+ """Build model_id → size lookup from the model registry."""
85
+ return {cfg.model_id: cfg.size for cfg in MODEL_REGISTRY.values()}
86
+
87
+
88
  def build_leaderboard_rows(board: Leaderboard) -> list[dict]:
89
  """Convert a Leaderboard into rows suitable for a Hub dataset."""
90
+ sizes = _get_model_sizes()
91
  rows = []
92
  for model, elo in board.ranked:
93
  total = board.wins[model] + board.losses[model] + board.ties[model]
94
  row = {
95
  "model": model,
96
  "elo": round(elo),
97
+ "params": sizes.get(model, ""),
98
  "wins": board.wins[model],
99
  "losses": board.losses[model],
100
  "ties": board.ties[model],
 
127
  board: Leaderboard,
128
  metadata: EvalMetadata,
129
  existing_metadata: list[dict] | None = None,
130
+ license_id: str | None = None,
131
  ) -> None:
132
  """Push evaluation results to Hub as a dataset with multiple configs.
133
 
 
160
  logger.info("published_metadata", repo=repo_id, n=len(all_meta))
161
 
162
  # README — auto-generated dataset card with leaderboard
163
+ readme = _build_readme(repo_id, rows, board, metadata, license_id=license_id)
164
  api = HfApi()
165
  api.upload_file(
166
  path_or_fileobj=readme.encode(),
 
176
  rows: list[dict],
177
  board: Leaderboard,
178
  metadata: EvalMetadata,
179
+ license_id: str | None = None,
180
  ) -> str:
181
  """Build a dataset card README with the leaderboard table."""
182
  has_ci = bool(board.elo_ci)
 
189
  judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
190
  n_comparisons = len(board.comparison_log)
191
 
192
+ # The card license describes the published results DATA (which embeds
193
+ # OCR text derived from the source dataset), not this tool — so there is
194
+ # no correct default; it's declared per-run via --license or set on the
195
+ # Hub repo by the publisher.
196
+ lines = ["---"]
197
+ if license_id:
198
+ lines.append(f"license: {license_id}")
199
+ lines += [
200
  "tags:",
201
  " - ocr-bench",
202
  " - leaderboard",
203
+ "source_datasets:",
204
+ f" - {metadata.source_dataset}",
205
  "configs:",
206
  " - config_name: default",
207
  " data_files:",
 
232
 
233
  # Table header
234
  if has_ci:
235
+ lines.append("| Rank | Model | Params | ELO | 95% CI | Wins | Losses | Ties | Win% |")
236
+ lines.append("|------|-------|--------|-----|--------|------|--------|------|------|")
237
  else:
238
+ lines.append("| Rank | Model | Params | ELO | Wins | Losses | Ties | Win% |")
239
+ lines.append("|------|-------|--------|-----|------|--------|------|------|")
240
 
241
  for rank, row in enumerate(rows, 1):
242
+ # Escape pipes so arbitrary model names can't break the table
243
+ model = str(row["model"]).replace("|", "\\|")
244
  elo = row["elo"]
245
+ params = row.get("params", "")
246
  if has_ci and "elo_low" in row:
247
  ci = f"{row['elo_low']}\u2013{row['elo_high']}"
248
  lines.append(
249
+ f"| {rank} | {model} | {params} | {elo} | {ci} "
250
  f"| {row['wins']} | {row['losses']} | {row['ties']} "
251
  f"| {row['win_pct']}% |"
252
  )
253
  else:
254
  lines.append(
255
+ f"| {rank} | {model} | {params} | {elo} "
256
  f"| {row['wins']} | {row['losses']} | {row['ties']} "
257
  f"| {row['win_pct']}% |"
258
  )
src/ocr_bench/py.typed ADDED
File without changes
src/ocr_bench/run.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
 
5
  import time
6
  from dataclasses import dataclass, field
 
7
 
8
  import structlog
9
  from huggingface_hub import HfApi, get_token
@@ -13,13 +14,33 @@ logger = structlog.get_logger()
13
 
14
  @dataclass
15
  class ModelConfig:
16
- """Configuration for a single OCR model."""
 
 
 
 
 
 
 
17
 
18
  script: str
19
  model_id: str
20
  size: str
21
  default_flavor: str = "l4x1"
22
  default_args: list[str] = field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  MODEL_REGISTRY: dict[str, ModelConfig] = {
@@ -48,9 +69,46 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
48
  size="1.7B",
49
  default_flavor="l4x1",
50
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
- DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr"]
54
 
55
 
56
  @dataclass
@@ -139,13 +197,30 @@ def launch_ocr_jobs(
139
  extra_args=config.default_args or None,
140
  )
141
 
142
- logger.info("launching_job", model=slug, flavor=flavor, script=config.script)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  job = api.run_uv_job(
144
  script=config.script,
145
  script_args=script_args,
146
  flavor=flavor,
147
  secrets={"HF_TOKEN": token},
148
  timeout=timeout,
 
149
  )
150
  jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
151
  logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
 
4
 
5
  import time
6
  from dataclasses import dataclass, field
7
+ from typing import Any
8
 
9
  import structlog
10
  from huggingface_hub import HfApi, get_token
 
14
 
15
  @dataclass
16
  class ModelConfig:
17
+ """Configuration for a single OCR model.
18
+
19
+ ``image`` / ``python`` / ``env`` are only needed by "image-mode" models —
20
+ ones whose CUDA kernels (e.g. flashinfer for Qwen3.5) must come from a
21
+ prebuilt Docker image because the default uv-script image lacks ``nvcc``.
22
+ They are passed straight through to ``run_uv_job`` and left ``None`` for
23
+ every standard model, which keeps the launch call identical to before.
24
+ """
25
 
26
  script: str
27
  model_id: str
28
  size: str
29
  default_flavor: str = "l4x1"
30
  default_args: list[str] = field(default_factory=list)
31
+ image: str | None = None
32
+ python: str | None = None
33
+ env: dict[str, str] | None = None
34
+
35
+
36
+ # Image-mode invocation for models needing prebuilt CUDA kernels (Qwen3.5 /
37
+ # flashinfer). The default uv-script image has no ``nvcc`` so flashinfer's JIT
38
+ # compile fails at vLLM warmup; the vllm/vllm-openai image ships them prebuilt.
39
+ # ``python`` points at that image's interpreter and ``env`` puts its site-packages
40
+ # on the path so ``uv run`` reuses them instead of rebuilding.
41
+ _VLLM_OPENAI_IMAGE = "vllm/vllm-openai:latest"
42
+ _VLLM_OPENAI_PYTHON = "/usr/bin/python3"
43
+ _VLLM_OPENAI_ENV = {"PYTHONPATH": "/usr/local/lib/python3.12/dist-packages"}
44
 
45
 
46
  MODEL_REGISTRY: dict[str, ModelConfig] = {
 
69
  size="1.7B",
70
  default_flavor="l4x1",
71
  ),
72
+ "firered-ocr": ModelConfig(
73
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/firered-ocr.py",
74
+ model_id="FireRedTeam/FireRed-OCR",
75
+ size="2.1B",
76
+ default_flavor="l4x1",
77
+ ),
78
+ "qianfan-ocr": ModelConfig(
79
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/qianfan-ocr.py",
80
+ model_id="baidu/Qianfan-OCR",
81
+ size="4.7B",
82
+ default_flavor="l4x1",
83
+ ),
84
+ "dots-mocr": ModelConfig(
85
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-mocr.py",
86
+ model_id="rednote-hilab/dots.mocr",
87
+ size="3B",
88
+ default_flavor="l4x1",
89
+ ),
90
+ # Image-mode models (Qwen3.5 / flashinfer) — need the vllm/vllm-openai image.
91
+ "nuextract3": ModelConfig(
92
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py",
93
+ model_id="numind/NuExtract3",
94
+ size="4B",
95
+ default_flavor="a100-large",
96
+ image=_VLLM_OPENAI_IMAGE,
97
+ python=_VLLM_OPENAI_PYTHON,
98
+ env=_VLLM_OPENAI_ENV,
99
+ ),
100
+ "paddleocr-vl-1.6": ModelConfig(
101
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.6.py",
102
+ model_id="PaddlePaddle/PaddleOCR-VL-1.6",
103
+ size="0.9B",
104
+ default_flavor="a100-large",
105
+ image=_VLLM_OPENAI_IMAGE,
106
+ python=_VLLM_OPENAI_PYTHON,
107
+ env=_VLLM_OPENAI_ENV,
108
+ ),
109
  }
110
 
111
+ DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr", "firered-ocr"]
112
 
113
 
114
  @dataclass
 
197
  extra_args=config.default_args or None,
198
  )
199
 
200
+ # Only image-mode models set image/python/env; standard models keep the
201
+ # exact same run_uv_job call as before (no extra kwargs).
202
+ extra_kwargs: dict[str, Any] = {}
203
+ if config.image:
204
+ extra_kwargs["image"] = config.image
205
+ if config.python:
206
+ extra_kwargs["python"] = config.python
207
+ if config.env:
208
+ extra_kwargs["env"] = config.env
209
+
210
+ logger.info(
211
+ "launching_job",
212
+ model=slug,
213
+ flavor=flavor,
214
+ script=config.script,
215
+ image=config.image,
216
+ )
217
  job = api.run_uv_job(
218
  script=config.script,
219
  script_args=script_args,
220
  flavor=flavor,
221
  secrets={"HF_TOKEN": token},
222
  timeout=timeout,
223
+ **extra_kwargs,
224
  )
225
  jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
226
  logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
src/ocr_bench/templates/leaderboard.html CHANGED
@@ -2,13 +2,21 @@
2
  {% block title %}Leaderboard — OCR Bench{% endblock %}
3
  {% block content %}
4
  <h2 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem;">Leaderboard</h2>
5
- <p style="font-size: 0.8rem; color: #888; margin-top: 0;">{{ repo_id }}</p>
 
 
 
 
 
 
 
6
 
7
  <table>
8
  <thead>
9
  <tr>
10
  <th>#</th>
11
  <th>Model</th>
 
12
  <th class="num">Judge ELO</th>
13
  {% if has_ci %}<th class="num">95% CI</th>{% endif %}
14
  <th class="num">Wins</th>
@@ -26,6 +34,7 @@
26
  <tr>
27
  <td>{{ loop.index }}</td>
28
  <td class="model">{{ row.model_short }}</td>
 
29
  <td class="num">{{ row.elo }}</td>
30
  {% if has_ci %}<td class="num">{{ row.elo_low }}&ndash;{{ row.elo_high }}</td>{% endif %}
31
  <td class="num">{{ row.wins }}</td>
@@ -40,4 +49,134 @@
40
  {% endfor %}
41
  </tbody>
42
  </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  {% endblock %}
 
2
  {% block title %}Leaderboard — OCR Bench{% endblock %}
3
  {% block content %}
4
  <h2 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem;">Leaderboard</h2>
5
+ <p style="font-size: 0.8rem; color: #888; margin-top: 0;"><a href="https://huggingface.co/datasets/{{ repo_id }}" style="color: #888; text-decoration: underline;" target="_blank">{{ repo_id }}</a></p>
6
+
7
+ <p style="font-size: 0.82rem; color: #999; line-height: 1.5; max-width: 48rem; margin: 0.75rem 0 1rem;">
8
+ Rankings are computed using <strong style="color: #bbb;">Bradley-Terry MLE</strong> from pairwise comparisons judged by a vision-language model.
9
+ The judge sees the original document image alongside two anonymised OCR outputs and picks the more faithful transcription.
10
+ Browse the <a href="/comparisons" style="color: #aaa;">comparisons</a> to see the evidence — and vote yourself to build a Human ELO column.
11
+ Human votes are stored locally for this session only and will reset when the server restarts.
12
+ </p>
13
 
14
  <table>
15
  <thead>
16
  <tr>
17
  <th>#</th>
18
  <th>Model</th>
19
+ <th class="num">Params</th>
20
  <th class="num">Judge ELO</th>
21
  {% if has_ci %}<th class="num">95% CI</th>{% endif %}
22
  <th class="num">Wins</th>
 
34
  <tr>
35
  <td>{{ loop.index }}</td>
36
  <td class="model">{{ row.model_short }}</td>
37
+ <td class="num">{{ row.params if row.params else "—" }}</td>
38
  <td class="num">{{ row.elo }}</td>
39
  {% if has_ci %}<td class="num">{{ row.elo_low }}&ndash;{{ row.elo_high }}</td>{% endif %}
40
  <td class="num">{{ row.wins }}</td>
 
49
  {% endfor %}
50
  </tbody>
51
  </table>
52
+
53
+ {% if chart_points|length >= 2 %}
54
+ <h3 style="font-size: 0.95rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem;">
55
+ ELO vs Parameter Count
56
+ </h3>
57
+ <p style="font-size: 0.78rem; color: #888; margin-top: 0; margin-bottom: 0.75rem;">
58
+ Smaller models can win on the right documents. Error bars show 95% confidence intervals.
59
+ </p>
60
+
61
+ <div style="max-width: 560px; position: relative;">
62
+ <canvas id="paramsChart"></canvas>
63
+ </div>
64
+
65
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
66
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
67
+ <script>
68
+ (function() {
69
+ const points = {{ chart_points | tojson }};
70
+ const colors = ['#6fa8dc', '#93c47d', '#e06666', '#f6b26b', '#8e7cc3'];
71
+
72
+ const gridColor = 'rgba(255,255,255,0.1)';
73
+ const textColor = '#aaa';
74
+
75
+ const datasets = points.map((p, i) => ({
76
+ label: p.name,
77
+ data: [{
78
+ x: p.params,
79
+ y: p.elo,
80
+ elo_low: p.elo_low,
81
+ elo_high: p.elo_high,
82
+ win_pct: p.win_pct,
83
+ }],
84
+ backgroundColor: colors[i % colors.length],
85
+ borderColor: colors[i % colors.length],
86
+ pointRadius: 10,
87
+ pointHoverRadius: 13,
88
+ }));
89
+
90
+ // Custom plugin to draw error bars
91
+ const errorBarPlugin = {
92
+ id: 'errorBars',
93
+ afterDatasetsDraw(chart) {
94
+ const { ctx } = chart;
95
+ chart.data.datasets.forEach((ds, i) => {
96
+ const meta = chart.getDatasetMeta(i);
97
+ meta.data.forEach((point, j) => {
98
+ const d = ds.data[j];
99
+ if (d.elo_low == null || d.elo_high == null) return;
100
+ const xPx = point.x;
101
+ const yLo = chart.scales.y.getPixelForValue(d.elo_low);
102
+ const yHi = chart.scales.y.getPixelForValue(d.elo_high);
103
+ const capW = 5;
104
+ ctx.save();
105
+ ctx.strokeStyle = ds.borderColor;
106
+ ctx.lineWidth = 1.5;
107
+ ctx.globalAlpha = 0.6;
108
+ // Vertical line
109
+ ctx.beginPath();
110
+ ctx.moveTo(xPx, yLo);
111
+ ctx.lineTo(xPx, yHi);
112
+ ctx.stroke();
113
+ // Top cap
114
+ ctx.beginPath();
115
+ ctx.moveTo(xPx - capW, yHi);
116
+ ctx.lineTo(xPx + capW, yHi);
117
+ ctx.stroke();
118
+ // Bottom cap
119
+ ctx.beginPath();
120
+ ctx.moveTo(xPx - capW, yLo);
121
+ ctx.lineTo(xPx + capW, yLo);
122
+ ctx.stroke();
123
+ ctx.restore();
124
+ });
125
+ });
126
+ },
127
+ };
128
+
129
+ Chart.register(ChartDataLabels, errorBarPlugin);
130
+
131
+ new Chart(document.getElementById('paramsChart'), {
132
+ type: 'scatter',
133
+ data: { datasets },
134
+ options: {
135
+ responsive: true,
136
+ animation: { duration: 400 },
137
+ scales: {
138
+ x: {
139
+ title: { display: true, text: 'Parameters (B)', color: textColor },
140
+ grid: { color: gridColor },
141
+ ticks: { color: textColor, callback: v => v + 'B' },
142
+ },
143
+ y: {
144
+ title: { display: true, text: 'ELO', color: textColor },
145
+ grid: { color: gridColor },
146
+ ticks: { color: textColor },
147
+ },
148
+ },
149
+ plugins: {
150
+ legend: { display: false },
151
+ tooltip: {
152
+ callbacks: {
153
+ label: ctx => {
154
+ const d = ctx.raw;
155
+ let s = `${ctx.dataset.label}: ${d.x}B, ELO ${d.y}`;
156
+ if (d.elo_low != null) s += ` (${d.elo_low}\u2013${d.elo_high})`;
157
+ s += `, ${d.win_pct}% wins`;
158
+ return s;
159
+ },
160
+ },
161
+ },
162
+ datalabels: {
163
+ align: function(ctx) {
164
+ const d = ctx.dataset.data[0];
165
+ const maxX = Math.max(...points.map(p => p.params));
166
+ // Rightmost point: label left. Otherwise label right.
167
+ if (d.x >= maxX) return 'left';
168
+ return 'right';
169
+ },
170
+ anchor: 'center',
171
+ offset: 12,
172
+ color: textColor,
173
+ font: { size: 11 },
174
+ formatter: (val, ctx) => ctx.dataset.label,
175
+ },
176
+ },
177
+ },
178
+ });
179
+ })();
180
+ </script>
181
+ {% endif %}
182
  {% endblock %}
src/ocr_bench/validate.py CHANGED
@@ -97,9 +97,23 @@ def _interleave_by_sample(
97
  return result
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  def build_validation_comparisons(
101
  comparison_rows: list[dict[str, Any]],
102
  *,
 
103
  n: int | None = None,
104
  prioritize_splits: bool = True,
105
  seed: int = 42,
@@ -108,6 +122,9 @@ def build_validation_comparisons(
108
 
109
  Args:
110
  comparison_rows: Rows from the comparisons config of a results dataset.
 
 
 
111
  n: Max number of comparisons to include (None = all).
112
  prioritize_splits: Show split-jury cases first (most informative).
113
  seed: Random seed for position-bias randomization.
@@ -144,7 +161,41 @@ def build_validation_comparisons(
144
  )
145
  )
146
 
147
- if prioritize_splits:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  splits = [c for c in comps if _is_split_jury(c.agreement)]
149
  unanimous = [c for c in comps if not _is_split_jury(c.agreement)]
150
  ordered = _interleave_by_sample(splits) + _interleave_by_sample(unanimous)
 
97
  return result
98
 
99
 
100
+ def _has_overlapping_cis(
101
+ model_a: str,
102
+ model_b: str,
103
+ ci_map: dict[str, tuple[float, float]],
104
+ ) -> bool:
105
+ """Check if two models have overlapping confidence intervals."""
106
+ if model_a not in ci_map or model_b not in ci_map:
107
+ return True # assume overlapping if CI data missing for a model
108
+ a_low, a_high = ci_map[model_a]
109
+ b_low, b_high = ci_map[model_b]
110
+ return max(a_low, b_low) < min(a_high, b_high)
111
+
112
+
113
  def build_validation_comparisons(
114
  comparison_rows: list[dict[str, Any]],
115
  *,
116
+ leaderboard_rows: list[dict[str, Any]] | None = None,
117
  n: int | None = None,
118
  prioritize_splits: bool = True,
119
  seed: int = 42,
 
122
 
123
  Args:
124
  comparison_rows: Rows from the comparisons config of a results dataset.
125
+ leaderboard_rows: Leaderboard rows with elo_low/elo_high for focus-pairs.
126
+ When provided, comparisons between models with overlapping CIs are
127
+ prioritized (those are where human input can change the ranking).
128
  n: Max number of comparisons to include (None = all).
129
  prioritize_splits: Show split-jury cases first (most informative).
130
  seed: Random seed for position-bias randomization.
 
161
  )
162
  )
163
 
164
+ # Build CI map from leaderboard rows (if available and has CI data)
165
+ ci_map: dict[str, tuple[float, float]] = {}
166
+ if leaderboard_rows:
167
+ for row in leaderboard_rows:
168
+ model = row.get("model", "")
169
+ lo = row.get("elo_low")
170
+ hi = row.get("elo_high")
171
+ if model and lo is not None and hi is not None:
172
+ ci_map[model] = (lo, hi)
173
+
174
+ if prioritize_splits and ci_map:
175
+ # 4-tier priority: overlapping+split > overlapping+unanimous >
176
+ # resolved+split > resolved+unanimous
177
+ overlap_split: list[ValidationComparison] = []
178
+ overlap_unanimous: list[ValidationComparison] = []
179
+ resolved_split: list[ValidationComparison] = []
180
+ resolved_unanimous: list[ValidationComparison] = []
181
+ for c in comps:
182
+ overlapping = _has_overlapping_cis(c.model_a, c.model_b, ci_map)
183
+ split = _is_split_jury(c.agreement)
184
+ if overlapping and split:
185
+ overlap_split.append(c)
186
+ elif overlapping:
187
+ overlap_unanimous.append(c)
188
+ elif split:
189
+ resolved_split.append(c)
190
+ else:
191
+ resolved_unanimous.append(c)
192
+ ordered = (
193
+ _interleave_by_sample(overlap_split)
194
+ + _interleave_by_sample(overlap_unanimous)
195
+ + _interleave_by_sample(resolved_split)
196
+ + _interleave_by_sample(resolved_unanimous)
197
+ )
198
+ elif prioritize_splits:
199
  splits = [c for c in comps if _is_split_jury(c.agreement)]
200
  unanimous = [c for c in comps if not _is_split_jury(c.agreement)]
201
  ordered = _interleave_by_sample(splits) + _interleave_by_sample(unanimous)
src/ocr_bench/web.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import io
6
  from dataclasses import dataclass, field
7
  from datetime import UTC, datetime
@@ -67,8 +68,10 @@ def _build_pair_summary_html(comparisons: list[dict[str, Any]]) -> str:
67
 
68
  rows = []
69
  for (ma, mb), counts in sorted(pair_counts.items()):
70
- short_a = _short_model(ma)
71
- short_b = _short_model(mb)
 
 
72
  wins, losses, ties = counts["W"], counts["L"], counts["T"]
73
  rows.append(
74
  f"<tr><td>{short_a}</td><td>{short_b}</td>"
@@ -151,7 +154,10 @@ def create_app(
151
  img_loader = ImageLoader(source_dataset, from_prs=from_prs)
152
 
153
  validation_comps = build_validation_comparisons(
154
- comparison_rows, n=n_validate, prioritize_splits=True
 
 
 
155
  )
156
 
157
  models = sorted(
@@ -303,12 +309,15 @@ def create_app(
303
 
304
  @app.get("/", response_class=RedirectResponse)
305
  async def index():
306
- return RedirectResponse(url="/comparisons", status_code=302)
307
 
308
  @app.get("/leaderboard", response_class=HTMLResponse)
309
  async def leaderboard(request: Request):
 
 
310
  # Build human ELO if we have annotations
311
  human_board = compute_human_elo(state.annotations, state.validation_comps)
 
312
 
313
  rows = []
314
  for row in sorted(state.leaderboard_rows, key=lambda r: r.get("elo", 0), reverse=True):
@@ -324,6 +333,7 @@ def create_app(
324
  rows.append({
325
  "model": model,
326
  "model_short": short,
 
327
  "elo": round(row.get("elo", 0)),
328
  "elo_low": row.get("elo_low"),
329
  "elo_high": row.get("elo_high"),
@@ -336,12 +346,32 @@ def create_app(
336
  })
337
 
338
  has_ci = any(r.get("elo_low") is not None for r in rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  return templates.TemplateResponse(request, "leaderboard.html", {
340
  "active_tab": "leaderboard",
341
  "repo_id": state.repo_id,
342
  "rows": rows,
343
  "has_ci": has_ci,
344
  "has_human_elo": human_board is not None,
 
345
  })
346
 
347
  @app.get("/comparisons", response_class=HTMLResponse)
@@ -441,7 +471,11 @@ def create_app(
441
  ctx["next_url"] = (
442
  f"/comparisons/{next_nav}"
443
  + (f"?winner={winner_filter}" if winner_filter != "All" else "")
444
- + (f"{'&' if winner_filter != 'All' else '?'}model={model_filter}" if model_filter != "All" else "")
 
 
 
 
445
  if next_nav is not None
446
  else None
447
  )
 
2
 
3
  from __future__ import annotations
4
 
5
+ import html
6
  import io
7
  from dataclasses import dataclass, field
8
  from datetime import UTC, datetime
 
68
 
69
  rows = []
70
  for (ma, mb), counts in sorted(pair_counts.items()):
71
+ # Model names come from dataset columns — escape before embedding in
72
+ # HTML that the template renders with `| safe`.
73
+ short_a = html.escape(_short_model(ma))
74
+ short_b = html.escape(_short_model(mb))
75
  wins, losses, ties = counts["W"], counts["L"], counts["T"]
76
  rows.append(
77
  f"<tr><td>{short_a}</td><td>{short_b}</td>"
 
154
  img_loader = ImageLoader(source_dataset, from_prs=from_prs)
155
 
156
  validation_comps = build_validation_comparisons(
157
+ comparison_rows,
158
+ leaderboard_rows=leaderboard_rows,
159
+ n=n_validate,
160
+ prioritize_splits=True,
161
  )
162
 
163
  models = sorted(
 
309
 
310
  @app.get("/", response_class=RedirectResponse)
311
  async def index():
312
+ return RedirectResponse(url="/leaderboard", status_code=302)
313
 
314
  @app.get("/leaderboard", response_class=HTMLResponse)
315
  async def leaderboard(request: Request):
316
+ from ocr_bench.publish import _get_model_sizes
317
+
318
  # Build human ELO if we have annotations
319
  human_board = compute_human_elo(state.annotations, state.validation_comps)
320
+ sizes = _get_model_sizes()
321
 
322
  rows = []
323
  for row in sorted(state.leaderboard_rows, key=lambda r: r.get("elo", 0), reverse=True):
 
333
  rows.append({
334
  "model": model,
335
  "model_short": short,
336
+ "params": row.get("params") or sizes.get(model, ""),
337
  "elo": round(row.get("elo", 0)),
338
  "elo_low": row.get("elo_low"),
339
  "elo_high": row.get("elo_high"),
 
346
  })
347
 
348
  has_ci = any(r.get("elo_low") is not None for r in rows)
349
+
350
+ # Build chart data — params (numeric) vs win%
351
+ chart_points = []
352
+ for r in rows:
353
+ params_str = r.get("params", "")
354
+ if params_str:
355
+ try:
356
+ params_num = float(params_str.rstrip("B"))
357
+ except ValueError:
358
+ continue
359
+ chart_points.append({
360
+ "name": r["model_short"],
361
+ "params": params_num,
362
+ "win_pct": r["win_pct"],
363
+ "elo": r["elo"],
364
+ "elo_low": r.get("elo_low"),
365
+ "elo_high": r.get("elo_high"),
366
+ })
367
+
368
  return templates.TemplateResponse(request, "leaderboard.html", {
369
  "active_tab": "leaderboard",
370
  "repo_id": state.repo_id,
371
  "rows": rows,
372
  "has_ci": has_ci,
373
  "has_human_elo": human_board is not None,
374
+ "chart_points": chart_points,
375
  })
376
 
377
  @app.get("/comparisons", response_class=HTMLResponse)
 
471
  ctx["next_url"] = (
472
  f"/comparisons/{next_nav}"
473
  + (f"?winner={winner_filter}" if winner_filter != "All" else "")
474
+ + (
475
+ f"{'&' if winner_filter != 'All' else '?'}model={model_filter}"
476
+ if model_filter != "All"
477
+ else ""
478
+ )
479
  if next_nav is not None
480
  else None
481
  )