File size: 14,439 Bytes
4cc47cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pure Python implementation of the topic-gallery-top20-v1 protocol.

Prediction JSONL rows contain exactly topic, query_id, and retrieved_ids.
Label rows contain topic, query_id, and target (private auxiliary fields are
ignored). IDs are topic-local nonnegative integers. No error includes input values.
"""

from __future__ import annotations

import json
import math
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path

from src.leaderboard.schema import (
    ACTIVE_BENCHMARK_VERSION, COGNITIVE_LABELS, KNOWLEDGE_GROUPS, KNOWLEDGE_LABELS,
    METRICS, REASONING_LABELS,
)
from src.leaderboard.validate_scores import validate_score_data

MAX_BYTES = 2 * 1024 * 1024
MAX_ROW_BYTES = 16 * 1024
QUERY_COUNT = 1750
PROTOCOL = "topic-gallery-top20-v1"
_CONFIG_ERROR = "Invalid evaluation configuration."
SKILL_LABELS = {
    "knowledge_reasoning": "KR", "spatial_reasoning": "SR",
    "fine_grained_visual_reasoning": "FGVR", "logical_reasoning": "LR",
    "symbolic_reasoning": "SyR", "conceptual_abstraction": "CA",
}
DEMAND_LABELS = {
    frozenset(["None"]): "PO",
    frozenset(["knowledge-intensive"]): "KI",
    frozenset(["reasoning-intensive"]): "RI",
    frozenset(["knowledge-intensive", "reasoning-intensive"]): "KRI",
}


class SubmissionError(ValueError):
    """A submission violates the public prediction protocol."""


class LabelSet(dict):
    """Private targets plus private grouping metadata used during scoring."""

    def __init__(self, targets, annotations, knowledge_by_topic):
        super().__init__(targets)
        self.annotations = annotations
        self.knowledge_by_topic = knowledge_by_topic


def _object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError
        result[key] = value
    return result


def _constant(value):
    raise ValueError


def _decode(line):
    return json.loads(line.decode("utf-8"), object_pairs_hook=_object, parse_constant=_constant)


def _key(row):
    if not isinstance(row, dict) or not isinstance(row.get("topic"), str) or type(row.get("query_id")) is not int or row["query_id"] < 0:
        raise ValueError
    return row["topic"], row["query_id"]


def _ids(values, gallery, count):
    return (isinstance(values, list) and len(values) == count
            and all(type(value) is int and value >= 0 and value in gallery for value in values)
            and len(set(values)) == count)


def _manifest(manifest):
    try:
        if (manifest["benchmark_version"] != "1.0"
                or manifest["benchmark_version"] != ACTIVE_BENCHMARK_VERSION
                or type(manifest["top_k"]) is not int or manifest["top_k"] != 20
                or not isinstance(manifest["dataset_revision"], str) or not manifest["dataset_revision"].strip()):
            raise ValueError
        topics = manifest["topics"]
        if not isinstance(topics, dict) or len(topics) != len(KNOWLEDGE_LABELS):
            raise ValueError
        galleries = {}
        knowledge = []
        for topic, data in topics.items():
            if not isinstance(topic, str) or not topic:
                raise ValueError
            values = data["gallery_ids"]
            if (not isinstance(values, list) or len(values) < 20
                    or any(type(value) is not int or value < 0 for value in values) or len(set(values)) != len(values)):
                raise ValueError
            galleries[topic] = set(values)
            knowledge.append(data["knowledge_label"])
        if set(knowledge) != set(KNOWLEDGE_LABELS):
            raise ValueError
        rows = manifest["queries"]
        if not isinstance(rows, list) or len(rows) != QUERY_COUNT:
            raise ValueError
        queries = {}
        used_topics = set()
        for row in rows:
            key = _key(row)
            if key in queries or key[0] not in galleries:
                raise ValueError
            queries[key] = row
            used_topics.add(key[0])
        if used_topics != set(topics):
            raise ValueError
        return queries, galleries
    except (KeyError, TypeError, ValueError, AttributeError):
        raise ValueError(_CONFIG_ERROR) from None


def validate_predictions(content: bytes, manifest: dict) -> dict[tuple[str, int], list[int]]:
    """Validate strict UTF-8 JSONL with complete coverage and 20 unique IDs.

    LF and CRLF are accepted, including one optional final line ending. Blank
    lines, BOMs, non-finite JSON numbers, and duplicate object keys are rejected.
    Limits count bytes; the row limit excludes its LF/CRLF line ending.
    """
    queries, galleries = _manifest(manifest)
    if not isinstance(content, bytes) or len(content) > MAX_BYTES:
        raise SubmissionError("Submission exceeds the byte limit or is not bytes.")
    predictions = {}
    lines = content.split(b"\n")
    if lines[-1] == b"":
        lines.pop()
    try:
        for line in lines:
            if line.endswith(b"\r"):
                line = line[:-1]
            if len(line) > MAX_ROW_BYTES:
                raise SubmissionError("Submission row exceeds the byte limit.")
            row = _decode(line)
            key = _key(row)
            if set(row) != {"topic", "query_id", "retrieved_ids"}:
                raise ValueError
            if key not in queries or key in predictions or not _ids(row["retrieved_ids"], galleries[key[0]], 20):
                raise ValueError
            predictions[key] = row["retrieved_ids"]
        if predictions.keys() != queries.keys():
            raise ValueError
    except SubmissionError:
        raise
    except (ValueError, TypeError, KeyError, RecursionError):
        raise SubmissionError("Invalid prediction JSONL or query/candidate coverage.") from None
    return predictions


def _knowledge_mapping(path: Path, topics: set[str]) -> dict[str, str]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_object,
                          parse_constant=_constant)
        if not isinstance(data, dict) or set(data) != {
                "Visual Cognition", "Natural Science", "Formal Science",
                "Humanities & Social Science", "Engineering & Technology"}:
            raise ValueError
        mapping = {}
        for domain, members in data.items():
            if not isinstance(members, list) or not members or any(not isinstance(x, str) for x in members):
                raise ValueError
            for topic in members:
                if topic in mapping:
                    raise ValueError
                mapping[topic] = domain
        if set(mapping) != topics:
            raise ValueError
        return mapping
    except (OSError, ValueError, TypeError, json.JSONDecodeError):
        raise ValueError(_CONFIG_ERROR) from None


def load_labels(path: Path, manifest: dict, knowledge_path: Path | None = None) -> LabelSet:
    """Read private targets and annotations; v1.0 requires one target per query."""
    queries, galleries = _manifest(manifest)
    labels = {}
    annotations = {}
    knowledge_by_topic = _knowledge_mapping(
        knowledge_path or path.with_name("knowledge_domains.json"), set(galleries))
    if any(manifest["topics"][topic]["knowledge_label"] not in KNOWLEDGE_GROUPS[domain]
           for topic, domain in knowledge_by_topic.items()):
        raise ValueError(_CONFIG_ERROR)
    try:
        with path.open("rb") as handle:
            for line in handle:
                row = _decode(line)
                key = _key(row)
                if key not in queries or key in labels or not _ids(row["target"], galleries[key[0]], 1):
                    raise ValueError
                demand = DEMAND_LABELS.get(frozenset(row["reasoning_demand"]))
                skills = row["reasoning_skill"]
                if (demand is None or not isinstance(skills, list)
                        or any(skill not in SKILL_LABELS for skill in skills)
                        or len(set(skills)) != len(skills)):
                    raise ValueError
                labels[key] = row["target"]
                annotations[key] = {
                    "reasoning_skill": [SKILL_LABELS[skill] for skill in skills],
                    "cognitive_demand": demand,
                }
        if labels.keys() != queries.keys():
            raise ValueError
    except (OSError, ValueError, TypeError, KeyError, RecursionError):
        raise ValueError(_CONFIG_ERROR) from None
    if ({skill for row in annotations.values() for skill in row["reasoning_skill"]}
            != set(REASONING_LABELS)
            or {row["cognitive_demand"] for row in annotations.values()} != set(COGNITIVE_LABELS)):
        raise ValueError(_CONFIG_ERROR)
    return LabelSet(labels, annotations, knowledge_by_topic)


def evaluate(predictions, labels, manifest, model: dict) -> dict:
    """Compute percentages without rounding; validate even direct API inputs."""
    queries, galleries = _manifest(manifest)
    try:
        if (not isinstance(labels, LabelSet) or labels.keys() != queries.keys()
                or labels.annotations.keys() != queries.keys()
                or set(labels.knowledge_by_topic) != set(galleries)):
            raise ValueError
        for key, targets in labels.items():
            if (not isinstance(key, tuple) or len(key) != 2 or type(key[1]) is not int or key[1] < 0
                    or not _ids(targets, galleries[key[0]], 1)):
                raise ValueError
        for annotation in labels.annotations.values():
            skills = annotation["reasoning_skill"]
            if (not isinstance(skills, list) or len(set(skills)) != len(skills)
                    or any(skill not in REASONING_LABELS for skill in skills)
                    or annotation["cognitive_demand"] not in COGNITIVE_LABELS):
                raise ValueError
        if ({skill for annotation in labels.annotations.values()
                for skill in annotation["reasoning_skill"]} != set(REASONING_LABELS)
                or {annotation["cognitive_demand"] for annotation in labels.annotations.values()}
                != set(COGNITIVE_LABELS)):
            raise ValueError
    except (ValueError, KeyError, TypeError, AttributeError):
        raise ValueError(_CONFIG_ERROR) from None
    try:
        if not isinstance(predictions, dict) or predictions.keys() != queries.keys():
            raise ValueError
        for key, candidates in predictions.items():
            if (not isinstance(key, tuple) or len(key) != 2 or type(key[1]) is not int or key[1] < 0
                    or not _ids(candidates, galleries[key[0]], 20)):
                raise ValueError
    except (ValueError, KeyError, TypeError):
        raise SubmissionError("Invalid prediction query/candidate coverage.") from None

    groups = {"knowledge_domain": KNOWLEDGE_LABELS, "reasoning_skill": REASONING_LABELS,
              "cognitive_demand": COGNITIVE_LABELS}
    results = {}
    for family, metrics in METRICS.items():
        buckets = {group: {label: {metric: [] for metric in metrics} for label in names}
                   for group, names in groups.items()}
        for key in queries:
            candidates = predictions[key]
            target = labels[key][0]
            rank = candidates.index(target) + 1 if target in candidates else 21
            annotation = labels.annotations[key]
            demand = annotation["cognitive_demand"]
            # Dual-tagged queries belong to both inclusive groups and their intersection.
            cognitive_membership = ["KI", "RI", "KRI"] if demand == "KRI" else [demand]
            membership = {"knowledge_domain": [manifest["topics"][key[0]]["knowledge_label"]],
                          "reasoning_skill": annotation["reasoning_skill"],
                          "cognitive_demand": cognitive_membership}
            for metric in metrics:
                cutoff = int(metric.split("@")[1])
                value = (100.0 if family == "Recall" else 100.0 / math.log2(rank + 1)) if rank <= cutoff else 0.0
                for group, names in membership.items():
                    for name in names:
                        buckets[group][name][metric].append(value)
        results[family] = {
            group: {name: {metric: math.fsum(values) / len(values) for metric, values in scores.items()}
                    for name, scores in members.items()}
            for group, members in buckets.items()
        }
        for group in ("knowledge_domain", "reasoning_skill"):
            results[family][group]["macro_avg"] = {
                metric: math.fsum(results[family][group][name][metric] for name in groups[group]) / len(groups[group])
                for metric in metrics
            }
    score = {"model": deepcopy(model), "meta_data": {
        "date": datetime.now(timezone.utc).date().isoformat(),
        "benchmark_version": ACTIVE_BENCHMARK_VERSION,
        "dataset_revision": manifest["dataset_revision"], "evaluation_protocol": PROTOCOL,
    }, "results": results}
    if validate_score_data(score, expected_benchmark_version=ACTIVE_BENCHMARK_VERSION):
        raise ValueError("Invalid score metadata or evaluation configuration.")
    return score


def summary(score) -> dict:
    """Return the leaderboard's equal-weight knowledge/reasoning overview."""
    metrics = []
    unavailable = []
    for family, names in METRICS.items():
        results = score["results"][family]
        for group, labels in (("knowledge_domain", KNOWLEDGE_LABELS),
                              ("reasoning_skill", REASONING_LABELS), ("cognitive_demand", COGNITIVE_LABELS)):
            for label in labels:
                if any(results.get(group, {}).get(label, {}).get(metric) is None for metric in names):
                    item = {"family": family, "group": group, "label": label}
                    unavailable.append(item)
        for metric in names:
            knowledge = results["knowledge_domain"]["macro_avg"][metric]
            reasoning = results["reasoning_skill"]["macro_avg"][metric]
            metrics.append({"family": family, "metric": metric, "knowledge_macro": knowledge,
                            "reasoning_macro": reasoning,
                            "overview": (knowledge + reasoning) / 2 if knowledge is not None and reasoning is not None else None})
    return {"metrics": metrics, "unavailable_groups": unavailable}