SeaWolf-AI commited on
Commit
91a564b
·
verified ·
1 Parent(s): 26852f2

Add decision core: autojev/evaluate.py

Browse files
Files changed (1) hide show
  1. autojev/evaluate.py +543 -0
autojev/evaluate.py ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accuracy and calibration on fixed Choice/Noul examples, with paired comparisons."""
2
+
3
+ import argparse
4
+ from collections import defaultdict
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from datetime import datetime, timezone
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import os
11
+ from pathlib import Path
12
+ import time
13
+ from typing import NotRequired, TypedDict, cast
14
+
15
+ import httpx
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+
19
+ from autojev.types import Example, JSONValue, Label, Question
20
+
21
+
22
+ ECE_BINS = 15
23
+ CALIBRATION_MARGIN = 0.010
24
+ JEV_ENDPOINT = "https://openrouter.ai/api/alpha/decisions"
25
+ type FloatArray = NDArray[np.float64]
26
+ type Infer = Callable[[Sequence[Example]], Sequence[Sequence[float]]]
27
+ type UsageCallback = Callable[[Mapping[str, JSONValue]], None]
28
+
29
+
30
+ class Prediction(TypedDict):
31
+ id: str
32
+ suite: str
33
+ dataset: str
34
+ family: str
35
+ label: Label
36
+ options: list[Label]
37
+ probabilities: list[float]
38
+ prediction: Label
39
+ correct: bool
40
+ confidence: float
41
+ temperature: float
42
+ logits: NotRequired[list[float]]
43
+ soft_target: NotRequired[list[float]]
44
+ answer: NotRequired[dict[str, JSONValue]]
45
+ model: NotRequired[str]
46
+ provider: NotRequired[str]
47
+ request_id: NotRequired[str]
48
+ latency_ms: NotRequired[float]
49
+ normalization_total: NotRequired[float]
50
+ created: NotRequired[str]
51
+ request_sha256: NotRequired[str]
52
+
53
+
54
+ class ReliabilityBin(TypedDict):
55
+ lower: float
56
+ upper: float
57
+ count: int
58
+ confidence: float | None
59
+ accuracy: float | None
60
+
61
+
62
+ class Metrics(TypedDict):
63
+ count: int
64
+ accuracy: float
65
+ ece: float
66
+ brier: float
67
+ nll: float
68
+ reliability: list[ReliabilityBin]
69
+ zero_probability_count: int
70
+ soft_count: NotRequired[int]
71
+ soft_nll: NotRequired[float]
72
+ soft_brier: NotRequired[float]
73
+
74
+
75
+ class Effect(TypedDict):
76
+ local: float
77
+ reference: float
78
+ difference: float
79
+ difference_ci95: list[float]
80
+
81
+
82
+ class Comparison(TypedDict):
83
+ count: int
84
+ families: int
85
+ effects: dict[str, Effect]
86
+ by_suite: dict[str, dict[str, Effect]]
87
+ empirical_target_met: bool
88
+ statistically_supported: bool
89
+ calibration_margin: float
90
+ bootstrap_replicates: int
91
+ bootstrap_seed: int
92
+
93
+
94
+ def options(question: Question) -> list[Label]:
95
+ if question["type"] == "noul":
96
+ return [False, True]
97
+ if question["type"] == "choice":
98
+ return list(question["criteria"])
99
+ if question["type"] == "score": # team-1: ordered rubric levels "0".."n-1", same as model.options
100
+ return [str(i) for i in range(len(question["criteria"]))]
101
+ raise ValueError("The accuracy/calibration experiment includes Choice, Noul and Score only")
102
+
103
+
104
+ def hard_label(row: Example) -> Label:
105
+ """Soft SFT targets never silently replace the evaluation reference label."""
106
+ label = row.get("label", row["target"])
107
+ if not isinstance(label, (str, int, bool)):
108
+ raise ValueError(f"An explicit hard label is required: {row['id']}")
109
+ return label
110
+
111
+
112
+ def label_index(labels: Sequence[Label], label: Label) -> int:
113
+ for index, value in enumerate(labels):
114
+ if type(value) is type(label) and value == label:
115
+ return index
116
+ raise ValueError(f"Reference or prediction {label!r} is not an option")
117
+
118
+
119
+ def make_prediction(
120
+ row: Example, probabilities: Sequence[float], *, prediction: Label | None = None,
121
+ temperature: float = 1.0,
122
+ ) -> Prediction:
123
+ labels = options(row["question"])
124
+ values = [float(value) for value in probabilities]
125
+ if len(values) != len(labels) or any(not math.isfinite(p) or not 0 <= p <= 1 for p in values):
126
+ raise ValueError(f"Invalid probability vector: {row['id']}")
127
+ if not math.isclose(sum(values), 1, abs_tol=1e-8):
128
+ raise ValueError(f"Probabilities must be normalized: {row['id']}")
129
+ label = hard_label(row)
130
+ label_index(labels, label)
131
+ chosen = labels[max(range(len(labels)), key=values.__getitem__)] if prediction is None else prediction
132
+ chosen_index = label_index(labels, chosen)
133
+ family = row.get("family")
134
+ dataset = row["source"].get("dataset", row["suite"])
135
+ if not isinstance(family, str) or not family or not isinstance(dataset, str):
136
+ raise ValueError(f"Missing dataset/family provenance: {row['id']}")
137
+ result: Prediction = {
138
+ "id": row["id"], "suite": row["suite"], "dataset": dataset, "family": family,
139
+ "label": label, "options": labels, "probabilities": values, "prediction": chosen,
140
+ "correct": type(chosen) is type(label) and chosen == label,
141
+ "confidence": values[chosen_index], "temperature": temperature,
142
+ }
143
+ human = row["source"].get("human_distribution")
144
+ soft: list[float] | None = None
145
+ if isinstance(human, dict):
146
+ keys = [str(value).lower() if isinstance(value, bool) else str(value) for value in labels]
147
+ if set(human) != set(keys):
148
+ raise ValueError(f"Human distribution does not match options: {row['id']}")
149
+ soft = [float(cast(float, human[key])) for key in keys]
150
+ elif isinstance(row["target"], list):
151
+ soft = [float(value) for value in row["target"]]
152
+ if soft is not None:
153
+ if len(soft) != len(labels) or any(not math.isfinite(p) or not 0 <= p <= 1 for p in soft) or not math.isclose(sum(soft), 1, abs_tol=1e-6):
154
+ raise ValueError(f"Invalid soft distribution: {row['id']}")
155
+ result["soft_target"] = soft
156
+ return result
157
+
158
+
159
+ def evaluate_logits(
160
+ rows: Sequence[Example], logits: Sequence[Sequence[float]], temperature: float = 1.0,
161
+ ) -> list[Prediction]:
162
+ if len(rows) != len(logits) or not math.isfinite(temperature) or temperature <= 0:
163
+ raise ValueError("Logit rows must match examples and temperature must be positive")
164
+ result: list[Prediction] = []
165
+ for row, values in zip(rows, logits, strict=True):
166
+ count = len(options(row["question"]))
167
+ raw = np.asarray(values[:count], dtype=np.float64)
168
+ if len(raw) != count or not np.isfinite(raw).all():
169
+ raise ValueError(f"Missing or nonfinite valid logits: {row['id']}")
170
+ shifted = (raw - raw.max()) / temperature
171
+ probabilities = np.exp(shifted)
172
+ probabilities /= probabilities.sum()
173
+ prediction = make_prediction(row, probabilities.tolist(), temperature=temperature)
174
+ prediction["logits"] = raw.tolist()
175
+ result.append(prediction)
176
+ validate_coverage(rows, result)
177
+ return result
178
+
179
+
180
+ def predict_local(rows: Sequence[Example], infer: Infer, temperature: float = 1.0) -> list[Prediction]:
181
+ """The model adapter returns raw logits in the supplied row/option order."""
182
+ return evaluate_logits(rows, infer(rows), temperature)
183
+
184
+
185
+ def fit_temperature(logits: Sequence[Sequence[float]], target_indices: Sequence[int]) -> float:
186
+ """Fit one scalar on the separate temperature fold using hard-label NLL."""
187
+ if not logits or len(logits) != len(target_indices):
188
+ raise ValueError("Temperature fitting needs nonempty matching logits and hard labels")
189
+ width = max(map(len, logits))
190
+ values = np.full((len(logits), width), -np.inf, dtype=np.float64)
191
+ for index, (row, target) in enumerate(zip(logits, target_indices, strict=True)):
192
+ if not row or not 0 <= target < len(row) or not np.isfinite(row).all():
193
+ raise ValueError("Temperature fitting requires finite, unpadded valid logits")
194
+ values[index, :len(row)] = row
195
+ values -= values.max(axis=1, keepdims=True)
196
+ target_logits = values[np.arange(len(values)), np.asarray(target_indices)]
197
+
198
+ def loss(log_temperature: float) -> float:
199
+ inverse = math.exp(-log_temperature)
200
+ return float(np.mean(np.log(np.exp(values * inverse).sum(axis=1)) - target_logits * inverse))
201
+
202
+ # A broad, fixed interval avoids selecting temperature bounds after seeing dev results.
203
+ low, high = math.log(0.05), math.log(20.0)
204
+ ratio = (math.sqrt(5) - 1) / 2
205
+ left, right = high - ratio * (high - low), low + ratio * (high - low)
206
+ left_loss, right_loss = loss(left), loss(right)
207
+ for _ in range(80):
208
+ if left_loss <= right_loss:
209
+ high, right, right_loss = right, left, left_loss
210
+ left = high - ratio * (high - low)
211
+ left_loss = loss(left)
212
+ else:
213
+ low, left, left_loss = left, right, right_loss
214
+ right = low + ratio * (high - low)
215
+ right_loss = loss(right)
216
+ candidates = [math.log(0.05), (low + high) / 2, math.log(20.0), 0.0]
217
+ return math.exp(min(candidates, key=loss))
218
+
219
+
220
+ def metrics(rows: Sequence[Prediction]) -> Metrics:
221
+ if not rows:
222
+ raise ValueError("Cannot score an empty evaluation")
223
+ bins: list[list[Prediction]] = [[] for _ in range(ECE_BINS)]
224
+ brier, nll, soft_brier, soft_nll = [], [], [], []
225
+ for row in rows:
226
+ index = label_index(row["options"], row["label"])
227
+ probabilities = row["probabilities"]
228
+ nll.append(-math.log(max(probabilities[index], 1e-12)))
229
+ brier.append(sum((p - int(i == index)) ** 2 for i, p in enumerate(probabilities)))
230
+ bins[min(ECE_BINS - 1, int(row["confidence"] * ECE_BINS))].append(row)
231
+ if "soft_target" in row:
232
+ soft = row["soft_target"]
233
+ soft_brier.append(sum((p - q) ** 2 for p, q in zip(probabilities, soft, strict=True)))
234
+ soft_nll.append(-sum(q * math.log(max(p, 1e-12)) for p, q in zip(probabilities, soft, strict=True)))
235
+ reliability: list[ReliabilityBin] = []
236
+ ece = 0.0
237
+ for index, group in enumerate(bins):
238
+ confidence = sum(row["confidence"] for row in group) / len(group) if group else None
239
+ accuracy = sum(row["correct"] for row in group) / len(group) if group else None
240
+ if confidence is not None and accuracy is not None:
241
+ ece += len(group) / len(rows) * abs(confidence - accuracy)
242
+ reliability.append({"lower": index / ECE_BINS, "upper": (index + 1) / ECE_BINS,
243
+ "count": len(group), "confidence": confidence, "accuracy": accuracy})
244
+ result: Metrics = {
245
+ "count": len(rows), "accuracy": sum(row["correct"] for row in rows) / len(rows),
246
+ "ece": ece, "brier": sum(brier) / len(rows), "nll": sum(nll) / len(rows),
247
+ "reliability": reliability,
248
+ "zero_probability_count": sum(p == 0 for row in rows for p in row["probabilities"]),
249
+ }
250
+ if soft_nll:
251
+ result.update({"soft_count": len(soft_nll), "soft_nll": sum(soft_nll) / len(soft_nll), "soft_brier": sum(soft_brier) / len(soft_brier)})
252
+ return result
253
+
254
+
255
+ def calibration_ok(local: Metrics, reference: Metrics, margin: float = CALIBRATION_MARGIN) -> bool:
256
+ return local["ece"] <= reference["ece"] + margin and local["brier"] <= reference["brier"] + margin
257
+
258
+
259
+ def selection_key(summary: Metrics, step: int) -> tuple[float, float, int]:
260
+ """Minimize this key among checkpoints passing calibration_ok."""
261
+ return -summary["accuracy"], summary["brier"], step
262
+
263
+
264
+ def validate_coverage(rows: Sequence[Example], predictions: Sequence[Prediction]) -> None:
265
+ expected = {row["id"]: row for row in rows}
266
+ actual = {row["id"]: row for row in predictions}
267
+ if len(expected) != len(rows) or len(actual) != len(predictions) or expected.keys() != actual.keys():
268
+ raise ValueError("Require exactly one successful prediction for every fixed evaluation ID")
269
+ for identifier, row in expected.items():
270
+ saved = actual[identifier]
271
+ rebuilt = make_prediction(row, saved["probabilities"], prediction=saved["prediction"], temperature=saved["temperature"])
272
+ for field in ("suite", "dataset", "family", "label", "options", "correct", "confidence"):
273
+ if saved[field] != rebuilt[field]:
274
+ raise ValueError(f"Saved prediction differs from its evaluation example: {identifier}/{field}")
275
+
276
+
277
+ def _statistics(row: Prediction) -> FloatArray:
278
+ """Additive sufficient statistics allow exact ECE recomputation per bootstrap draw."""
279
+ output = np.zeros(4 + 3 * ECE_BINS, dtype=np.float64)
280
+ index = label_index(row["options"], row["label"])
281
+ probabilities = row["probabilities"]
282
+ output[:4] = [1, int(row["correct"]), sum((p - int(i == index)) ** 2 for i, p in enumerate(probabilities)),
283
+ -math.log(max(probabilities[index], 1e-12))]
284
+ bucket = min(ECE_BINS - 1, int(row["confidence"] * ECE_BINS))
285
+ output[4 + 3 * bucket:7 + 3 * bucket] = [1, row["confidence"], int(row["correct"])]
286
+ return output
287
+
288
+
289
+ def _stat_metrics(values: FloatArray) -> dict[str, FloatArray]:
290
+ return {"accuracy": values[..., 1] / values[..., 0], "brier": values[..., 2] / values[..., 0],
291
+ "nll": values[..., 3] / values[..., 0],
292
+ "ece": np.abs(values[..., 5::3] - values[..., 6::3]).sum(axis=-1) / values[..., 0]}
293
+
294
+
295
+ def compare(
296
+ local: Sequence[Prediction], reference: Sequence[Prediction], *, replicates: int = 10000,
297
+ seed: int = 20260920, margin: float = CALIBRATION_MARGIN,
298
+ ) -> Comparison:
299
+ """Paired family bootstrap preserves each suite's fixed contribution to the headline."""
300
+ if replicates < 1000:
301
+ raise ValueError("Use at least 1,000 paired bootstrap draws")
302
+ left, right = {row["id"]: row for row in local}, {row["id"]: row for row in reference}
303
+ if not left or len(left) != len(local) or len(right) != len(reference) or left.keys() != right.keys():
304
+ raise ValueError("Comparison requires complete unique matching prediction IDs")
305
+ suites = sorted({row["suite"] for row in local})
306
+ suite_index = {suite: index for index, suite in enumerate(suites)}
307
+ counts = np.asarray([sum(row["suite"] == suite for row in local) for suite in suites], dtype=np.float64)
308
+ families: dict[tuple[str, str], dict[int, FloatArray]] = {}
309
+ for identifier, row in left.items():
310
+ other = right[identifier]
311
+ if any(row[field] != other[field] for field in ("suite", "dataset", "family", "label", "options")):
312
+ raise ValueError(f"Paired prediction provenance differs: {identifier}")
313
+ group = families.setdefault((row["dataset"], row["family"]), {})
314
+ index = suite_index[row["suite"]]
315
+ group.setdefault(index, np.zeros((2, 4 + 3 * ECE_BINS), dtype=np.float64))
316
+ group[index] += np.stack([_statistics(row), _statistics(other)])
317
+ strata: defaultdict[tuple[int, ...], list[dict[int, FloatArray]]] = defaultdict(list)
318
+ for key in sorted(families):
319
+ family = families[key]
320
+ strata[tuple(sorted(family))].append(family)
321
+ rng = np.random.default_rng(seed)
322
+ samples = np.zeros((replicates, len(suites), 2, 4 + 3 * ECE_BINS), dtype=np.float64)
323
+ for membership in sorted(strata):
324
+ clusters = strata[membership]
325
+ values = np.stack([np.stack([cluster[index] for index in membership]) for cluster in clusters])
326
+ flattened = values.reshape(len(clusters), -1)
327
+ for start in range(0, replicates, 128):
328
+ size = min(128, replicates - start)
329
+ draws = rng.multinomial(len(clusters), np.full(len(clusters), 1 / len(clusters)), size=size)
330
+ totals = (draws @ flattened).reshape(size, len(membership), 2, -1)
331
+ for position, index in enumerate(membership):
332
+ samples[start:start + size, index] += totals[:, position]
333
+ # Whole-family draws vary record counts. Retain the predeclared task mixture.
334
+ weights = counts[None, :, None, None] / samples[..., :1]
335
+ pooled = (samples * weights).sum(axis=1)
336
+ pooled_metrics = _stat_metrics(pooled)
337
+ suite_metrics = _stat_metrics(samples)
338
+
339
+ def effects(a: Sequence[Prediction], b: Sequence[Prediction], draws: Mapping[str, FloatArray]) -> dict[str, Effect]:
340
+ a_metrics, b_metrics = metrics(a), metrics(b)
341
+ result: dict[str, Effect] = {}
342
+ for name in ("accuracy", "ece", "brier", "nll"):
343
+ a_value = a_metrics[name]
344
+ b_value = b_metrics[name]
345
+ difference = draws[name][..., 0] - draws[name][..., 1]
346
+ result[name] = {"local": a_value, "reference": b_value, "difference": a_value - b_value,
347
+ "difference_ci95": np.quantile(difference, [0.025, 0.975]).tolist()}
348
+ return result
349
+
350
+ overall = effects(local, reference, pooled_metrics)
351
+ by_suite = {suite: effects([r for r in local if r["suite"] == suite], [r for r in reference if r["suite"] == suite],
352
+ {name: values[:, index] for name, values in suite_metrics.items()})
353
+ for index, suite in enumerate(suites)}
354
+ return {
355
+ "count": len(local), "families": len(families), "effects": overall, "by_suite": by_suite,
356
+ "empirical_target_met": overall["accuracy"]["difference"] > 0 and all(overall[name]["difference"] <= margin for name in ("ece", "brier")),
357
+ "statistically_supported": overall["accuracy"]["difference_ci95"][0] > 0 and all(overall[name]["difference_ci95"][1] <= margin for name in ("ece", "brier")),
358
+ "calibration_margin": margin, "bootstrap_replicates": replicates, "bootstrap_seed": seed,
359
+ }
360
+
361
+
362
+ def remote_prediction(row: Example, client: httpx.Client, *, model: str, url: str, token: str,
363
+ usage_callback: UsageCallback | None = None) -> Prediction:
364
+ if row.get("images"):
365
+ raise ValueError("The paired Jev comparison is text only")
366
+ labels = options(row["question"])
367
+ key = row["source"].get("question_key", "q")
368
+ if not isinstance(key, str):
369
+ raise ValueError("Question key must be a string")
370
+ payload = {"model": model, "state": row["state"], "questions": {key: row["question"]}}
371
+ started = time.perf_counter()
372
+ response: httpx.Response | None = None
373
+ for attempt in range(3):
374
+ response = client.post(url, json=payload, headers={"Authorization": f"Bearer {token}"})
375
+ if response.status_code not in (429, 500, 502, 503, 504, 529):
376
+ break
377
+ if attempt < 2:
378
+ time.sleep(2 ** attempt)
379
+ assert response is not None
380
+ response.raise_for_status()
381
+ body = cast(dict[str, JSONValue], response.json())
382
+ if usage_callback is not None:
383
+ usage_callback(body)
384
+ answers = cast(dict[str, JSONValue], body["answers"])
385
+ answer = cast(dict[str, JSONValue], answers[key])
386
+ if answer["type"] != row["question"]["type"]:
387
+ raise ValueError(f"Response question type differs: {row['id']}")
388
+ chosen: Label
389
+ if row["question"]["type"] == "noul":
390
+ probability = float(cast(float, answer["noul"]))
391
+ values = [1 - probability, probability]
392
+ chosen = probability > 0.5
393
+ else:
394
+ distribution = cast(dict[str, float], answer["probabilities"])
395
+ if set(distribution) != set(labels):
396
+ raise ValueError(f"Response options differ: {row['id']}")
397
+ values = [distribution[str(label)] for label in labels]
398
+ chosen = cast(str, answer["choice"])
399
+ if any(not math.isfinite(value) or not 0 <= value <= 1 for value in values) or abs(sum(values) - 1) > 0.025:
400
+ raise ValueError(f"Invalid provider probabilities: {row['id']}")
401
+ total = sum(values)
402
+ result = make_prediction(row, [value / total for value in values], prediction=chosen)
403
+ result.update({"answer": answer, "latency_ms": (time.perf_counter() - started) * 1000, "normalization_total": total})
404
+ result["created"] = datetime.now(timezone.utc).isoformat()
405
+ result["request_sha256"] = hashlib.sha256(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
406
+ for field in ("model", "provider"):
407
+ if isinstance(body.get(field), str):
408
+ result[field] = cast(str, body[field])
409
+ if isinstance(body.get("id"), str):
410
+ result["request_id"] = cast(str, body["id"])
411
+ return result
412
+
413
+
414
+ def read_rows(path: Path) -> list[Example]:
415
+ with path.open(encoding="utf-8") as stream:
416
+ return [cast(Example, json.loads(line)) for line in stream if line.strip()]
417
+
418
+
419
+ def read_predictions(path: Path) -> list[Prediction]:
420
+ with path.open(encoding="utf-8") as stream:
421
+ return [cast(Prediction, json.loads(line)) for line in stream if line.strip()]
422
+
423
+
424
+ def write_json(path: Path, value: object) -> None:
425
+ path.parent.mkdir(parents=True, exist_ok=True)
426
+ path.write_text(json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n")
427
+
428
+
429
+ class Arguments(argparse.Namespace):
430
+ data: Path
431
+ output: Path
432
+ predictions: Path | None
433
+ reference: Path | None
434
+ jev: bool
435
+ local: bool
436
+ checkpoint: Path | None
437
+ batch_size: int
438
+ temperature: float | None
439
+ model: str
440
+ resume: bool
441
+
442
+
443
+ def main() -> None:
444
+ parser = argparse.ArgumentParser(description=__doc__)
445
+ parser.add_argument("--data", type=Path, required=True)
446
+ parser.add_argument("--output", type=Path, required=True, help="JSON summary; predictions use the same stem")
447
+ parser.add_argument("--predictions", type=Path, help="Recompute saved predictions without inference")
448
+ parser.add_argument("--reference", type=Path, help="Paired Jev predictions")
449
+ parser.add_argument("--jev", action="store_true", help="Query Jev's native OpenRouter decisions endpoint")
450
+ parser.add_argument("--local", action="store_true", help="Evaluate the pinned Qwen base or a local checkpoint")
451
+ parser.add_argument("--checkpoint", type=Path)
452
+ parser.add_argument("--batch-size", type=int, default=8)
453
+ parser.add_argument("--temperature", type=float, help="Override the checkpoint temperature; use 1 for raw results")
454
+ parser.add_argument("--model", default="typesafe/jev-1.13")
455
+ parser.add_argument("--resume", action="store_true")
456
+ args = parser.parse_args(namespace=Arguments())
457
+ if sum((args.jev, args.local, args.predictions is not None)) != 1:
458
+ parser.error("Choose exactly one of --jev, --local or --predictions")
459
+ if args.batch_size < 1:
460
+ parser.error("--batch-size must be positive")
461
+ if not args.local and (args.checkpoint is not None or args.temperature is not None):
462
+ parser.error("--checkpoint and --temperature require --local")
463
+ if args.resume and not args.jev:
464
+ parser.error("--resume applies to interrupted remote evaluations")
465
+ rows = read_rows(args.data)
466
+ identity = {"dataset_sha256": hashlib.sha256(args.data.read_bytes()).hexdigest(), "model": args.model,
467
+ "endpoint": JEV_ENDPOINT, "ece_bins": ECE_BINS,
468
+ "evaluator_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
469
+ if args.predictions is not None:
470
+ predictions = read_predictions(args.predictions)
471
+ elif args.local:
472
+ import torch
473
+ from autojev.model import DecisionModel
474
+
475
+ destination = args.output.with_suffix(".predictions.jsonl")
476
+ if destination.exists():
477
+ raise FileExistsError(f"Refusing to overwrite an evaluation: {destination}")
478
+ model = DecisionModel(checkpoint=args.checkpoint)
479
+ scale = model.temperature if args.temperature is None else args.temperature
480
+
481
+ @torch.inference_mode()
482
+ def infer(batch_rows: Sequence[Example]) -> list[list[float]]:
483
+ output: list[list[float]] = []
484
+ for start in range(0, len(batch_rows), args.batch_size):
485
+ batch = model.prepare(batch_rows[start:start + args.batch_size])
486
+ logits: list[list[float]] = model(batch).cpu().tolist()
487
+ output.extend(values[:count] for values, count in zip(logits, batch.counts, strict=True))
488
+ return output
489
+
490
+ predictions = predict_local(rows, infer, scale)
491
+ identity.update({"model": model.base_model, "revision": model.revision, "temperature": scale,
492
+ "checkpoint": str(args.checkpoint.resolve()) if args.checkpoint else None, "endpoint": "local"})
493
+ if args.checkpoint is not None:
494
+ identity["checkpoint_config_sha256"] = hashlib.sha256((args.checkpoint / "decision_config.json").read_bytes()).hexdigest()
495
+ write_json(args.output.with_suffix(".manifest.json"), identity)
496
+ destination.parent.mkdir(parents=True, exist_ok=True)
497
+ destination.write_text("".join(json.dumps(row, ensure_ascii=False, allow_nan=False) + "\n" for row in predictions))
498
+ else:
499
+ destination = args.output.with_suffix(".predictions.jsonl")
500
+ manifest_path = args.output.with_suffix(".manifest.json")
501
+ if args.resume and destination.exists():
502
+ if not manifest_path.exists() or json.loads(manifest_path.read_text()) != identity:
503
+ raise ValueError("Cannot resume predictions from a different dataset/model/protocol")
504
+ predictions = read_predictions(destination)
505
+ known = {row["id"] for row in predictions}
506
+ validate_coverage([row for row in rows if row["id"] in known], predictions)
507
+ else:
508
+ if destination.exists():
509
+ raise FileExistsError(f"Refusing to overwrite an evaluation: {destination}")
510
+ predictions = []
511
+ write_json(manifest_path, identity)
512
+ done = {row["id"] for row in predictions}
513
+ destination.parent.mkdir(parents=True, exist_ok=True)
514
+ with httpx.Client(timeout=120) as client, destination.open("a") as stream:
515
+ for row in rows:
516
+ if row["id"] in done:
517
+ continue
518
+ prediction = remote_prediction(row, client, model=args.model, url=JEV_ENDPOINT, token=os.environ["OPENROUTER_API_KEY"])
519
+ stream.write(json.dumps(prediction, ensure_ascii=False, allow_nan=False) + "\n")
520
+ stream.flush()
521
+ predictions.append(prediction)
522
+ if len(predictions) % 50 == 0:
523
+ print(f"Evaluated {len(predictions)}/{len(rows)}", flush=True)
524
+ validate_coverage(rows, predictions)
525
+ result: dict[str, object] = {
526
+ "created": datetime.now(timezone.utc).isoformat(), "dataset_sha256": identity["dataset_sha256"],
527
+ "inference": identity if args.predictions is None else {"predictions_sha256": hashlib.sha256(args.predictions.read_bytes()).hexdigest()},
528
+ "overall": metrics(predictions), "by_suite": {suite: metrics([row for row in predictions if row["suite"] == suite])
529
+ for suite in sorted({row["suite"] for row in predictions})},
530
+ "ece_bins": ECE_BINS, "labels": "hard reference labels; soft diagnostics are separate",
531
+ "observed_models": sorted({row["model"] for row in predictions if "model" in row}),
532
+ "observed_providers": sorted({row["provider"] for row in predictions if "provider" in row}),
533
+ }
534
+ if args.reference is not None:
535
+ reference = read_predictions(args.reference)
536
+ validate_coverage(rows, reference)
537
+ result["comparison"] = compare(predictions, reference)
538
+ write_json(args.output, result)
539
+ print(json.dumps(result["overall"], indent=2))
540
+
541
+
542
+ if __name__ == "__main__":
543
+ main()