File size: 42,791 Bytes
a0e2620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3329d00
a0e2620
3329d00
a0e2620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3329d00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e2620
 
 
 
 
3329d00
 
 
 
a0e2620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
"""Small, reproducible benchmark adapters for pure diffusion evaluation."""
from __future__ import annotations

import json
import math
import random
import re
import subprocess
import sys
import tempfile
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from statistics import median
from typing import Any, Callable

import torch

from .generation_prompts import DEFAULT_GENERATION_PROMPTS
from .metrics import distinct_n


MC_TASKS = {"mmlu", "mmlu_pro", "hellaswag", "arc_c", "gpqa"}
SUBJECT_CATEGORY_TASKS = {"mmlu", "mmlu_pro"}
ALL_TASKS = ["mmlu", "mmlu_pro", "hellaswag", "arc_c", "gsm8k", "math", "gpqa", "humaneval", "mbpp", "bidirectional_infilling"]
OPEN_ENDED_TASK = "open_ended"
BIDIRECTIONAL_INFILLING_TASK = "bidirectional_infilling"
AVAILABLE_TASKS = [*ALL_TASKS, OPEN_ENDED_TASK]
BENCHMARK_SAMPLE_SEED = 1234
DIFFUSION_SAMPLERS = {"denoise_stream", "llada_official"}

# Published pure-diffusion settings for LLaDA-8B-Instruct (paper Appendix B.4
# and the official OpenCompass reproduction configs).  The paper profiles use
# one full generation block, so they contain no semi-autoregressive decoding.
LLADA_INSTRUCT_TASK_SETTINGS: dict[str, dict[str, Any]] = {
    "mmlu": {"max_new_tokens": 3, "num_steps": 3, "block_length": 3},
    "mmlu_pro": {"max_new_tokens": 256, "num_steps": 256, "block_length": 256},
    "hellaswag": {"max_new_tokens": 3, "num_steps": 3, "block_length": 3},
    "arc_c": {"max_new_tokens": 512, "num_steps": 512, "block_length": 512},
    "gsm8k": {"max_new_tokens": 512, "num_steps": 512, "block_length": 512, "confidence_eos_eot_inf": True},
    "math": {"max_new_tokens": 512, "num_steps": 512, "block_length": 512, "confidence_eos_eot_inf": True},
    "gpqa": {"max_new_tokens": 64, "num_steps": 64, "block_length": 64, "confidence_eos_eot_inf": True},
    "humaneval": {"max_new_tokens": 512, "num_steps": 512, "block_length": 512, "logits_eos_inf": True},
    "mbpp": {"max_new_tokens": 256, "num_steps": 256, "block_length": 256, "confidence_eos_eot_inf": True},
}


def _path_slug(value: str) -> str:
    """Turn a model/task label into a stable, filesystem-safe component."""
    slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip("-.").lower()
    return slug or "unnamed"


class BenchmarkRunReporter:
    """Write one benchmark invocation into an isolated, structured directory."""

    schema_version = 1

    def __init__(self, results_dir: str | Path, config: dict[str, Any], run_name: str | None = None):
        self.started_at = datetime.now(timezone.utc)
        timestamp = self.started_at.strftime("%Y%m%dT%H%M%S.%fZ")
        self.run_id = timestamp + (f"--{_path_slug(run_name)}" if run_name else "")
        self.path = Path(results_dir) / self.run_id
        self.path.mkdir(parents=True, exist_ok=False)
        self.config = config
        self.summaries: list[dict[str, Any]] = []
        self._write_manifest("running")

    def _write_json(self, path: Path, value: Any) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(value, indent=2, ensure_ascii=False, default=str) + "\n")

    def _write_manifest(self, status: str, completed_at: str | None = None) -> None:
        manifest = {
            "schema_version": self.schema_version,
            "run_id": self.run_id,
            "status": status,
            "started_at": self.started_at.isoformat(),
            "completed_at": completed_at,
            "config": self.config,
        }
        self._write_json(self.path / "run.json", manifest)

    def group_path(self, model: str, task: str, method: str) -> Path:
        """Return the directory for one model/task/method result group."""
        return self.path / "models" / _path_slug(model) / _path_slug(task) / _path_slug(method)

    def save_result(self, result: dict[str, Any]) -> None:
        """Append one example only to its model/task/method result file."""
        path = self.group_path(result["model"], result["task"], result["method"]) / "results.jsonl"
        path.parent.mkdir(parents=True, exist_ok=True)
        save_result(path, result)

    def save_summary(self, summary: dict[str, Any]) -> None:
        """Save a group summary and retain it for run/model rollups."""
        self.summaries.append(summary)
        path = self.group_path(summary["model"], summary["task"], summary["method"]) / "summary.json"
        self._write_json(path, summary)

    def save_run_json(self, filename: str, value: Any) -> None:
        """Save a structured artifact at the root of this benchmark run."""
        self._write_json(self.path / filename, value)

    def save_run_records(self, filename: str, records: list[dict[str, Any]]) -> None:
        """Save newline-delimited records at the root of this benchmark run."""
        path = self.path / filename
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w", encoding="utf-8") as stream:
            for record in records:
                stream.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")

    def complete(self) -> Path:
        """Write model and run rollups, then mark the invocation complete."""
        by_model: dict[str, list[dict[str, Any]]] = {}
        for summary in self.summaries:
            by_model.setdefault(str(summary["model"]), []).append(summary)
        models = []
        for model, summaries in by_model.items():
            model_summary = {"model": model, "results": summaries}
            models.append(model_summary)
            self._write_json(self.path / "models" / _path_slug(model) / "summary.json", model_summary)
        completed_at = datetime.now(timezone.utc).isoformat()
        self._write_json(self.path / "summary.json", {
            "schema_version": self.schema_version,
            "run_id": self.run_id,
            "started_at": self.started_at.isoformat(),
            "completed_at": completed_at,
            "models": models,
        })
        self._write_manifest("completed", completed_at)
        return self.path


def resolve_generation_settings(config: dict[str, Any], task: str, mode: str) -> dict[str, Any]:
    """Resolve generation settings for a task and corruption mode."""
    settings = dict(config.get("generation", {}))
    mode_settings = config.get("generation_by_corruption", {}).get(mode, {})
    if mode == "legacy" and not mode_settings:
        mode_settings = config.get("generation_by_corruption", {}).get("structured", {})
    settings.update(mode_settings)
    settings.update(config.get("task_generation", {}).get(task, {}))
    settings.update(config.get("task_generation_by_corruption", {}).get(mode, {}).get(task, {}))
    if mode == "mask_only":
        # Mask-only training is evaluated with the full-remasking setup used
        # by the training-time generation validation. Retention behavior stays
        # explicitly configurable when the denoise-stream sampler is selected.
        settings["noise_level"] = 1.0
        settings.setdefault("permanent_unmask", True)
        settings.setdefault("confidence_guided", True)
    if "diffusion_sampler" in config:
        settings["sampler"] = config["diffusion_sampler"]
    if "sampler" in settings:
        sampler = str(settings["sampler"])
        if sampler not in DIFFUSION_SAMPLERS:
            raise ValueError(
                f"diffusion sampler must be one of {sorted(DIFFUSION_SAMPLERS)}; received {sampler!r}"
            )
        settings["sampler"] = sampler
    return settings


def resolve_autoregressive_generation_settings(config: dict[str, Any], task: str) -> dict[str, Any]:
    """Resolve settings for the independent autoregressive baseline."""
    settings = dict(config.get("autoregressive_generation", {}))
    settings.update(config.get("autoregressive_task_generation", {}).get(task, {}))
    settings.setdefault("max_new_tokens", 256)
    return settings


def resolve_llada_generation_settings(config: dict[str, Any], task: str) -> dict[str, Any]:
    """Resolve selectable denoise-stream or official decoding for hosted LLaDA."""
    settings = resolve_generation_settings(config, task, "mask_only")
    profile = LLADA_INSTRUCT_TASK_SETTINGS.get(task, {})
    settings.update(profile)
    family_settings = dict(config.get("llada_generation", {}))
    task_settings = dict(config.get("llada_task_generation", {}).get(task, {}))
    sampler_settings = dict(settings)
    sampler_settings.update(family_settings)
    sampler_settings.update(task_settings)
    sampler = str(config.get("diffusion_sampler", sampler_settings.get("sampler", "llada_official")))
    if sampler not in DIFFUSION_SAMPLERS:
        raise ValueError(
            f"diffusion sampler must be one of {sorted(DIFFUSION_SAMPLERS)}; received {sampler!r}"
        )
    official_defaults = {
        "sampler": "llada_official",
        "temperature": 0.0,
        "cfg_scale": 0.0,
        "remasking": "low_confidence",
        "logits_eos_inf": bool(profile.get("logits_eos_inf", False)),
        "confidence_eos_eot_inf": bool(profile.get("confidence_eos_eot_inf", False)),
        "eot_token_id": 126348,
        "proportional_unmask": False,
    }
    if sampler == "llada_official":
        # Official defaults supersede generic denoise-stream settings, while
        # explicit family/task overrides retain their existing precedence.
        settings.update(official_defaults)
        settings.update(family_settings)
        settings.update(task_settings)
        for unused in (
            "noise_level", "top_k", "permanent_unmask", "confidence_guided",
            "early_stopping", "freeze_retained_tokens",
        ):
            settings.pop(unused, None)
        settings["proportional_unmask"] = False
    else:
        # Family-wide llada_generation contains official-only controls. Shared
        # denoise controls come from generation/generation_by_corruption, while
        # task overrides remain useful to both samplers.
        settings.update(task_settings)
    settings["sampler"] = sampler
    settings["block_length"] = int(settings.get("block_length", settings.get("max_new_tokens", 128)))
    return settings


def resolve_mask_only_generation_settings(config: dict[str, Any], task: str) -> dict[str, Any]:
    """Resolve selectable denoise-stream or official decoding for a mask-only adapter."""
    settings = resolve_generation_settings(config, task, "mask_only")
    profile = LLADA_INSTRUCT_TASK_SETTINGS.get(task, {})
    settings.update(profile)
    family_settings = dict(config.get("mask_only_generation", {}))
    task_settings = dict(config.get("mask_only_task_generation", {}).get(task, {}))
    sampler_settings = dict(settings)
    sampler_settings.update(family_settings)
    sampler_settings.update(task_settings)
    sampler = str(config.get("diffusion_sampler", sampler_settings.get("sampler", "llada_official")))
    if sampler not in DIFFUSION_SAMPLERS:
        raise ValueError(
            f"diffusion sampler must be one of {sorted(DIFFUSION_SAMPLERS)}; received {sampler!r}"
        )
    official_defaults = {
        "sampler": "llada_official",
        "temperature": 0.0,
        "cfg_scale": 0.0,
        "remasking": "low_confidence",
        "logits_eos_inf": bool(profile.get("logits_eos_inf", False)),
        "confidence_eos_eot_inf": bool(profile.get("confidence_eos_eot_inf", False)),
        "proportional_unmask": False,
    }
    if sampler == "llada_official":
        settings.update(official_defaults)
        settings.update(family_settings)
        settings.update(task_settings)
        for unused in (
            "noise_level", "top_k", "permanent_unmask", "confidence_guided",
            "early_stopping", "freeze_retained_tokens",
        ):
            settings.pop(unused, None)
        settings["proportional_unmask"] = False
    else:
        settings.update(task_settings)
    settings["sampler"] = sampler
    settings["block_length"] = int(settings.get("block_length", settings.get("max_new_tokens", 128)))
    return settings

# Fixed prompts make comparisons between runs reproducible.  `limit` can be
# used to evaluate a smaller prefix, while the default benchmark config uses
# all 30 questions.
OPEN_ENDED_PROMPTS = list(DEFAULT_GENERATION_PROMPTS)


@dataclass
class BenchmarkExample:
    """Normalized benchmark item consumed by both diffusion and AR evaluators."""
    task: str
    example_id: str
    prompt: str
    answer: str
    kind: str
    metadata: dict[str, Any]


def _choice_prompt(name: str, question: str, choices: list[Any], category: str | None = None) -> str:
    """Format multiple choice and explicitly request an extractable answer label."""
    letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    options = "\n".join(f"{letters[i]}: {choice}" for i, choice in enumerate(choices))
    if name in {"mmlu_pro", "gpqa"}:
        answer_format = (
            "Think through the problem concisely, then end with exactly one final line containing "
            "`ANSWER:` followed by the correct option label. Put no option text or punctuation "
            "after the label on that line."
        )
    elif name == "mmlu":
        answer_format = "Start your response with the correct option label followed by a colon."
    else:
        answer_format = "Start your response with the correct option label followed by a colon, for example `A:`."
    if name == "hellaswag":
        instruction = f"Choose the option that most plausibly continues the described event. {answer_format}"
        task_input = f"Beginning of the event:\n{question.strip()}\n\nWhat most plausibly happens next?\n{options}"
    else:
        instruction = f"Answer the following multiple-choice question. {answer_format}"
        task_input = f"{question.strip()}\n\n{options}"
    if category and str(category).strip():
        category_label = str(category).strip().replace("_", " ")
        instruction += f" Subject category: {category_label}."
    return f"{instruction}\n\n{task_input}"


def _multiple_choice_fields(name: str, row: dict[str, Any], index: int) -> tuple[str, list[Any], str]:
    """Normalize task-specific question, choice, and answer schemas."""
    letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    if name == "hellaswag":
        question = row.get("ctx", "")
        choices = row.get("endings")
        answer = row.get("label")
    elif name == "arc_c":
        question = row.get("question", "")
        choice_group = row.get("choices") or {}
        choices = choice_group.get("text") if isinstance(choice_group, dict) else choice_group
        labels = [str(label) for label in choice_group.get("label", [])] if isinstance(choice_group, dict) else []
        answer_key = str(row.get("answerKey", ""))
        answer = letters[labels.index(answer_key)] if answer_key in labels else answer_key
    elif name == "gpqa" and not row.get("choices") and not row.get("options"):
        question = row.get("Question", row.get("question", ""))
        choices = [row["Correct Answer"], row["Incorrect Answer 1"], row["Incorrect Answer 2"], row["Incorrect Answer 3"]]
        correct = choices[0]
        # Make option order stable for a question even when evaluating a
        # different subset, whose local enumeration indices may change.
        random.Random(f"gpqa:{question}").shuffle(choices)
        answer = letters[choices.index(correct)]
    else:
        question = row.get("question", row.get("Question", row.get("query", row.get("ctx", ""))))
        choices = row.get("choices", row.get("options"))
        answer = row.get("answer", row.get("answerKey", row.get("label", row.get("answer_index"))))
    if not isinstance(choices, (list, tuple)) or not choices:
        raise ValueError(f"{name} example {index} has no usable answer choices")
    if isinstance(answer, int) or str(answer).isdigit():
        answer_index = int(answer)
        if not 0 <= answer_index < len(choices):
            raise ValueError(f"{name} example {index} has out-of-range answer index {answer_index}")
        answer = letters[answer_index]
    return str(question), list(choices), str(answer).upper()


def _boxed(text: str) -> str:
    """Extract the last boxed/math answer, including nested LaTeX braces."""
    text = text or ""
    openings = list(re.finditer(r"\\(?:boxed|fbox)\s*\{", text))
    for opening in reversed(openings):
        start = opening.end()
        depth = 1
        for index in range(start, len(text)):
            if text[index] == "{":
                depth += 1
            elif text[index] == "}":
                depth -= 1
                if depth == 0:
                    return text[start:index].strip()
    hashes = re.findall(r"####\s*([^\n]+)", text)
    return hashes[-1].strip() if hashes else text.strip()


def _last_number(text: str) -> str:
    """Extract the final numeric candidate, following common GSM8K evaluation."""
    candidates = re.findall(r"[-+]?(?:\d[\d,]*\.?\d*|\.\d+)(?:[eE][-+]?\d+)?", text or "")
    return candidates[-1].replace(",", "").rstrip(".") if candidates else ""


def _normalize_math_answer(text: str) -> str:
    """Normalize a generated or reference final MATH answer for comparison."""
    has_box = re.search(r"\\(?:boxed|fbox)\s*\{", text) is not None
    value = _boxed(text).strip()
    if not has_box:
        # Accept only a terminal inline expression as a fallback. This recovers
        # answers such as "Therefore ... $(3, \\frac{\\pi}{2}).$" without
        # accidentally selecting an intermediate expression from a rationale.
        terminal_math = re.search(r"\$([^$\n]+)\$\s*[.!]?\s*\Z", value)
        if terminal_math:
            value = terminal_math.group(1).strip()
    answer_match = re.search(r"(?is)(?:final\s+answer|answer)\s*(?:is|:)\s*(.+)$", value)
    if answer_match:
        value = answer_match.group(1).strip()
    value = re.sub(r"^\$|\$$", "", value.strip())
    value = value.rstrip(".。;,!").strip()
    value = value.replace("\\left", "").replace("\\right", "")
    # Repair duplicated command escapes occasionally emitted by diffusion
    # decoding, while retaining legitimate LaTeX row separators such as `\\`.
    value = re.sub(r"\\\\(?=[A-Za-z])", r"\\", value)
    value = re.sub(r"\s+", "", value)
    # Remove commas only inside conventional thousands-grouped numerals. A
    # blanket removal corrupts tuples, coordinate pairs, intervals, and sets.
    value = re.sub(
        r"(?<![\d,])([+-]?\d{1,3}(?:,\d{3})+)(?![\d,])",
        lambda match: match.group(1).replace(",", ""),
        value,
    )
    # Normalize common answer-only presentation variants without attempting
    # broad unit conversion. Redundant grouping braces and degree notation do
    # not change the mathematical value of these terminal answers.
    while value.startswith("{") and value.endswith("}"):
        depth = 0
        encloses_all = True
        for index, character in enumerate(value):
            if character == "{":
                depth += 1
            elif character == "}":
                depth -= 1
                if depth == 0 and index != len(value) - 1:
                    encloses_all = False
                    break
        if not encloses_all or depth != 0:
            break
        value = value[1:-1]
    value = re.sub(r"(?:\^\{?\\circ\}?|\\circ|°|degrees?)\Z", "", value, flags=re.IGNORECASE)
    return value


def _numeric_answers_equal(left: str, right: str) -> bool:
    """Compare normalized decimal answers exactly when both are numeric."""
    try:
        return Decimal(left) == Decimal(right)
    except InvalidOperation:
        return False


def _math_answers_equal(prediction: str, target: str) -> bool:
    """Use symbolic MATH verification when installed, with a strict fallback."""
    normalized_prediction = _normalize_math_answer(prediction)
    normalized_target = _normalize_math_answer(target)
    if normalized_prediction == normalized_target or _numeric_answers_equal(normalized_prediction, normalized_target):
        return True
    try:
        from math_verify import parse, verify

        return bool(verify(parse(target), parse(prediction)))
    except (ImportError, TypeError, ValueError):
        return False


def _sample_indices(size: int, limit: int | None = None, limit_fraction: float | None = None, shuffle: bool = False) -> list[int]:
    """Select a prefix/fraction, optionally shuffling grouped datasets first."""
    if limit is not None and limit_fraction is not None:
        raise ValueError("Set either limit or limit_fraction, not both")
    if limit_fraction is not None:
        fraction = float(limit_fraction)
        if not 0.0 < fraction <= 1.0:
            raise ValueError("limit_fraction must be greater than 0 and at most 1")
        count = min(size, max(1, math.ceil(size * fraction))) if size else 0
    elif limit is not None:
        count = int(limit)
        if count < 1:
            raise ValueError("limit must be positive")
        count = min(count, size)
    else:
        count = size
    if count >= size:
        return list(range(size))
    if shuffle:
        indices = list(range(size))
        random.Random(BENCHMARK_SAMPLE_SEED).shuffle(indices)
        return indices[:count]
    if limit_fraction is not None:
        return [(index * size) // count for index in range(count)]
    return list(range(count))


def _benchmark_spec(name: str, split: str) -> tuple[str, str | None, str]:
    """Resolve the dataset configuration and locally scoreable task split."""
    specs = {
        "mmlu": ("cais/mmlu", "all", split),
        "mmlu_pro": ("TIGER-Lab/MMLU-Pro", None, split),
        # HellaSwag's public test labels are withheld, so validation is the
        # standard locally-scoreable evaluation split.
        "hellaswag": ("Rowan/hellaswag", None, "validation" if split == "test" else split),
        "arc_c": ("allenai/ai2_arc", "ARC-Challenge", "test" if split == "test" else split),
        "gsm8k": ("openai/gsm8k", "main", split),
        "math": ("HuggingFaceH4/MATH-500", None, "test" if split == "test" else split),
        # The Hugging Face GPQA release exposes its 448 benchmark examples as
        # `train`; they are the evaluation set, not model-training data here.
        "gpqa": ("Idavidrein/gpqa", "gpqa_main", "train"),
        "humaneval": ("openai/openai_humaneval", None, split),
        "mbpp": ("google-research-datasets/mbpp", "sanitized", split),
    }
    if name not in specs:
        raise ValueError(f"Unknown benchmark {name}; available: {AVAILABLE_TASKS}")
    return specs[name]


def _mbpp_prompt(row: dict[str, Any]) -> str:
    """Build a test-informed MBPP prompt that emphasizes exact semantics."""
    description = str(row.get("text") or row.get("prompt") or "").strip()
    test_imports = [str(statement) for statement in (row.get("test_imports") or [])]
    tests = [str(test) for test in (row.get("test_list") or [])]
    sections = [description]
    if test_imports or tests:
        test_block = "\n".join(test_imports + tests)
        sections.append(
            "Your function must use the name and interface demonstrated by these tests:\n"
            f"```python\n{test_block}\n```"
        )
    sections.append(
        "Carefully infer the exact required behavior from the description and every assertion. "
        "Pay particular attention to the exact function name and number of positional arguments; "
        "words such as remove/keep, first/last/all, and ascending/descending; and the direction of "
        "arithmetic relationships. Silently check the implementation against every shown assertion "
        "before answering.\n\n"
        "Return exactly one complete Markdown code block tagged `python`. Do not write any text "
        "outside that block."
    )
    return "\n\n".join(section for section in sections if section)


def _humaneval_prompt(prompt: str) -> str:
    """Wrap canonical HumanEval source for instruction-tuned chat models."""
    return (
        "Implement the Python function described below. Preserve the exact function name, signature, "
        "and return type. Carefully follow the entire docstring, including edge cases and examples. "
        "Silently trace the implementation against every shown example before answering.\n\n"
        "Return exactly one complete Markdown code block tagged `python`, containing the complete "
        "function and any required imports. Do not write any text outside that block.\n\n"
        "Function specification:\n\n"
        + prompt.strip()
    )


def _math_prompt(problem: str) -> str:
    """Request checked, concise reasoning followed by an exact answer marker."""
    return (
        "Solve the following mathematics problem step by step. Keep the reasoning concise. "
        "Check every arithmetic and algebraic step, and verify that the final result satisfies "
        "all conditions in the problem. Simplify fractions, radicals, and expressions completely.\n\n"
        "End with exactly one final line in this format:\n\n"
        "FINAL: \\boxed{answer}\n\n"
        "Put only the answer inside the box. Do not omit the final line.\n\n"
        "Problem:\n\n" + problem.strip()
    )


def _gsm8k_prompt(question: str) -> str:
    """Request GSM8K reasoning followed by its canonical numeric answer marker."""
    return (
        "Solve the following math problem step by step. End your response with a final line in the "
        "form `#### number`, containing only the final numeric answer after `####`.\n\n"
        + question.strip()
    )


def _bidirectional_infilling_examples() -> list[BenchmarkExample]:
    """Build a deterministic suffix-dependent, contamination-free diagnostic.

    Targets are synthetic identifiers or nonce labels repeated only in the
    visible suffix.  A bidirectional denoiser can recover them in place, while
    a causal model scoring the same target positions cannot inspect that
    suffix.  The two templates avoid reducing the benchmark to one surface
    form while keeping its dependency mechanically auditable.
    """
    objects = [
        "bronze compass", "cedar box", "glass astrolabe", "silver lantern", "linen map",
        "marble key", "copper telescope", "porcelain seal", "leather folio", "ivory dial",
    ]
    nonce_labels = [
        "velora", "tavren", "mirel", "sovik", "caldra",
        "norven", "pelith", "darsen", "quorin", "zareph",
    ]
    anchors = [
        "amber marker", "blue arch", "central gate", "delta stone", "eastern post",
        "forest sign", "granite tower", "harbor bell", "iron column", "jade flag",
    ]
    examples: list[BenchmarkExample] = []
    instruction = (
        "Recover the missing span in the assistant response. Use the visible "
        "text on both sides of the missing span."
    )
    for index in range(50):
        object_name = objects[index % len(objects)]
        code = f"{chr(65 + (index * 7) % 26)}{(37 * index + 11) % 100:02d}{chr(65 + (index * 11 + 3) % 26)}"
        target = f" {code}"
        metadata = {
            "subset": "identifier_copy",
            "answer_prefix": f"The archive lists the access code for the {object_name} as",
            "target_text": target,
            "answer_suffix": f". A later verification note confirms that the {object_name}'s access code is {code}.",
        }
        examples.append(BenchmarkExample(
            BIDIRECTIONAL_INFILLING_TASK, f"identifier-{index:03d}", instruction,
            target.strip(), "bidirectional_infilling", metadata,
        ))
    for index in range(50):
        target_label = nonce_labels[index % len(nonce_labels)] + str((index * 13 + 5) % 97)
        anchor = anchors[index % len(anchors)]
        final_label = nonce_labels[(index + 3) % len(nonce_labels)] + str((index * 17 + 9) % 97)
        target = f" {target_label}"
        metadata = {
            "subset": "ordered_relation",
            "answer_prefix": f"In the recorded sequence, the label immediately before the {anchor} is",
            "target_text": target,
            "answer_suffix": f". The complete left-to-right order is {target_label}, the {anchor}, then {final_label}.",
        }
        examples.append(BenchmarkExample(
            BIDIRECTIONAL_INFILLING_TASK, f"relation-{index:03d}", instruction,
            target.strip(), "bidirectional_infilling", metadata,
        ))
    return examples


def load_benchmark(name: str, split: str, limit: int | None, cache_dir: str, token: str | None, limit_fraction: float | None = None) -> list[BenchmarkExample]:
    """Download one configured benchmark split and normalize its records."""
    if name == OPEN_ENDED_TASK:
        indices = _sample_indices(len(OPEN_ENDED_PROMPTS), limit, limit_fraction)
        return [BenchmarkExample(name, str(index), OPEN_ENDED_PROMPTS[index], "", "open_ended", {}) for index in indices]
    if name == BIDIRECTIONAL_INFILLING_TASK:
        examples = _bidirectional_infilling_examples()
        indices = _sample_indices(len(examples), limit, limit_fraction)
        return [examples[index] for index in indices]
    from datasets import load_dataset
    path, config, actual_split = _benchmark_spec(name, split)
    dataset = load_dataset(path, config, split=actual_split, cache_dir=cache_dir, token=token)
    indices = _sample_indices(len(dataset), limit, limit_fraction, shuffle=name in SUBJECT_CATEGORY_TASKS)
    if len(indices) != len(dataset):
        dataset = dataset.select(indices)
    items = []
    for index, row in enumerate(dataset):
        if name in MC_TASKS:
            question, choices, answer = _multiple_choice_fields(name, row, index)
            answer_index = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".index(answer)
            target = f"{answer}: {choices[answer_index]}"
            category = row.get("subject", row.get("category")) if name in SUBJECT_CATEGORY_TASKS else None
            items.append(BenchmarkExample(name, str(index), _choice_prompt(name, question, choices, category), target, "multiple_choice", row))
        elif name == "gsm8k":
            prompt = _gsm8k_prompt(row["question"])
            items.append(BenchmarkExample(name, str(index), prompt, row["answer"].strip(), "gsm8k", row))
        elif name == "math":
            problem = row.get("problem", row.get("question", ""))
            solution = row.get("solution", row.get("answer", ""))
            prompt = _math_prompt(problem)
            items.append(BenchmarkExample(name, str(index), prompt, solution.strip(), "math", row))
        elif name == "humaneval":
            items.append(BenchmarkExample(name, str(index), _humaneval_prompt(row["prompt"]), row.get("canonical_solution", ""), "code", row))
        elif name == "mbpp":
            items.append(BenchmarkExample(name, str(index), _mbpp_prompt(row), row.get("code", ""), "code", row))
    return items


def _declared_option_text_matches(text: str, reference: str) -> bool:
    """Match a terminal textual ANS/ANSWER against one labelled reference option."""
    declarations = re.findall(r"(?im)^\s*(?:ANS|ANSWER)\s*:\s*(.*?)\s*$", text or "")
    reference_match = re.match(r"^\s*[A-Z]\s*:\s*(.+?)\s*$", reference or "", flags=re.DOTALL)
    if not declarations or not reference_match:
        return False

    def normalize(value: str) -> str:
        value = re.sub(r"\s+", " ", value.strip()).casefold()
        return value.rstrip(" .。;,:!?")

    return normalize(declarations[-1]) == normalize(reference_match.group(1))


def extract_answer(text: str, kind: str, reference: str | None = None) -> str:
    """Extract a comparable answer from free-form model output."""
    if kind == "multiple_choice":
        # Prefer the requested leading `A: ...` format. If a model ignores that
        # instruction, accept only an explicit answer declaration rather than
        # searching for an arbitrary capital letter later in its explanation.
        match = re.match(r"\s*([A-Z])(?=\s*(?::|[.)-]|$))", text.upper())
        if match:
            return match.group(1)
        answer_line = re.search(r"(?im)^\s*ANSWER\s*:\s*[*_`(\[]*([A-Z])(?=\s*(?::|[.)\]`*_]|$))", text)
        if answer_line:
            return answer_line.group(1).upper()
        declared = re.search(
            r"\b(?:THE\s+)?(?:CORRECT\s+)?ANSWER\s+(?:IS|WOULD\s+BE)\s+"
            r"(?:OPTION\s+)?[*_`(\[]*([A-Z])(?=\s*(?::|[.)\]-]|$))",
            text.upper(),
        )
        if declared:
            return declared.group(1)
        option = re.search(
            r"\b(?:CHOOSE|SELECT)\s+(?:OPTION\s+)?[*_`(\[]*([A-Z])"
            r"(?=\s*(?::|[.)\]-]|$))",
            text.upper(),
        )
        if option:
            return option.group(1)
        if reference and _declared_option_text_matches(text, reference):
            return extract_answer(reference, kind)
        return ""
    if kind == "gsm8k":
        return _last_number(_boxed(text))
    if kind == "math":
        return _normalize_math_answer(text)
    return text.strip()


def _extract_python_code(candidate: str, entry_point: str | None = None) -> str:
    """Extract a Python block while preserving body-completion indentation."""
    fenced = re.findall(r"```(?:python|py)?\s*\n?(.*?)```", candidate, flags=re.IGNORECASE | re.DOTALL)
    if fenced:
        if entry_point:
            definition = re.compile(rf"(?m)^\s*(?:async\s+)?def\s+{re.escape(entry_point)}\s*\(")
            matching = next((block for block in fenced if definition.search(block)), None)
            candidate = matching if matching is not None else fenced[0]
        else:
            candidate = fenced[0]
    else:
        # Remove a standalone final closing fence before looking for an
        # unterminated opening fence; otherwise the closing fence itself would
        # be mistaken for the opening and all preceding Python would be lost.
        candidate = re.sub(r"\n?[ \t]*```[ \t]*\Z", "", candidate)
        # Also handle an unterminated Markdown fence, which is common when a
        # fixed generation budget cuts off just after otherwise valid code.
        opening = re.search(r"```(?:python|py)?\s*\n?", candidate, flags=re.IGNORECASE)
        if opening:
            candidate = candidate[opening.end():]
        elif entry_point:
            # If prose precedes a complete function, discard only that prose.
            definition = re.search(rf"(?m)^\s*(?:async\s+)?def\s+{re.escape(entry_point)}\s*\(", candidate)
            if definition:
                candidate = candidate[definition.start():]
    return candidate.strip("\n")


def _run_code(candidate: str, example: BenchmarkExample, timeout: float = 10.0) -> bool:
    """Execute one generated code answer with its benchmark tests in a timeout."""
    metadata = example.metadata
    if example.task == "humaneval":
        entry_point = str(metadata["entry_point"])
        candidate = _extract_python_code(candidate, entry_point)
        full_function = re.search(
            rf"(?m)^\s*(?:async\s+)?def\s+{re.escape(entry_point)}\s*\(", candidate
        )
        if full_function:
            solution = candidate
        else:
            # HumanEval's canonical answer is a function-body completion. Join
            # it to the benchmark prompt exactly as the reference harness does.
            completion = candidate
            first_line = next((line for line in completion.splitlines() if line.strip()), "")
            if first_line and not first_line[:1].isspace():
                completion = "\n".join(f"    {line}" if line else line for line in completion.splitlines())
            prompt = str(metadata["prompt"])
            solution = prompt + ("" if prompt.endswith("\n") else "\n") + completion.lstrip("\n")
        program = solution + "\n\n" + metadata["test"] + f"\ncheck({entry_point})\n"
    else:
        candidate = _extract_python_code(candidate)
        tests = metadata.get("test_list", [])
        setup_parts = metadata.get("test_imports", []) or []
        legacy_setup = metadata.get("test_setup_code", "")
        if legacy_setup:
            setup_parts = [*setup_parts, legacy_setup]
        setup = "\n".join(str(statement) for statement in setup_parts)
        program = setup + "\n" + candidate + "\n" + "\n".join(tests)
    with tempfile.TemporaryDirectory(prefix="diffusion-lm-eval-") as directory:
        path = Path(directory) / "candidate.py"
        path.write_text(program)
        try:
            result = subprocess.run([sys.executable, "-I", str(path)], capture_output=True, timeout=timeout, cwd=directory)
            return result.returncode == 0
        except (subprocess.TimeoutExpired, OSError):
            return False


def score_prediction(example: BenchmarkExample, generated: str) -> bool:
    """Score one normalized prediction with exact-match or benchmark tests."""
    if example.kind == "multiple_choice":
        return extract_answer(generated, example.kind, example.answer) == extract_answer(example.answer, example.kind)
    if example.kind == "gsm8k":
        prediction = extract_answer(generated, example.kind)
        target = extract_answer(example.answer, example.kind)
        return prediction == target or _numeric_answers_equal(prediction, target)
    if example.kind == "math":
        return _math_answers_equal(generated, example.answer)
    return _run_code(generated, example)


def save_result(path: Path, result: dict[str, Any]) -> None:
    """Append one per-example benchmark result as JSONL."""
    with path.open("a") as stream:
        stream.write(json.dumps(result, ensure_ascii=False, default=str) + "\n")


@torch.no_grad()
def score_texts_with_model(model: Any, tokenizer: Any, device: torch.device, texts: list[str]) -> dict[str, Any]:
    """Score texts with one fixed causal reference model.

    This deliberately does not disable adapters or restore normalization
    parameters: the supplied model is the shared perplexity reference model.
    """
    import torch.nn.functional as F

    total_nll = 0.0
    total_tokens = 0
    per_text = []
    model.eval()
    for text in texts:
        encoded = tokenizer(text, return_tensors="pt", add_special_tokens=True)
        input_ids = encoded["input_ids"].to(device)
        if input_ids.shape[1] < 2:
            perplexity = None
        else:
            outputs = model(input_ids=input_ids, use_cache=False)
            labels = input_ids[:, 1:]
            logits = outputs.logits[:, :-1].float()
            nll = F.cross_entropy(logits.transpose(1, 2), labels, reduction="sum")
            text_nll = float(nll.cpu())
            text_tokens = int(labels.numel())
            total_nll += text_nll
            total_tokens += text_tokens
            perplexity = float(torch.exp(torch.tensor(text_nll / text_tokens)))
        per_text.append({
            "perplexity": perplexity,
        })
    mean_nll = total_nll / max(total_tokens, 1)
    valid_perplexities = [item["perplexity"] for item in per_text if item["perplexity"] is not None]
    return {
        "perplexity": float(torch.exp(torch.tensor(mean_nll))),
        "mean_perplexity": float(sum(valid_perplexities) / len(valid_perplexities)) if valid_perplexities else None,
        "median_perplexity": float(median(valid_perplexities)) if valid_perplexities else None,
        "mean_nll": mean_nll,
        "tokens": total_tokens,
        "per_text": per_text,
    }


@torch.no_grad()
def score_open_ended_generations(session: Any, texts: list[str]) -> dict[str, Any]:
    """Score generated texts with base-model perplexity and Distinct-n metrics.

    Perplexity is measured with adapters disabled and the saved initial
    normalization weights restored, matching training-time generation
    perplexity.  The aggregate perplexity is token-weighted; each text also
    receives its own perplexity in ``per_text``.
    """
    import torch.nn.functional as F

    model = session.model
    tokenizer = session.tokenizer
    trained_norms = {name: parameter.detach().cpu().clone() for name, parameter in model.named_parameters() if "norm" in name.lower()}
    initial_path = Path(session.adapter_path) / "normalization_initial_state.pt"
    initial_norms = torch.load(initial_path, map_location="cpu", weights_only=True) if initial_path.is_file() else trained_norms
    total_nll = 0.0
    total_tokens = 0
    per_text = []
    try:
        named = dict(model.named_parameters())
        for name, value in initial_norms.items():
            if name in named:
                named[name].data.copy_(value.to(named[name].device, dtype=named[name].dtype))
        adapter_context = model.disable_adapter() if hasattr(model, "disable_adapter") else nullcontext()
        with adapter_context:
            for text in texts:
                encoded = tokenizer(text, return_tensors="pt", add_special_tokens=True)
                input_ids = encoded["input_ids"].to(session.device)
                if input_ids.shape[1] < 2:
                    perplexity = None
                else:
                    outputs = model(input_ids=input_ids) if getattr(session, "llada", False) else model(input_ids=input_ids, use_cache=False)
                    labels = input_ids[:, 1:]
                    logits = outputs.logits[:, :-1].float()
                    nll = F.cross_entropy(logits.transpose(1, 2), labels, reduction="sum")
                    text_nll = float(nll.cpu())
                    text_tokens = int(labels.numel())
                    total_nll += text_nll
                    total_tokens += text_tokens
                    perplexity = float(torch.exp(torch.tensor(text_nll / text_tokens)))
                per_text.append({
                    "perplexity": perplexity,
                    "distinct_1": distinct_n(text, tokenizer, 1),
                    "distinct_2": distinct_n(text, tokenizer, 2),
                    "distinct_3": distinct_n(text, tokenizer, 3),
                })
    finally:
        named = dict(model.named_parameters())
        for name, value in trained_norms.items():
            if name in named:
                named[name].data.copy_(value.to(named[name].device, dtype=named[name].dtype))
    mean_nll = total_nll / max(total_tokens, 1)
    valid_perplexities = [item["perplexity"] for item in per_text if item["perplexity"] is not None]
    return {
        "perplexity": float(torch.exp(torch.tensor(mean_nll))),
        "mean_perplexity": float(sum(valid_perplexities) / len(valid_perplexities)) if valid_perplexities else None,
        "median_perplexity": float(median(valid_perplexities)) if valid_perplexities else None,
        "mean_nll": mean_nll,
        "tokens": total_tokens,
        "per_text": per_text,
    }