maverick84's picture
Minimal inference scorer
b9ff17f verified
Raw History Blame Contribute Delete
13.3 kB
"""JevBench evaluation using the released runtime or its HTTP endpoint."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import platform
import sys
from collections import defaultdict
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
if __package__:
from .serve import (
LoadedRuntime,
load_runtime,
normalize_state,
parse_question,
score_question,
)
else:
from serve import (
LoadedRuntime,
load_runtime,
normalize_state,
parse_question,
score_question,
)
ECE_BINS = 15
PROBABILITY_FLOOR = 1e-12
@dataclass(frozen=True)
class HttpRuntime:
"""HTTP scoring client with server-advertised inference settings."""
endpoint: str
timeout: float
token: str | None
settings: dict[str, Any]
@property
def max_options(self) -> int:
return self.settings["max_options"]
def request(
self, route: str, payload: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Send a bounded JSON request, surfacing transport and protocol failures."""
headers = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
request = Request(
self.endpoint + route,
data=None
if payload is None
else json.dumps(payload, allow_nan=False).encode(),
headers=headers,
)
try:
with urlopen(request, timeout=self.timeout) as response:
result = json.load(response)
except HTTPError as exc:
raise ValueError(f"{route} returned HTTP {exc.code}") from exc
except (URLError, TimeoutError, OSError) as exc:
raise ValueError(f"{route}: connection failed or timed out") from exc
if not isinstance(result, dict):
raise TypeError(f"{route}: response must be a JSON object")
return result
def load_http_runtime(endpoint: str, timeout: float) -> HttpRuntime:
"""Connect to a server and validate the metadata needed for evaluation."""
parsed = urlsplit(endpoint)
if (
parsed.scheme not in ("http", "https")
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
raise ValueError(
"Endpoint must be an HTTP(S) URL without credentials, query or fragment"
)
client = HttpRuntime(
endpoint.rstrip("/"), timeout, os.environ.get("CUEREFLEX_TOKEN"), {}
)
settings = client.request("/v1/version")
if (
type(settings.get("max_options")) is not int
or not 2 <= settings["max_options"] <= 26
):
raise ValueError("Server version response requires max_options in 2..26")
if not isinstance(settings.get("model"), str) or not settings["model"]:
raise ValueError("Server version response requires a model identity")
return HttpRuntime(client.endpoint, timeout, client.token, settings)
def read_rows(paths: list[Path]) -> list[dict[str, Any]]:
"""Read JSONL files, rejecting malformed rows and duplicate IDs."""
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for path in paths:
with path.open(encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
if not line.strip():
continue
try:
row = json.loads(line)
if not isinstance(row, dict):
raise TypeError("Each row must be an object")
row_id = row.get("id")
if not isinstance(row_id, str) or not row_id:
raise ValueError("Each row requires a nonempty string ID")
if row_id in seen:
raise ValueError(f"Duplicate ID: {row_id}")
except (ValueError, TypeError) as exc:
raise ValueError(f"{path}:{line_number}: {exc}") from exc
seen.add(row_id)
rows.append(row)
if not rows:
raise ValueError("No evaluation rows found")
return rows
def score_row(
runtime: LoadedRuntime | HttpRuntime, row: dict[str, Any]
) -> dict[str, Any]:
"""Score only inference fields, then attach the held-out expected answer."""
labels = row["labels"]
if (
not isinstance(labels, list)
or any(not isinstance(label, str) or not label for label in labels)
or len(set(labels)) != len(labels)
or not 2 <= len(labels) <= runtime.max_options
):
raise ValueError("Labels must be distinct strings within the option limit")
question = row["question"]
if not isinstance(question, dict):
raise TypeError("Question must be an object")
kind = question.get("type")
if kind == "noul" and labels != ["no", "yes"]:
raise ValueError("Noul labels must be ['no', 'yes']")
if kind == "score" and labels != [str(i) for i in range(len(labels))]:
raise ValueError("Score labels must be consecutive indices starting at '0'")
expected = row["expected"]
if kind == "score":
if type(expected) is not int:
raise ValueError("Score expected answer must be an integer")
expected_label = str(expected)
else:
if not isinstance(expected, str):
raise ValueError("Expected answer must be a string label")
expected_label = expected
if expected_label not in labels:
raise ValueError("Expected answer must belong to labels")
spec = {
key: question[key]
for key in ("type", "instructions", "criteria")
if key in question
}
if kind == "choice":
spec = {**spec, "labels": labels}
prepared = parse_question(normalize_state(row["state"]), spec, runtime.max_options)
if kind == "score" and len(prepared.options) != len(labels):
raise ValueError("Score criteria must align with labels")
if isinstance(runtime, HttpRuntime):
response = runtime.request(
"/v1/systemone",
{"state": row["state"], "questions": {"decision": spec}},
)
if response.get("model") != runtime.settings["model"]:
raise ValueError("Server model identity changed during evaluation")
answer = response["answers"]["decision"]
input_tokens = response["usage"]["input_tokens"]
if not isinstance(answer, dict) or answer.get("type") != kind:
raise ValueError("Server answer type does not match question")
if type(input_tokens) is not int or input_tokens < 0:
raise ValueError("Server input_tokens must be a nonnegative integer")
else:
answer, input_tokens = score_question(runtime, prepared)
probabilities = answer["probabilities"]
if not isinstance(probabilities, dict):
raise TypeError("Response probabilities must be an object")
if set(probabilities) != set(labels):
raise ValueError("Response probability labels do not match dataset labels")
if any(
isinstance(p, bool)
or not isinstance(p, (int, float))
or not math.isfinite(p)
or not 0 <= p <= 1
for p in probabilities.values()
) or not math.isclose(sum(probabilities.values()), 1.0, abs_tol=1e-3):
raise ValueError("Response must contain a normalized finite distribution")
predicted = min(labels, key=lambda label: (-probabilities[label], label))
return {
"id": row["id"],
"kind": kind,
"expected": expected,
"predicted": int(predicted) if kind == "score" else predicted,
"correct": predicted == expected_label,
"confidence": probabilities[predicted],
"loss": -math.log(max(probabilities[expected_label], PROBABILITY_FLOOR)),
"brier": sum(
(probabilities[label] - float(label == expected_label)) ** 2
for label in labels
),
"probabilities": probabilities,
"input_tokens": input_tokens,
}
def summarize(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Calculate pooled hard-label metrics and equal-width confidence-bin ECE."""
if not items:
raise ValueError("Cannot summarize zero rows")
buckets: dict[int, list[dict[str, Any]]] = defaultdict(list)
for item in items:
buckets[min(int(item["confidence"] * ECE_BINS), ECE_BINS - 1)].append(item)
correct = sum(item["correct"] for item in items)
return {
"rows": len(items),
"correct": correct,
"accuracy": correct / len(items),
"loss": sum(item["loss"] for item in items) / len(items),
"brier": sum(item["brier"] for item in items) / len(items),
"ece": sum(
abs(sum(item["correct"] - item["confidence"] for item in bucket))
for bucket in buckets.values()
)
/ len(items),
}
def evaluate(
runtime: LoadedRuntime | HttpRuntime, rows: list[dict[str, Any]]
) -> dict[str, Any]:
"""Evaluate every row, failing explicitly rather than dropping failed items."""
items: list[dict[str, Any]] = []
for index, row in enumerate(rows, 1):
try:
item = score_row(runtime, row)
except (ValueError, KeyError, TypeError, RuntimeError) as exc:
raise ValueError(f"Evaluation failed for row {row['id']}: {exc}") from exc
items.append(item)
print(
f"[{index}/{len(rows)}] {row['id']}: {item['predicted']}", file=sys.stderr
)
kinds = sorted({item["kind"] for item in items})
return {
"overall": summarize(items),
"by_kind": {
kind: summarize([item for item in items if item["kind"] == kind])
for kind in kinds
},
"items": items,
}
def main() -> None:
"""Evaluate locally or over HTTP and save a reproducible diagnostic report."""
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--model-dir", type=Path)
source.add_argument(
"--endpoint", help="Server base URL, e.g. http://127.0.0.1:8000"
)
parser.add_argument(
"--timeout", type=float, default=120.0, help="HTTP timeout in seconds"
)
parser.add_argument("--base-model", default=None)
parser.add_argument("--tasks", type=Path, nargs="+", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if not math.isfinite(args.timeout) or args.timeout <= 0:
parser.error("--timeout must be a finite positive number")
if args.endpoint and args.base_model:
parser.error("--base-model is only valid with --model-dir")
try:
if args.output.exists():
raise FileExistsError(f"Refusing to overwrite {args.output}")
rows = read_rows(args.tasks)
datasets = [
{
"path": str(path.resolve()),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
for path in args.tasks
]
runtime = (
load_http_runtime(args.endpoint, args.timeout)
if args.endpoint
else load_runtime(args.model_dir.resolve(), args.base_model)
)
report = evaluate(runtime, rows)
if isinstance(runtime, HttpRuntime):
source_provenance = {
"mode": "http",
"endpoint": runtime.endpoint,
"timeout_seconds": runtime.timeout,
"server": runtime.settings,
}
else:
source_provenance = {
"mode": "offline",
"model_dir": str(args.model_dir.resolve()),
"model": runtime.model_id,
"max_seq_length": runtime.max_seq_length,
"max_options": runtime.max_options,
"enable_thinking": runtime.enable_thinking,
"temperatures": runtime.temperatures,
"packages": {
name: version(name) for name in ("torch", "transformers", "peft")
},
}
report["provenance"] = {
**source_provenance,
"datasets": datasets,
"python": platform.python_version(),
"ece_bins": ECE_BINS,
"probability_floor_for_loss": PROBABILITY_FLOOR,
"protocol": "explicit canonical choice labels; not stock typesafe adapter",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("x", encoding="utf-8") as handle:
json.dump(report, handle, indent=2, allow_nan=False)
handle.write("\n")
except (OSError, ValueError, TypeError, RuntimeError) as exc:
parser.exit(1, f"Evaluation error: {exc}\n")
print(json.dumps(report["overall"], indent=2))
print(f"Report saved to {args.output}")
if __name__ == "__main__":
main()