ocr-bench 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: ocr-bench
3
+ Version: 0.0.1
4
+ Summary: OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards
5
+ Author: Daniel van Strien
6
+ Author-email: Daniel van Strien <davanstrien@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Dist: datasets>=4.0.0
9
+ Requires-Dist: huggingface-hub
10
+ Requires-Dist: numpy
11
+ Requires-Dist: openai
12
+ Requires-Dist: pillow
13
+ Requires-Dist: rich
14
+ Requires-Dist: scipy
15
+ Requires-Dist: stamina
16
+ Requires-Dist: structlog
17
+ Requires-Dist: tqdm
18
+ Requires-Dist: fastapi>=0.115 ; extra == 'viewer'
19
+ Requires-Dist: uvicorn[standard]>=0.32 ; extra == 'viewer'
20
+ Requires-Dist: jinja2>=3.1 ; extra == 'viewer'
21
+ Requires-Dist: python-multipart>=0.0.9 ; extra == 'viewer'
22
+ Requires-Python: >=3.11
23
+ Project-URL: Homepage, https://github.com/davanstrien/ocr-bench
24
+ Project-URL: Repository, https://github.com/davanstrien/ocr-bench
25
+ Project-URL: Demo, https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer
26
+ Provides-Extra: viewer
27
+ Description-Content-Type: text/markdown
28
+
29
+ # ocr-bench
30
+
31
+ **There is no single best OCR model.** Rankings change depending on your documents — manuscript cards, printed books, and historical texts all produce different winners.
32
+
33
+ ocr-bench allows you to create **per-collection leaderboards** using a VLM-as-judge approach, so you can find what works best for _your_ documents rather than relying on generic benchmarks. You can validate the VLM's judgement with human votes, and share results via the Hugging Face Hub.
34
+
35
+ ## Why?
36
+
37
+ Generic OCR benchmarks tell you which model wins _on average_. But if you're digitising 18th-century encyclopaedias, that average doesn't help — the best model for your documents might be the worst on someone else's.
38
+
39
+ ocr-bench lets you run the same set of OCR models on a sample of _your_ collection, then uses a vision-language model to judge which produces the best transcription for each document. The result is a leaderboard specific to your data.
40
+
41
+ | Model | BPL card catalog | Britannica 1771 |
42
+ | ------------------ | :--------------: | :-------------: |
43
+ | GLM-OCR (0.9B) | #2 (1535) | **#1** (1787) |
44
+ | LightOnOCR-2 (1B) | **#1** (1559) | #2 (1780) |
45
+ | FireRed-OCR (2.1B) | — | #3 (1551) |
46
+ | DeepSeek-OCR (4B) | #4 (1452) | #4 (1437) |
47
+ | dots.ocr (1.7B) | #3 (1453) | #5 (945) |
48
+
49
+ Rankings flip completely between collections. The model that's #1 on BPL cards drops to #2 on Britannica, and dots.ocr goes from #3 to dead last.
50
+
51
+ ## Hub-native by design
52
+
53
+ The entire evaluation loop lives on the Hugging Face Hub:
54
+
55
+ 1. **Your dataset** on the Hub (images + optional ground truth)
56
+ 2. **OCR models** run via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview) → outputs written as PRs on a Hub dataset
57
+ 3. **VLM judge** via [HF Inference Providers](https://huggingface.co/docs/inference-providers/index) — only needs an HF token
58
+ 4. **Results** published to a Hub dataset (leaderboard + pairwise comparisons)
59
+ 5. **Viewer** as a [HF Space](https://huggingface.co/spaces) for browsing and human validation
60
+
61
+ No local GPU required. Everything is shareable via Hub URLs.
62
+
63
+ ## Quickstart
64
+
65
+ ```bash
66
+ pip install ocr-bench[viewer]
67
+
68
+ # 1. Run OCR models on your dataset
69
+ ocr-bench run <input-dataset> <output-repo> --max-samples 50
70
+
71
+ # 2. Judge outputs pairwise with a VLM
72
+ ocr-bench judge <output-repo>
73
+
74
+ # 3. Browse results + validate
75
+ ocr-bench view <output-repo>-results
76
+ ```
77
+
78
+ ## How it works
79
+
80
+ **`ocr-bench run`** launches OCR models on your dataset via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview). Each model writes its output as a PR on the same Hub dataset, keeping everything together without merge conflicts.
81
+
82
+ **`ocr-bench judge`** runs pairwise comparisons using a VLM judge (default: [Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) via HF Inference Providers). For each document, the judge sees the original image and two OCR outputs (anonymised as A/B) and picks the better transcription. Results are fit to a [Bradley-Terry model](https://en.wikipedia.org/wiki/Bradley%E2%80%93Terry_model) to produce ELO ratings with bootstrap 95% confidence intervals. Adaptive stopping halts early when rankings are statistically resolved.
83
+
84
+ **`ocr-bench view`** serves a local web viewer with a leaderboard, comparison browser, and human validation. Vote on comparisons to cross-check the automated judge with human judgement.
85
+
86
+ ## Available models
87
+
88
+ ocr-bench ships with 5 OCR models ready to run:
89
+
90
+ | Model | Size | Best for | Notes |
91
+ |-------|------|----------|-------|
92
+ | `glm-ocr` | 0.9B | Historical printed text | Top performer on Britannica |
93
+ | `lighton-ocr-2` | 1B | Card catalogs, manuscripts | Top performer on BPL |
94
+ | `firered-ocr` | 2.1B | Clean printed text | Mid-pack on degraded docs |
95
+ | `deepseek-ocr` | 4B | Diverse documents | Most consistent across types |
96
+ | `dots-ocr` | 1.7B | General | Struggles on historical text |
97
+
98
+ All model scripts are available at [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) on the Hub.
99
+
100
+ By default all 5 run. To pick specific models:
101
+
102
+ ```bash
103
+ ocr-bench run <dataset> <output> --models glm-ocr lighton-ocr-2
104
+ ```
105
+
106
+ ## Example results
107
+
108
+ Browse these on the Hub:
109
+
110
+ - [davanstrien/ocr-bench-britannica-results-qwen35](https://huggingface.co/datasets/davanstrien/ocr-bench-britannica-results-qwen35) — Encyclopaedia Britannica 1771, 5 models, 50 samples
111
+ - [davanstrien/bpl-ocr-bench-results](https://huggingface.co/datasets/davanstrien/bpl-ocr-bench-results) — Boston Public Library card catalog, 4 models, 150 samples
112
+ - [Live viewer](https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer) — Britannica leaderboard with ELO chart and comparison browser
113
+
114
+ ## Install
115
+
116
+ ```bash
117
+ pip install ocr-bench # Core (run + judge)
118
+ pip install ocr-bench[viewer] # With web UI
119
+ ```
120
+
121
+ Or with [uv](https://docs.astral.sh/uv/):
122
+
123
+ ```bash
124
+ uv pip install ocr-bench[viewer]
125
+ ```
126
+
127
+ Requires Python >= 3.11 and an [HF token](https://huggingface.co/settings/tokens).
128
+
129
+ ## Status
130
+
131
+ Working proof of concept. The core pipeline (run → judge → view) is functional. Not polished production software — expect rough edges.
@@ -0,0 +1,103 @@
1
+ # ocr-bench
2
+
3
+ **There is no single best OCR model.** Rankings change depending on your documents — manuscript cards, printed books, and historical texts all produce different winners.
4
+
5
+ ocr-bench allows you to create **per-collection leaderboards** using a VLM-as-judge approach, so you can find what works best for _your_ documents rather than relying on generic benchmarks. You can validate the VLM's judgement with human votes, and share results via the Hugging Face Hub.
6
+
7
+ ## Why?
8
+
9
+ Generic OCR benchmarks tell you which model wins _on average_. But if you're digitising 18th-century encyclopaedias, that average doesn't help — the best model for your documents might be the worst on someone else's.
10
+
11
+ ocr-bench lets you run the same set of OCR models on a sample of _your_ collection, then uses a vision-language model to judge which produces the best transcription for each document. The result is a leaderboard specific to your data.
12
+
13
+ | Model | BPL card catalog | Britannica 1771 |
14
+ | ------------------ | :--------------: | :-------------: |
15
+ | GLM-OCR (0.9B) | #2 (1535) | **#1** (1787) |
16
+ | LightOnOCR-2 (1B) | **#1** (1559) | #2 (1780) |
17
+ | FireRed-OCR (2.1B) | — | #3 (1551) |
18
+ | DeepSeek-OCR (4B) | #4 (1452) | #4 (1437) |
19
+ | dots.ocr (1.7B) | #3 (1453) | #5 (945) |
20
+
21
+ Rankings flip completely between collections. The model that's #1 on BPL cards drops to #2 on Britannica, and dots.ocr goes from #3 to dead last.
22
+
23
+ ## Hub-native by design
24
+
25
+ The entire evaluation loop lives on the Hugging Face Hub:
26
+
27
+ 1. **Your dataset** on the Hub (images + optional ground truth)
28
+ 2. **OCR models** run via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview) → outputs written as PRs on a Hub dataset
29
+ 3. **VLM judge** via [HF Inference Providers](https://huggingface.co/docs/inference-providers/index) — only needs an HF token
30
+ 4. **Results** published to a Hub dataset (leaderboard + pairwise comparisons)
31
+ 5. **Viewer** as a [HF Space](https://huggingface.co/spaces) for browsing and human validation
32
+
33
+ No local GPU required. Everything is shareable via Hub URLs.
34
+
35
+ ## Quickstart
36
+
37
+ ```bash
38
+ pip install ocr-bench[viewer]
39
+
40
+ # 1. Run OCR models on your dataset
41
+ ocr-bench run <input-dataset> <output-repo> --max-samples 50
42
+
43
+ # 2. Judge outputs pairwise with a VLM
44
+ ocr-bench judge <output-repo>
45
+
46
+ # 3. Browse results + validate
47
+ ocr-bench view <output-repo>-results
48
+ ```
49
+
50
+ ## How it works
51
+
52
+ **`ocr-bench run`** launches OCR models on your dataset via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview). Each model writes its output as a PR on the same Hub dataset, keeping everything together without merge conflicts.
53
+
54
+ **`ocr-bench judge`** runs pairwise comparisons using a VLM judge (default: [Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) via HF Inference Providers). For each document, the judge sees the original image and two OCR outputs (anonymised as A/B) and picks the better transcription. Results are fit to a [Bradley-Terry model](https://en.wikipedia.org/wiki/Bradley%E2%80%93Terry_model) to produce ELO ratings with bootstrap 95% confidence intervals. Adaptive stopping halts early when rankings are statistically resolved.
55
+
56
+ **`ocr-bench view`** serves a local web viewer with a leaderboard, comparison browser, and human validation. Vote on comparisons to cross-check the automated judge with human judgement.
57
+
58
+ ## Available models
59
+
60
+ ocr-bench ships with 5 OCR models ready to run:
61
+
62
+ | Model | Size | Best for | Notes |
63
+ |-------|------|----------|-------|
64
+ | `glm-ocr` | 0.9B | Historical printed text | Top performer on Britannica |
65
+ | `lighton-ocr-2` | 1B | Card catalogs, manuscripts | Top performer on BPL |
66
+ | `firered-ocr` | 2.1B | Clean printed text | Mid-pack on degraded docs |
67
+ | `deepseek-ocr` | 4B | Diverse documents | Most consistent across types |
68
+ | `dots-ocr` | 1.7B | General | Struggles on historical text |
69
+
70
+ All model scripts are available at [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) on the Hub.
71
+
72
+ By default all 5 run. To pick specific models:
73
+
74
+ ```bash
75
+ ocr-bench run <dataset> <output> --models glm-ocr lighton-ocr-2
76
+ ```
77
+
78
+ ## Example results
79
+
80
+ Browse these on the Hub:
81
+
82
+ - [davanstrien/ocr-bench-britannica-results-qwen35](https://huggingface.co/datasets/davanstrien/ocr-bench-britannica-results-qwen35) — Encyclopaedia Britannica 1771, 5 models, 50 samples
83
+ - [davanstrien/bpl-ocr-bench-results](https://huggingface.co/datasets/davanstrien/bpl-ocr-bench-results) — Boston Public Library card catalog, 4 models, 150 samples
84
+ - [Live viewer](https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer) — Britannica leaderboard with ELO chart and comparison browser
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ pip install ocr-bench # Core (run + judge)
90
+ pip install ocr-bench[viewer] # With web UI
91
+ ```
92
+
93
+ Or with [uv](https://docs.astral.sh/uv/):
94
+
95
+ ```bash
96
+ uv pip install ocr-bench[viewer]
97
+ ```
98
+
99
+ Requires Python >= 3.11 and an [HF token](https://huggingface.co/settings/tokens).
100
+
101
+ ## Status
102
+
103
+ Working proof of concept. The core pipeline (run → judge → view) is functional. Not polished production software — expect rough edges.
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "ocr-bench"
3
+ version = "0.0.1"
4
+ description = "OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Daniel van Strien", email = "davanstrien@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "datasets>=4.0.0",
13
+ "huggingface-hub",
14
+ "numpy",
15
+ "openai",
16
+ "pillow",
17
+ "rich",
18
+ "scipy",
19
+ "stamina",
20
+ "structlog",
21
+ "tqdm",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/davanstrien/ocr-bench"
26
+ Repository = "https://github.com/davanstrien/ocr-bench"
27
+ Demo = "https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer"
28
+
29
+ [project.scripts]
30
+ ocr-bench = "ocr_bench.cli:main"
31
+
32
+ [project.optional-dependencies]
33
+ viewer = ["fastapi>=0.115", "uvicorn[standard]>=0.32", "jinja2>=3.1", "python-multipart>=0.0.9"]
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.9.27,<0.10.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP"]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=9.0.2",
48
+ "ruff>=0.11",
49
+ "ty>=0.0.17",
50
+ ]
@@ -0,0 +1,3 @@
1
+ """OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,238 @@
1
+ """Judge backends — API-based (HF Inference Providers, OpenAI-compatible)."""
2
+
3
+ from __future__ import annotations
4
+
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):
24
+ """Base class for judge backends."""
25
+
26
+ name: str
27
+ concurrency: int = 1
28
+
29
+ @abc.abstractmethod
30
+ def _call_single(self, comp: Comparison) -> dict[str, str]:
31
+ """Run the judge on a single comparison."""
32
+
33
+ def judge(self, comparisons: list[Comparison]) -> list[dict[str, str]]:
34
+ """Run the judge on a list of comparisons (concurrently if supported).
35
+
36
+ Returns a list of parsed results (one per comparison).
37
+ Each result is a dict with ``winner`` and ``reason`` keys,
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)
45
+ with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
46
+ future_to_idx = {
47
+ pool.submit(self._call_single, comp): i
48
+ for i, comp in enumerate(comparisons)
49
+ }
50
+ for future in as_completed(future_to_idx):
51
+ idx = future_to_idx[future]
52
+ try:
53
+ results[idx] = future.result()
54
+ except Exception as exc:
55
+ logger.warning("judge_call_failed", idx=idx, error=str(exc))
56
+ results[idx] = {}
57
+ return results
58
+
59
+
60
+ DEFAULT_MAX_TOKENS = 1024
61
+
62
+
63
+ class InferenceProviderJudge(JudgeBackend):
64
+ """HF Inference Providers backend (Novita, Together, etc.)."""
65
+
66
+ def __init__(
67
+ self, model: str, provider: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS,
68
+ ):
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,
80
+ response_format={"type": "json_object"},
81
+ extra_body={"chat_template_kwargs": {"enable_thinking": False}},
82
+ )
83
+ raw = response.choices[0].message.content.strip()
84
+ result = parse_judge_output(raw)
85
+ if not result:
86
+ logger.warning("empty_parse", backend=self.name, sample=comp.sample_idx)
87
+ return result
88
+
89
+
90
+ class OpenAICompatibleJudge(JudgeBackend):
91
+ """OpenAI-compatible endpoint (local vLLM server, Ollama, HF IE, etc.)."""
92
+
93
+ def __init__(
94
+ self,
95
+ base_url: str,
96
+ model: str = "default",
97
+ max_tokens: int = DEFAULT_MAX_TOKENS,
98
+ api_key: str = "not-needed",
99
+ extra_body: dict | None = None,
100
+ temperature: float = 0.0,
101
+ concurrency: int = 1,
102
+ ):
103
+ self.name = model if model != "default" else f"openai@{base_url}"
104
+ self.model = model
105
+ self.max_tokens = max_tokens
106
+ self.temperature = temperature
107
+ self.extra_body = extra_body if extra_body is not None else {"guided_json": JUDGE_SCHEMA}
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,
119
+ )
120
+ raw = response.choices[0].message.content.strip()
121
+ result = parse_judge_output(raw)
122
+ if not result:
123
+ logger.warning("empty_parse", backend=self.name, sample=comp.sample_idx)
124
+ return result
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Spec parsing
129
+ # ---------------------------------------------------------------------------
130
+
131
+ DEFAULT_JUDGE = "novita:Qwen/Qwen3.5-35B-A3B"
132
+
133
+
134
+ def parse_judge_spec(
135
+ spec: str, max_tokens: int = DEFAULT_MAX_TOKENS, concurrency: int = 1,
136
+ ) -> JudgeBackend:
137
+ """Parse a judge specification string into a backend.
138
+
139
+ Formats:
140
+ - ``"https://xxx.endpoints.huggingface.cloud"`` → :class:`OpenAICompatibleJudge`
141
+ (HF Inference Endpoints, OpenAI-compatible with HF token auth)
142
+ - ``"http://..."`` or ``"https://..."`` (other) → :class:`OpenAICompatibleJudge`
143
+ - ``"provider:org/model"`` (colon before first ``/``) → :class:`InferenceProviderJudge`
144
+ - anything else → :class:`InferenceProviderJudge` (no provider)
145
+ """
146
+ if spec.startswith("http://") or spec.startswith("https://"):
147
+ # Check for url:model format (e.g. https://...cloud/v1/:org/model)
148
+ url_part = spec
149
+ model_name = "default"
150
+ # Split on /v1/: to separate URL from model name
151
+ if "/v1/:" in spec:
152
+ url_part, model_name = spec.split("/v1/:", 1)
153
+ url_part += "/v1"
154
+
155
+ # HF Inference Endpoints — OpenAI-compatible, auth via HF token
156
+ if ".endpoints.huggingface." in url_part:
157
+ from huggingface_hub import get_token
158
+
159
+ base_url = url_part.rstrip("/")
160
+ if not base_url.endswith("/v1"):
161
+ base_url += "/v1"
162
+ token = get_token() or "not-needed"
163
+ return OpenAICompatibleJudge(
164
+ base_url=base_url,
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
+ )
172
+ return OpenAICompatibleJudge(
173
+ base_url=url_part, model=model_name, max_tokens=max_tokens,
174
+ concurrency=concurrency,
175
+ )
176
+
177
+ if ":" in spec:
178
+ # provider:model format — colon must come before first slash
179
+ colon_idx = spec.index(":")
180
+ slash_idx = spec.find("/")
181
+ if slash_idx == -1 or colon_idx < slash_idx:
182
+ provider, model = spec.split(":", 1)
183
+ return InferenceProviderJudge(model=model, provider=provider, max_tokens=max_tokens)
184
+
185
+ return InferenceProviderJudge(model=spec, max_tokens=max_tokens)
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Jury aggregation
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def aggregate_jury_votes(
194
+ all_results: list[list[dict[str, str]]],
195
+ judge_names: list[str],
196
+ ) -> list[dict[str, Any]]:
197
+ """Aggregate votes from multiple judges using majority voting.
198
+
199
+ Args:
200
+ all_results: List of result lists, one per judge. Each inner list
201
+ has one dict per comparison.
202
+ judge_names: Names of the judges (same order as *all_results*).
203
+
204
+ Returns:
205
+ Aggregated results with ``winner``, ``reason``, and ``agreement`` fields.
206
+ """
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({
233
+ "winner": majority_winner,
234
+ "reason": "; ".join(reasons),
235
+ "agreement": agreement,
236
+ })
237
+
238
+ return aggregated