Goddragon's picture
Publish model and typed-decisions evaluation
fdcd3c3 verified
Raw History Blame Contribute Delete
12.9 kB
#!/usr/bin/env python3
"""Evaluate a System One compatible endpoint on LocalLLaMA/typed-decisions.
The runner intentionally uses only Python's standard library. It sends all five
questions in a case together, writes one durable JSONL record per case, and can
resume an interrupted run.
"""
import argparse
import json
import math
import os
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
EPSILON = 1e-12
WORKFLOWS = (
"agent_trace_observability",
"customer_service",
"invoice_processing",
"security_incidents",
)
def parse_args():
here = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(
description="Run typed-decisions against a /v1/systemone endpoint."
)
parser.add_argument("--endpoint", default="http://127.0.0.1:8741/v1/systemone")
parser.add_argument("--data", type=Path, default=here / "data" / "test.jsonl")
parser.add_argument("--output-dir", type=Path, default=here / "runs" / "default")
parser.add_argument("--workflow", action="append", choices=WORKFLOWS,
help="Only run this workflow; repeat to select more than one.")
parser.add_argument("--limit", type=int, default=None,
help="Run at most N selected cases (useful for an explicit smoke run).")
parser.add_argument("--timeout", type=float, default=120.0)
parser.add_argument("--retries", type=int, default=2,
help="Retries after the first attempt for transient HTTP/network errors.")
parser.add_argument("--retry-delay", type=float, default=2.0)
parser.add_argument("--no-resume", action="store_true",
help="Refuse to reuse an existing predictions.jsonl.")
parser.add_argument("--api-key-env", default="",
help="Optional environment variable containing a Bearer token.")
return parser.parse_args()
def read_rows(path, workflows=None, limit=None):
selected = []
with path.open(encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError("invalid JSON on {}:{}: {}".format(path, line_number, exc))
if workflows and row.get("workflow") not in workflows:
continue
selected.append(row)
if limit is not None and len(selected) >= limit:
break
return selected
def load_completed(path):
completed = {}
if not path.exists():
return completed
with path.open(encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError("invalid JSON on {}:{}: {}".format(path, line_number, exc))
# Failed cases are deliberately retried when a run resumes.
if item.get("ok") and item.get("id"):
completed[item["id"]] = item
return completed
def decode_row(row):
return tuple(json.loads(row[name]) for name in ("state", "questions", "gold"))
def post_json(endpoint, body, timeout, token):
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
request = urllib.request.Request(
endpoint,
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers=headers,
method="POST",
)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read()
status = response.status
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise RuntimeError("HTTP {}: {}".format(exc.code, detail[:1000])) from exc
elapsed_ms = (time.perf_counter() - started) * 1000.0
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError("endpoint returned non-JSON data: {!r}".format(raw[:200])) from exc
if status < 200 or status >= 300:
raise RuntimeError("HTTP {}: {}".format(status, payload))
return payload, elapsed_ms
def probabilities_for(question, answer, labels):
typ = question.get("type", "choice")
if typ == "noul":
if "noul" in answer:
p_true = float(answer["noul"])
raw = {"false": 1.0 - p_true, "true": p_true}
else:
raw = answer.get("probabilities", {})
else:
raw = answer.get("probabilities", {})
try:
values = [float(raw[label]) for label in labels]
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("missing or invalid probabilities for labels {}".format(labels)) from exc
if any(not math.isfinite(value) or value < 0.0 for value in values):
raise ValueError("probabilities must be finite and non-negative")
total = sum(values)
if total <= 0.0:
raise ValueError("probabilities sum to zero")
return [value / total for value in values]
def decision_records(result):
response = result["response"]
answers = response.get("answers", response)
questions = result["questions"]
gold = result["gold"]
if not isinstance(answers, dict):
raise ValueError("response has no answers object")
for name, question in questions.items():
if name not in gold or name not in answers:
raise ValueError("response is missing question {!r}".format(name))
gold_answer = gold[name]
gold_probs_map = gold_answer["probabilities"]
labels = list(gold_probs_map)
gold_probs = [float(gold_probs_map[label]) for label in labels]
pred_probs = probabilities_for(question, answers[name], labels)
pred_index = max(range(len(labels)), key=pred_probs.__getitem__)
predicted = labels[pred_index]
expected = str(gold_answer.get("label", labels[max(
range(len(labels)), key=gold_probs.__getitem__
)]))
correct = predicted == expected
kl = sum(g * math.log(g / max(p, EPSILON))
for g, p in zip(gold_probs, pred_probs) if g > 0.0)
brier = sum((p - g) ** 2 for p, g in zip(pred_probs, gold_probs))
yield {
"workflow": result["workflow"],
"question": name,
"type": question.get("type", "choice"),
"correct": correct,
"confidence": max(pred_probs),
"kl": kl,
"brier": brier,
}
def expected_calibration_error(items, bins=15):
if not items:
return None
buckets = [[] for _ in range(bins)]
for item in items:
index = min(bins - 1, int(item["confidence"] * bins))
buckets[index].append(item)
total = len(items)
return sum(
(len(bucket) / total) * abs(
sum(item["confidence"] for item in bucket) / len(bucket)
- sum(float(item["correct"]) for item in bucket) / len(bucket)
)
for bucket in buckets if bucket
)
def aggregate(items):
if not items:
return {"decisions": 0, "accuracy": None, "kl": None, "brier": None, "ece_15": None}
return {
"decisions": len(items),
"correct": sum(item["correct"] for item in items),
"accuracy": sum(item["correct"] for item in items) / len(items),
"kl": sum(item["kl"] for item in items) / len(items),
"brier": sum(item["brier"] for item in items) / len(items),
"ece_15": expected_calibration_error(items, 15),
}
def percentile(values, fraction):
if not values:
return None
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, math.ceil(fraction * len(ordered)) - 1))
return ordered[index]
def build_report(rows, results):
decisions = []
scoring_errors = []
successful = [result for result in results if result.get("ok")]
for result in successful:
try:
decisions.extend(decision_records(result))
except (KeyError, TypeError, ValueError) as exc:
scoring_errors.append({"id": result.get("id"), "error": str(exc)})
groups = {
"by_workflow": lambda item: item["workflow"],
"by_type": lambda item: item["type"],
"by_question": lambda item: item["workflow"] + "/" + item["question"],
}
report = {
"dataset": "LocalLLaMA/typed-decisions",
"split": "test",
"selected_cases": len(rows),
"successful_cases": len(successful),
"failed_cases": len(rows) - len(successful),
"scored_cases": len(successful) - len(scoring_errors),
"overall": aggregate(decisions),
"metric_notes": {
"accuracy": "argmax prediction against the gold argmax label",
"kl": "mean KL(gold || prediction), natural logarithm",
"brier": "mean sum over classes of (prediction - soft gold)^2",
"ece_15": "15 equal-width bins using top probability and hard argmax correctness",
},
"latency_ms": {
"p50": percentile([r["latency_ms"] for r in successful], 0.50),
"p95": percentile([r["latency_ms"] for r in successful], 0.95),
},
"scoring_errors": scoring_errors,
}
for title, key_fn in groups.items():
grouped = defaultdict(list)
for item in decisions:
grouped[key_fn(item)].append(item)
report[title] = {key: aggregate(value) for key, value in sorted(grouped.items())}
return report
def main():
args = parse_args()
if args.limit is not None and args.limit < 1:
raise SystemExit("--limit must be at least 1")
rows = read_rows(args.data, set(args.workflow or []), args.limit)
if not rows:
raise SystemExit("no cases selected")
args.output_dir.mkdir(parents=True, exist_ok=True)
predictions_path = args.output_dir / "predictions.jsonl"
if args.no_resume and predictions_path.exists():
raise SystemExit("{} already exists; choose a new --output-dir or omit --no-resume".format(
predictions_path
))
completed = load_completed(predictions_path)
token = os.environ.get(args.api_key_env, "") if args.api_key_env else ""
results = dict(completed)
pending = [row for row in rows if row["id"] not in completed]
print("selected {} cases; {} complete; {} pending".format(len(rows), len(completed), len(pending)))
with predictions_path.open("a", encoding="utf-8", buffering=1) as output:
for position, row in enumerate(pending, 1):
state, questions, gold = decode_row(row)
error = None
for attempt in range(args.retries + 1):
try:
response, elapsed_ms = post_json(
args.endpoint, {"state": state, "questions": questions}, args.timeout, token
)
item = {
"id": row["id"], "workflow": row["workflow"], "ok": True,
"latency_ms": elapsed_ms, "questions": questions, "gold": gold,
"response": response,
}
results[row["id"]] = item
output.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
print("[{}/{}] {} {:.1f} ms".format(position, len(pending), row["id"], elapsed_ms))
break
except (OSError, RuntimeError, TimeoutError) as exc:
error = str(exc)
if attempt < args.retries:
time.sleep(args.retry_delay * (attempt + 1))
else:
item = {"id": row["id"], "workflow": row["workflow"], "ok": False,
"error": error}
output.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
print("[{}/{}] {} ERROR {}".format(position, len(pending), row["id"], error),
file=sys.stderr)
ordered_results = [results[row["id"]] for row in rows if row["id"] in results]
report = build_report(rows, ordered_results)
report_path = args.output_dir / "report.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
overall = report["overall"]
print("report: {}".format(report_path))
print("accuracy={:.4f} KL={:.4f} Brier={:.4f} ECE-15={:.4f}".format(
overall["accuracy"], overall["kl"], overall["brier"], overall["ece_15"]
))
return 0 if report["failed_cases"] == 0 and not report["scoring_errors"] else 2
if __name__ == "__main__":
raise SystemExit(main())