File size: 42,964 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""

metrics.py — Vision scoring (replaces the text substring-grounding metric).



Every category carries two UNIVERSAL metrics that encode the project's thesis —

robust, schema-valid JSON — plus a category-specific accuracy scorer:



  schema_valid : did the output validate against the category's Pydantic model

                 (after the never-raises recovery walk)?

  json_robust  : did it parse WITHOUT repair (clean bare JSON)?  This, measured

                 in json_mode, is the native-capability signal driving the

                 no-finetune decision.



The headline `labeler_score` MULTIPLIES accuracy by validity and robustness, so a

model that is accurate but emits fragile JSON scores worse than a slightly less

accurate model that emits clean JSON — exactly what you want when pointing a

labeler at a million images with no human in the loop.

"""

from __future__ import annotations

import math
import re
from dataclasses import asdict, dataclass
from typing import Callable, Optional

from ..evaluator import parse_against
from .coords import XYWH, XYXY, BBox, CoordSpace, to_canonical
from .tasks_vision import VisionTaskSpec, model_for


# ──────────────────────────────────────────────────────────────────────────────
# Result types
# ──────────────────────────────────────────────────────────────────────────────

@dataclass
class MetricResult:
    category: str
    image_id: str
    mode: str
    parse_ok: bool          # a JSON object was recovered + decoded (maybe invalid schema)
    schema_valid: bool      # validated against the category model
    needed_repair: bool     # recovery had to strip fences / skip prose / trim junk
    grammar_conformant: bool  # constrained decoding actually applied (backend == xgrammar)
    primary_score: Optional[float]   # task accuracy 0..1; None if no GT / invalid
    metrics: dict
    needed_structural_repair: bool = False  # repair beyond a benign fence strip
    n_output_tokens: int = 0
    gen_seconds: float = 0.0
    error: Optional[str] = None
    notes: str = ""

    @property
    def json_robust(self) -> bool:
        """Valid JSON that needed no STRUCTURAL repair — the native-capability signal.

        A benign markdown-fence wrap is tolerated (fence-stripping is deterministic);

        prose/runaway/malformed is not."""
        return self.schema_valid and not self.needed_structural_repair

    def to_dict(self) -> dict:
        d = asdict(self)
        d["json_robust"] = self.json_robust
        return d


@dataclass
class VisionRunMetrics:
    category: str
    model: str
    reasoning: str
    mode: str
    n: int
    schema_valid_rate: float
    json_robustness: float
    has_task_score: bool
    primary_score_mean: Optional[float]
    metrics_mean: dict
    mean_output_tokens: float
    total_gen_seconds: float
    tokens_per_sec: float
    labeler_score: Optional[float]

    def __str__(self) -> str:
        acc = "n/a" if self.primary_score_mean is None else f"{self.primary_score_mean:.3f}"
        lab = "n/a" if self.labeler_score is None else f"{self.labeler_score:.3f}"
        return (f"[{self.model}/{self.reasoning}/{self.category}/{self.mode}] n={self.n} "
                f"valid={self.schema_valid_rate:.1%} robust={self.json_robustness:.1%} "
                f"acc={acc} labeler={lab} tok/s={self.tokens_per_sec:.0f}")


# ──────────────────────────────────────────────────────────────────────────────
# The labeler-selection composite (the verdict core)
# ──────────────────────────────────────────────────────────────────────────────

def labeler_score(accuracy: Optional[float], schema_valid_rate: float,

                  json_robustness: float) -> Optional[float]:
    """Multiplicative composite: accuracy × validity-gate × robustness-penalty.



    Invalid JSON is unusable (hard-ish cap via the 0.5+0.5·valid term); fragile

    but repairable JSON is penalized, not killed (0.7+0.3·robust term).

    """
    if accuracy is None:
        return None
    return accuracy * (0.5 + 0.5 * schema_valid_rate) * (0.7 + 0.3 * json_robustness)


# ──────────────────────────────────────────────────────────────────────────────
# Small pure helpers (no external deps — editdistance/jiwer are optional accel)
# ──────────────────────────────────────────────────────────────────────────────

def _norm_text(s: str) -> str:
    return re.sub(r"\s+", " ", (s or "").strip().lower())


def _levenshtein(a: list, b: list) -> int:
    if a == b:
        return 0
    if not a:
        return len(b)
    if not b:
        return len(a)
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def _cer(pred: str, gt: str) -> float:
    g = _norm_text(gt)
    p = _norm_text(pred)
    if not g:
        return 0.0 if not p else 1.0
    return _levenshtein(list(p), list(g)) / len(g)


def _wer(pred: str, gt: str) -> float:
    g = _norm_text(gt).split()
    p = _norm_text(pred).split()
    if not g:
        return 0.0 if not p else 1.0
    return _levenshtein(p, g) / len(g)


# ──────────────────────────────────────────────────────────────────────────────
# Tolerant label matching — VLMs use richer / synonymous labels than dataset
# vocabularies (e.g. "television" vs COCO's "tv"). Without this, correct boxes are
# discarded on a string mismatch (observed: Qwen3-VL localizes COCO near-perfectly
# but exact-match F1 read ~0.25). Synonym groups + substring + plural fallback.
# ──────────────────────────────────────────────────────────────────────────────

_SYNONYM_GROUPS = [
    {"tv", "television", "televisions", "telly", "monitor", "screen"},
    {"couch", "sofa", "settee", "loveseat"},
    {"motorcycle", "motorbike", "moped", "scooter"},
    {"airplane", "aeroplane", "plane", "aircraft", "jet"},
    {"cell phone", "cellphone", "mobile phone", "mobile", "phone", "smartphone"},
    {"potted plant", "houseplant", "plant", "pot plant", "flowerpot", "flower pot"},
    {"dining table", "table", "desk"},
    {"car", "automobile", "sedan", "vehicle"},
    {"bicycle", "bike", "cycle"},
    {"person", "people", "man", "men", "woman", "women", "human", "boy", "girl",
     "child", "kid", "pedestrian", "player", "lady", "guy", "skier", "surfer",
     "rider", "athlete", "batter", "pitcher", "catcher"},
    {"hot dog", "hotdog"},
    {"donut", "doughnut"},
    {"remote", "remote control"},
    {"sports ball", "ball"},
    {"wine glass", "wineglass", "glass"},
    {"tie", "necktie"},
]
_SYN_GROUP: dict[str, int] = {}
for _gi, _grp in enumerate(_SYNONYM_GROUPS):
    for _w in _grp:
        _SYN_GROUP[_w] = _gi


def _depluralize(t: str) -> str:
    if len(t) <= 3:
        return t
    if t.endswith("ies"):
        return t[:-3] + "y"
    if t.endswith("es"):
        return t[:-2]
    if t.endswith("s"):
        return t[:-1]
    return t


def labels_match(a: str, b: str) -> bool:
    """True if two object labels refer to the same thing (tolerant)."""
    a, b = _norm_text(a), _norm_text(b)
    if not a or not b:
        return False
    if a == b:
        return True
    if _depluralize(a) == _depluralize(b):
        return True
    ga, gb = _SYN_GROUP.get(a), _SYN_GROUP.get(b)
    if ga is not None and ga == gb:
        return True
    # word-level containment: "dining table" vs "table", "red car" vs "car"
    aw, bw = set(a.split()), set(b.split())
    if aw and bw and (aw <= bw or bw <= aw):
        return True
    return False


# ──────────────────────────────────────────────────────────────────────────────
# Per-category scorers: (pred_dict, gt, ctx) -> (primary_score|None, metrics_dict)
# ctx carries {"size": (W,H), "coord_space": CoordSpace}
# ──────────────────────────────────────────────────────────────────────────────

def _acceptable_labels(gt) -> set[str]:
    if isinstance(gt, dict):
        if "labels" in gt:
            return {_norm_text(x) for x in gt["labels"]}
        if "label" in gt:
            return {_norm_text(gt["label"])}
    if isinstance(gt, str):
        return {_norm_text(gt)}
    return set()


def score_classification(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    accept = _acceptable_labels(gt)
    pred_label = _norm_text(str(pred.get("label", "")))
    # tolerant: "spaghetti" credits "spaghetti bolognese", "tv" credits "television"
    top1 = 1.0 if any(labels_match(pred_label, a) for a in accept) else 0.0
    top5_labels = {_norm_text(str(d.get("label", ""))) for d in (pred.get("top5") or [])}
    top5_labels.add(pred_label)
    top5 = 1.0 if any(labels_match(p, a) for p in top5_labels for a in accept) else 0.0
    return top1, {"top1": top1, "top5": top5}


def _gt_boxes_to_canonical(gt, size) -> list[tuple[str, BBox]]:
    out = []
    for b in (gt.get("boxes") if isinstance(gt, dict) else []) or []:
        label = _norm_text(str(b.get("label", "")))
        fmt = b.get("fmt", XYWH)
        box = to_canonical(b["bbox"], CoordSpace.PIXEL_ABS, size, fmt=fmt)
        out.append((label, box))
    return out


def _greedy_match_f1(preds, gts, iou_thr, require_label) -> tuple[float, float, float, int]:
    """Greedy IoU matching (preds pre-sorted by score). Returns (precision, recall, f1, tp).

    `require_label` toggles labeled vs class-agnostic matching."""
    matched: set[int] = set()
    tp = 0
    for plabel, _score, pbox in preds:
        best_gi, best_iou = -1, iou_thr
        for gi, (glabel, gbox) in enumerate(gts):
            if gi in matched:
                continue
            if require_label and not labels_match(plabel, glabel):
                continue
            iou = pbox.iou(gbox)
            if iou >= best_iou:
                best_gi, best_iou = gi, iou
        if best_gi >= 0:
            matched.add(best_gi)
            tp += 1
    fp = len(preds) - tp
    fn = len(gts) - len(matched)
    precision = tp / (tp + fp) if (tp + fp) else (1.0 if not gts else 0.0)
    recall = tp / (tp + fn) if (tp + fn) else 1.0
    f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0
    return precision, recall, f1, tp


def score_detection(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    size = ctx["size"]
    space = ctx["coord_space"]
    gts = _gt_boxes_to_canonical(gt, size)

    preds = []
    for d in (pred.get("detections") or []):
        box_raw = d.get("box")
        if not (isinstance(box_raw, (list, tuple)) and len(box_raw) == 4):
            continue
        try:
            box = to_canonical(box_raw, space, size, fmt=XYXY)
        except (ValueError, TypeError):
            continue
        preds.append((_norm_text(str(d.get("label", ""))), float(d.get("score", 1.0) or 1.0), box))
    preds.sort(key=lambda t: t[1], reverse=True)

    iou_thr = 0.5
    # labeled (tolerant) match — the headline accuracy
    precision, recall, f1, _ = _greedy_match_f1(preds, gts, iou_thr, require_label=True)
    # class-agnostic localization — "can it find/box objects" regardless of naming
    loc_p, loc_r, loc_f1, _ = _greedy_match_f1(preds, gts, iou_thr, require_label=False)

    pred_count = pred.get("count")
    count_err = abs(int(pred_count) - len(gts)) if isinstance(pred_count, (int, float)) else len(preds) - len(gts)
    return f1, {"precision": precision, "recall": recall, "f1": f1,
                "localization_f1": loc_f1, "localization_recall": loc_r,
                "iou_thr": iou_thr, "count_abs_err": float(abs(count_err)),
                "n_pred": float(len(preds)), "n_gt": float(len(gts))}


def score_ocr(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    gt_text = gt.get("text") if isinstance(gt, dict) else str(gt)
    pred_text = str(pred.get("full_text", ""))
    cer = _cer(pred_text, gt_text)
    wer = _wer(pred_text, gt_text)
    exact = 1.0 if _norm_text(pred_text) == _norm_text(gt_text) else 0.0
    # answer-containment credit (TextVQA-style: GT is the answer phrase)
    contains = 1.0 if _norm_text(gt_text) and _norm_text(gt_text) in _norm_text(pred_text) else 0.0
    primary = max(exact, contains, max(0.0, 1.0 - cer))
    return primary, {"cer": cer, "wer": wer, "exact": exact, "contains": contains}


def score_datatype_diff(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Did the model identify the rendered data format (json/yaml/md/...)?"""
    gt_type = _norm_text(gt.get("data_type") if isinstance(gt, dict) else str(gt))
    pred_type = _norm_text(str(pred.get("data_type", "")))
    ok = 1.0 if pred_type == gt_type else 0.0
    return ok, {"type_acc": ok}


def _flatten_kv(obj, prefix="") -> set[str]:
    """Flatten a parsed JSON-ish object to a set of 'path=value' leaf strings."""
    out: set[str] = set()
    if isinstance(obj, dict):
        for k, v in obj.items():
            out |= _flatten_kv(v, f"{prefix}{k}.")
    elif isinstance(obj, list):
        for i, v in enumerate(obj):
            out |= _flatten_kv(v, f"{prefix}{i}.")
    else:
        out.add(f"{prefix.rstrip('.')}={_norm_text(str(obj))}")
    return out


def score_datatype_util(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Did the model re-emit the rendered data as normalized JSON matching the GT?

    Scored by leaf 'path=value' F1 (order-independent), plus a type-correct bonus."""
    import json as _json
    gt_obj = gt.get("content") if isinstance(gt, dict) else gt
    if isinstance(gt_obj, str):
        try:
            gt_obj = _json.loads(gt_obj)
        except (ValueError, TypeError):
            pass
    pred_content = pred.get("content")
    if isinstance(pred_content, str):
        try:
            pred_content = _json.loads(pred_content)
        except (ValueError, TypeError):
            pass  # leave as string → flatten will treat as a single leaf
    g = _flatten_kv(gt_obj)
    p = _flatten_kv(pred_content)
    if not g:
        return (1.0 if not p else 0.0), {"kv_f1": 0.0}
    tp = len(g & p)
    prec = tp / len(p) if p else 0.0
    rec = tp / len(g)
    f1 = (2 * prec * rec / (prec + rec)) if (prec + rec) else 0.0
    type_ok = 1.0 if _norm_text(str(pred.get("data_type", ""))) == _norm_text(
        gt.get("data_type", "") if isinstance(gt, dict) else "") else 0.0
    return f1, {"kv_f1": f1, "kv_precision": prec, "kv_recall": rec, "type_acc": type_ok}


_SPATIAL_PRED_NORM = {
    "left of": "left_of", "to the left of": "left_of", "left": "left_of",
    "right of": "right_of", "to the right of": "right_of", "right": "right_of",
    "in front of": "in_front_of", "front of": "in_front_of",
}


def _norm_pred(p: str) -> str:
    n = _norm_text(p)
    return _SPATIAL_PRED_NORM.get(n, n.replace(" ", "_"))


def _pred_triples(pred: dict) -> list[tuple]:
    """Extract (subject, predicate, object) triples from either the spatial

    ('relations': [{subject,predicate,object}]) or semantic

    ('associations': [{a,relation,b}]) shape."""
    out = []
    for r in (pred.get("relations") or []):
        if isinstance(r, dict):
            out.append((_norm_text(str(r.get("subject", ""))), _norm_pred(str(r.get("predicate", ""))),
                        _norm_text(str(r.get("object", "")))))
    for r in (pred.get("associations") or []):
        if isinstance(r, dict):
            out.append((_norm_text(str(r.get("a", ""))), _norm_pred(str(r.get("relation", ""))),
                        _norm_text(str(r.get("b", "")))))
    return out


def score_triples(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Triple-F1 with tolerant subject/object matching + exact (normalized) predicate."""
    gts = [(_norm_text(s), _norm_pred(p), _norm_text(o))
           for s, p, o in (gt.get("triples", []) if isinstance(gt, dict) else [])]
    preds = _pred_triples(pred)
    matched = set()
    tp = 0
    for ps, pp, po in preds:
        for gi, (gs, gp, go) in enumerate(gts):
            if gi in matched or pp != gp:
                continue
            if labels_match(ps, gs) and labels_match(po, go):
                matched.add(gi)
                tp += 1
                break
    prec = tp / len(preds) if preds else (1.0 if not gts else 0.0)
    rec = tp / len(gts) if gts else 1.0
    f1 = (2 * prec * rec / (prec + rec)) if (prec + rec) else 0.0
    return f1, {"triple_f1": f1, "precision": prec, "recall": rec,
                "n_pred": float(len(preds)), "n_gt": float(len(gts))}


def score_depth_order(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Pairwise nearer/farther ordering accuracy over the GT pairs."""
    gpairs = gt.get("pairs", []) if isinstance(gt, dict) else []
    preds = pred.get("relative_depth") or []

    def _find(a, b):
        for r in preds:
            if not isinstance(r, dict):
                continue
            ra, rb = _norm_text(str(r.get("a", ""))), _norm_text(str(r.get("b", "")))
            if labels_match(ra, a) and labels_match(rb, b):
                return _norm_text(str(r.get("a_is", "")))
            if labels_match(ra, b) and labels_match(rb, a):  # reversed → flip
                v = _norm_text(str(r.get("a_is", "")))
                return {"nearer": "farther", "farther": "nearer"}.get(v, v)
        return None

    correct = 0
    for p in gpairs:
        got = _find(_norm_text(p["a"]), _norm_text(p["b"]))
        if got == _norm_text(p["a_is"]):
            correct += 1
    acc = correct / len(gpairs) if gpairs else (1.0 if not preds else 0.0)
    return acc, {"order_acc": acc, "n_gt_pairs": float(len(gpairs))}


def score_subject_fixation(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """IoU of the predicted primary-subject box vs the GT salient box (+ label)."""
    size = ctx["size"]
    space = ctx["coord_space"]
    ps = pred.get("primary_subject") or {}
    raw = ps.get("box") if isinstance(ps, dict) else None
    if not (isinstance(raw, (list, tuple)) and len(raw) == 4):
        return 0.0, {"iou": 0.0, "label_ok": 0.0}
    try:
        pbox = to_canonical(raw, space, size, fmt=XYXY)
    except (ValueError, TypeError):
        return 0.0, {"iou": 0.0, "label_ok": 0.0}
    gbox = to_canonical(gt["box"], CoordSpace.PIXEL_ABS, size, fmt=gt.get("fmt", XYXY))
    iou = pbox.iou(gbox)
    label_ok = 1.0 if labels_match(str(ps.get("label", "")), str(gt.get("label", ""))) else 0.0
    primary = 1.0 if (iou >= 0.5 and label_ok) else (0.5 if iou >= 0.5 else 0.0)
    return primary, {"iou": iou, "label_ok": label_ok}


def _seg_poly_points(flat, sx=1.0, sy=1.0):
    """Flat [x1,y1,x2,y2,...] -> list of (x,y) tuples scaled by (sx,sy).

    Tolerant: ignores a trailing odd value and skips non-numeric entries."""
    pts = []
    if not isinstance(flat, (list, tuple)):
        return pts
    n = (len(flat) // 2) * 2
    for i in range(0, n, 2):
        try:
            x = float(flat[i]) * sx
            y = float(flat[i + 1]) * sy
        except (TypeError, ValueError):
            continue
        pts.append((x, y))
    # Tolerate a 4-number bbox-as-polygon: expand [x1,y1,x2,y2] -> rectangle corners.
    if len(pts) == 2:
        (x1, y1), (x2, y2) = pts
        pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
    return pts


def _seg_rasterize(points, grid, gsx, gsy):
    """Fill a polygon (pixel-coord points) onto a grid×grid boolean mask.

    Maps pixel->grid via (px*gsx, py*gsy). Returns a bool ndarray or None."""
    import numpy as np
    from PIL import Image, ImageDraw
    if len(points) < 3:
        return None
    mapped = [(px * gsx, py * gsy) for (px, py) in points]
    img = Image.new("L", (grid, grid), 0)
    ImageDraw.Draw(img).polygon(mapped, fill=1)
    return np.asarray(img, dtype=bool)


def score_segmentation(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Instance-segmentation mIoU. For each GT mask, greedily match a predicted

    mask of the same (tolerant) label by polygon IoU, computed by rasterizing

    both polygons onto a shared grid. mIoU is averaged over GT masks.



    GT polygons (gt['masks'][i]['polygon_pixels']) are absolute pixels.

    Predicted polygons (pred['masks'][i]['polygon']) are in ctx['coord_space']

    (NORM_0_1000 by default) and are scaled to pixels before rasterizing.

    Never raises: missing / short / malformed polygons score 0 for that mask."""
    import numpy as np

    size = ctx.get("size", (1, 1))
    space = ctx.get("coord_space", CoordSpace.NORM_0_1000)
    if isinstance(size, (list, tuple)) and len(size) == 2:
        W, H = size
    else:
        W, H = (1, 1)
    W = max(int(W or 1), 1)
    H = max(int(H or 1), 1)

    # GT masks (pixel coords). Accept 'polygon_pixels' or 'polygon' as a fallback.
    gts = []
    for m in (gt.get("masks") if isinstance(gt, dict) else []) or []:
        if not isinstance(m, dict):
            continue
        poly = m.get("polygon_pixels")
        if poly is None:
            poly = m.get("polygon") or []
        pts = _seg_poly_points(poly)
        if len(pts) >= 3:
            gts.append((_norm_text(str(m.get("label", ""))), pts))

    # Predicted masks: scale ctx-space polygons to pixels.
    if space == CoordSpace.NORM_0_1:
        sx, sy = float(W), float(H)
    elif space == CoordSpace.NORM_0_1000:
        sx, sy = float(W) / 1000.0, float(H) / 1000.0
    else:  # PIXEL_ABS or unknown -> treat as pixels
        sx, sy = 1.0, 1.0
    preds = []
    for m in (pred.get("masks") if isinstance(pred, dict) else []) or []:
        if not isinstance(m, dict):
            continue
        pts = _seg_poly_points(m.get("polygon") or [], sx, sy)
        if len(pts) >= 3:
            preds.append((_norm_text(str(m.get("label", ""))), pts))

    if not gts:
        ok = 1.0 if not preds else 0.0
        return ok, {"miou": ok, "n_pred": float(len(preds)), "n_gt": 0.0, "matched": 0.0}

    # Shared raster grid. Per-axis scale preserves the image aspect ratio so a
    # non-square image doesn't distort IoU. 128 keeps cost trivial.
    GRID = 128
    gsx = GRID / float(W)
    gsy = GRID / float(H)
    gt_masks = [(lbl, _seg_rasterize(pts, GRID, gsx, gsy)) for (lbl, pts) in gts]
    pred_masks = [(lbl, _seg_rasterize(pts, GRID, gsx, gsy)) for (lbl, pts) in preds]

    used: set = set()
    ious = []
    matched = 0
    for glabel, garr in gt_masks:
        if garr is None or not garr.any():
            ious.append(0.0)
            continue
        best_iou, best_j = 0.0, -1
        for j, (plabel, parr) in enumerate(pred_masks):
            if j in used or parr is None:
                continue
            if not labels_match(plabel, glabel):
                continue
            inter = int(np.logical_and(garr, parr).sum())
            if inter == 0:
                continue
            union = int(np.logical_or(garr, parr).sum())
            iou = inter / union if union else 0.0
            if iou > best_iou:
                best_iou, best_j = iou, j
        if best_j >= 0:
            used.add(best_j)
            matched += 1
        ious.append(best_iou)

    miou = sum(ious) / len(ious) if ious else 0.0
    return miou, {"miou": miou, "n_pred": float(len(preds)),
                  "n_gt": float(len(gts)), "matched": float(matched)}


def _outline_points(flat, space, size):
    """Parse a flat [x1,y1,x2,y2,...] list into pixel (x,y) vertices.

    Each vertex is run through to_canonical as a degenerate 1px box so the

    documented space/clip handling is reused. Robust to odd length / junk;

    returns [] (no polygon) if fewer than 3 usable vertices."""
    import math as _math
    if not isinstance(flat, (list, tuple)):
        return []
    pts = []
    n = len(flat) // 2
    for k in range(n):
        try:
            x = float(flat[2 * k]); y = float(flat[2 * k + 1])
        except (TypeError, ValueError, IndexError):
            continue
        if _math.isnan(x) or _math.isnan(y) or _math.isinf(x) or _math.isinf(y):
            continue
        b = to_canonical([x, y, x, y], space, size, fmt=XYXY)  # scales space + clips to image
        pts.append((b.x1, b.y1))
    # Tolerate a 4-number bbox-as-outline: expand [x1,y1,x2,y2] -> rectangle corners.
    if len(pts) == 2:
        (x1, y1), (x2, y2) = pts
        pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
    return pts if len(pts) >= 3 else []


def _outline_raster_iou(poly_a, poly_b, size, max_dim=200):
    """Polygon IoU by scanline rasterization on a downscaled grid (<= max_dim on

    the long side). Even-odd fill; handles convex + concave outlines. Never raises."""
    import math as _math
    import numpy as np
    W, H = size
    if len(poly_a) < 3 or len(poly_b) < 3:
        return 0.0
    scale = min(1.0, float(max_dim) / max(1, max(W, H)))
    rw = max(1, int(round(W * scale))); rh = max(1, int(round(H * scale)))

    def _fill(poly):
        grid = np.zeros((rh, rw), dtype=bool)
        xs = [p[0] * scale for p in poly]
        ys = [p[1] * scale for p in poly]
        m = len(poly)
        y0 = max(0, int(_math.floor(min(ys)))); y1 = min(rh - 1, int(_math.ceil(max(ys))))
        for yy in range(y0, y1 + 1):
            yc = yy + 0.5
            xint = []
            for i in range(m):
                xi, yi = xs[i], ys[i]
                xj, yj = xs[(i + 1) % m], ys[(i + 1) % m]
                if (yi <= yc < yj) or (yj <= yc < yi):
                    xint.append(xi + (yc - yi) / (yj - yi) * (xj - xi))
            xint.sort()
            for c in range(0, len(xint) - 1, 2):
                xa = max(0, int(_math.ceil(xint[c] - 0.5)))
                xb = min(rw - 1, int(_math.floor(xint[c + 1] - 0.5)))
                if xb >= xa:
                    grid[yy, xa:xb + 1] = True
        return grid

    a = _fill(poly_a); b = _fill(poly_b)
    inter = int(np.logical_and(a, b).sum())
    union = int(np.logical_or(a, b).sum())
    return inter / union if union else 0.0


def score_outline_iou(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Polygon IoU (rasterized) of the predicted main-object outline vs the GT

    outline, gated by a tolerant label match.

      primary = 0.0                       if IoU < 0.5

              = 1.0 if label matches else 0.5   if IoU >= 0.5

    Pred outline is in ctx['coord_space']; GT outline is pixel-abs. Never raises."""
    size = ctx["size"]
    space = ctx["coord_space"]
    pred_poly = _outline_points(pred.get("outline"), space, size)
    if isinstance(gt, dict):
        gt_flat = gt.get("outline") or []
        gt_label = str(gt.get("label", ""))
    else:
        gt_flat, gt_label = [], ""
    gt_poly = _outline_points(gt_flat, CoordSpace.PIXEL_ABS, size)
    iou = _outline_raster_iou(pred_poly, gt_poly, size)
    label_ok = 1.0 if labels_match(str(pred.get("label", "")), gt_label) else 0.0
    if iou >= 0.5:
        primary = 1.0 if label_ok else 0.5
    else:
        primary = 0.0
    return primary, {"poly_iou": iou, "label_ok": label_ok,
                     "n_pred_pts": float(len(pred_poly)), "n_gt_pts": float(len(gt_poly))}


def _as_xyzwhl_yaw(b):
    """Coerce a bbox3d list to 7 floats [x,y,z,w,h,l,yaw]; pad missing yaw.

    Returns None if fewer than 6 usable numbers (need at least center+size)."""
    if not isinstance(b, (list, tuple)):
        return None
    vals = []
    for v in b[:7]:
        try:
            vals.append(float(v))
        except (TypeError, ValueError):
            vals.append(0.0)
    if len(vals) < 6:
        return None
    while len(vals) < 7:
        vals.append(0.0)  # missing yaw -> 0
    return vals


def score_iou3d(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """3D-center matching for the simplified ground-plane proxy.



    A predicted object matches a GT object when (a) their 3D-center L2 distance is

    below `dist_thr` (normalized units) and (b) the size ratio is reasonable (each

    of w,h,l within [1/size_tol, size_tol]); class is checked separately for credit.

    Headline = (0.5*matched + 0.5*class_correct) / n_gt, so a box in the right place

    but mislabeled earns half credit. Reports center distance + class/recall metrics.

    Never raises: short/missing bbox3d entries are dropped, not fatal.

    """
    import math
    dist_thr = 0.3
    size_tol = 3.0

    gts = gt.get("objects", []) if isinstance(gt, dict) else []
    raw_preds = pred.get("objects") if isinstance(pred, dict) else None
    preds = []
    for d in (raw_preds or []):
        if not isinstance(d, dict):
            continue
        vec = _as_xyzwhl_yaw(d.get("bbox3d"))
        if vec is None:
            continue
        cls = _norm_text(str(d.get("class", d.get("label", ""))))
        try:
            sc = float(d.get("score", 1.0))
        except (TypeError, ValueError):
            sc = 1.0
        preds.append((cls, sc, vec))
    preds.sort(key=lambda t: t[1], reverse=True)

    if not gts:
        return (1.0 if not preds else 0.0), {"matched_frac": 0.0, "precision": 0.0,
                                             "class_acc": 0.0, "center_dist": float(dist_thr),
                                             "n_pred": float(len(preds)), "n_gt": 0.0}

    matched_gt: set = set()
    n_class_ok = 0
    dist_sum = 0.0
    dist_n = 0
    for pcls, _sc, pvec in preds:
        best_gi, best_d = -1, dist_thr
        for gi, g in enumerate(gts):
            if gi in matched_gt:
                continue
            gvec = _as_xyzwhl_yaw(g.get("bbox3d"))
            if gvec is None:
                continue
            dx, dy, dz = pvec[0] - gvec[0], pvec[1] - gvec[1], pvec[2] - gvec[2]
            d3 = math.sqrt(dx * dx + dy * dy + dz * dz)
            if d3 > best_d:
                continue
            ok_size = True
            for idx in (3, 4, 5):  # w, h, l
                ps, gs = abs(pvec[idx]), abs(gvec[idx])
                if gs <= 1e-6:
                    continue
                r = (ps / gs) if ps > 1e-6 else 0.0
                if r < (1.0 / size_tol) or r > size_tol:
                    ok_size = False
                    break
            if not ok_size:
                continue
            best_gi, best_d = gi, d3
        if best_gi >= 0:
            matched_gt.add(best_gi)
            dist_sum += best_d
            dist_n += 1
            gcls = _norm_text(str(gts[best_gi].get("class", gts[best_gi].get("label", ""))))
            if labels_match(pcls, gcls):
                n_class_ok += 1

    n_gt = len(gts)
    n_pred = len(preds)
    matched = len(matched_gt)
    recall = matched / n_gt
    precision = matched / n_pred if n_pred else 0.0
    class_acc = n_class_ok / matched if matched else 0.0
    mean_center_dist = (dist_sum / dist_n) if dist_n else float(dist_thr)
    primary = (0.5 * matched + 0.5 * n_class_ok) / n_gt
    return primary, {"matched_frac": recall, "precision": precision,
                     "class_acc": class_acc, "center_dist": mean_center_dist,
                     "n_pred": float(n_pred), "n_gt": float(n_gt)}


def _wrap_deg(a: float) -> float:
    """Wrap an angle (degrees) into [-180, 180)."""
    return (float(a) + 180.0) % 360.0 - 180.0


def score_camera_rotation(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Camera rotation: mean absolute angular error across yaw/pitch/roll, each wrapped

    to [-180,180). primary = acc@30deg = fraction of the 3 axes within 30 deg of GT.

    Robust to missing / short / non-numeric rotation lists (never raises)."""
    gt_rot = (gt or {}).get("rotation") if isinstance(gt, dict) else None
    if not isinstance(gt_rot, (list, tuple)) or len(gt_rot) < 3:
        return None, {}
    raw = pred.get("rotation")
    p = list(raw) if isinstance(raw, (list, tuple)) else []
    p = (p + [0.0, 0.0, 0.0])[:3]  # pad missing axes with 0 so a short list scores, not crashes
    errs, within = [], 0
    for i in range(3):
        try:
            d = abs(_wrap_deg(float(p[i]) - float(gt_rot[i])))
        except (TypeError, ValueError):
            d = 180.0
        errs.append(d)
        if d <= 30.0:
            within += 1
    mean_abs_err = sum(errs) / 3.0
    acc30 = within / 3.0
    return acc30, {"acc@30deg": acc30, "mean_abs_err": mean_abs_err,
                   "yaw_err": errs[0], "pitch_err": errs[1], "roll_err": errs[2]}


def score_vqa(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Grounded-VQA answer accuracy (OCR-style containment, reused here).



    Credit if the predicted answer EQUALS any gold answer, CONTAINS a gold

    answer, or is CONTAINED IN a gold answer (all after _norm_text). Reports

    `exact` and `contains` separately. The optional grounded_region box is NOT

    scored — VQA datasets carry no per-question GT box, so answer text is the

    only signal. Never raises: missing/short fields collapse to a 0.0 score.

    """
    golds = _vqa_gold_answers(gt)
    pred_ans = _norm_text(str(pred.get("answer", "")))
    if not golds:
        # no GT answers -> only an empty prediction can be "correct"
        return (1.0 if not pred_ans else 0.0), {"exact": 0.0, "contains": 0.0}
    exact = 1.0 if pred_ans in golds else 0.0
    contains = 0.0
    if pred_ans:
        for g in golds:
            if g and (g in pred_ans or pred_ans in g):
                contains = 1.0
                break
    primary = max(exact, contains)
    return primary, {"exact": exact, "contains": contains}


def _vqa_gold_answers(gt) -> list[str]:
    """Normalize GT into a list of acceptable (normalized) gold answer strings.

    Accepts {"answers": [...]}, {"answers": "x"}, {"answer": "x"}, a bare list,

    or a bare string. Anything else -> []."""
    if isinstance(gt, dict):
        a = gt.get("answers")
        if isinstance(a, (list, tuple)):
            return [_norm_text(str(x)) for x in a if str(x).strip()]
        if a is not None:
            return [_norm_text(str(a))]
        if "answer" in gt:
            return [_norm_text(str(gt["answer"]))]
    if isinstance(gt, (list, tuple)):
        return [_norm_text(str(x)) for x in gt if str(x).strip()]
    if isinstance(gt, str):
        return [_norm_text(gt)]
    return []


def score_style(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Style accuracy = 1.0 if pred style matches gt style (tolerant via labels_match),

    else 0.0. Layout/symmetry accuracy reported as side metrics when the GT carries them.

    Robust: never raises; guards missing/short fields and non-dict GT."""
    def _acc(pred_val, gt_val):
        if gt_val is None:
            return None
        gv = _norm_text(str(gt_val))
        if not gv:
            return None
        pv = _norm_text(str(pred_val if pred_val is not None else ""))
        if not pv:
            return 0.0
        return 1.0 if (pv == gv or labels_match(pv, gv)) else 0.0

    if not isinstance(gt, dict):
        gt = {"style": gt}

    style_acc = _acc(pred.get("style"), gt.get("style"))
    metrics: dict = {}
    if style_acc is not None:
        metrics["style_acc"] = style_acc
    layout_acc = _acc(pred.get("layout"), gt.get("layout"))
    if layout_acc is not None:
        metrics["layout_acc"] = layout_acc
    symmetry_acc = _acc(pred.get("symmetry"), gt.get("symmetry"))
    if symmetry_acc is not None:
        metrics["symmetry_acc"] = symmetry_acc

    # Headline accuracy is style accuracy (the category's defining axis).
    primary = style_acc if style_acc is not None else None
    return primary, metrics


def score_schema_only(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
    """Stub categories: no GT wired yet → no task accuracy, only universal metrics."""
    return None, {}


_SCORERS: dict[str, Callable] = {
    "classification": score_classification,
    "detection": score_detection,
    "ocr": score_ocr,
    "datatype_diff": score_datatype_diff,
    "datatype_util": score_datatype_util,
    "triples": score_triples,
    "depth_order": score_depth_order,
    "subject_fixation": score_subject_fixation,
    "segmentation": score_segmentation,
    "outline_iou": score_outline_iou,
    "iou3d": score_iou3d,
    "angular_error": score_camera_rotation,
    "vqa": score_vqa,
    "style": score_style,
    "schema_only": score_schema_only,
}


# ──────────────────────────────────────────────────────────────────────────────
# Sample + run scoring
# ──────────────────────────────────────────────────────────────────────────────

def score_vision_sample(

    spec: VisionTaskSpec,

    raw_output: str,

    gt,

    *,

    mode: str,

    image_id: str,

    image_size: tuple[int, int],

    grammar_conformant: bool = False,

    n_output_tokens: int = 0,

    gen_seconds: float = 0.0,

) -> MetricResult:
    parse = parse_against(raw_output, model_for(spec))
    parse_ok = parse.schema_valid or (parse.error or "").startswith("schema:")

    if not parse.schema_valid or parse.parsed is None:
        return MetricResult(
            category=spec.category, image_id=image_id, mode=mode,
            parse_ok=parse_ok, schema_valid=False, needed_repair=parse.needed_repair,
            needed_structural_repair=parse.needed_structural_repair,
            grammar_conformant=grammar_conformant, primary_score=None, metrics={},
            n_output_tokens=n_output_tokens, gen_seconds=gen_seconds, error=parse.error,
        )

    pred = parse.parsed.model_dump()
    ctx = {"size": image_size, "coord_space": spec.coord_space}
    scorer = _SCORERS.get(spec.metric, score_schema_only)
    try:
        primary, m = scorer(pred, gt, ctx)
    except Exception as e:  # a scorer bug must never crash a long run
        primary, m = None, {}
        return MetricResult(
            category=spec.category, image_id=image_id, mode=mode, parse_ok=True,
            schema_valid=True, needed_repair=parse.needed_repair,
            needed_structural_repair=parse.needed_structural_repair,
            grammar_conformant=grammar_conformant, primary_score=None, metrics={},
            n_output_tokens=n_output_tokens, gen_seconds=gen_seconds,
            error=f"scorer error: {e}",
        )
    return MetricResult(
        category=spec.category, image_id=image_id, mode=mode, parse_ok=True,
        schema_valid=True, needed_repair=parse.needed_repair,
        needed_structural_repair=parse.needed_structural_repair,
        grammar_conformant=grammar_conformant, primary_score=primary, metrics=m,
        n_output_tokens=n_output_tokens, gen_seconds=gen_seconds,
    )


def score_vision_run(results: list[MetricResult], *, model: str = "", reasoning: str = "",

                     category: str = "", mode: str = "") -> VisionRunMetrics:
    n = len(results)
    if n == 0:
        return VisionRunMetrics(category, model, reasoning, mode, 0, 0.0, 0.0,
                                False, None, {}, 0.0, 0.0, 0.0, None)
    valid = [r for r in results if r.schema_valid]
    schema_valid_rate = len(valid) / n
    json_robustness = sum(1 for r in results if r.json_robust) / n
    scored = [r for r in valid if r.primary_score is not None]
    has_task = bool(scored)
    primary_mean = (sum(r.primary_score for r in scored) / len(scored)) if scored else None

    # average the per-sample metric dicts
    metrics_mean: dict = {}
    keys = set()
    for r in scored:
        keys |= set(r.metrics.keys())
    for k in keys:
        vals = [r.metrics[k] for r in scored if k in r.metrics and not math.isnan(r.metrics[k])]
        if vals:
            metrics_mean[k] = sum(vals) / len(vals)

    total_tokens = sum(r.n_output_tokens for r in results)
    total_secs = sum(r.gen_seconds for r in results)
    mean_tokens = total_tokens / n
    tok_per_sec = (total_tokens / total_secs) if total_secs > 0 else 0.0

    return VisionRunMetrics(
        category=category or results[0].category, model=model, reasoning=reasoning,
        mode=mode or results[0].mode, n=n,
        schema_valid_rate=schema_valid_rate, json_robustness=json_robustness,
        has_task_score=has_task, primary_score_mean=primary_mean, metrics_mean=metrics_mean,
        mean_output_tokens=mean_tokens, total_gen_seconds=total_secs, tokens_per_sec=tok_per_sec,
        labeler_score=labeler_score(primary_mean, schema_valid_rate, json_robustness),
    )