evalmetry 1.0.0__py3-none-any.whl

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.
evalmetry/report.py ADDED
@@ -0,0 +1,2193 @@
1
+ """Collect finished runs, group the ones that may be compared, and draw them.
2
+
3
+ `report.py` only ever reads what `storage.py` wrote.
4
+ It does not parse directory paths - `--output` lets a run live anywhere - so everything it needs comes from the manifest inside `results.json`.
5
+
6
+ Partitioning
7
+ ------------
8
+ Several runs go into one figure, but not every run found may share an axis.
9
+ If the document set differs, or a signal's definition changed, putting the numbers side by side produces a comparison that is wrong while looking fine.
10
+ So runs are grouped before anything is drawn, and the grouping is printed *first*: a figure that appears before the grouping is a figure you read without knowing which runs are missing from it.
11
+
12
+ Two modes, one machinery
13
+ ------------------------
14
+ What a report holds constant and what it varies are two different questions, and this module answers both without a second pipeline.
15
+
16
+ * the default varies the **model** and holds the data fixed.
17
+ Comparability is *proved*: runs share a ``doc_id_set_hash``, and the examples additionally require an identical ``prompt_hash`` per document.
18
+ * ``report --multilingual`` varies the **language** and holds the model fixed - the same benchmark evaluated in several languages.
19
+ Here the proof is not available: different languages mean different documents and different prompts by construction, so ``doc_id_set_hash`` and ``prompt_hash`` can never agree and requiring them would reject every real case.
20
+
21
+ The multilingual mode therefore rests on a *claim* - that these runs are translations of one benchmark, so ``doc_id`` 5 is the same question in each - and a claim that nothing checks is how a report ends up placing MMLU next to KMMLU as though document 5 were shared.
22
+ So the claim is stated - ``--multilingual en=global_mmlu_en,ko=global_mmlu_ko`` names the datasets outright, because benchmarks spell their languages in too many ways for any rule to guess - and then *tested* against the data: `gold_alignment` asks whether the runs agree on which choice is the gold one, document by document.
23
+ A translated benchmark agrees on essentially all of them; two unrelated benchmarks agree at chance.
24
+ Below the threshold the group keeps its curves - averages over different documents are still each meaningful - and loses the per-document half, with the measured rate printed as the reason.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import os
31
+ import json
32
+ import re
33
+ from collections import Counter
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Iterable, Sequence
36
+
37
+ from .storage import (
38
+ SAMPLING_SEED,
39
+ SCHEMA_VERSION,
40
+ TABLES,
41
+ load_signals,
42
+ read_results,
43
+ read_table,
44
+ )
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Discovery
49
+ # --------------------------------------------------------------------------
50
+
51
+
52
+ @dataclass
53
+ class Run:
54
+ """One run directory, as far as the report is concerned.
55
+
56
+ Attributes:
57
+ path: the directory holding `results.json`.
58
+ manifest: the manifest block of `results.json`.
59
+ scores: lm-eval's scores, printed alongside the signals.
60
+ """
61
+
62
+ path: str
63
+ manifest: dict[str, Any]
64
+ scores: dict[str, Any]
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ """Short label for listings: the directory name."""
69
+ return os.path.basename(os.path.normpath(self.path))
70
+
71
+ @property
72
+ def model_id(self) -> str:
73
+ return str(self.manifest.get("model_id", "?"))
74
+
75
+ @property
76
+ def tasks(self) -> tuple[str, ...]:
77
+ return tuple(self.manifest.get("tasks", ()))
78
+
79
+ @property
80
+ def reducer_versions(self) -> dict[str, int]:
81
+ """Reducer name -> version, the thing that decides comparability.
82
+
83
+ Example:
84
+ >>> run.reducer_versions # doctest: +SKIP
85
+ {'logit_lens': 1, 'layer_similarity': 2}
86
+ """
87
+ return {r["name"]: int(r["version"]) for r in self.manifest.get("reducers", [])}
88
+
89
+ @property
90
+ def n_documents(self) -> int:
91
+ return int(self.manifest.get("n_documents", 0))
92
+
93
+ @property
94
+ def num_fewshot(self) -> int:
95
+ return int(self.manifest.get("num_fewshot", 0) or 0)
96
+
97
+ @property
98
+ def vocab_size(self) -> int:
99
+ return int(self.manifest.get("vocab_size", 0))
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Dataset:
104
+ """One language of a multilingual comparison, as the user declared it.
105
+
106
+ Attributes:
107
+ label: what the line is called - "ko", or the task name when no label was given.
108
+ task: the lm-eval task name that was run for it.
109
+ """
110
+
111
+ label: str
112
+ task: str
113
+
114
+
115
+ def parse_datasets(spec: str) -> list[Dataset]:
116
+ """Parse `en=global_mmlu_en,ko=global_mmlu_ko` into the datasets being compared.
117
+
118
+ There is no inference here, and that is the design.
119
+ Benchmarks spell their languages in every possible way - `global_mmlu_ko` puts the tag in the middle, `m_mmlu_ko` at the end, `kmmlu` at the front, `belebele_kor_Hang` with a script subtag, and `mmlu_pro` is not a language at all - so any rule that guesses is right for one convention and quietly wrong for the rest.
120
+ Which runs are being compared is therefore stated, once, in the same place the comparison is asked for.
121
+
122
+ A bare task name is allowed and labels itself, for when the task names are already short enough to read in a legend.
123
+
124
+ Raises:
125
+ ValueError: on fewer than two datasets, or a repeated label or task.
126
+ One language is not a comparison, and a repeat would draw two lines with one meaning.
127
+
128
+ Example:
129
+ >>> parse_datasets("en=global_mmlu_en,ko=global_mmlu_ko")
130
+ [Dataset(label='en', task='global_mmlu_en'), Dataset(label='ko', task='global_mmlu_ko')]
131
+ >>> parse_datasets("mmlu,kmmlu")[1]
132
+ Dataset(label='kmmlu', task='kmmlu')
133
+ """
134
+ datasets: list[Dataset] = []
135
+ for entry in spec.split(","):
136
+ entry = entry.strip()
137
+ if not entry:
138
+ continue
139
+ label, _, task = entry.partition("=")
140
+ label, task = label.strip(), task.strip()
141
+ if not task: # a bare task name labels itself
142
+ label, task = label, label
143
+ if not task:
144
+ raise ValueError(f"empty dataset in --multilingual: {spec!r}")
145
+ datasets.append(Dataset(label=label, task=task))
146
+ if len(datasets) < 2:
147
+ raise ValueError(
148
+ "--multilingual needs at least two datasets to compare, as "
149
+ f"LANG=TASK,LANG=TASK (got {spec!r})")
150
+ for field_name in ("label", "task"):
151
+ seen = [getattr(d, field_name) for d in datasets]
152
+ repeated = [name for name in seen if seen.count(name) > 1]
153
+ if repeated:
154
+ raise ValueError(f"--multilingual repeats the {field_name} {repeated[0]!r}; "
155
+ "each language needs its own line")
156
+ return datasets
157
+
158
+
159
+ def discover_runs(paths: Iterable[str | os.PathLike]) -> list[Run]:
160
+ """Walk the given directories and load every run found.
161
+
162
+ Works with a single run directory just as well as with a tree of them.
163
+
164
+ Example:
165
+ >>> runs = discover_runs(["results/"]) # doctest: +SKIP
166
+ >>> len(runs)
167
+ 10
168
+ """
169
+ found: list[Run] = []
170
+ seen: set[str] = set()
171
+ for root in paths:
172
+ for directory, _subdirs, files in os.walk(str(root)):
173
+ if "results.json" not in files:
174
+ continue
175
+ real = os.path.realpath(directory)
176
+ if real in seen:
177
+ continue
178
+ seen.add(real)
179
+ try:
180
+ payload = read_results(directory)
181
+ except (OSError, ValueError):
182
+ continue # not one of ours
183
+ found.append(
184
+ Run(
185
+ path=directory,
186
+ manifest=payload.get("manifest", {}),
187
+ scores=payload.get("results", {}),
188
+ )
189
+ )
190
+ return sorted(found, key=lambda run: run.path)
191
+
192
+
193
+ # --------------------------------------------------------------------------
194
+ # Partitioning
195
+ # --------------------------------------------------------------------------
196
+
197
+
198
+ @dataclass
199
+ class Group:
200
+ """A set of runs that may be plotted on the same axis.
201
+
202
+ Attributes:
203
+ label: "A", "B", ... in size order.
204
+ axis: what varies between the members - "model" or "language".
205
+ tasks / doc_id_set_hash / reducer_versions: what made them one group.
206
+ datasets: under `--multilingual`, the languages that were declared, in the order given.
207
+ runs: the members.
208
+ warnings: why this group must not simply be read next to another one.
209
+ """
210
+
211
+ label: str
212
+ tasks: tuple[str, ...]
213
+ doc_id_set_hash: str
214
+ n_documents: int
215
+ reducer_versions: dict[str, int]
216
+ runs: list[Run] = field(default_factory=list)
217
+ warnings: list[str] = field(default_factory=list)
218
+ axis: str = "model"
219
+ datasets: tuple[Dataset, ...] = ()
220
+ pairing: "Pairing | None" = None
221
+
222
+ @property
223
+ def limited(self) -> bool:
224
+ """Whether this group ran with `--limit` applied."""
225
+ return any(run.manifest.get("limit") for run in self.runs)
226
+
227
+ def vocab_sizes(self) -> set[int]:
228
+ return {run.vocab_size for run in self.runs if run.vocab_size}
229
+
230
+ def dataset_of(self, run: Run) -> Dataset | None:
231
+ """The declared dataset this run evaluated, matched on its task name."""
232
+ for dataset in self.datasets:
233
+ if run.tasks == (dataset.task,):
234
+ return dataset
235
+ return None
236
+
237
+ def series_of(self, run: Run) -> str:
238
+ """The label this run is drawn and named under.
239
+
240
+ Normally that is the model; under `--multilingual` the model is what is held constant, so it is the declared label instead.
241
+ Two runs of one language would otherwise collapse into a single line, so a repeated label keeps its run directory.
242
+ """
243
+ if self.axis != "language":
244
+ return run.model_id
245
+ def label_of(one: Run) -> str:
246
+ found = self.dataset_of(one)
247
+ return found.label if found else one.name
248
+ label = label_of(run)
249
+ repeated = sum(1 for other in self.runs if label_of(other) == label)
250
+ return label if repeated < 2 else f"{label} ({run.name})"
251
+
252
+ def doc_key(self, run: Run, task_name: str, doc_id: int) -> str:
253
+ """The key a document is compared under across this group's runs.
254
+
255
+ Normally every run ran the same task, so the task name is part of the key and carries its own meaning.
256
+
257
+ Under `--multilingual` the task name is what differs, and the declaration says exactly how: whatever the user named as this language's dataset is removed from the front, and what remains is the part that has to match.
258
+ `global_mmlu_en_stem` and `global_mmlu_ko_stem` both reduce to `_stem`, so no rule about where a language tag sits is needed, and none is used.
259
+
260
+ The suffix is kept rather than dropped, because an lm-eval group expands into subtasks that each number their documents from zero: collapsing them would make `..._business` #5 and `..._stem` #5 one key, and two different questions would be compared as though they were the same one.
261
+
262
+ A subtask whose name does not extend the declared one keeps its full name, so the keys simply fail to meet across languages - which `gold_alignment` reports as sharing no documents, rather than pairing the wrong ones.
263
+
264
+ All of that is the fallback. When the runs carry a field that *identifies* a document - Global-MMLU logs `sample_id`, the same `high_school_world_history/test/33` in every language - `attach_pairing` puts it here instead, and position stops being trusted at all.
265
+ """
266
+ if self.axis == "language":
267
+ source = self.pairing.source if self.pairing else "position"
268
+ if source in ("field", "mapping"):
269
+ found = self.pairing.keys.get(run.path, {}).get((task_name, int(doc_id)))
270
+ # A document the pairing key does not cover cannot be matched to another
271
+ # language, and a positional fallback for it alone would be the guess the
272
+ # key exists to avoid. Its own run and doc_id keep it out of every set.
273
+ return found if found is not None else f"{run.path}\x00{task_name}#{doc_id}"
274
+ if source == "none":
275
+ # Strict pairing without an identity: no document meets another language.
276
+ return f"{run.path}\x00{task_name}#{doc_id}"
277
+ dataset = self.dataset_of(run)
278
+ declared = dataset.task if dataset else ""
279
+ suffix = (task_name[len(declared):]
280
+ if declared and task_name.startswith(declared) else task_name)
281
+ return f"{suffix}#{doc_id}"
282
+ return f"{task_name}#{doc_id}"
283
+
284
+ @property
285
+ def held_constant(self) -> str:
286
+ """What this group fixes, for the header line of a figure."""
287
+ if self.axis == "language":
288
+ return self.runs[0].model_id if self.runs else "?"
289
+ return "+".join(self.tasks)
290
+
291
+ @property
292
+ def missing_languages(self) -> list[str]:
293
+ """Declared languages that no run in this group covers."""
294
+ present = {self.dataset_of(run) for run in self.runs}
295
+ return [d.label for d in self.datasets if d not in present]
296
+
297
+
298
+ @dataclass
299
+ class Partition:
300
+ """The result of grouping: what is comparable, and what was left out and why."""
301
+
302
+ groups: list[Group]
303
+ excluded: list[tuple[Run, str]]
304
+ n_found: int
305
+ axis: str = "model"
306
+ datasets: tuple[Dataset, ...] = ()
307
+
308
+
309
+ def screen_runs(runs: Sequence[Run]) -> tuple[list[Run], list[tuple[Run, str]], dict[str, int]]:
310
+ """Drop the runs that cannot enter *any* group, whatever the axis, and say why.
311
+
312
+ Exclusions, i.e. runs that join no group at all:
313
+
314
+ * no completion marker - the run died part-way, so its document set is truncated in a way nothing downstream can see;
315
+ * a different `schema_version` - the columns do not line up, so the files cannot even be read together;
316
+ * reducer versions that disagree with the majority - the column names are the same but the numbers mean something else.
317
+
318
+ Returns:
319
+ (candidates, excluded, majority reducer versions).
320
+ """
321
+ excluded: list[tuple[Run, str]] = []
322
+ candidates: list[Run] = []
323
+ for run in runs:
324
+ if not run.manifest.get("completed"):
325
+ excluded.append((run, "no completion marker (the run did not finish)"))
326
+ elif str(run.manifest.get("schema_version")) != SCHEMA_VERSION:
327
+ excluded.append(
328
+ (run, f"schema_version {run.manifest.get('schema_version')} (current {SCHEMA_VERSION})")
329
+ )
330
+ else:
331
+ candidates.append(run)
332
+
333
+ # The reducer version set that most runs agree on.
334
+ # A run that disagrees is computing a differently defined number under the same column name.
335
+ version_counts = Counter(
336
+ tuple(sorted(run.reducer_versions.items())) for run in candidates
337
+ )
338
+ majority = dict(version_counts.most_common(1)[0][0]) if version_counts else {}
339
+
340
+ kept: list[Run] = []
341
+ for run in candidates:
342
+ versions = run.reducer_versions
343
+ if versions != majority:
344
+ # Name the reducer that differs and what everyone else used, so the reason is actionable without opening the manifests.
345
+ differing = []
346
+ for name, version in sorted(versions.items()):
347
+ if majority.get(name) != version:
348
+ expected = majority.get(name)
349
+ others = f"other runs v{expected}" if expected is not None else "not in other runs"
350
+ differing.append(f"{name} v{version} ({others})")
351
+ for name in sorted(set(majority) - set(versions)):
352
+ differing.append(f"{name} missing (other runs v{majority[name]})")
353
+ excluded.append((run, "; ".join(differing)))
354
+ continue
355
+ kept.append(run)
356
+ return kept, excluded, majority
357
+
358
+
359
+ def partition_runs(
360
+ runs: Sequence[Run], datasets: Sequence[Dataset] | None = None
361
+ ) -> Partition:
362
+ """Split runs into comparable groups, with a reason for every exclusion.
363
+
364
+ Args:
365
+ datasets: the languages `--multilingual` declared.
366
+ Given, the report fixes the model and varies the language over exactly these datasets; omitted, it fixes the data and varies the model, which is the default report.
367
+
368
+ Grouping key, default: task set plus `doc_id_set_hash`.
369
+ Two runs of the same task that solved different documents (because one used `--limit`) end up in different groups; they are still each usable, they just must not be read as one series.
370
+
371
+ Grouping key, `--multilingual`: model plus `num_fewshot`, over the declared datasets.
372
+ The document sets differ on purpose here, so they cannot be part of the key - which is exactly why this axis carries `gold_alignment` instead.
373
+ `num_fewshot` is in the key rather than merely warned about: a group that varies both the language and the number of examples answers neither question.
374
+
375
+ Example:
376
+ >>> part = partition_runs(discover_runs(["results/"])) # doctest: +SKIP
377
+ >>> [g.label for g in part.groups], len(part.excluded)
378
+ (['A', 'B'], 3)
379
+ """
380
+ axis = "language" if datasets else "model"
381
+ candidates, excluded, _majority = screen_runs(runs)
382
+ if datasets and any(run.manifest.get("benchmarks") for run in candidates):
383
+ raise ValueError("custom benchmark multilingual protocol comparison is not supported yet")
384
+ groups, more_excluded = (
385
+ _group_by_language(candidates, datasets) if datasets
386
+ else _group_by_model(candidates))
387
+ excluded += more_excluded
388
+
389
+ groups = sorted(groups, key=lambda g: (-len(g.runs), g.tasks))
390
+ for index, group in enumerate(groups):
391
+ group.label = chr(ord("A") + index)
392
+ for group in groups:
393
+ vocabs = group.vocab_sizes()
394
+ if len(vocabs) > 1:
395
+ group.warnings.append(
396
+ f"mixed vocabulary sizes {sorted(vocabs)}: signals marked "
397
+ "same-vocab-only must not share an axis here"
398
+ )
399
+ return Partition(groups=groups, excluded=excluded, n_found=len(runs), axis=axis,
400
+ datasets=tuple(datasets or ()))
401
+
402
+
403
+ def _group_by_model(candidates: Sequence[Run]) -> tuple[list[Group], list[tuple[Run, str]]]:
404
+ """The default axis: one group per (task set, document set)."""
405
+ grouped: dict[tuple[Any, ...], Group] = {}
406
+ for run in candidates:
407
+ key = (run.tasks, run.manifest.get("doc_id_set_hash"),
408
+ json.dumps(run.manifest.get("benchmarks", {}), sort_keys=True))
409
+ group = grouped.get(key)
410
+ if group is None:
411
+ group = Group(
412
+ label="",
413
+ tasks=run.tasks,
414
+ doc_id_set_hash=str(run.manifest.get("doc_id_set_hash", "")),
415
+ n_documents=run.n_documents,
416
+ reducer_versions=run.reducer_versions,
417
+ )
418
+ grouped[key] = group
419
+ group.runs.append(run)
420
+
421
+ groups = sorted(grouped.values(), key=lambda g: (-len(g.runs), g.tasks))
422
+ # Grouping alone is not enough: it has to be visible *why* the split happened, or the second group looks like an arbitrary omission.
423
+ first = groups[0] if groups else None
424
+ for group in groups[1:]:
425
+ # Name the actual reason, so the split does not look arbitrary.
426
+ if group.tasks != first.tasks:
427
+ group.warnings.append(f"different task from group {first.label or 'A'}")
428
+ else:
429
+ group.warnings.append(f"different document set from group {first.label or 'A'}")
430
+ return groups, []
431
+
432
+
433
+ def _group_by_language(
434
+ candidates: Sequence[Run], datasets: Sequence[Dataset]
435
+ ) -> tuple[list[Group], list[tuple[Run, str]]]:
436
+ """The multilingual mode: one group per (model, shot count), over the declared datasets.
437
+
438
+ Which runs take part is not discovered, it is declared - so a run is excluded here for exactly one reason, that its task is not on the list, and the message says which task that was. A typo in a dataset name shows up as the run it failed to match rather than as a comparison that is quietly one language short.
439
+
440
+ The model stays in the key because a comparison that varies the language *and* the model answers neither question, and `num_fewshot` stays in it for the same reason.
441
+
442
+ A group covering fewer than two of the declared languages is excluded: one line is not a comparison.
443
+ """
444
+ excluded: list[tuple[Run, str]] = []
445
+ by_task = {dataset.task: dataset for dataset in datasets}
446
+ grouped: dict[tuple[Any, ...], Group] = {}
447
+ for run in candidates:
448
+ if len(run.tasks) != 1 or run.tasks[0] not in by_task:
449
+ excluded.append(
450
+ (run, f"ran {'+'.join(run.tasks) or '(none)'!r}, "
451
+ "which is not one of the declared datasets"))
452
+ continue
453
+ key = (run.model_id, run.num_fewshot)
454
+ group = grouped.get(key)
455
+ if group is None:
456
+ group = Group(
457
+ label="",
458
+ tasks=(),
459
+ doc_id_set_hash="",
460
+ n_documents=0,
461
+ reducer_versions=run.reducer_versions,
462
+ axis="language",
463
+ datasets=tuple(datasets),
464
+ )
465
+ grouped[key] = group
466
+ group.runs.append(run)
467
+
468
+ order = {dataset.task: index for index, dataset in enumerate(datasets)}
469
+ groups: list[Group] = []
470
+ for group in grouped.values():
471
+ group.runs.sort(key=lambda r: (order.get(r.tasks[0], len(order)), r.path))
472
+ group.tasks = tuple(run.tasks[0] for run in group.runs)
473
+ covered = {group.dataset_of(run) for run in group.runs}
474
+ if len(covered) < 2:
475
+ for run in group.runs:
476
+ excluded.append((run, f"only one of the declared languages was run for "
477
+ f"{run.model_id} at {run.num_fewshot}-shot - "
478
+ "nothing to compare it against"))
479
+ continue
480
+ if group.missing_languages:
481
+ group.warnings.append(
482
+ "no run for " + ", ".join(group.missing_languages)
483
+ + ": the comparison is missing a declared language, not showing it as absent")
484
+ sizes = {run.n_documents for run in group.runs}
485
+ group.n_documents = sizes.pop() if len(sizes) == 1 else 0
486
+ if group.n_documents == 0:
487
+ group.warnings.append(
488
+ "the languages cover different numbers of documents "
489
+ + ", ".join(f"{group.series_of(r)} {r.n_documents}" for r in group.runs)
490
+ + " - the score difference is partly a difference in what was asked")
491
+ if len({run.manifest.get("revision") for run in group.runs}) > 1:
492
+ group.warnings.append(
493
+ "the languages ran different model revisions, so the model is not held constant")
494
+ groups.append(group)
495
+ return groups, excluded
496
+
497
+
498
+ def _format_score(run: Run) -> str:
499
+ """The run's headline metric, for the listing that is printed before any figure.
500
+
501
+ Under `--multilingual` this line *is* the score comparison - the question "does this model lose accuracy in Korean" is answered by four characters, and it would be perverse to make someone open a parquet for them.
502
+ """
503
+ score = _score_of(run)
504
+ protocols = run.manifest.get("benchmarks", {}).get("protocols", {})
505
+ if score is not None and len(run.tasks) == 1 and run.tasks[0] in protocols:
506
+ spec = protocols[run.tasks[0]]
507
+ direction = "higher is better" if spec["higher_is_better"] else "lower is better"
508
+ return f"{spec['primary_metric']},{spec['primary_filter']} {score:.4f} ({direction})"
509
+ return "score n/a" if score is None else f"score {score:.4f}"
510
+
511
+
512
+ def format_partition(partition: Partition) -> str:
513
+ """Render the grouping as text, printed before anything is drawn.
514
+
515
+ Under `--multilingual` the line also says where the languages came from, because an inferred language and a declared one are not equally trustworthy and the difference has to be visible before the figure, not after it.
516
+
517
+ Example:
518
+ >>> print(format_partition(part)) # doctest: +SKIP
519
+ Found 10 runs
520
+ comparing across: model (data held constant)
521
+ <BLANKLINE>
522
+ [Group A] xnli_ko / 2490 docs / logit_lens v1, layer_similarity v2 -> 5 runs
523
+ Qwen/Qwen3-8B score 0.7120
524
+ meta-llama/Llama-3.1-8B score 0.6840
525
+ <BLANKLINE>
526
+ [Group B] xnli_ko / 500 docs (limit) / logit_lens v1, layer_similarity v2 -> 2 runs
527
+ warning: different document set from group A
528
+ <BLANKLINE>
529
+ [Excluded] 3 runs
530
+ qwen3-8b-mmlu-jan schema_version 0.1 (current 0.3)
531
+ """
532
+ axis = partition.axis
533
+ lines = [f"Found {partition.n_found} runs",
534
+ "mode: multilingual - one model, several declared languages"
535
+ if axis == "language" else
536
+ "mode: several models over one dataset",
537
+ ""]
538
+ for group in partition.groups:
539
+ versions = ", ".join(f"{name} v{v}" for name, v in sorted(group.reducer_versions.items()))
540
+ limit_note = " (limit)" if group.limited else ""
541
+ plural = "run" if len(group.runs) == 1 else "runs"
542
+ if group.axis == "language":
543
+ docs = (f"{group.n_documents} docs each" if group.n_documents
544
+ else "differing doc counts")
545
+ lines.append(
546
+ f"[Group {group.label}] {group.runs[0].model_id} / "
547
+ f"{group.runs[0].num_fewshot}-shot / {docs}{limit_note} / {versions} "
548
+ f"-> {len(group.runs)} {plural}"
549
+ )
550
+ for run in group.runs:
551
+ lines.append(
552
+ f" {group.series_of(run):<12} {run.tasks[0]:<24} "
553
+ f"{run.n_documents:>6} docs {_format_score(run)}")
554
+ lines.append(" declared: " + ", ".join(
555
+ f"{d.label}={d.task}" for d in group.datasets))
556
+ else:
557
+ lines.append(
558
+ f"[Group {group.label}] {'+'.join(group.tasks)} / "
559
+ f"{group.n_documents} docs{limit_note} / {versions} -> {len(group.runs)} {plural}"
560
+ )
561
+ for run in group.runs:
562
+ lines.append(f" {run.model_id:<36} {_format_score(run)}")
563
+ for warning in group.warnings:
564
+ lines.append(f" warning: {warning}")
565
+ lines.append("")
566
+ if not partition.groups:
567
+ # Eighteen lines carrying one reason is a reader's job that belongs to the tool: what they need is the one sentence saying nothing was compared, and what was there instead.
568
+ lines.append("Nothing was compared: no group could be formed.")
569
+ if partition.datasets:
570
+ lines.append(" declared: " + ", ".join(
571
+ f"{d.label}={d.task}" for d in partition.datasets))
572
+ found = Counter(
573
+ "+".join(run.tasks) or "(none)" for run, _reason in partition.excluded)
574
+ lines.append(" tasks found instead: " + ", ".join(
575
+ f"{task} ({count} {'run' if count == 1 else 'runs'})"
576
+ for task, count in sorted(found.items())))
577
+ lines.append("")
578
+ if partition.excluded:
579
+ plural = "run" if len(partition.excluded) == 1 else "runs"
580
+ lines.append(f"[Excluded] {len(partition.excluded)} {plural}")
581
+ width = max(len(run.name) for run, _ in partition.excluded)
582
+ for run, reason in partition.excluded:
583
+ lines.append(f" {run.name:<{width}} {reason}")
584
+ lines.append("")
585
+ return "\n".join(lines)
586
+
587
+
588
+ # --------------------------------------------------------------------------
589
+ # Pairing: which document in one language is which in another
590
+ # --------------------------------------------------------------------------
591
+
592
+
593
+ @dataclass
594
+ class Pairing:
595
+ """How documents of different languages are matched to each other.
596
+
597
+ Attributes:
598
+ field: the `doc` field that identifies a document, or None when position is used.
599
+ keys: run path -> (task_name, doc_id) -> the shared key, for paired documents.
600
+ matched: documents the key covers in every language.
601
+ note: the sentence printed and recorded, saying what was paired on and why.
602
+ source: "field" (an identity field in the data), "mapping" (`--pair-mapping`),
603
+ "position" (document order) or "none" (`--pair-strict` found no identity).
604
+ strict: whether position pairing was refused.
605
+ mapping: the mapping's path, content hash and scope, when one was used.
606
+ canonical: run path -> (task_name, doc_id) -> canonical document id, including
607
+ documents that were then excluded, so the table can say what they mapped to.
608
+ excluded: run path -> (task_name, doc_id) -> why the pairing itself left it out.
609
+ Not being in every language is derived by the table, not stored here.
610
+ choice_ids / answer_ids: run path -> (task_name, doc_id) -> canonical choice ids by
611
+ choice index, or the canonical answer id of a generative document.
612
+ """
613
+
614
+ field: str | None
615
+ keys: dict[str, dict[tuple[str, int], str]]
616
+ matched: int
617
+ note: str
618
+ source: str = ""
619
+ strict: bool = False
620
+ mapping: dict[str, Any] | None = None
621
+ canonical: dict[str, dict[tuple[str, int], str]] = dataclasses.field(default_factory=dict)
622
+ excluded: dict[str, dict[tuple[str, int], str]] = dataclasses.field(default_factory=dict)
623
+ choice_ids: dict[str, dict[tuple[str, int], tuple[str, ...]]] = dataclasses.field(
624
+ default_factory=dict)
625
+ answer_ids: dict[str, dict[tuple[str, int], str]] = dataclasses.field(default_factory=dict)
626
+
627
+ def __post_init__(self) -> None:
628
+ if not self.source:
629
+ self.source = "field" if self.field else "position"
630
+
631
+
632
+ def _documents(run: Run) -> set[tuple[str, int]]:
633
+ """Every (task_name, doc_id) the run graded, from `docs`, else from `samples.jsonl`."""
634
+ docs = read_table(run.path, "docs")
635
+ if len(docs):
636
+ return {(str(task), int(doc)) for task, doc in zip(docs.task_name, docs.doc_id)}
637
+ from .storage import read_samples
638
+
639
+ return {(str(s["task_name"]), int(s["doc_id"])) for s in read_samples(run.path)}
640
+
641
+
642
+ # : Columns a `--pair-mapping` row must carry. `language` is a declared label or task.
643
+ MAPPING_FIELDS = ("language", "task_name", "doc_id", "canonical_doc_id")
644
+
645
+
646
+ @dataclass(frozen=True)
647
+ class PairingMapping:
648
+ """An external document mapping, as read: its rows and the hash of its bytes."""
649
+
650
+ path: str
651
+ sha256: str
652
+ rows: tuple[dict[str, Any], ...]
653
+
654
+
655
+ def load_pairing_mapping(path: str | os.PathLike) -> PairingMapping:
656
+ """Read a `--pair-mapping` file: CSV with a header, or JSON Lines.
657
+
658
+ Each row names one document of one language and the canonical document it is:
659
+
660
+ language,task_name,doc_id,canonical_doc_id,canonical_choice_ids,canonical_answer_id
661
+ en,mmlu_en,0,q-17,a|b|c|d,
662
+ ko,mmlu_ko,3,q-17,b|a|c|d,
663
+
664
+ `canonical_choice_ids` lists a canonical id per choice index (`|`-separated in CSV, a
665
+ list in JSON Lines), for a translation that shuffled its choices; `canonical_answer_id`
666
+ names the answer of a generative document whose translated target strings differ.
667
+ Both are optional. Rows are validated for shape here; duplicates, many-to-one and
668
+ coverage are properties of a group and are recorded when the mapping is applied.
669
+
670
+ Raises:
671
+ ValueError: on an unknown extension, a malformed line, a missing required column
672
+ or a doc_id that is not a non-negative integer.
673
+ """
674
+ import csv
675
+ import hashlib
676
+ import io
677
+
678
+ with open(path, "rb") as handle:
679
+ content = handle.read()
680
+ text = content.decode("utf-8")
681
+ lower = str(path).lower()
682
+ if lower.endswith(".csv"):
683
+ records = list(csv.DictReader(io.StringIO(text)))
684
+ elif lower.endswith(".jsonl"):
685
+ records = []
686
+ for number, line in enumerate(text.splitlines(), 1):
687
+ if not line.strip():
688
+ continue
689
+ try:
690
+ record = json.loads(line)
691
+ except json.JSONDecodeError as error:
692
+ raise ValueError(f"{path}:{number}: not JSON: {error}") from error
693
+ if not isinstance(record, dict):
694
+ raise ValueError(f"{path}:{number}: each line must be a JSON object")
695
+ records.append(record)
696
+ else:
697
+ raise ValueError(f"--pair-mapping must be a .csv or .jsonl file, got {path}")
698
+
699
+ rows = []
700
+ for number, record in enumerate(records, 1):
701
+ missing = [name for name in MAPPING_FIELDS
702
+ if record.get(name) is None or str(record.get(name)).strip() == ""]
703
+ if missing:
704
+ raise ValueError(f"{path} row {number}: missing {', '.join(missing)}")
705
+ raw = record["doc_id"]
706
+ if isinstance(raw, bool) or not re.fullmatch(r"\d+", str(raw).strip()):
707
+ raise ValueError(f"{path} row {number}: doc_id {raw!r} is not a non-negative integer")
708
+ choices = record.get("canonical_choice_ids")
709
+ if isinstance(choices, str):
710
+ choices = choices.split("|") if choices.strip() else None
711
+ elif choices is not None and not isinstance(choices, list):
712
+ raise ValueError(f"{path} row {number}: canonical_choice_ids must be a list")
713
+ answer = record.get("canonical_answer_id")
714
+ rows.append({
715
+ "row": number,
716
+ "language": str(record["language"]).strip(),
717
+ "task_name": str(record["task_name"]).strip(),
718
+ "doc_id": int(str(raw).strip()),
719
+ "canonical_doc_id": str(record["canonical_doc_id"]).strip(),
720
+ "canonical_choice_ids": (tuple(str(c).strip() for c in choices)
721
+ if choices is not None else None),
722
+ "canonical_answer_id": (None if answer is None or str(answer).strip() == ""
723
+ else str(answer).strip()),
724
+ })
725
+ return PairingMapping(str(path), hashlib.sha256(content).hexdigest(), tuple(rows))
726
+
727
+
728
+ def _index_mapping_rows(
729
+ datasets: Sequence[Dataset], mapping: PairingMapping,
730
+ ) -> tuple[dict[Dataset, dict[tuple[str, int], list[dict[str, Any]]]], int]:
731
+ """dataset별 문서 키로 mapping을 묶고, 그룹 밖의 행 수를 센다.
732
+
733
+ label과 task 이름을 모두 허용한다. 같은 이름이 여러 dataset에 걸리면 기존과
734
+ 같이 먼저 등록한 dataset을 사용한다. 중복 행은 덮어쓰지 않고 검사에 넘긴다.
735
+ """
736
+ by_name: dict[str, Dataset] = {}
737
+ for dataset in datasets:
738
+ by_name.setdefault(dataset.label, dataset)
739
+ by_name.setdefault(dataset.task, dataset)
740
+ table: dict[Dataset, dict[tuple[str, int], list[dict[str, Any]]]] = {}
741
+ outside = 0
742
+ for row in mapping.rows:
743
+ dataset = by_name.get(row["language"])
744
+ if dataset is None:
745
+ outside += 1
746
+ continue
747
+ table.setdefault(dataset, {}).setdefault(
748
+ (row["task_name"], row["doc_id"]), []).append(row)
749
+
750
+ return table, outside
751
+
752
+
753
+ def _match_run_documents(
754
+ documents: set[tuple[str, int]],
755
+ rows_by_document: dict[tuple[str, int], list[dict[str, Any]]],
756
+ ) -> tuple[dict[tuple[str, int], dict[str, Any]], dict[tuple[str, int], str], set[str]]:
757
+ """한 실행 안에서 mapping 누락·중복을 판정하고 모호한 canonical ID를 반환한다.
758
+
759
+ 한 문서에 mapping 행이 여러 개면 duplicate_mapping_rows로 제외한다.
760
+ 남은 문서들끼리 canonical ID를 공유하면 many_to_one이다. 후자의 행은
761
+ 반환값에 남겨 제외된 문서도 원래 canonical ID를 추적할 수 있게 한다.
762
+ """
763
+ selected: dict[tuple[str, int], dict[str, Any]] = {}
764
+ excluded: dict[tuple[str, int], str] = {}
765
+ for document in sorted(documents):
766
+ found = rows_by_document.get(document)
767
+ if not found:
768
+ excluded[document] = "unmapped"
769
+ elif len(found) > 1:
770
+ excluded[document] = "duplicate_mapping_rows"
771
+ else:
772
+ selected[document] = found[0]
773
+
774
+ owners = Counter(row["canonical_doc_id"] for row in selected.values())
775
+ ambiguous: set[str] = set()
776
+ for document, row in selected.items():
777
+ if owners[row["canonical_doc_id"]] > 1:
778
+ excluded[document] = "many_to_one"
779
+ ambiguous.add(row["canonical_doc_id"])
780
+ return selected, excluded, ambiguous
781
+
782
+
783
+ def _mapping_pairing(group: Group, mapping: PairingMapping) -> Pairing:
784
+ """Pair this group's documents through an explicit mapping, recording what it left out.
785
+
786
+ A row applies to the runs of the declared dataset its `language` names. Within one
787
+ run, a document with no row is `unmapped`, one with several rows is
788
+ `duplicate_mapping_rows`, and documents sharing one canonical id are `many_to_one` -
789
+ which also removes that id from every other language (`counterpart_many_to_one`),
790
+ because there is no ground for choosing which of the two is the counterpart.
791
+ """
792
+ table, outside = _index_mapping_rows(group.datasets, mapping)
793
+
794
+ used: set[tuple[Dataset, tuple[str, int]]] = set()
795
+ chosen: dict[str, dict[tuple[str, int], dict[str, Any]]] = {}
796
+ canonical: dict[str, dict[tuple[str, int], str]] = {}
797
+ excluded: dict[str, dict[tuple[str, int], str]] = {}
798
+ ambiguous: set[str] = set()
799
+ for run in group.runs:
800
+ dataset = group.dataset_of(run)
801
+ rows_of = table.get(dataset, {})
802
+ documents = _documents(run)
803
+ run_rows, run_excluded, run_ambiguous = _match_run_documents(documents, rows_of)
804
+ # 중복 때문에 pairing에서 빠진 행도 이 실행의 문서에 적용된 mapping이다.
805
+ # 따라서 provenance의 미사용 행으로 세지 않는다.
806
+ used.update((dataset, document) for document in documents if rows_of.get(document))
807
+ ambiguous.update(run_ambiguous)
808
+ chosen[run.path] = run_rows
809
+ canonical[run.path] = {doc: row["canonical_doc_id"] for doc, row in run_rows.items()}
810
+ excluded[run.path] = run_excluded
811
+
812
+ # 한 실행에서 모호한 canonical ID는 다른 언어에서도 대응 대상을 확정할 수
813
+ # 없다. 모든 실행을 검사한 뒤 전파하되 이미 기록한 제외 사유는 덮어쓰지 않는다.
814
+ candidates = []
815
+ for run in group.runs:
816
+ for document, row in chosen[run.path].items():
817
+ if row["canonical_doc_id"] in ambiguous and document not in excluded[run.path]:
818
+ excluded[run.path][document] = "counterpart_many_to_one"
819
+ candidates.append({row["canonical_doc_id"] for document, row in chosen[run.path].items()
820
+ if document not in excluded[run.path]})
821
+ # 단순히 다른 언어에 없는 문서는 excluded에 넣지 않는다. 그 사유는
822
+ # pairing_table에서 계산하고, 여기서는 모든 실행의 교집합만 선택한다.
823
+ shared = set.intersection(*candidates) if candidates else set()
824
+
825
+ keys: dict[str, dict[tuple[str, int], str]] = {}
826
+ choice_ids: dict[str, dict[tuple[str, int], tuple[str, ...]]] = {}
827
+ answer_ids: dict[str, dict[tuple[str, int], str]] = {}
828
+ for run in group.runs:
829
+ paired = {doc: row for doc, row in chosen[run.path].items()
830
+ if doc not in excluded[run.path] and row["canonical_doc_id"] in shared}
831
+ keys[run.path] = {doc: row["canonical_doc_id"] for doc, row in paired.items()}
832
+ choice_ids[run.path] = {doc: row["canonical_choice_ids"] for doc, row in paired.items()
833
+ if row["canonical_choice_ids"] is not None}
834
+ answer_ids[run.path] = {doc: row["canonical_answer_id"] for doc, row in paired.items()
835
+ if row["canonical_answer_id"] is not None}
836
+
837
+ without_document = Counter(
838
+ dataset.label for dataset, rows_of in table.items()
839
+ for document in rows_of if (dataset, document) not in used)
840
+ provenance = {
841
+ "path": mapping.path,
842
+ "sha256": mapping.sha256,
843
+ "rows": len(mapping.rows),
844
+ "rows_outside_group": outside,
845
+ "applied_to": [d.label for d in group.datasets if d in table],
846
+ "keys_without_document": {d.label: without_document.get(d.label, 0)
847
+ for d in group.datasets if d in table},
848
+ "duplicate_keys": sum(1 for rows_of in table.values()
849
+ for found in rows_of.values() if len(found) > 1),
850
+ "scope": "rows whose language names a declared dataset label or task, matched on "
851
+ "(task_name, doc_id) in that dataset's runs of this group",
852
+ }
853
+ return Pairing(
854
+ None, keys, len(shared),
855
+ f"paired through the mapping {os.path.basename(mapping.path)} (sha256 "
856
+ f"{mapping.sha256[:12]}), which pairs {len(shared)} documents in every language - "
857
+ "taken as given, not as proof that the documents say the same thing",
858
+ source="mapping", mapping=provenance, canonical=canonical, excluded=excluded,
859
+ choice_ids=choice_ids, answer_ids=answer_ids)
860
+
861
+
862
+ def _doc_fields(run: Run) -> dict[tuple[str, int], dict[str, str]]:
863
+ """Each document's raw dataset row, as lm-eval logged it, reduced to scalar strings."""
864
+ from .storage import read_samples
865
+
866
+ found: dict[tuple[str, int], dict[str, str]] = {}
867
+ for sample in read_samples(run.path):
868
+ document = sample.get("doc")
869
+ if not isinstance(document, dict):
870
+ continue
871
+ found[(sample["task_name"], int(sample["doc_id"]))] = {
872
+ name: str(value) for name, value in document.items()
873
+ if isinstance(value, (str, int, float, bool)) and value is not None
874
+ }
875
+ return found
876
+
877
+
878
+ def find_pairing_field(per_run: Sequence[dict[tuple[str, int], dict[str, str]]]) -> list[str]:
879
+ """Fields that could identify the same document in every language.
880
+
881
+ The test is structural, not a list of field names to hope for:
882
+
883
+ * **unique within each run** - a field repeated across documents names a category, not a document;
884
+ * **the same set of values in every run** - a field whose values differ between languages is the content, not the identity. `question` fails here and `sample_id` does not, which is the whole distinction.
885
+
886
+ Both conditions are checked against the data, so a field that does not identify documents cannot be accepted by mistake; the cost of the rule being too strict is a fallback to position, which is what the report did before.
887
+
888
+ Returns:
889
+ Every qualifying field name, sorted. More than one is not yet an answer - two of them can induce different pairings - so the caller compares them.
890
+ """
891
+ if not per_run or any(not rows for rows in per_run):
892
+ return []
893
+ shared_names = set.intersection(*(
894
+ set.intersection(*(set(fields) for fields in rows.values())) for rows in per_run))
895
+ qualifying = []
896
+ for name in sorted(shared_names):
897
+ values = [[fields[name] for fields in rows.values()] for rows in per_run]
898
+ if any(len(set(column)) != len(column) for column in values):
899
+ continue # repeated inside one run: a category, not an identity
900
+ if len({frozenset(column) for column in values}) != 1:
901
+ continue # a different set per language: content, not identity
902
+ qualifying.append(name)
903
+ return qualifying
904
+
905
+
906
+ def attach_pairing(group: Group, field: str | None = None, strict: bool = False,
907
+ mapping: "PairingMapping | str | os.PathLike | None" = None) -> Pairing:
908
+ """Decide how this group's documents are matched, and record the decision on the group.
909
+
910
+ Position is a guess that usually happens to be right: it assumes the languages were published in one order and never reordered, filtered or deduplicated. When the runs carry a field that identifies a document, that assumption can be dropped rather than defended - and it is then the languages' *intersection* on that field that is compared, so differing document counts stop being a problem at all.
911
+
912
+ Args:
913
+ field: a field to use, from `--pair-on`. `"position"` forces the old behaviour.
914
+ Without it the field is looked for and accepted only if it verifies.
915
+ strict: `--pair-strict`. With no identity field and no mapping, compare no
916
+ document one to one instead of falling back to position.
917
+ mapping: `--pair-mapping`, a file or an already loaded `PairingMapping`. It
918
+ replaces field discovery, so it cannot be combined with `field`.
919
+
920
+ Ambiguity is refused rather than resolved: if several fields qualify and they do not induce the same pairing, there is no ground for preferring one, and `--pair-on` is how the user says which.
921
+ """
922
+ if group.axis != "language":
923
+ return Pairing(None, {}, 0, "")
924
+ if mapping is not None and field:
925
+ raise ValueError("--pair-mapping and --pair-on both say how documents are paired; "
926
+ "pass one of them")
927
+ if strict and field == "position":
928
+ raise ValueError("--pair-strict refuses position pairing, which --pair-on position "
929
+ "asks for")
930
+ if mapping is not None:
931
+ if not isinstance(mapping, PairingMapping):
932
+ mapping = load_pairing_mapping(mapping)
933
+ group.pairing = _mapping_pairing(group, mapping)
934
+ group.pairing.strict = strict
935
+ return group.pairing
936
+ if field == "position":
937
+ group.pairing = Pairing(None, {}, 0, "paired on position, as --pair-on position asked")
938
+ return group.pairing
939
+ per_run = [_doc_fields(run) for run in group.runs]
940
+
941
+ candidates = find_pairing_field(per_run)
942
+ if field:
943
+ if field not in candidates:
944
+ raise LookupError(
945
+ f"--pair-on {field!r} does not identify documents across these languages. "
946
+ f"Fields that do: {candidates or 'none'}. Pass --pair-on position to "
947
+ "compare by document order instead")
948
+ candidates = [field]
949
+ elif len(candidates) > 1:
950
+ pairings = {
951
+ name: [sorted(fields[name] for fields in rows.values()) for rows in per_run]
952
+ for name in candidates}
953
+ if len({tuple(map(tuple, value)) for value in pairings.values()}) != 1:
954
+ raise LookupError(
955
+ f"several fields identify documents here and they disagree: {candidates}. "
956
+ "Pass --pair-on FIELD to say which, or --pair-on position")
957
+
958
+ if not candidates:
959
+ if strict:
960
+ group.pairing = Pairing(
961
+ None, {}, 0,
962
+ "strict pairing: no field identifies a document in every language and no "
963
+ "mapping was given, so no document is compared one to one - position is "
964
+ "not used", source="none", strict=True)
965
+ return group.pairing
966
+ group.pairing = Pairing(
967
+ None, {}, 0,
968
+ "no field identifies a document in every language, so documents are paired by "
969
+ "position - which trusts that the languages were published in one order")
970
+ return group.pairing
971
+
972
+ chosen = candidates[0]
973
+ keys = {
974
+ run.path: {key: fields[chosen] for key, fields in rows.items()}
975
+ for run, rows in zip(group.runs, per_run)}
976
+ matched = len(set.intersection(*(set(values.values()) for values in keys.values())))
977
+ excluded = {run.path: {document: "no_identity_field" for document in _documents(run)
978
+ if document not in rows}
979
+ for run, rows in zip(group.runs, per_run)}
980
+ group.pairing = Pairing(
981
+ chosen, keys, matched,
982
+ f"paired on `{chosen}`, which identifies {matched} documents in every language - "
983
+ "so the document order is not relied on",
984
+ source="field", strict=strict,
985
+ canonical={path: dict(values) for path, values in keys.items()}, excluded=excluded)
986
+ return group.pairing
987
+
988
+
989
+ # --------------------------------------------------------------------------
990
+ # Does a shared doc_id mean a shared question?
991
+ # --------------------------------------------------------------------------
992
+
993
+ # : How much of the gold-choice agreement has to hold before documents of
994
+ # : different languages are compared one against one.
995
+ # A translated benchmark
996
+ # : preserves the position of the correct answer and scores near 1.0; two
997
+ # : unrelated benchmarks agree at chance, which is 1/n_choices. Nothing sits
998
+ # : near 0.9 by accident.
999
+ ALIGNMENT_THRESHOLD = 0.9
1000
+
1001
+
1002
+ @dataclass
1003
+ class Alignment:
1004
+ """Evidence that two runs' `doc_id`s refer to the same questions.
1005
+
1006
+ Attributes:
1007
+ shared: documents present in every run of the group.
1008
+ agree: how many of those put the gold answer in the same place.
1009
+ rate: `agree / shared`.
1010
+ aligned: whether the per-document half of the report is allowed to run.
1011
+ source: "evidence" when measured, "asserted" when `--assume-aligned` overrode it.
1012
+ note: the sentence printed and written into the provenance.
1013
+ disagreeing: the documents whose gold answer sits in a different place per language.
1014
+ unverifiable: shared documents whose languages carry different kinds of gold
1015
+ evidence, or canonical choice ids that do not fit their choices. Neither
1016
+ agreement nor disagreement, so they are outside `rate`.
1017
+ status: shared doc key -> "agree", "disagree" or "unverifiable".
1018
+ evidence: run path -> doc key -> the gold evidence compared, as `kind:value`.
1019
+ """
1020
+
1021
+ shared: int
1022
+ agree: int
1023
+ rate: float
1024
+ aligned: bool
1025
+ source: str
1026
+ note: str
1027
+ disagreeing: tuple[str, ...] = ()
1028
+ unverifiable: tuple[str, ...] = ()
1029
+ status: dict[str, str] = dataclasses.field(default_factory=dict)
1030
+ evidence: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict)
1031
+
1032
+
1033
+ def _gold_position(run: Run, group: Group) -> dict[str, str]:
1034
+ """doc key -> something that identifies the gold answer without naming it in one language.
1035
+
1036
+ For a multiple-choice task that is the *index* of the gold choice: a translation replaces every string in the document, but the answer stays in position C.
1037
+ For a single-row (generative) task there are no positions, so the target string itself is used - which works for the numeric answers of a translated maths benchmark and for little else, and that limit is why the rate is reported rather than only its verdict.
1038
+
1039
+ A `--pair-mapping` can say more, and where it does its evidence replaces those two: `choice_id:` is the canonical id of the gold choice, so a translation that shuffled its choices is compared by which choice is gold rather than where it sits; `answer_id:` is a generative document's canonical answer, for targets whose strings were translated. Canonical choice ids that do not cover exactly this document's choice indices are `invalid:` rather than guessed at.
1040
+ """
1041
+ docs = read_table(run.path, "docs")
1042
+ if not len(docs):
1043
+ return {}
1044
+ pairing = group.pairing
1045
+ choice_ids = pairing.choice_ids.get(run.path, {}) if pairing else {}
1046
+ answer_ids = pairing.answer_ids.get(run.path, {}) if pairing else {}
1047
+ rows_of: dict[str, tuple[tuple[str, int], list[Any]]] = {}
1048
+ for row in docs.itertuples():
1049
+ document = (str(row.task_name), int(row.doc_id))
1050
+ rows_of.setdefault(group.doc_key(run, *document), (document, []))[1].append(row)
1051
+ positions: dict[str, str] = {}
1052
+ for key, (document, rows) in rows_of.items():
1053
+ gold = [int(row.choice_idx) for row in rows
1054
+ if getattr(row, "is_target_choice", None) is True]
1055
+ if gold:
1056
+ ids = choice_ids.get(document)
1057
+ if ids is None:
1058
+ positions[key] = f"choice:{gold[-1]}"
1059
+ elif (sorted({int(row.choice_idx) for row in rows}) != list(range(len(ids)))
1060
+ or len(set(ids)) != len(ids)):
1061
+ positions[key] = "invalid:canonical_choice_ids_do_not_match_choices"
1062
+ else:
1063
+ positions[key] = f"choice_id:{ids[gold[-1]]}"
1064
+ elif len(rows) == 1:
1065
+ answer = answer_ids.get(document)
1066
+ positions[key] = (f"answer_id:{answer}" if answer is not None
1067
+ else f"target:{str(rows[0].target).strip()}")
1068
+ return positions
1069
+
1070
+
1071
+ def gold_alignment(group: Group, assume: bool = False) -> Alignment:
1072
+ """Ask the data whether this group's `doc_id`s line up, instead of assuming it.
1073
+
1074
+ The claim under a multilingual comparison is that document 5 is the same question in every language.
1075
+ Nothing in a manifest can establish that - both runs will happily report 500 documents whether they are translations of each other or two unrelated benchmarks that happen to be the same size.
1076
+
1077
+ What *can* be measured is where the gold answer sits.
1078
+ A translated multiple-choice benchmark keeps the correct answer in the same position, so the runs agree on essentially every document; two different benchmarks agree at chance, around 1/n_choices.
1079
+ The gap between those is wide enough that a single rate decides it, and the rate is printed either way so the decision can be checked.
1080
+
1081
+ Args:
1082
+ assume: take alignment as given, still measuring and reporting the rate.
1083
+ For the case this check cannot see: a translation that also shuffled the choices, where the documents do correspond but the gold position does not.
1084
+
1085
+ Example:
1086
+ >>> gold_alignment(group).rate # doctest: +SKIP
1087
+ 0.998
1088
+ """
1089
+ per_run = [_gold_position(run, group) for run in group.runs]
1090
+ if not per_run or any(not positions for positions in per_run):
1091
+ # `is_target_choice` is false on every row of at least one run, which happens when the task's target could not be matched to any of its choices. Naming the run and the cause is the difference between a check that declined and a check that looks broken.
1092
+ silent = [group.series_of(run) for run, positions in zip(group.runs, per_run)
1093
+ if not positions]
1094
+ return Alignment(
1095
+ 0, 0, 0.0, bool(assume), "asserted" if assume else "evidence",
1096
+ f"no gold answer is marked in {', '.join(silent) or 'any run'} - the task's "
1097
+ "target matched none of its choices, so where the answer sits cannot be "
1098
+ "compared. Re-run to rebuild `docs` if this run predates that being resolved "
1099
+ "for label targets")
1100
+ evidence = {run.path: positions for run, positions in zip(group.runs, per_run)}
1101
+ shared = set(per_run[0])
1102
+ for positions in per_run[1:]:
1103
+ shared &= set(positions)
1104
+ if not shared:
1105
+ strict = group.pairing is not None and group.pairing.source == "none"
1106
+ return Alignment(0, 0, 0.0, bool(assume), "asserted" if assume else "evidence",
1107
+ "strict pairing paired no document across the languages" if strict
1108
+ else "the languages share no doc_id at all", evidence=evidence)
1109
+ status: dict[str, str] = {}
1110
+ for key in shared:
1111
+ values = [positions[key] for positions in per_run]
1112
+ kinds = {value.partition(":")[0] for value in values}
1113
+ status[key] = ("unverifiable" if "invalid" in kinds or len(kinds) > 1
1114
+ else "disagree" if len(set(values)) > 1 else "agree")
1115
+ disagreeing = tuple(sorted(key for key, state in status.items() if state == "disagree"))
1116
+ unverifiable = tuple(sorted(key for key, state in status.items() if state == "unverifiable"))
1117
+ agree = len(shared) - len(disagreeing) - len(unverifiable)
1118
+ compared = agree + len(disagreeing)
1119
+ rate = agree / compared if compared else 0.0
1120
+ aligned = bool(compared) and rate >= ALIGNMENT_THRESHOLD
1121
+ kinds = {positions[key].partition(":")[0] for positions in per_run for key in shared}
1122
+ note = (f"{agree}/{compared} shared documents ({rate:.1%}) put the gold answer in the "
1123
+ "same place across the languages"
1124
+ + (" (canonical choice/answer ids from the mapping where given)"
1125
+ if kinds & {"choice_id", "answer_id"} else "")
1126
+ + (f"; {len(unverifiable)} more cannot be checked, because their languages carry "
1127
+ "different kinds of gold evidence or canonical choice ids that do not fit "
1128
+ "their choices" if unverifiable else ""))
1129
+ if aligned:
1130
+ refused = len(disagreeing) + len(unverifiable)
1131
+ dropped = (f"; the {refused} that do not or cannot be checked are left out of the "
1132
+ "per-document half, because a document whose answer key differs by "
1133
+ "language is not the same question however the group as a whole scored"
1134
+ if refused else "")
1135
+ return Alignment(len(shared), agree, rate, True, "evidence",
1136
+ note + " - read as translations of one document set" + dropped,
1137
+ disagreeing, unverifiable, status, evidence)
1138
+ if assume:
1139
+ # Asserted alignment is the case where the gold positions are *expected* to differ - a translation that shuffled its choices - so dropping the documents that differ would drop exactly what was asserted.
1140
+ return Alignment(len(shared), agree, rate, True, "asserted",
1141
+ note + " - below the threshold, but alignment was asserted "
1142
+ "with --assume-aligned, so every shared document is kept",
1143
+ (), (), status, evidence)
1144
+ return Alignment(
1145
+ len(shared), agree, rate, False, "evidence",
1146
+ note + f" - below {ALIGNMENT_THRESHOLD:.0%}, so these doc_ids are not treated as the "
1147
+ "same questions; curves are kept, per-document examples are not. If the "
1148
+ "translation shuffled the choices, give canonical choice ids with "
1149
+ "--pair-mapping or pass --assume-aligned",
1150
+ disagreeing, unverifiable, status, evidence)
1151
+
1152
+
1153
+ # --------------------------------------------------------------------------
1154
+ # Relative depth
1155
+ # --------------------------------------------------------------------------
1156
+
1157
+
1158
+ def relative_depth(index: float, n_blocks: int, layer_domain: str) -> float:
1159
+ """Where a layer sits on the 0..1 depth axis.
1160
+
1161
+ Absolute layer numbers cannot be compared between models of different depths, so the report always normalises.
1162
+ The two axes normalise differently:
1163
+
1164
+ * residual (`layer`, 0..L): `i / L`.
1165
+ Layer 0 is 0.0, layer L is 1.0.
1166
+ * block (`block`, 0..L-1): `(j + 0.5) / L`.
1167
+ Block j's attention happens between `layer j` and `layer j+1`, so it belongs at the midpoint.
1168
+ Placing it at `j / L` would shift the attention curve half a layer against the residual curve whenever the two are drawn together.
1169
+
1170
+ Example:
1171
+ >>> relative_depth(0, 32, "residual"), relative_depth(32, 32, "residual")
1172
+ (0.0, 1.0)
1173
+ >>> relative_depth(0, 32, "block")
1174
+ 0.015625
1175
+ """
1176
+ if n_blocks <= 0:
1177
+ return 0.0
1178
+ if layer_domain == "block":
1179
+ return (index + 0.5) / n_blocks
1180
+ return index / n_blocks
1181
+
1182
+
1183
+ def prediction_depth(signals, n_blocks: int):
1184
+ """The depth at which the lens settles on the model's final answer.
1185
+
1186
+ For one scored position: the smallest layer `k` such that the logit-lens top-1 at every layer from `k` to `L` equals the top-1 at `L`.
1187
+ Returned as a relative depth, so models of different depths are comparable.
1188
+
1189
+ A value near 1.0 means the answer only appears in the last layers; a smaller value means it is settled earlier and the remaining layers only sharpen it.
1190
+
1191
+ Returns:
1192
+ A DataFrame with one row per (doc_id, choice_idx, step).
1193
+
1194
+ Example:
1195
+ >>> prediction_depth(signals, n_blocks=24).depth.median() # doctest: +SKIP
1196
+ 0.7916666666666666
1197
+ """
1198
+ import pandas as pd
1199
+
1200
+ rows = []
1201
+ keys = ["task_name", "doc_id", "choice_idx", "step"]
1202
+ for key, group in signals.groupby(keys, sort=False):
1203
+ group = group.sort_values("layer")
1204
+ tokens = group.lens_token_id.tolist()
1205
+ layers = group.layer.tolist()
1206
+ final = tokens[-1]
1207
+ # Walk back from the last layer while the prediction is unchanged; the first layer that disagrees ends the settled run.
1208
+ settled = layers[-1]
1209
+ for layer, token in zip(reversed(layers), reversed(tokens)):
1210
+ if token != final:
1211
+ break
1212
+ settled = layer
1213
+ record = dict(zip(keys, key))
1214
+ record["settled_layer"] = settled
1215
+ record["depth"] = relative_depth(settled, n_blocks, "residual")
1216
+ record["is_correct"] = bool(group.is_correct.iloc[0]) if "is_correct" in group else None
1217
+ record["is_target_choice"] = (
1218
+ bool(group.is_target_choice.iloc[0]) if "is_target_choice" in group else None
1219
+ )
1220
+ rows.append(record)
1221
+ return pd.DataFrame(rows)
1222
+
1223
+
1224
+ # : Which columns of which table become a depth curve, and on which axis they : are placed.
1225
+ # "pair" means the value describes the gap between layer i and : i+1, so it is drawn at the midpoint like a block-domain signal.
1226
+ COMPARISON_SIGNALS: list[dict[str, Any]] = [
1227
+ {"table": "signals", "column": "lens_prob", "index": "layer", "domain": "residual"},
1228
+ {"table": "signals", "column": "target_percentile", "index": "layer", "domain": "residual"},
1229
+ {"table": "signals", "column": "target_rank", "index": "layer", "domain": "residual"},
1230
+ {"table": "similarity", "column": "cos", "index": "layer_i", "domain": "pair",
1231
+ "filter": "adjacent"},
1232
+ {"table": "attn_norm", "column": "value_norm", "index": "block", "domain": "block"},
1233
+ ]
1234
+
1235
+
1236
+ def _column_is_vocab_comparable(table: str, column: str) -> bool:
1237
+ """Read `comparable_across_vocab` straight off the schema declaration."""
1238
+ for spec in TABLES[table].columns:
1239
+ if spec.name == column:
1240
+ return spec.comparable_across_vocab
1241
+ return True
1242
+
1243
+
1244
+ def summarize_run(run: Run, group: "Group | None" = None):
1245
+ """Reduce one run to a long-format depth curve per signal.
1246
+
1247
+ Averages over documents, choices and steps, which is a sanity-check view - the per-document detail stays in the parquet files for whoever draws the real figure.
1248
+
1249
+ Returns:
1250
+ A pandas DataFrame with one row per (signal, layer index).
1251
+
1252
+ Example:
1253
+ >>> summarize_run(run).head(2) # doctest: +SKIP
1254
+ signal layer_domain layer_index relative_depth value
1255
+ 0 lens_prob residual 0 0.0 0.0021
1256
+ 1 lens_prob residual 1 0.03125 0.0034
1257
+ """
1258
+ import pandas as pd
1259
+
1260
+ # `series` is the line a curve is drawn as, and it is the one column that changes meaning with the axis: the model when the data is fixed, the language when the model is.
1261
+ # Both `model_id` and `language` stay in the table regardless, so a saved `comparison.parquet` can always be re-read without knowing which command wrote it.
1262
+ group_label = group.label if group else ""
1263
+ dataset = group.dataset_of(run) if group else None
1264
+ series = group.series_of(run) if group else run.model_id
1265
+ n_blocks = int(run.manifest.get("n_blocks", 0))
1266
+ frames = []
1267
+ for signal in COMPARISON_SIGNALS:
1268
+ table = read_table(run.path, signal["table"])
1269
+ if not len(table) or signal["column"] not in table.columns:
1270
+ continue
1271
+ if signal.get("filter") == "adjacent":
1272
+ # Only the layer i -> i+1 pairs: the full triangle is a matrix, not a curve, and belongs in the parquet rather than in this summary.
1273
+ table = table[table["layer_j"] == table["layer_i"] + 1]
1274
+ values = table.dropna(subset=[signal["column"]])
1275
+ if not len(values):
1276
+ continue
1277
+ grouped = values.groupby(signal["index"])[signal["column"]].agg(["mean", "size"])
1278
+ domain = "block" if signal["domain"] == "pair" else signal["domain"]
1279
+ frames.append(
1280
+ pd.DataFrame(
1281
+ {
1282
+ "group": group_label,
1283
+ "axis": group.axis if group else "model",
1284
+ "series": series,
1285
+ "run_path": run.path,
1286
+ "run_name": run.name,
1287
+ "model_id": run.model_id,
1288
+ "language": dataset.label if dataset else None,
1289
+ "task": "+".join(run.tasks),
1290
+ "vocab_size": run.vocab_size,
1291
+ "n_blocks": n_blocks,
1292
+ "signal": signal["column"],
1293
+ "layer_domain": signal["domain"],
1294
+ "layer_index": grouped.index.astype(int),
1295
+ "relative_depth": [
1296
+ relative_depth(int(i), n_blocks, domain) for i in grouped.index
1297
+ ],
1298
+ "value": grouped["mean"].to_numpy(),
1299
+ "n_rows": grouped["size"].to_numpy(),
1300
+ "comparable_across_vocab": _column_is_vocab_comparable(
1301
+ signal["table"], signal["column"]
1302
+ ),
1303
+ }
1304
+ )
1305
+ )
1306
+ if not frames:
1307
+ return pd.DataFrame()
1308
+ return pd.concat(frames, ignore_index=True)
1309
+
1310
+
1311
+ def build_comparison(partition: Partition):
1312
+ """Long-format model x layer x signal table across every grouped run.
1313
+
1314
+ Example:
1315
+ >>> build_comparison(part).head(1) # doctest: +SKIP
1316
+ group model_id signal layer_index relative_depth value
1317
+ 0 A Qwen/Qwen3-8B lens_prob 0 0.0 0.0021
1318
+ """
1319
+ import pandas as pd
1320
+
1321
+ frames = [
1322
+ summarize_run(run, group)
1323
+ for group in partition.groups
1324
+ for run in group.runs
1325
+ ]
1326
+ frames = [frame for frame in frames if len(frame)]
1327
+ if not frames:
1328
+ return pd.DataFrame()
1329
+ return pd.concat(frames, ignore_index=True)
1330
+
1331
+
1332
+ # --------------------------------------------------------------------------
1333
+ # Outputs
1334
+ # --------------------------------------------------------------------------
1335
+
1336
+
1337
+ def wrap_for_page(text: str, width: int = 108) -> str:
1338
+ """Fold long lines so the page keeps them.
1339
+
1340
+ `figure.text` does not wrap: anything past the page edge is drawn outside the canvas and simply lost. At 8pt monospace on A4 that edge is 118 characters, and three lines of a seven-model report ran past it - including the one naming which models a group contains, so the page could not say what it had compared, and a vocabulary warning that ended mid-word.
1341
+
1342
+ Continuations keep the original line's indentation plus two spaces, so a folded model list still reads as one item.
1343
+ """
1344
+ import textwrap
1345
+
1346
+ out = []
1347
+ for line in text.splitlines():
1348
+ stripped = line.lstrip()
1349
+ indent = line[: len(line) - len(stripped)]
1350
+ if len(line) <= width or not stripped:
1351
+ out.append(line)
1352
+ continue
1353
+ out.extend(textwrap.wrap(
1354
+ stripped, width=width, initial_indent=indent,
1355
+ subsequent_indent=indent + " ", break_long_words=False,
1356
+ break_on_hyphens=False))
1357
+ return "\n".join(out)
1358
+
1359
+
1360
+ def render_pdf(partition: Partition, comparison, output_path: str) -> str:
1361
+ """Draw one page of grouping text plus one page of curves per group.
1362
+
1363
+ This is a sanity check, not a paper figure.
1364
+ There is no plotting API: the real figures are drawn by the user from `load_signals()`.
1365
+
1366
+ Signals that are not comparable across vocabularies are skipped in a group whose runs disagree on vocabulary size, rather than being drawn with a caveat nobody reads.
1367
+ """
1368
+ import matplotlib
1369
+
1370
+ matplotlib.use("Agg")
1371
+ import matplotlib.pyplot as plt
1372
+ from matplotlib.backends.backend_pdf import PdfPages
1373
+
1374
+ with PdfPages(output_path) as pdf:
1375
+ # Page 1: what was found, what was grouped, what was dropped.
1376
+ figure = plt.figure(figsize=(8.27, 11.69))
1377
+ header = (
1378
+ f"runs found: {partition.n_found} "
1379
+ f"grouped: {sum(len(g.runs) for g in partition.groups)} "
1380
+ f"excluded: {len(partition.excluded)}\n\n"
1381
+ )
1382
+ figure.text(0.05, 0.95, wrap_for_page(header + format_partition(partition)),
1383
+ va="top", family="monospace", fontsize=8)
1384
+ pdf.savefig(figure)
1385
+ plt.close(figure)
1386
+
1387
+ for group in partition.groups:
1388
+ rows = comparison[comparison["group"] == group.label] if len(comparison) else comparison
1389
+ if not len(rows):
1390
+ continue
1391
+ mixed_vocab = len(group.vocab_sizes()) > 1
1392
+ signals = [
1393
+ name
1394
+ for name in rows["signal"].unique()
1395
+ if not (mixed_vocab and not rows[rows["signal"] == name]
1396
+ ["comparable_across_vocab"].iloc[0])
1397
+ ]
1398
+ if not signals:
1399
+ continue
1400
+ figure, axes = plt.subplots(
1401
+ len(signals), 1, figsize=(8.27, 2.4 * len(signals) + 1), squeeze=False
1402
+ )
1403
+ for axis, signal in zip(axes[:, 0], signals):
1404
+ subset = rows[rows["signal"] == signal]
1405
+ for series, curve in subset.groupby("series"):
1406
+ curve = curve.sort_values("relative_depth")
1407
+ axis.plot(curve["relative_depth"], curve["value"], marker=".", label=series)
1408
+ axis.set_ylabel(signal)
1409
+ axis.set_xlabel("relative depth l / L")
1410
+ axis.legend(fontsize=6)
1411
+ if group.axis == "language":
1412
+ title = (f"Group {group.label}: {group.runs[0].model_id} across "
1413
+ + ", ".join(group.series_of(run) for run in group.runs))
1414
+ else:
1415
+ title = (f"Group {group.label}: {'+'.join(group.tasks)} "
1416
+ f"({group.n_documents} docs)")
1417
+ axes[0, 0].set_title(title)
1418
+ figure.tight_layout()
1419
+ pdf.savefig(figure)
1420
+ plt.close(figure)
1421
+ return output_path
1422
+
1423
+
1424
+ def run_report(
1425
+ paths: Sequence[str],
1426
+ output_dir: str,
1427
+ reference: str | None = None,
1428
+ examples_per_category: int = 3,
1429
+ datasets: Sequence[Dataset] | None = None,
1430
+ assume_aligned: bool = False,
1431
+ pair_on: str | None = None,
1432
+ pair_strict: bool = False,
1433
+ pair_mapping: str | None = None,
1434
+ ) -> Partition:
1435
+ """The `report` command: discover, partition, print, then write the outputs.
1436
+
1437
+ Writes four files into `output_dir`:
1438
+
1439
+ * `comparison.parquet` and `report.pdf` - the aggregate view, model x layer x signal, which says *which* model is better;
1440
+ * `examples.parquet` and `examples.md` - the qualitative view, which says *on what*, by bucketing documents by who got them right.
1441
+
1442
+ The two share a partition rather than each computing their own.
1443
+ Which runs may be compared is one question, and letting the curves and the examples answer it differently would be a way to draw a figure from one set of runs and quote examples from another.
1444
+
1445
+ Args:
1446
+ reference: substring of the series label the example buckets are defined against - a model id normally, a language under `--multilingual`.
1447
+ Default: the highest-scoring run in each group, printed.
1448
+ examples_per_category: documents sampled per bucket; 0 skips the qualitative half.
1449
+ datasets: what `--multilingual` declared, or None for the default report.
1450
+ One report answers one of those questions: the reference, the buckets and the figure legends all mean something different in each, so producing both at once would produce a file whose columns mean two things.
1451
+ assume_aligned: under `--multilingual`, compare documents by `doc_id` even when the gold-answer check falls below the threshold.
1452
+ pair_on: a `doc` field that identifies a document across languages, or "position".
1453
+ Without it a field is looked for and used only if it verifies.
1454
+ pair_strict: under `--multilingual`, compare no document one to one when neither
1455
+ an identity field nor a mapping pairs it, instead of pairing by position.
1456
+ pair_mapping: a CSV/JSONL file mapping (language, task_name, doc_id) to canonical
1457
+ document, choice and answer ids. Its hash and scope go into `pairing.json`.
1458
+
1459
+ Under `--multilingual` two more files are written: `pairing.parquet`, one row per graded document with its pairing and answer status, and `pairing.json`, the per-group source, mapping scope, coverage and alignment.
1460
+
1461
+ Example:
1462
+ >>> run_report(["results/"], ".") # doctest: +SKIP
1463
+ Found 10 runs
1464
+ ...
1465
+ """
1466
+ runs = discover_runs(paths)
1467
+ for run in runs:
1468
+ if run.manifest.get("internal_signals_available") is False:
1469
+ print(f"{run.name}: scoring-only run; internal-signal curves and prediction depth are unavailable")
1470
+ partition = partition_runs(runs, datasets=datasets)
1471
+ # Printed before anything is drawn, on purpose.
1472
+ print(format_partition(partition))
1473
+
1474
+ os.makedirs(output_dir, exist_ok=True)
1475
+ comparison = build_comparison(partition)
1476
+ if len(comparison):
1477
+ comparison.to_parquet(os.path.join(output_dir, "comparison.parquet"), index=False)
1478
+ render_pdf(partition, comparison, os.path.join(output_dir, "report.pdf"))
1479
+
1480
+ if pair_mapping is not None and partition.axis != "language":
1481
+ raise ValueError("--pair-mapping applies to --multilingual reports only")
1482
+ mapping = load_pairing_mapping(pair_mapping) if pair_mapping is not None else None
1483
+ if partition.axis == "language":
1484
+ pairing = write_pairing(partition, output_dir, pair_on, pair_strict, mapping,
1485
+ assume_aligned)
1486
+ if len(pairing):
1487
+ print(f"wrote {len(pairing)} pairing rows to {output_dir}/pairing.parquet "
1488
+ "and pairing.json")
1489
+
1490
+ if examples_per_category > 0 and partition.groups:
1491
+ examples = write_examples(partition, output_dir, reference, examples_per_category,
1492
+ assume_aligned=assume_aligned, pair_on=pair_on,
1493
+ pair_strict=pair_strict, pair_mapping=mapping)
1494
+ if len(examples):
1495
+ print(f"wrote {len(examples)} example rows to {output_dir}/examples.parquet "
1496
+ "and examples.md")
1497
+ else:
1498
+ # Saying a file was written when it was not is how someone ends up looking for it.
1499
+ print("no examples were written: no document was eligible in any group "
1500
+ "(the reason per group is printed above)")
1501
+ return partition
1502
+
1503
+
1504
+ # --------------------------------------------------------------------------
1505
+ # Qualitative examples
1506
+ # --------------------------------------------------------------------------
1507
+
1508
+ # : How documents are bucketed when several models solved the same set. : Aggregate curves say a model is better; they never say on *what*.
1509
+ # These : buckets do, and the two disagreement ones are where a paper's examples come : from - a document everyone gets right shows nothing.
1510
+ CATEGORIES = (
1511
+ "reference_only_correct", # the reference is right where every other model is wrong
1512
+ "reference_only_wrong", # the reference is wrong where every other model is right
1513
+ "all_correct", # control
1514
+ "all_wrong", # control
1515
+ )
1516
+
1517
+
1518
+ def reference_run(group: Group, name: str | None = None) -> Run:
1519
+ """Pick the run the buckets are defined against.
1520
+
1521
+ Args:
1522
+ name: substring of the series label - a model id normally, a language under `--multilingual`.
1523
+ Without it the highest-scoring run is used, and the choice is printed.
1524
+
1525
+ "the last run in the sweep" is deliberately not the default: it is whatever order the shell loop happened to use, so reordering the loop would silently change which model the examples are about.
1526
+
1527
+ Raises:
1528
+ LookupError: when `name` matches no run, or more than one.
1529
+ """
1530
+ if name:
1531
+ matches = [run for run in group.runs if name in group.series_of(run)]
1532
+ if len(matches) != 1:
1533
+ raise LookupError(
1534
+ f"--reference {name!r} matched {len(matches)} runs in group "
1535
+ f"{group.label}: {[group.series_of(r) for r in group.runs]}"
1536
+ )
1537
+ return matches[0]
1538
+ def rank(run):
1539
+ score = _score_of(run)
1540
+ if score is None:
1541
+ return float('-inf')
1542
+ protocols = run.manifest.get("benchmarks", {}).get("protocols", {})
1543
+ spec = protocols.get(run.tasks[0], {}) if len(run.tasks) == 1 else {}
1544
+ return score if spec.get("higher_is_better", True) else -score
1545
+ return max(group.runs, key=rank)
1546
+
1547
+
1548
+ def _score_of(run: Run) -> float | None:
1549
+ """The run's headline metric, preferring a length-normalised accuracy.
1550
+
1551
+ Only the rows for the tasks the run actually asked for are read.
1552
+ An lm-eval *group* writes one row per subtask **and** its own aggregate row into the same results dict, so taking whichever row came first makes the headline an arbitrary subtask's score: `global_mmlu_en` yields `global_mmlu_en_business`, 20 documents standing in for 120. The aggregate row is lm-eval's own, computed with the weighting the group declared (`weight_by_size` for Global-MMLU), so it is the number to read - and it agrees with the `docs` table, which is how this was found.
1553
+
1554
+ A run that asked for several tasks has no single headline, so those are averaged, unweighted.
1555
+
1556
+ Keys without a comma - `sample_len` and other bookkeeping lm-eval writes alongside the metrics - are skipped: a metric key carries its filter after a comma, and `sample_len` would otherwise be eligible to become the score of a task with no recognised metric.
1557
+ """
1558
+ protocols = run.manifest.get("benchmarks", {}).get("protocols", {})
1559
+ if protocols:
1560
+ # Different tasks/metrics have no common scale; do not invent an average.
1561
+ if len(run.tasks) != 1 or run.tasks[0] not in protocols:
1562
+ return None
1563
+ task = run.tasks[0]
1564
+ spec = protocols[task]
1565
+ value = run.scores.get(task, {}).get(f"{spec['primary_metric']},{spec['primary_filter']}")
1566
+ return float(value) if isinstance(value, (float, int)) else None
1567
+ rows = [run.scores[task] for task in run.tasks if task in run.scores]
1568
+ if not rows: # not a group run, or the task was renamed
1569
+ rows = list(run.scores.values())
1570
+ collected: dict[str, list[float]] = {}
1571
+ for task_scores in rows:
1572
+ for key, value in task_scores.items():
1573
+ if "," in key and "stderr" not in key and isinstance(value, (int, float)):
1574
+ collected.setdefault(key.split(",")[0], []).append(float(value))
1575
+ for name in ("acc_norm", "acc", "exact_match"):
1576
+ if name in collected:
1577
+ return sum(collected[name]) / len(collected[name])
1578
+ first = next(iter(collected.values()), None)
1579
+ return sum(first) / len(first) if first else None
1580
+
1581
+
1582
+ def _verdicts(run: Run, group: Group) -> dict[str, bool]:
1583
+ """doc key -> did this run answer it correctly.
1584
+
1585
+ Read from the `docs` table, which carries lm-eval's own grading; correctness is per document, so any choice row of it will do.
1586
+ The key comes from the group rather than from the row, because under `--multilingual` the task name is what differs between the runs being compared.
1587
+ """
1588
+ docs = read_table(run.path, "docs")
1589
+ if not len(docs):
1590
+ return {}
1591
+ return {
1592
+ group.doc_key(run, task, int(doc_id)): bool(correct)
1593
+ for task, doc_id, correct in zip(docs.task_name, docs.doc_id, docs.is_correct)
1594
+ if correct is not None and not _isna(correct)
1595
+ }
1596
+
1597
+
1598
+ def _isna(value: object) -> bool:
1599
+ import pandas as pd
1600
+
1601
+ return bool(pd.isna(value))
1602
+
1603
+
1604
+ def prompt_hashes(run: Run, group: Group | None = None) -> dict[str, str]:
1605
+ """doc key -> the hash lm-eval recorded for that document's prompt."""
1606
+ from .storage import read_samples
1607
+
1608
+ found = {}
1609
+ for sample in read_samples(run.path):
1610
+ digest = sample.get("prompt_hash")
1611
+ if digest:
1612
+ key = (group.doc_key(run, sample["task_name"], int(sample["doc_id"])) if group
1613
+ else (sample["task_name"], int(sample["doc_id"])))
1614
+ found[key] = digest
1615
+ return found
1616
+
1617
+
1618
+ def comparable_documents(
1619
+ group: Group, alignment: "Alignment | None" = None
1620
+ ) -> tuple[list[str], list[str]]:
1621
+ """The documents whose answers may be put in one row, and the ones that may not.
1622
+
1623
+ The two axes are held to different standards, because only one of them can meet the stricter one.
1624
+
1625
+ **Default - the same prompt.** Sharing a `doc_id` set is not enough to put two models' answers in one row.
1626
+ Few-shot examples are drawn per run, so the same `doc_id` can carry a different prompt, and a side-by-side table of answers to different questions looks entirely normal. lm-eval records a `prompt_hash` per document, so this is cheap to check rather than assume.
1627
+
1628
+ **`--multilingual` - the same question.** Here the prompts differ by construction: that is the whole point of the comparison, and requiring an identical `prompt_hash` would reject every document and quietly produce an empty examples file.
1629
+ So the gate becomes the one thing that is still checkable - `gold_alignment` - and it is a gate on the *group*: either the languages are translations of one document set and every shared `doc_id` is eligible, or they are not and none of them is.
1630
+
1631
+ Returns:
1632
+ (comparable, mismatched) keys.
1633
+ """
1634
+ if group.axis == "language":
1635
+ alignment = alignment if alignment is not None else gold_alignment(group)
1636
+ per_run = [set(_verdicts(run, group)) for run in group.runs]
1637
+ if not per_run:
1638
+ return [], []
1639
+ shared = sorted(set.intersection(*per_run))
1640
+ if not alignment.aligned:
1641
+ return [], shared
1642
+ # The group-level threshold decides whether to compare at all; the evidence is
1643
+ # per document, so it is also applied per document. A document the languages
1644
+ # disagree about has a different answer key in each, and quoting it as "right in
1645
+ # English, wrong in Korean" would describe a difference in the dataset as a
1646
+ # difference in the model.
1647
+ refused = set(alignment.disagreeing) | set(alignment.unverifiable)
1648
+ return ([key for key in shared if key not in refused],
1649
+ [key for key in shared if key in refused])
1650
+
1651
+ per_run = [prompt_hashes(run, group) for run in group.runs]
1652
+ if not per_run or not per_run[0]:
1653
+ return [], []
1654
+ shared = set(per_run[0])
1655
+ for hashes in per_run[1:]:
1656
+ shared &= set(hashes)
1657
+ comparable, mismatched = [], []
1658
+ for key in sorted(shared):
1659
+ digests = {hashes[key] for hashes in per_run}
1660
+ (comparable if len(digests) == 1 else mismatched).append(key)
1661
+ return comparable, mismatched
1662
+
1663
+
1664
+ def categorize(group: Group, reference: Run, keys: Sequence[str]):
1665
+ """Bucket documents by who got them right.
1666
+
1667
+ Returns:
1668
+ (buckets, mixed) - `mixed` holds documents where the other models disagree among themselves, which is neither a clean contrast nor a control; it is counted and reported, not sampled.
1669
+ """
1670
+ verdicts = {run.path: _verdicts(run, group) for run in group.runs}
1671
+ others = [run for run in group.runs if run.path != reference.path]
1672
+
1673
+ buckets: dict[str, list[str]] = {name: [] for name in CATEGORIES}
1674
+ mixed: list[str] = []
1675
+ for key in keys:
1676
+ if any(key not in verdicts[run.path] for run in group.runs):
1677
+ continue
1678
+ mine = verdicts[reference.path][key]
1679
+ rest = [verdicts[run.path][key] for run in others]
1680
+ if not rest: # a group of one
1681
+ buckets["all_correct" if mine else "all_wrong"].append(key)
1682
+ elif mine and all(rest):
1683
+ buckets["all_correct"].append(key)
1684
+ elif not mine and not any(rest):
1685
+ buckets["all_wrong"].append(key)
1686
+ elif mine and not any(rest):
1687
+ buckets["reference_only_correct"].append(key)
1688
+ elif not mine and all(rest):
1689
+ buckets["reference_only_wrong"].append(key)
1690
+ else:
1691
+ mixed.append(key)
1692
+ return buckets, mixed
1693
+
1694
+
1695
+ def _settle_depth(run: Run, group: Group, keys: Sequence[str]) -> dict[str, float]:
1696
+ """Relative depth at which each document's answer stops changing.
1697
+
1698
+ This is what makes these examples worth more than a generic evaluation dump: alongside "model A was right and model B was wrong" it says where in the stack each of them made up its mind.
1699
+ """
1700
+ signals = load_signals(run.path, "signals")
1701
+ if not len(signals):
1702
+ return {}
1703
+ wanted = set(keys)
1704
+ signals = signals[
1705
+ [group.doc_key(run, task, int(doc_id)) in wanted
1706
+ for task, doc_id in zip(signals.task_name, signals.doc_id)]
1707
+ ]
1708
+ if "is_target_choice" in signals and signals.is_target_choice.any():
1709
+ signals = signals[signals.is_target_choice == True] # noqa: E712
1710
+ if not len(signals):
1711
+ return {}
1712
+ depths = prediction_depth(signals, int(run.manifest.get("n_blocks", 0)))
1713
+ return {
1714
+ group.doc_key(run, task, int(doc_id)): float(depth)
1715
+ for task, doc_id, depth in zip(depths.task_name, depths.doc_id, depths.depth)
1716
+ }
1717
+
1718
+
1719
+ def _answers(run: Run, group: Group, keys: Sequence[str]) -> dict[str, dict]:
1720
+ """What this run predicted for each document, as lm-eval graded it."""
1721
+ docs = read_table(run.path, "docs")
1722
+ wanted = set(keys)
1723
+ found: dict[str, dict] = {}
1724
+ for row in docs.itertuples():
1725
+ key = group.doc_key(run, row.task_name, int(row.doc_id))
1726
+ if key not in wanted or key in found:
1727
+ continue
1728
+ found[key] = {
1729
+ "predicted": row.predicted,
1730
+ "target": row.target,
1731
+ "is_correct": None if _isna(row.is_correct) else bool(row.is_correct),
1732
+ # Where *this* run keeps it. The shared key names the document across languages and locates it in none of them, so the prompt could not be looked up again from it - and under a pairing key each language has its own doc_id as well as its own task.
1733
+ "task_name": row.task_name,
1734
+ "doc_id": int(row.doc_id),
1735
+ }
1736
+ return found
1737
+
1738
+
1739
+ def select_examples(
1740
+ group: Group,
1741
+ reference: Run,
1742
+ per_category: int = 3,
1743
+ seed: int = SAMPLING_SEED,
1744
+ assume_aligned: bool = False,
1745
+ ):
1746
+ """Sample documents from each bucket, and record how the sample was drawn.
1747
+
1748
+ The selection rule is part of the output on purpose.
1749
+ A tool that picks examples is ordinary; what separates a systematic sample from a hand-picked one, to a reader, is being able to see the seed, the buckets, how many documents were eligible and how many were taken.
1750
+
1751
+ Returns:
1752
+ (rows, provenance) - `rows` is one record per (document, model), `provenance` describes the draw.
1753
+ """
1754
+ import random
1755
+
1756
+ alignment = gold_alignment(group, assume_aligned) if group.axis == "language" else None
1757
+ comparable, mismatched = comparable_documents(group, alignment)
1758
+ buckets, mixed = categorize(group, reference, comparable)
1759
+
1760
+ rng = random.Random(seed)
1761
+ chosen: dict[str, list[str]] = {}
1762
+ for name in CATEGORIES:
1763
+ pool = buckets[name]
1764
+ chosen[name] = sorted(rng.sample(pool, min(per_category, len(pool))))
1765
+
1766
+ picked = [key for keys in chosen.values() for key in keys]
1767
+ depths = {run.path: _settle_depth(run, group, picked) for run in group.runs}
1768
+ answers = {run.path: _answers(run, group, picked) for run in group.runs}
1769
+
1770
+ rows = []
1771
+ for category, keys in chosen.items():
1772
+ for key in keys:
1773
+ for run in group.runs:
1774
+ answer = answers[run.path].get(key, {})
1775
+ rows.append({
1776
+ "group": group.label,
1777
+ "axis": group.axis,
1778
+ "category": category,
1779
+ # `document` is the shared identity; `task_name` and `doc_id` are where this run keeps it, which under a pairing key differ per language.
1780
+ "document": key,
1781
+ "task_name": answer.get("task_name"),
1782
+ "doc_id": answer.get("doc_id"),
1783
+ "series": group.series_of(run),
1784
+ # The subtask this run actually ran, which is what locates the prompt in its own samples.jsonl.
1785
+ "run_task": answer.get("task_name") or (
1786
+ run.tasks[0] if len(run.tasks) == 1 else "+".join(run.tasks)),
1787
+
1788
+ # `model` and `language` are both kept whichever mode wrote the file: which of the two varies is a property of the report, not of the row, and a saved parquet outlives the command that produced it.
1789
+ "model": run.model_id,
1790
+ "language": (group.dataset_of(run).label
1791
+ if group.dataset_of(run) else None),
1792
+ "is_reference": run.path == reference.path,
1793
+ "is_correct": answer.get("is_correct"),
1794
+ "predicted": answer.get("predicted"),
1795
+ "target": answer.get("target"),
1796
+ "settle_depth": depths[run.path].get(key),
1797
+ "n_blocks": int(run.manifest.get("n_blocks", 0)),
1798
+ "run_path": run.path,
1799
+ })
1800
+
1801
+ provenance = {
1802
+ "group": group.label,
1803
+ "axis": group.axis,
1804
+ "datasets": [f"{d.label}={d.task}" for d in group.datasets],
1805
+ "tasks": list(group.tasks),
1806
+ "reference": group.series_of(reference),
1807
+ "models": [group.series_of(run) for run in group.runs],
1808
+ "seed": seed,
1809
+ "per_category": per_category,
1810
+ "eligible": len(comparable),
1811
+ # The two modes exclude documents for different reasons, and one count holding both would be a count nobody can read: normally a document drops out because the prompts differed, under `--multilingual` because the whole group failed the alignment check.
1812
+ "excluded_prompt_mismatch": len(mismatched) if group.axis != "language" else 0,
1813
+ "excluded_not_aligned": len(mismatched) if group.axis == "language" else 0,
1814
+ "disagreeing": list(mismatched) if group.axis == "language" else [],
1815
+ "pool_sizes": {name: len(buckets[name]) for name in CATEGORIES},
1816
+ "taken": {name: len(chosen[name]) for name in CATEGORIES},
1817
+ "mixed_not_sampled": len(mixed),
1818
+ "paired_on": group.pairing.note if group.pairing else "",
1819
+ "pairing_source": group.pairing.source if group.pairing else "",
1820
+ "unverifiable": list(alignment.unverifiable) if alignment else [],
1821
+ "eligibility": (
1822
+ "identical prompt_hash per document" if group.axis != "language"
1823
+ else f"gold-answer alignment across languages ({alignment.source}): {alignment.note}"
1824
+ ),
1825
+ }
1826
+ return rows, provenance
1827
+
1828
+
1829
+ def _prompt_and_choices(run_dir: str, task_name: str | None, doc_id: int, tail: int = 400):
1830
+ """The question as the model saw it, plus its choices.
1831
+
1832
+ Taken from `samples.jsonl`'s `arguments`, which is the actual request, so this works for any task without knowing that task's dataset fields.
1833
+ Few-shot prompts run to hundreds of tokens, so only the tail is shown - that is the question itself.
1834
+ """
1835
+ from .storage import read_samples
1836
+
1837
+ for sample in read_samples(run_dir):
1838
+ # `task_name` is None when the caller has no name to give: the doc_id alone then locates the sample, which is only safe for a run with a single task.
1839
+ if (task_name is not None and sample["task_name"] != task_name) \
1840
+ or int(sample["doc_id"]) != doc_id:
1841
+ continue
1842
+ arguments = sample.get("arguments") or []
1843
+ if not arguments:
1844
+ return "", []
1845
+ context = str(arguments[0][0])
1846
+ choices = [str(a[1]) for a in arguments if len(a) > 1 and isinstance(a[1], str)]
1847
+ prefix = "..." if len(context) > tail else ""
1848
+ return prefix + context[-tail:], choices
1849
+ return "", []
1850
+
1851
+
1852
+ def render_examples(rows, provenance_list, output_path: str) -> str:
1853
+ """Write the readable half: one markdown section per bucket.
1854
+
1855
+ Markdown rather than a figure, because these go into a draft as text.
1856
+ """
1857
+ import pandas as pd
1858
+
1859
+ frame = pd.DataFrame(rows)
1860
+ lines = ["# Qualitative examples", ""]
1861
+ for provenance in provenance_list:
1862
+ per_language = provenance.get("axis") == "language"
1863
+ heading = (f"## Group {provenance['group']} - {', '.join(provenance['datasets'])}"
1864
+ if per_language
1865
+ else f"## Group {provenance['group']} - {'+'.join(provenance['tasks'])}")
1866
+ dropped = provenance.get("disagreeing") or []
1867
+ eligibility = (
1868
+ f"- {provenance['eligible']} documents are shared by every language and treated as "
1869
+ f"the same question. Paired: {provenance['paired_on']}. "
1870
+ f"Checked: {provenance['eligibility']}."
1871
+ + ("\n- Left out, because the languages disagree on which answer is gold, or "
1872
+ "that cannot be checked: "
1873
+ + ", ".join(f"`{str(key).lstrip('_')}`" for key in dropped[:20])
1874
+ + (" ..." if len(dropped) > 20 else "")
1875
+ + ". These are worth reading as dataset findings rather than model findings."
1876
+ if dropped else "")
1877
+ if per_language else
1878
+ f"- {provenance['eligible']} documents were solved by every model with an "
1879
+ f"identical prompt (`prompt_hash`); {provenance['excluded_prompt_mismatch']} "
1880
+ "were excluded because the prompt differed.")
1881
+ lines += [
1882
+ heading, "",
1883
+ f"Reference {'language' if per_language else 'model'}: "
1884
+ f"**{provenance['reference']}** ",
1885
+ f"Compared against: {', '.join(m for m in provenance['models'] if m != provenance['reference'])} ",
1886
+ "",
1887
+ "How these were drawn:", "",
1888
+ eligibility,
1889
+ f"- Bucket sizes: " + ", ".join(
1890
+ f"`{name}` {provenance['pool_sizes'][name]}" for name in CATEGORIES) +
1891
+ f"; `mixed` {provenance['mixed_not_sampled']} (not sampled).",
1892
+ f"- Up to {provenance['per_category']} taken per bucket at random, seed "
1893
+ f"{provenance['seed']}: " + ", ".join(
1894
+ f"`{name}` {provenance['taken'][name]}" for name in CATEGORIES) + ".",
1895
+ "",
1896
+ ]
1897
+ per_language = provenance.get("axis") == "language"
1898
+ column = "language" if per_language else "model"
1899
+ subset = frame[frame.group == provenance["group"]]
1900
+ for category in CATEGORIES:
1901
+ picked = subset[subset.category == category]
1902
+ if not len(picked):
1903
+ continue
1904
+ lines += [f"### {category.replace('_', ' ')}", ""]
1905
+ for document, rows_for_doc in picked.groupby("document", sort=False):
1906
+ rows_for_doc = rows_for_doc.sort_values("is_reference", ascending=False)
1907
+ reference_row = rows_for_doc[rows_for_doc.is_reference].iloc[0]
1908
+ # Paired on a field, the key *is* the document's name and says everything.
1909
+ # Paired on position it is `_stem#5`, or bare `#5` for a run with a single
1910
+ # task, so the reference's own task fills in what the key cannot say.
1911
+ subject = str(document).lstrip("_")
1912
+ if subject.startswith("#"):
1913
+ subject = f"{reference_row.run_task} {subject}"
1914
+ lines += [f"**{subject}** - gold: `{reference_row.target}`", ""]
1915
+ if per_language:
1916
+ # One prompt per language, not one for the reference. The document is only "the same question" by the alignment argument, and the way to see whether that argument holds - or where a translation went wrong - is to read the languages next to each other.
1917
+ for row in rows_for_doc.itertuples():
1918
+ prompt, choices = _prompt_and_choices(
1919
+ row.run_path, row.run_task, int(row.doc_id), tail=300)
1920
+ lines += [f"*{row.series}* (`{row.run_task}`)", "",
1921
+ "```", prompt, "```", ""]
1922
+ if choices:
1923
+ lines.append("Choices: " + ", ".join(
1924
+ f"`{c.strip()}`" for c in choices))
1925
+ lines.append("")
1926
+ else:
1927
+ prompt, choices = _prompt_and_choices(
1928
+ reference_row.run_path, reference_row.task_name,
1929
+ int(reference_row.doc_id))
1930
+ lines += ["```", prompt, "```", ""]
1931
+ if choices:
1932
+ lines.append("Choices: " + ", ".join(f"`{c.strip()}`" for c in choices))
1933
+ lines.append("")
1934
+ lines += [f"| {column} | correct | predicted | settles at depth |",
1935
+ "| --- | --- | --- | --- |"]
1936
+ for row in rows_for_doc.itertuples():
1937
+ mark = "**<-**" if row.is_reference else ""
1938
+ depth = "-" if row.settle_depth is None or _isna(row.settle_depth) \
1939
+ else f"{row.settle_depth:.2f}"
1940
+ predicted = str(row.predicted or "")[:60].replace("\n", " ")
1941
+ lines.append(
1942
+ f"| {row.series} {mark} | {'yes' if row.is_correct else 'no'} "
1943
+ f"| `{predicted}` | {depth} |")
1944
+ lines.append("")
1945
+ text = "\n".join(lines)
1946
+ with open(output_path, "w", encoding="utf-8") as handle:
1947
+ handle.write(text)
1948
+ return text
1949
+
1950
+
1951
+ def write_examples(
1952
+ partition: Partition,
1953
+ output_dir: str,
1954
+ reference: str | None = None,
1955
+ per_category: int = 3,
1956
+ assume_aligned: bool = False,
1957
+ pair_on: str | None = None,
1958
+ pair_strict: bool = False,
1959
+ pair_mapping: "PairingMapping | str | None" = None,
1960
+ ):
1961
+ """Write the qualitative half of a report: which documents to actually read.
1962
+
1963
+ Takes an already-computed `Partition` rather than re-discovering runs, because deciding which runs may be compared is the same question for the curves and for the examples - and the answer must not be allowed to differ between them.
1964
+ A group already paired by `write_pairing` keeps that pairing.
1965
+
1966
+ Returns:
1967
+ The examples DataFrame, empty when nothing could be sampled.
1968
+ """
1969
+ import pandas as pd
1970
+
1971
+ all_rows, provenance_list = [], []
1972
+ for group in partition.groups:
1973
+ if group.axis == "language" and group.pairing is None:
1974
+ # Before anything is compared, and printed: how two documents were decided to be the same document is the first thing the rest of this section depends on.
1975
+ pairing = attach_pairing(group, pair_on, strict=pair_strict, mapping=pair_mapping)
1976
+ print(f"[Group {group.label}] {pairing.note}")
1977
+ chosen_reference = reference_run(group, reference)
1978
+ if reference is None and len(group.runs) > 1:
1979
+ noun = "language" if group.axis == "language" else "model"
1980
+ print(f"[Group {group.label}] examples reference {noun}: "
1981
+ f"{group.series_of(chosen_reference)} (highest score; "
1982
+ "pass --reference to choose another)")
1983
+ rows, provenance = select_examples(
1984
+ group, chosen_reference, per_category, assume_aligned=assume_aligned)
1985
+ if provenance["excluded_prompt_mismatch"]:
1986
+ print(f"[Group {group.label}] warning: "
1987
+ f"{provenance['excluded_prompt_mismatch']} documents excluded from "
1988
+ "examples - the models saw different prompts for the same doc_id")
1989
+ if group.axis == "language":
1990
+ # Printed whether it passed or failed. Every per-document row under `--multilingual` rests on this one measurement, and evidence that is only visible when it refuses is evidence the reader has to go looking for - by which point the figure has already been read.
1991
+ headline = ("no per-document examples" if not provenance["eligible"]
1992
+ else f"{provenance['eligible']} documents compared one to one")
1993
+ print(f"[Group {group.label}] {headline}: {provenance['eligibility']}")
1994
+ all_rows += rows
1995
+ provenance_list.append(provenance)
1996
+
1997
+ frame = pd.DataFrame(all_rows)
1998
+ if len(frame):
1999
+ frame.to_parquet(os.path.join(output_dir, "examples.parquet"), index=False)
2000
+ render_examples(all_rows, provenance_list,
2001
+ os.path.join(output_dir, "examples.md"))
2002
+ return frame
2003
+
2004
+
2005
+ # --------------------------------------------------------------------------
2006
+ # The pairing table: what was compared with what, and what was not
2007
+ # --------------------------------------------------------------------------
2008
+
2009
+ PAIRING_STATEMENT = (
2010
+ "A pairing records which documents were compared and on what ground. An identity "
2011
+ "field or an explicit mapping is taken as given; it is not verified to be the same "
2012
+ "content. Gold alignment measures only whether the answer keys agree. Neither says "
2013
+ "that the tasks' metrics are comparable.")
2014
+
2015
+
2016
+ def pairing_table(group: Group, alignment: Alignment | None = None) -> list[dict[str, Any]]:
2017
+ """One row per graded document of every run in a multilingual group.
2018
+
2019
+ `pairing_status` and `answer_status` are separate on purpose: a document can be paired
2020
+ and still have an answer key that disagrees, or cannot be checked, across languages.
2021
+ A document that was not paired says why in `exclusion_reason` - `unmapped`,
2022
+ `duplicate_mapping_rows`, `many_to_one`, `counterpart_many_to_one`,
2023
+ `no_identity_field`, `strict_no_identity` or `not_in_every_language`.
2024
+ """
2025
+ pairing = group.pairing
2026
+ source = pairing.source if pairing else "position"
2027
+ documents = {run.path: sorted(_documents(run)) for run in group.runs}
2028
+ keys = {run.path: {group.doc_key(run, *doc) for doc in documents[run.path]}
2029
+ for run in group.runs}
2030
+ shared = set.intersection(*keys.values()) if keys else set()
2031
+ rows = []
2032
+ for run in group.runs:
2033
+ dataset = group.dataset_of(run)
2034
+ excluded = pairing.excluded.get(run.path, {}) if pairing else {}
2035
+ canonical = pairing.canonical.get(run.path, {}) if pairing else {}
2036
+ evidence = alignment.evidence.get(run.path, {}) if alignment else {}
2037
+ for document in documents[run.path]:
2038
+ key = group.doc_key(run, *document)
2039
+ paired = key in shared
2040
+ reason = None if paired else (
2041
+ excluded.get(document)
2042
+ or ("strict_no_identity" if source == "none" else "not_in_every_language"))
2043
+ gold = evidence.get(key)
2044
+ kind, _, value = gold.partition(":") if gold else (None, None, None)
2045
+ answer = None
2046
+ if paired:
2047
+ answer = (alignment.status.get(key, "no_gold_evidence") if alignment
2048
+ else None)
2049
+ rows.append({
2050
+ "group": group.label,
2051
+ "model": run.model_id,
2052
+ "num_fewshot": run.num_fewshot,
2053
+ "series": group.series_of(run),
2054
+ "language": dataset.label if dataset else None,
2055
+ "run_path": run.path,
2056
+ "task_name": document[0],
2057
+ "doc_id": document[1],
2058
+ "pairing_source": source,
2059
+ "pairing_field": pairing.field if pairing else None,
2060
+ "mapping_sha256": (pairing.mapping or {}).get("sha256") if pairing else None,
2061
+ "canonical_doc_id": (key if source == "position" else canonical.get(document)),
2062
+ "pairing_status": "paired" if paired else "excluded",
2063
+ "exclusion_reason": reason,
2064
+ "gold_evidence": kind,
2065
+ "gold_value": value,
2066
+ "answer_status": answer,
2067
+ })
2068
+ return rows
2069
+
2070
+
2071
+ def pairing_provenance(group: Group, alignment: Alignment | None,
2072
+ rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
2073
+ """The group-level record beside the table: source, mapping scope, coverage, alignment."""
2074
+ pairing = group.pairing
2075
+ coverage: dict[str, dict[str, Any]] = {}
2076
+ for row in rows:
2077
+ entry = coverage.setdefault(row["series"], {
2078
+ "run_path": row["run_path"], "task": None, "documents": 0, "paired": 0,
2079
+ "excluded": {}})
2080
+ dataset = next((run for run in group.runs if run.path == row["run_path"]), None)
2081
+ entry["task"] = "+".join(dataset.tasks) if dataset else None
2082
+ entry["documents"] += 1
2083
+ if row["pairing_status"] == "paired":
2084
+ entry["paired"] += 1
2085
+ else:
2086
+ reason = row["exclusion_reason"]
2087
+ entry["excluded"][reason] = entry["excluded"].get(reason, 0) + 1
2088
+ counts = Counter(alignment.status.values()) if alignment else Counter()
2089
+ return {
2090
+ "group": group.label,
2091
+ "model": group.held_constant,
2092
+ "num_fewshot": group.runs[0].num_fewshot if group.runs else None,
2093
+ "datasets": [f"{d.label}={d.task}" for d in group.datasets],
2094
+ "source": pairing.source if pairing else "position",
2095
+ "field": pairing.field if pairing else None,
2096
+ "strict": pairing.strict if pairing else False,
2097
+ "note": pairing.note if pairing else "",
2098
+ "mapping": pairing.mapping if pairing else None,
2099
+ "paired_documents": pairing.matched if pairing and pairing.source != "position"
2100
+ else len({row["canonical_doc_id"] for row in rows
2101
+ if row["pairing_status"] == "paired"}),
2102
+ "coverage": coverage,
2103
+ "alignment": None if alignment is None else {
2104
+ "shared": alignment.shared,
2105
+ "agree": counts.get("agree", 0),
2106
+ "disagree": counts.get("disagree", 0),
2107
+ "unverifiable": counts.get("unverifiable", 0),
2108
+ "rate": alignment.rate,
2109
+ "aligned": alignment.aligned,
2110
+ "source": alignment.source,
2111
+ "threshold": ALIGNMENT_THRESHOLD,
2112
+ "evidence_kinds": dict(sorted(Counter(
2113
+ value.partition(":")[0] for positions in alignment.evidence.values()
2114
+ for value in positions.values()).items())),
2115
+ "note": alignment.note,
2116
+ },
2117
+ "statement": PAIRING_STATEMENT,
2118
+ }
2119
+
2120
+
2121
+ def write_pairing(
2122
+ partition: Partition,
2123
+ output_dir: str,
2124
+ pair_on: str | None = None,
2125
+ pair_strict: bool = False,
2126
+ pair_mapping: "PairingMapping | str | None" = None,
2127
+ assume_aligned: bool = False,
2128
+ ):
2129
+ """Pair every multilingual group, then write `pairing.parquet` and `pairing.json`.
2130
+
2131
+ Written whether or not examples are drawn: which documents were treated as the same
2132
+ document is an output in its own right, and the curves never depended on it.
2133
+
2134
+ Returns:
2135
+ The pairing DataFrame, empty for a report with no multilingual group.
2136
+ """
2137
+ import pandas as pd
2138
+
2139
+ rows, groups = [], []
2140
+ for group in partition.groups:
2141
+ if group.axis != "language":
2142
+ continue
2143
+ pairing = attach_pairing(group, pair_on, strict=pair_strict, mapping=pair_mapping)
2144
+ print(f"[Group {group.label}] {pairing.note}")
2145
+ alignment = gold_alignment(group, assume_aligned)
2146
+ group_rows = pairing_table(group, alignment)
2147
+ rows += group_rows
2148
+ groups.append(pairing_provenance(group, alignment, group_rows))
2149
+ frame = pd.DataFrame(rows)
2150
+ if groups:
2151
+ if len(frame):
2152
+ frame.to_parquet(os.path.join(output_dir, "pairing.parquet"), index=False)
2153
+ with open(os.path.join(output_dir, "pairing.json"), "w", encoding="utf-8") as handle:
2154
+ json.dump({"pairing_schema": 1, "groups": groups}, handle, indent=2,
2155
+ ensure_ascii=False)
2156
+ handle.write("\n")
2157
+ return frame
2158
+
2159
+
2160
+ __all__ = [
2161
+ "Run",
2162
+ "Group",
2163
+ "Partition",
2164
+ "Alignment",
2165
+ "discover_runs",
2166
+ "Dataset",
2167
+ "parse_datasets",
2168
+ "screen_runs",
2169
+ "partition_runs",
2170
+ "gold_alignment",
2171
+ "Pairing",
2172
+ "attach_pairing",
2173
+ "find_pairing_field",
2174
+ "PairingMapping",
2175
+ "load_pairing_mapping",
2176
+ "pairing_table",
2177
+ "pairing_provenance",
2178
+ "write_pairing",
2179
+ "format_partition",
2180
+ "relative_depth",
2181
+ "summarize_run",
2182
+ "build_comparison",
2183
+ "render_pdf",
2184
+ "run_report",
2185
+ "load_signals",
2186
+ "CATEGORIES",
2187
+ "reference_run",
2188
+ "comparable_documents",
2189
+ "categorize",
2190
+ "select_examples",
2191
+ "render_examples",
2192
+ "write_examples",
2193
+ ]