File size: 46,634 Bytes
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
 
 
 
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
 
 
 
 
 
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
aad9f16
 
7f8d254
 
 
 
 
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
aad9f16
7f8d254
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
 
 
aad9f16
 
 
 
 
 
 
 
 
 
7f8d254
 
 
 
 
 
 
 
 
 
 
 
 
aad9f16
 
 
 
 
 
7f8d254
 
 
 
 
 
 
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
 
 
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
aad9f16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f8d254
aad9f16
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
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
"""ChartQA evaluation that can run against a model already resident in memory.

The command-line evaluator in :mod:`eval.eval_chartqa` deliberately keeps model
loading at its edge.  Training code should import this module instead, pass its
live student model, and use the returned score for checkpoint selection.

All ranks must invoke :func:`evaluate_chartqa_in_memory` together.  The helper
uses a deterministic contiguous shard and pads its final/empty rank batches
with discarded, real examples.  Consequently every rank enters ``generate``
the same number of times with the same batch geometry, which is important for
ZeRO-3/FSDP generation contexts whose parameter gathers are collective
operations.
"""
from __future__ import annotations

import math
import os
import time
from contextlib import contextmanager, nullcontext
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, ContextManager, Iterable, Mapping, Optional, Sequence

from eval.distributed_eval_utils import distributed_batch_plan


_TEMPLATE_BEHAVIOR_KEYS = (
    "full_cot_template",
    "partial_cot_template",
    "goal_without_answer",
    "empty_cot_skeleton",
    "malformed_answer_section",
)
_OUTPUT_TYPE_KEYS = (
    "char_repeat_guang",
    "goalie",
    "answer_flag",
    "full_cot",
    "other",
)
_ANSWER_MARKER_KEYS = (
    "missing",
    "exactly_once",
    "multiple",
)




@dataclass
class ChartQAEvaluationConfig:
    """Generation settings for :func:`evaluate_chartqa_in_memory`.

    A mapping with the same keys is accepted as well.  ``dummy_batch_factory``
    is an escape hatch for datasets whose first example cannot be reused as a
    valid dummy input.  It is called as
    ``factory(examples=..., batch_index=..., batch_size=..., config=...)`` and
    must return exactly ``batch_size`` already-prepared ChartQA examples.

    ``batch_generator`` is primarily useful for tests or custom model backends.
    Its signature is ``(generation_model, processor, batch, config)`` and it
    must return one generated string per item in ``batch``.
    """

    batch_size: int = 1
    max_new_tokens: int = 1024
    do_sample: bool = False
    temperature: float = 0.0
    top_p: float = 1.0
    repetition_penalty: float = 1.0
    force_left_padding: bool = True
    input_dtype: Any = None
    device: Any = None
    synced_gpus: Optional[bool] = None
    release_cuda_cache: bool = True
    progress_every: int = 0
    prompt_template: Optional[str] = None
    dummy_example: Optional[Mapping[str, Any]] = None
    dummy_batch_factory: Optional[Callable[..., Sequence[Mapping[str, Any]]]] = None
    batch_generator: Optional[Callable[..., Sequence[str]]] = None


def _config_value(config: ChartQAEvaluationConfig | Mapping[str, Any] | None, key: str, default: Any) -> Any:
    if config is None:
        return default
    if isinstance(config, Mapping):
        return config.get(key, default)
    return getattr(config, key, default)


def _empty_template_behavior_counts() -> dict[str, int]:
    return {"total": 0, **{key: 0 for key in _TEMPLATE_BEHAVIOR_KEYS}}


def classify_output_type(text: str) -> str:
    """Keep the historical ChartQA output-type labels used by the CLI logs."""
    text = text or ""
    lower = text.lower()
    if "光" * 6 in text:
        return "char_repeat_guang"
    if "goalie" in lower:
        return "goalie"
    if lower.count("answer:") != 1:
        return "answer_flag"
    if all(key in lower for key in ("goal", "observation", "reasoning")):
        return "full_cot"
    return "other"


def classify_answer_marker_count(text: str) -> str:
    """Classify the exact case-insensitive ``Answer:`` marker count."""
    count = str(text or "").lower().count("answer:")
    if count == 0:
        return "missing"
    if count == 1:
        return "exactly_once"
    return "multiple"

def split_chartqa_initial_context(text: str) -> tuple[str, str]:
    """Match the historical ``split_initial_context`` ChartQA parsing exactly."""
    text = str(text or "").lower()
    flag = "answer:"
    if flag in text:
        answer = text.split(flag)[-1].strip().strip(".")
        context = text.split(flag)[0].strip()
    else:
        context = text
        answer = text
    return context, answer


def _first_answer(value: Any) -> Optional[str]:
    """Return the first ChartQA label without treating a string as characters."""
    if value is None:
        return None
    if isinstance(value, str):
        return value.strip() or None
    try:
        if len(value) == 0:
            return None
        value = value[0]
    except (TypeError, KeyError, IndexError):
        pass
    if value is None:
        return None
    value = str(value).strip()
    return value or None


def prepare_chartqa_examples(dataset: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
    """Normalize raw ChartQA rows or previously prepared rows for generation.

    Raw Hugging Face rows use ``image``, ``query``, and ``label``.  The returned
    shape intentionally mirrors the old evaluator's ``eval_datasets_all_prepared``
    entries so callers can also supply their own pre-prepared examples.
    Invalid rows (missing image, question, or answer) are skipped, matching the
    previous behavior for missing labels.
    """
    if dataset is None:
        raise ValueError("ChartQA evaluation dataset is required")

    prepared: list[dict[str, Any]] = []
    for raw_item in dataset:
        if not isinstance(raw_item, Mapping):
            try:
                raw_item = dict(raw_item)
            except (TypeError, ValueError) as exc:
                raise TypeError("Each ChartQA evaluation item must be mapping-like") from exc

        is_prepared = "model_input_text" in raw_item or "image_path" in raw_item
        image = raw_item.get("image_path") if is_prepared else raw_item.get("image")
        question = raw_item.get("model_input_text") if is_prepared else raw_item.get("query")
        answer = _first_answer(raw_item.get("answer") if is_prepared else raw_item.get("label"))

        if image is None or question is None or answer is None:
            continue

        question = str(question).strip()
        if not question:
            continue
        prepared.append(
            {
                "image_path": image,
                "model_input_text": question,
                "answer": answer,
                "original_question": str(raw_item.get("original_question", question)),
            }
        )
    return prepared


def shard_chartqa_examples(
    examples: Sequence[Mapping[str, Any]], *, num_processes: int, process_index: int
) -> list[Mapping[str, Any]]:
    """Return this rank's exact, contiguous ChartQA shard."""
    if process_index < 0 or process_index >= num_processes:
        raise ValueError(
            f"process_index must be in [0, {num_processes}), got {process_index}"
        )
    plan = distributed_batch_plan(
        total_items=len(examples), num_processes=num_processes, batch_size=1
    )
    start = sum(plan.local_item_counts[:process_index])
    end = start + plan.local_item_counts[process_index]
    return list(examples[start:end])


def _get_prompt_template(config: ChartQAEvaluationConfig | Mapping[str, Any] | None) -> str:
    configured = _config_value(config, "prompt_template", None)
    if configured is not None:
        return str(configured)
    # Import lazily: importing the core must not instantiate a model or force the
    # full training configuration into light-weight unit tests.
    from data_utils.rl_prompt import PROMPT_TEMPLATE

    return PROMPT_TEMPLATE


def _load_rgb_image(image_source: Any) -> Any:
    """Load a path or normalize an already decoded PIL image to RGB."""
    if isinstance(image_source, (str, bytes, os.PathLike, Path)):
        from PIL import Image

        with Image.open(image_source) as image:
            return image.convert("RGB")
    if hasattr(image_source, "convert"):
        return image_source.convert("RGB")
    raise TypeError(
        "ChartQA image must be a filesystem path or an image object with convert('RGB')"
    )


def _try_import_torch() -> Any:
    try:
        import torch
    except ImportError:
        return None
    return torch


def _is_floating_tensor(value: Any) -> bool:
    predicate = getattr(value, "is_floating_point", None)
    if callable(predicate):
        return bool(predicate())
    return bool(predicate) if predicate is not None else False


def _infer_input_dtype(model: Any, configured_dtype: Any) -> Any:
    if configured_dtype is not None:
        if isinstance(configured_dtype, str):
            torch = _try_import_torch()
            if torch is None or not hasattr(torch, configured_dtype):
                raise ValueError(f"Unknown or unavailable input dtype: {configured_dtype!r}")
            return getattr(torch, configured_dtype)
        return configured_dtype

    # Most transformer wrappers expose dtype directly.  Fall back through
    # ``module`` (DDP/DeepSpeed) and finally the first parameter.
    current = model
    for _ in range(3):
        dtype = getattr(current, "dtype", None)
        if dtype is not None:
            return dtype
        current = getattr(current, "module", None)
        if current is None:
            break
    try:
        return next(model.parameters()).dtype
    except (AttributeError, StopIteration, TypeError):
        torch = _try_import_torch()
        return getattr(torch, "bfloat16", None) if torch is not None else None


def _move_inputs_to_device(inputs: Mapping[str, Any], *, device: Any, input_dtype: Any) -> dict[str, Any]:
    moved: dict[str, Any] = {}
    for key, value in inputs.items():
        if not hasattr(value, "to"):
            moved[key] = value
            continue
        value = value.to(device) if device is not None else value
        if input_dtype is not None and _is_floating_tensor(value):
            value = value.to(input_dtype)
        moved[key] = value
    return moved


def build_chartqa_batch_inputs(
    processor: Any,
    batch: Sequence[Mapping[str, Any]],
    *,
    device: Any,
    input_dtype: Any,
    prompt_template: str,
) -> dict[str, Any]:
    """Build the same multimodal prompts and processor inputs as the old CLI."""
    images = []
    formatted_prompts = []
    for item in batch:
        question = str(item["model_input_text"]).strip()
        question_with_tags = prompt_template.format(question=question)
        images.append(_load_rgb_image(item["image_path"]))
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "image"},
                    {"type": "text", "text": question_with_tags},
                ],
            }
        ]
        try:
            formatted_prompt = processor.apply_chat_template(messages, add_generation_prompt=True)
            formatted_prompt = formatted_prompt.strip()
        except Exception:
            formatted_prompt = f"USER: <image>\n{question_with_tags}\nASSISTANT:"
        formatted_prompts.append(formatted_prompt)

    inputs = processor(
        text=formatted_prompts,
        images=images,
        return_tensors="pt",
        padding=True,
        truncation=True,
    )
    return _move_inputs_to_device(inputs, device=device, input_dtype=input_dtype)


@contextmanager
def _generation_model(model: Any, generation_context: Any) -> Iterable[Any]:
    """Open a caller-supplied model-unwrapping context exactly once per eval."""
    context_or_model = generation_context
    if context_or_model is None:
        yield model
        return

    # A factory is the convenient form for ``unwrap_model_for_generation``.
    # Do not call a context manager object even if it happens to be callable.
    if callable(context_or_model) and not hasattr(context_or_model, "__enter__"):
        context_or_model = context_or_model()

    if hasattr(context_or_model, "__enter__") and hasattr(context_or_model, "__exit__"):
        with context_or_model as unwrapped_model:
            yield unwrapped_model if unwrapped_model is not None else model
    else:
        yield context_or_model if context_or_model is not None else model


def _inference_mode() -> ContextManager[Any]:
    torch = _try_import_torch()
    if torch is not None and hasattr(torch, "inference_mode"):
        return torch.inference_mode()
    return nullcontext()


def _generation_kwargs(
    processor: Any,
    config: ChartQAEvaluationConfig | Mapping[str, Any] | None,
    *,
    num_processes: int,
) -> dict[str, Any]:
    do_sample = bool(_config_value(config, "do_sample", False))
    kwargs: dict[str, Any] = {
        "max_new_tokens": int(_config_value(config, "max_new_tokens", 1024)),
        "do_sample": do_sample,
        "repetition_penalty": float(_config_value(config, "repetition_penalty", 1.0)),
    }
    tokenizer = getattr(processor, "tokenizer", None)
    if tokenizer is not None:
        pad_token_id = getattr(tokenizer, "pad_token_id", None)
        eos_token_id = getattr(tokenizer, "eos_token_id", None)
        if pad_token_id is not None:
            kwargs["pad_token_id"] = pad_token_id
        if eos_token_id is not None:
            kwargs["eos_token_id"] = eos_token_id
    if do_sample:
        kwargs["temperature"] = max(float(_config_value(config, "temperature", 0.0)), 1e-5)
        kwargs["top_p"] = float(_config_value(config, "top_p", 1.0))

    # Explicitly synchronize decoding in distributed mode unless the caller opts
    # out.  This avoids rank-dependent early-EOS loops under ZeRO-3/FSDP.
    synced_gpus = _config_value(config, "synced_gpus", None)
    if synced_gpus is None:
        synced_gpus = num_processes > 1
    if synced_gpus:
        kwargs["synced_gpus"] = True
    return kwargs


def _default_batch_generator(
    generation_model: Any,
    processor: Any,
    batch: Sequence[Mapping[str, Any]],
    config: ChartQAEvaluationConfig | Mapping[str, Any] | None,
    *,
    device: Any,
    input_dtype: Any,
    prompt_template: str,
    num_processes: int,
) -> list[str]:
    """Prepare and generate one local batch for non-distributed callers.

    The distributed evaluation loop prepares default batches itself before
    calling ``generate`` so it can synchronize preparation failures across
    ranks.  Keeping this convenience wrapper preserves the small standalone
    helper while routing both paths through the same decode implementation.
    """
    inputs = build_chartqa_batch_inputs(
        processor,
        batch,
        device=device,
        input_dtype=input_dtype,
        prompt_template=prompt_template,
    )
    return _generate_prepared_chartqa_batch(
        generation_model,
        processor,
        inputs,
        generation_kwargs=_generation_kwargs(processor, config, num_processes=num_processes),
    )


def _generate_prepared_chartqa_batch(
    generation_model: Any,
    processor: Any,
    inputs: Mapping[str, Any],
    *,
    generation_kwargs: Mapping[str, Any],
) -> list[str]:
    """Generate from inputs that were already prepared and safety-synchronized."""
    generated_ids = _generate_chartqa_ids(
        generation_model,
        inputs,
        generation_kwargs=generation_kwargs,
    )
    return _decode_generated_chartqa_ids(processor, inputs, generated_ids)


def _generate_chartqa_ids(
    generation_model: Any,
    inputs: Mapping[str, Any],
    *,
    generation_kwargs: Mapping[str, Any],
) -> Any:
    """Run model generation after the caller synchronized input preparation.

    In ZeRO-3/FSDP mode generation itself can contain collectives.  A rank-local
    failure *inside* that call is handled by the distributed backend; do not
    attempt a second object collective until every healthy rank has returned.
    Decode and scoring, which happen only after this returns, are synchronized
    separately by :func:`evaluate_chartqa_in_memory`.
    """
    _validate_prepared_chartqa_batch_inputs(inputs)
    generated_ids = generation_model.generate(
        **inputs,
        **generation_kwargs,
    )
    return getattr(generated_ids, "sequences", generated_ids)


def _decode_generated_chartqa_ids(
    processor: Any,
    inputs: Mapping[str, Any],
    generated_ids: Any,
) -> list[str]:
    """Decode a completed generation without performing model collectives."""
    input_ids = inputs.get("input_ids")
    # ``input_ids`` was validated before generation; the explicit assertion
    # gives type checkers a non-optional value here.
    assert input_ids is not None
    input_ids_length = input_ids.shape[1]
    newly_generated_ids = generated_ids[:, input_ids_length:]
    texts = processor.batch_decode(newly_generated_ids, skip_special_tokens=True)
    return [str(text).strip(".").strip() for text in texts]


def _validate_prepared_chartqa_batch_inputs(inputs: Mapping[str, Any]) -> None:
    """Reject malformed processor output before a distributed ``generate`` call."""
    if "input_ids" not in inputs:
        raise KeyError("ChartQA processor inputs must contain input_ids")
    input_ids = inputs["input_ids"]
    shape = getattr(input_ids, "shape", None)
    try:
        input_width = shape[1]
    except (TypeError, IndexError, KeyError) as exc:
        raise ValueError(
            "ChartQA processor input_ids must be a rank-2 tensor-like value"
        ) from exc
    try:
        if int(input_width) < 0:
            raise ValueError("ChartQA processor input_ids has an invalid negative width")
    except (TypeError, ValueError) as exc:
        if isinstance(exc, ValueError) and str(exc).startswith("ChartQA processor"):
            raise
        raise ValueError(
            "ChartQA processor input_ids must expose a numeric sequence width"
        ) from exc


def _gather_batch_preparation_statuses(
    accelerator: Any,
    local_status: Mapping[str, Any],
) -> list[Any]:
    """All-gather one small pre-generation readiness record per rank.

    This intentionally uses the process group's object collective instead of
    gathering a tensor flag and then trying to reconstruct a remote exception.
    The complete, tiny record lets every rank raise the same useful error
    *before* any rank reaches ZeRO-3/FSDP's collective ``generate`` path.
    """
    num_processes = int(getattr(accelerator, "num_processes", 1))
    torch = _try_import_torch()
    if torch is None:
        raise RuntimeError("Distributed ChartQA batch preparation requires torch")
    try:
        import torch.distributed as dist
    except ImportError as exc:  # pragma: no cover - torch ships this in training environments
        raise RuntimeError(
            "Distributed ChartQA batch preparation requires torch.distributed"
        ) from exc
    if not dist.is_available() or not dist.is_initialized():
        raise RuntimeError(
            "Distributed ChartQA batch preparation requires an initialized "
            "torch.distributed process group"
        )
    world_size = int(dist.get_world_size())
    if world_size != num_processes:
        raise RuntimeError(
            "Distributed ChartQA batch preparation found a process-group size "
            f"mismatch: accelerator.num_processes={num_processes}, world_size={world_size}"
        )
    gathered: list[Any] = [None] * num_processes
    dist.all_gather_object(gathered, dict(local_status))
    return gathered


def _batch_stage_failure_message(
    statuses: Sequence[Any],
    *,
    batch_index: int,
    num_processes: int,
    stage: str,
) -> tuple[str, int | None]:
    """Validate gathered rank records and format one deterministic error."""
    if stage == "preparation":
        noun = "preparation"
        action = "before distributed generation"
    else:
        noun = stage
        action = "during distributed evaluation"
    if len(statuses) != num_processes:
        return (
            f"ChartQA distributed batch {noun} received an unexpected number "
            f"of rank records {action} for batch {batch_index}: "
            f"expected={num_processes}, actual={len(statuses)}",
            None,
        )

    failures: list[tuple[int, str, str]] = []
    seen_ranks: set[int] = set()
    for status in statuses:
        if not isinstance(status, Mapping):
            return (
                f"ChartQA distributed batch {noun} received an invalid rank "
                f"record {action} for batch {batch_index}",
                None,
            )
        rank = status.get("rank")
        ok = status.get("ok")
        if (
            not isinstance(rank, int)
            or isinstance(rank, bool)
            or rank < 0
            or rank >= num_processes
            or rank in seen_ranks
            or not isinstance(ok, bool)
        ):
            return (
                f"ChartQA distributed batch {noun} received an invalid rank "
                f"record {action} for batch {batch_index}",
                None,
            )
        seen_ranks.add(rank)
        if not ok:
            error_type = str(status.get("error_type") or "RuntimeError")
            error = str(status.get("error") or "unknown batch preparation error")
            failures.append((rank, error_type, error))

    if len(seen_ranks) != num_processes:
        return (
            f"ChartQA distributed batch {noun} received incomplete rank "
            f"records {action} for batch {batch_index}",
            None,
        )
    if not failures:
        return "", None

    # ``all_gather_object`` returns rank order, but sorting makes the exact
    # exception independent of backend implementation details.
    rank, error_type, error = min(failures, key=lambda value: value[0])
    return (
        f"ChartQA batch {noun} failed {action} "
        f"(batch={batch_index}, rank={rank}, {error_type}: {error})",
        rank,
    )


def _synchronize_batch_stage(
    accelerator: Any,
    *,
    batch_index: int,
    local_error: Exception | None,
    stage: str,
) -> None:
    """Raise a shared batch-stage error before ranks can diverge.

    ``preparation`` is synchronized before any rank can enter collective
    generation.  ``generation/scoring`` is synchronized after a batch's model
    call, decode, and metric work so a rank-local failure cannot strand healthy
    peers at the next batch's readiness or final reduction collective.  The
    single-GPU path deliberately performs no import or collective work.
    """
    num_processes = int(getattr(accelerator, "num_processes", 1))
    if num_processes <= 1:
        if local_error is not None:
            raise local_error
        return

    rank = int(getattr(accelerator, "process_index", 0))
    if rank < 0 or rank >= num_processes:
        raise RuntimeError(
            "ChartQA distributed batch preparation received an invalid accelerator "
            f"process_index={rank} for num_processes={num_processes}"
        )
    status: dict[str, Any] = {"rank": rank, "ok": local_error is None}
    if local_error is not None:
        status["error_type"] = type(local_error).__name__
        try:
            detail = str(local_error)
        except Exception:  # pragma: no cover - defensive for malformed exception classes
            detail = "unprintable batch preparation exception"
        status["error"] = detail or "batch preparation raised without a message"

    message, failing_rank = _batch_stage_failure_message(
        _gather_batch_preparation_statuses(accelerator, status),
        batch_index=batch_index,
        num_processes=num_processes,
        stage=stage,
    )
    if message:
        if local_error is not None and failing_rank == rank:
            raise RuntimeError(message) from local_error
        raise RuntimeError(message)


def _synchronize_batch_preparation(
    accelerator: Any,
    *,
    batch_index: int,
    local_error: Exception | None,
) -> None:
    """Raise a shared input-preparation error before distributed ``generate``."""
    _synchronize_batch_stage(
        accelerator,
        batch_index=batch_index,
        local_error=local_error,
        stage="preparation",
    )


def _synchronize_batch_generation_and_scoring(
    accelerator: Any,
    *,
    batch_index: int,
    local_error: Exception | None,
) -> None:
    """Synchronize post-generation/decode/scoring failures across ranks."""
    _synchronize_batch_stage(
        accelerator,
        batch_index=batch_index,
        local_error=local_error,
        stage="generation/scoring",
    )


def _dummy_batch(
    examples: Sequence[Mapping[str, Any]],
    *,
    batch_index: int,
    batch_size: int,
    config: ChartQAEvaluationConfig | Mapping[str, Any] | None,
) -> list[Mapping[str, Any]]:
    factory = _config_value(config, "dummy_batch_factory", None)
    if factory is not None:
        batch = list(
            factory(
                examples=examples,
                batch_index=batch_index,
                batch_size=batch_size,
                config=config,
            )
        )
    else:
        example = _config_value(config, "dummy_example", None)
        if example is None:
            example = examples[0] if examples else None
        batch = [example] * batch_size if example is not None else []
    if not batch:
        raise RuntimeError(
            "A distributed ChartQA rank has no real batch and no valid dummy example. "
            "Provide checkpoint_eval.dummy_example or dummy_batch_factory."
        )
    if len(batch) != batch_size:
        raise RuntimeError(
            "ChartQA dummy_batch_factory must return exactly one full dummy batch: "
            f"expected={batch_size}, actual={len(batch)}"
        )
    return batch


def _reduce_score_count(
    accelerator: Any,
    *,
    score_sum: float,
    count: int,
) -> tuple[float, int]:
    """All-reduce the only numeric values needed for exact global accuracy."""
    num_processes = int(getattr(accelerator, "num_processes", 1))
    if num_processes <= 1:
        return score_sum, count

    torch = _try_import_torch()
    if torch is None:
        raise RuntimeError("Distributed ChartQA evaluation requires torch")
    payload = torch.tensor(
        [score_sum, float(count)],
        dtype=torch.float64,
        device=getattr(accelerator, "device", None),
    )
    try:
        import torch.distributed as dist

        if dist.is_available() and dist.is_initialized():
            dist.all_reduce(payload, op=dist.ReduceOp.SUM)
            return float(payload[0].item()), int(round(float(payload[1].item())))
    except (ImportError, RuntimeError):
        pass

    gather = getattr(accelerator, "gather", None)
    if callable(gather):
        gathered = gather(payload)
        gathered = gathered.reshape(-1, 2)
        return (
            float(gathered[:, 0].sum().item()),
            int(round(float(gathered[:, 1].sum().item()))),
        )
    raise RuntimeError(
        "Distributed ChartQA evaluation needs an initialized torch.distributed group "
        "or accelerator.gather()."
    )


def _reduce_fixed_count_mapping(
    accelerator: Any,
    value: Mapping[str, int],
    *,
    keys: Sequence[str],
) -> dict[str, int]:
    """All-reduce a fixed diagnostic counter mapping without gathering texts.

    The dynamic output-type labels are first made deterministic by sorting their
    union through ``all_gather_object`` only when necessary.  The actual counts
    (and all template counters) use a compact tensor all-reduce.  This keeps the
    training path's cross-rank traffic bounded regardless of response length.
    """
    num_processes = int(getattr(accelerator, "num_processes", 1))
    if num_processes <= 1:
        return {key: int(value.get(key, 0)) for key in keys}

    torch = _try_import_torch()
    if torch is None:
        raise RuntimeError("Distributed ChartQA evaluation requires torch")
    payload = torch.tensor(
        [int(value.get(key, 0)) for key in keys],
        dtype=torch.int64,
        device=getattr(accelerator, "device", None),
    )
    try:
        import torch.distributed as dist

        if dist.is_available() and dist.is_initialized():
            dist.all_reduce(payload, op=dist.ReduceOp.SUM)
            return {key: int(payload[index].item()) for index, key in enumerate(keys)}
    except (ImportError, RuntimeError):
        pass

    gather = getattr(accelerator, "gather", None)
    if callable(gather):
        gathered = gather(payload).reshape(-1, len(keys))
        return {
            key: int(gathered[:, index].sum().item())
            for index, key in enumerate(keys)
        }
    raise RuntimeError(
        "Distributed ChartQA evaluation needs an initialized torch.distributed group "
        "or accelerator.gather()."
    )


def _summarize_template_behavior(texts: list[str]) -> dict[str, int]:
    try:
        from eval.output_behavior import summarize_output_behavior_counts

        return summarize_output_behavior_counts(texts)
    except (ImportError, ModuleNotFoundError):
        # Keep the core importable in lightweight environments.  Real training
        # installations have the diagnostics dependency and take the branch above.
        return _empty_template_behavior_counts() | {"total": len(texts)}


def _restore_model_mode(model: Any, was_training: Any) -> None:
    if was_training is None:
        return
    train = getattr(model, "train", None)
    if callable(train):
        try:
            train(bool(was_training))
            return
        except TypeError:
            pass
    if bool(was_training):
        if callable(train):
            train()
    else:
        evaluate = getattr(model, "eval", None)
        if callable(evaluate):
            evaluate()


def _release_evaluation_cuda_cache(
    config: ChartQAEvaluationConfig | Mapping[str, Any] | None,
    *,
    device: Any,
) -> None:
    """Release transient generation blocks before training resumes.

    Checkpoint evaluation creates variable-length KV caches that are not
    useful to the next OPD update. Keeping those blocks in the caching
    allocator is harmless on an exclusive GPU, but can leave essentially no
    device headroom when a frozen teacher and another process share the card.
    Synchronizing here also attributes an asynchronous generation failure to
    evaluation instead of letting the next DDP forward time out much later.
    """
    if not bool(_config_value(config, "release_cuda_cache", True)):
        return
    torch = _try_import_torch()
    if torch is None or not torch.cuda.is_available():
        return
    if device is None:
        torch.cuda.synchronize()
    else:
        torch.cuda.synchronize(device)
    torch.cuda.empty_cache()


def evaluate_chartqa_in_memory(
    model: Any,
    processor: Any,
    accelerator: Any,
    dataset: Iterable[Mapping[str, Any]],
    generation_context: Any = None,
    config: ChartQAEvaluationConfig | Mapping[str, Any] | None = None,
) -> dict[str, Any]:
    """Evaluate a live student model on ChartQA without loading any weights.

    ``generation_context`` may be a context manager, a factory returning one, or
    an already-unwrapped generation model.  For the training path, pass a factory
    that opens ``unwrap_model_for_generation(model_wrapped, accelerator, ...)``;
    the core then uses that yielded model directly.

    The returned ``checkpoint_score``, ``accuracy``, and ``chartqa_accuracy`` are
    identical aliases so a training callback can log one and use the same value
    for best-checkpoint comparison.
    """
    examples = prepare_chartqa_examples(dataset)
    if not examples:
        raise ValueError("ChartQA evaluation has no valid examples after filtering")

    batch_size = int(_config_value(config, "batch_size", 1))
    if batch_size < 1:
        raise ValueError("ChartQA evaluation batch_size must be >= 1")
    num_processes = int(getattr(accelerator, "num_processes", 1))
    process_index = int(getattr(accelerator, "process_index", 0))
    if num_processes < 1:
        raise ValueError("accelerator.num_processes must be >= 1")
    local_examples = shard_chartqa_examples(
        examples, num_processes=num_processes, process_index=process_index
    )
    batch_plan = distributed_batch_plan(
        total_items=len(examples), num_processes=num_processes, batch_size=batch_size
    )
    progress_every = int(_config_value(config, "progress_every", 0))
    if progress_every < 0:
        raise ValueError("ChartQA evaluation progress_every must be >= 0")

    device = _config_value(config, "device", None)
    if device is None:
        device = getattr(accelerator, "device", None)
    input_dtype = _infer_input_dtype(model, _config_value(config, "input_dtype", None))
    prompt_template = _get_prompt_template(config)
    custom_batch_generator = _config_value(config, "batch_generator", None)

    tokenizer = getattr(processor, "tokenizer", None)
    old_padding_side = getattr(tokenizer, "padding_side", None) if tokenizer is not None else None
    force_left_padding = bool(_config_value(config, "force_left_padding", True))
    was_training = getattr(model, "training", None)
    generation_was_training = None
    score_sum = 0.0
    local_texts: list[str] = []
    output_type_counts: dict[str, int] = {}
    answer_marker_counts: dict[str, int] = {}
    dummy_batches = 0
    generation_model = model
    prepared_inputs: Mapping[str, Any] | None = None
    generation_kwargs: Mapping[str, Any] | None = None
    generated_ids: Any = None
    predictions: Sequence[str] = ()
    current_batch: list[Mapping[str, Any]] = []
    next_progress = progress_every
    evaluation_started = time.monotonic()

    if tokenizer is not None and force_left_padding and hasattr(tokenizer, "padding_side"):
        tokenizer.padding_side = "left"
    evaluate = getattr(model, "eval", None)
    if callable(evaluate):
        evaluate()

    try:
        with _generation_model(model, generation_context) as generation_model:
            if generation_model is not model:
                generation_was_training = getattr(generation_model, "training", None)
                generation_evaluate = getattr(generation_model, "eval", None)
                if callable(generation_evaluate):
                    generation_evaluate()
            with _inference_mode():
                for batch_index in range(batch_plan.sync_batches):
                    start = batch_index * batch_size
                    current_batch = []
                    real_item_count = 0
                    prepared_inputs = None
                    generation_kwargs = None
                    preparation_error: Exception | None = None
                    try:
                        current_batch = list(local_examples[start : start + batch_size])
                        real_item_count = len(current_batch)
                        if real_item_count < batch_size:
                            dummy_items = _dummy_batch(
                                examples,
                                batch_index=batch_index,
                                batch_size=batch_size - real_item_count,
                                config=config,
                            )
                            current_batch = current_batch + dummy_items
                            dummy_batches += 1

                        # Processor/image work must finish on every rank before
                        # any one rank can enter collective generation.  A custom
                        # batch generator owns its own preparation by contract,
                        # so only its common batch construction is checked here.
                        if custom_batch_generator is None:
                            prepared_inputs = build_chartqa_batch_inputs(
                                processor,
                                current_batch,
                                device=device,
                                input_dtype=input_dtype,
                                prompt_template=prompt_template,
                            )
                            generation_kwargs = _generation_kwargs(
                                processor,
                                config,
                                num_processes=num_processes,
                            )
                            _validate_prepared_chartqa_batch_inputs(prepared_inputs)
                    except Exception as exc:
                        # Do not raise yet in distributed mode.  Every peer
                        # must enter the readiness collective below so it can
                        # avoid an otherwise deadlocking ``generate`` call.
                        preparation_error = exc

                    _synchronize_batch_preparation(
                        accelerator,
                        batch_index=batch_index,
                        local_error=preparation_error,
                    )
                    if custom_batch_generator is None:
                        # A successful local readiness record guarantees these
                        # values were built above.  Keep the checks explicit so
                        # a malformed custom processor cannot reach generate.
                        if prepared_inputs is None or generation_kwargs is None:
                            raise RuntimeError(
                                "ChartQA batch preparation succeeded without generation inputs"
                            )
                        generated_ids = _generate_chartqa_ids(
                            generation_model,
                            prepared_inputs,
                            generation_kwargs=generation_kwargs,
                        )
                        try:
                            predictions = _decode_generated_chartqa_ids(
                                processor,
                                prepared_inputs,
                                generated_ids,
                            )
                        except Exception as exc:
                            # Decoding happens after every rank has returned
                            # from the model's collective generation path.
                            generation_or_scoring_error: Exception | None = exc
                        else:
                            generation_or_scoring_error = None
                    else:
                        # Custom generators own their generation contract.  A
                        # custom distributed implementation must ensure its own
                        # model-call collectives complete; its returned texts
                        # and all scoring below are still failure-synchronized.
                        try:
                            predictions = list(
                                custom_batch_generator(
                                    generation_model, processor, current_batch, config
                                )
                            )
                        except Exception as exc:
                            generation_or_scoring_error = exc
                        else:
                            generation_or_scoring_error = None
                    if generation_or_scoring_error is None:
                        try:
                            if len(predictions) != len(current_batch):
                                raise RuntimeError(
                                    "ChartQA batch generator returned a different number of predictions "
                                    f"({len(predictions)}) than inputs ({len(current_batch)})"
                                )
                            for item, full_prediction in zip(
                                current_batch[:real_item_count], predictions[:real_item_count]
                            ):
                                full_prediction = str(full_prediction).strip(".").strip()
                                _, parsed_prediction = split_chartqa_initial_context(full_prediction)
                                if not parsed_prediction.strip():
                                    parsed_prediction = full_prediction
                                # Imported here to keep module import side-effect
                                # free for training/test environments that do not
                                # need scoring.
                                from data_utils.chart.evaluator import eval_one_chart

                                score = float(eval_one_chart(parsed_prediction, item["answer"]))
                                if not math.isfinite(score):
                                    raise ValueError(
                                        "ChartQA metric returned a non-finite score for "
                                        f"{item['original_question']!r}"
                                    )
                                score_sum += score
                                local_texts.append(full_prediction)
                                output_type = classify_output_type(full_prediction)
                                output_type_counts[output_type] = output_type_counts.get(output_type, 0) + 1
                                marker_type = classify_answer_marker_count(full_prediction)
                                answer_marker_counts[marker_type] = (
                                    answer_marker_counts.get(marker_type, 0) + 1
                                )
                        except Exception as exc:
                            # Decode/scoring failures happen after default
                            # generation returned, so every rank can rendezvous
                            # here before a peer advances to the next batch.
                            generation_or_scoring_error = exc
                    _synchronize_batch_generation_and_scoring(
                        accelerator,
                        batch_index=batch_index,
                        local_error=generation_or_scoring_error,
                    )
                    if (
                        progress_every
                        and bool(getattr(accelerator, "is_main_process", process_index == 0))
                        and len(local_texts) >= next_progress
                    ):
                        elapsed = max(time.monotonic() - evaluation_started, 1e-9)
                        print(
                            "[ChartQA eval] "
                            f"rank={process_index} processed={len(local_texts)}/{len(local_examples)} "
                            f"elapsed={elapsed:.1f}s rate={len(local_texts) / elapsed:.3f}/s",
                            flush=True,
                        )
                        next_progress += progress_every
    finally:
        if generation_was_training is not None and generation_model is not model:
            _restore_model_mode(generation_model, generation_was_training)
        if tokenizer is not None and old_padding_side is not None and hasattr(tokenizer, "padding_side"):
            tokenizer.padding_side = old_padding_side
        _restore_model_mode(model, was_training)
        # Drop the final batch's references before emptying the allocator;
        # otherwise its generated ids/KV-related tensors remain live until
        # this function returns and cannot be released here.
        prepared_inputs = None
        generation_kwargs = None
        generated_ids = None
        predictions = ()
        current_batch = []
        _release_evaluation_cuda_cache(config, device=device)

    local_count = len(local_texts)
    if local_count != len(local_examples):
        raise RuntimeError(
            "ChartQA local processed count does not match its deterministic shard: "
            f"processed={local_count}, shard={len(local_examples)}"
        )
    global_score_sum, global_count = _reduce_score_count(
        accelerator, score_sum=score_sum, count=local_count
    )
    if global_count != len(examples):
        raise RuntimeError(
            "ChartQA global processed count does not match the prepared dataset: "
            f"processed={global_count}, expected={len(examples)}"
        )
    if global_count == 0:
        raise ValueError("ChartQA evaluation produced zero scored examples")

    global_output_types = _reduce_fixed_count_mapping(
        accelerator, output_type_counts, keys=_OUTPUT_TYPE_KEYS
    )
    global_answer_markers = _reduce_fixed_count_mapping(
        accelerator, answer_marker_counts, keys=_ANSWER_MARKER_KEYS
    )
    local_template_behavior = _summarize_template_behavior(local_texts)
    global_template_behavior = _reduce_fixed_count_mapping(
        accelerator,
        local_template_behavior,
        keys=tuple(_empty_template_behavior_counts()),
    )
    accuracy = global_score_sum / global_count
    return {
        "checkpoint_score": accuracy,
        "accuracy": accuracy,
        "chartqa_accuracy": accuracy,
        "score_sum": global_score_sum,
        "sample_count": global_count,
        "processed_count": global_count,
        "total_items": len(examples),
        "output_type_counts": global_output_types,
        "answer_marker_counts": global_answer_markers,
        "template_behavior_counts": global_template_behavior,
        "local_score_sum": score_sum,
        "local_sample_count": local_count,
        "local_dummy_batches": dummy_batches,
        "num_processes": num_processes,
        "process_index": process_index,
        "is_main_process": bool(getattr(accelerator, "is_main_process", process_index == 0)),
    }


# Short alias for callers that prefer the old evaluator's terminology.
evaluate_chartqa = evaluate_chartqa_in_memory


def print_chartqa_evaluation(result: Mapping[str, Any], *, print_fn: Callable[..., None] = print) -> None:
    """Print the legacy final-summary lines consumed by existing log parsers."""
    # Several existing evaluation runners use this marker to distinguish a
    # completed evaluation from a partial/failed log.  Keep it even though the
    # in-memory core itself no longer emits intermediate progress reports.
    print_fn("--- Final Report ---")
    print_fn(
        f"Global samples processed: {int(result['sample_count'])} / {int(result['total_items'])}"
    )
    print_fn(f"Current Global Mean Accuracy: {float(result['accuracy']):.4f}")
    print_fn(f"Output type counts: {dict(result['output_type_counts'])}")
    print_fn(f"Answer marker counts: {dict(result.get('answer_marker_counts', {}))}")
    print_fn(f"Template behavior counts: {dict(result['template_behavior_counts'])}")