flavourbench / lab_api.py
josefchen's picture
Add lab evaluator and training reward API
e38c605 verified
Raw
History Blame Contribute Delete
9.31 kB
"""Small, dependency-free scoring surface for the Hugging Face Space."""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from typing import Any
REPORT_SCHEMA_VERSION = "flavourbench-lab-report-v1"
PRIMARY_FAMILIES = ("substitution", "pairing", "constraint")
_MARKER = re.compile(r"FINAL_SELECTION\s*:\s*", flags=re.IGNORECASE)
_LABEL_TRIPLE = re.compile(
r"^\s*([A-H])\s*,\s*([A-H])\s*,\s*([A-H])"
r"\s*(?:[\x60*_]+\s*)?(?:<\|close\|>response\s*)?$",
flags=re.IGNORECASE,
)
class SpaceLabError(ValueError):
"""The uploaded lab artifact is invalid."""
def _canonical(value: object) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
allow_nan=False,
).encode()
def _normal_name(value: str) -> str:
return " ".join(value.replace("_", " ").casefold().split())
def _parse(task: Mapping[str, Any], completion: str) -> str | None:
if len(completion.encode()) > 1024 * 1024:
raise SpaceLabError("completion exceeds the 1 MiB endpoint limit")
choices = task.get("choices") or {}
if set(choices) != set("ABCDEFGH"):
return None
names = {_normal_name(str(name)): str(label) for label, name in choices.items()}
if len(names) != len(choices):
return None
matches = tuple(_MARKER.finditer(completion))
candidates: set[str] = set()
for index, match in enumerate(matches):
stop = matches[index + 1].start() if index + 1 < len(matches) else len(completion)
segment = completion[match.end() : stop].splitlines()[0].strip()
label_match = _LABEL_TRIPLE.fullmatch(segment)
if label_match:
labels = tuple(label.upper() for label in label_match.groups())
if len(set(labels)) == 3:
candidates.add("".join(sorted(labels)))
continue
rendered = segment.strip().strip("*_").strip(chr(96)).strip()
ingredients = tuple(_normal_name(value) for value in rendered.split(","))
if len(ingredients) == 3 and all(value in names for value in ingredients):
labels = tuple(names[value] for value in ingredients)
if len(set(labels)) == 3:
candidates.add("".join(sorted(labels)))
return next(iter(candidates)) if len(candidates) == 1 else None
def _extract(record: Mapping[str, Any]) -> str | None:
if str(record.get("status") or "completed") not in {"completed", "success", "ok"}:
return None
for key in ("response", "completion", "answer", "answer_markdown"):
value = record.get(key)
if isinstance(value, str):
return value
return None
def _records(payload: str) -> list[dict[str, Any]]:
if len(payload.encode()) > 16 * 1024 * 1024:
raise SpaceLabError("artifact exceeds the 16 MiB Space limit")
try:
value = json.loads(payload)
except json.JSONDecodeError:
output = []
for number, line in enumerate(payload.splitlines(), start=1):
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError as error:
raise SpaceLabError(f"invalid JSON on line {number}") from error
if not isinstance(row, dict):
raise SpaceLabError(f"line {number} is not an object") from None
output.append(dict(row))
return output
if isinstance(value, list) and all(isinstance(row, dict) for row in value):
return [dict(row) for row in value]
if isinstance(value, dict) and isinstance(value.get("responses"), list):
rows = value["responses"]
if all(isinstance(row, dict) for row in rows):
return [dict(row) for row in rows]
if isinstance(value, dict):
return [dict(value)]
raise SpaceLabError("artifact must be JSON Lines, an array, or an object with responses")
def score_completion(
tasks_by_id: Mapping[str, Mapping[str, Any]], task_id: str, completion: str
) -> dict[str, Any]:
task = tasks_by_id.get(task_id)
if task is None:
raise SpaceLabError(f"unknown task_id: {task_id}")
selection = _parse(task, completion)
score_bps = int(task["selection_scores_bps"].get(selection, 0)) if selection else 0
return {
"task_id": task_id,
"observed_selection": selection,
"parseable": selection is not None,
"score_bps": score_bps,
"score": score_bps / 100,
"reward": score_bps / 10_000,
"optimal": score_bps == 10_000,
"optimal_selection": task["optimal_selection"],
}
def score_payload(
tasks: Sequence[Mapping[str, Any]], payload: str
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
responses = _records(payload)
task_by_id = {str(task["task_id"]): task for task in tasks}
response_by_id: dict[str, dict[str, Any]] = {}
for number, row in enumerate(responses, start=1):
task_id = str(row.get("task_id") or "")
if not task_id:
raise SpaceLabError(f"response {number} has no task_id")
if task_id not in task_by_id:
raise SpaceLabError(f"unknown task_id: {task_id}")
if task_id in response_by_id:
raise SpaceLabError(f"duplicate task_id: {task_id}")
response_by_id[task_id] = row
per_task = []
for task in tasks:
task_id = str(task["task_id"])
response = response_by_id.get(task_id)
completion = _extract(response) if response is not None else None
scoring = (
score_completion(task_by_id, task_id, completion)
if completion is not None
else {
"task_id": task_id,
"observed_selection": None,
"parseable": False,
"score_bps": 0,
"score": 0.0,
"reward": 0.0,
"optimal": False,
"optimal_selection": task["optimal_selection"],
}
)
per_task.append(
{
"family": task["family"],
"anchor_ingredient": task.get("anchor_ingredient"),
"status": (
"valid"
if scoring["parseable"]
else "missing"
if response is None
else "invalid"
),
**scoring,
}
)
valid = sum(row["parseable"] is True for row in per_task)
complete = valid == len(tasks)
family_rows = []
for family in PRIMARY_FAMILIES:
rows = [row for row in per_task if row["family"] == family]
accepted = [row for row in rows if row["parseable"]]
family_rows.append(
{
"family": family,
"tasks": len(rows),
"valid": len(accepted),
"coverage": len(accepted) / len(rows),
"score": (
sum(float(row["score"]) for row in accepted) / len(accepted)
if rows and len(accepted) == len(rows)
else None
),
"diagnostic_valid_score": (
sum(float(row["score"]) for row in accepted) / len(accepted)
if accepted
else None
),
}
)
comparable_score = (
sum(float(row["score"]) for row in family_rows) / len(PRIMARY_FAMILIES)
if complete
else None
)
diagnostic_families = [
float(row["diagnostic_valid_score"])
for row in family_rows
if row["diagnostic_valid_score"] is not None
]
report = {
"schema_version": REPORT_SCHEMA_VERSION,
"comparable": complete,
"flavourbench_score": comparable_score,
"diagnostic_valid_score": (
sum(diagnostic_families) / len(diagnostic_families) if diagnostic_families else None
),
"coverage": {
"tasks": len(tasks),
"submitted": len(response_by_id),
"valid": valid,
"missing": sum(row["status"] == "missing" for row in per_task),
"invalid": sum(row["status"] == "invalid" for row in per_task),
"fraction_valid": valid / len(tasks),
},
"families": family_rows,
"task_set_semantic_sha256": hashlib.sha256(
_canonical(sorted((dict(task) for task in tasks), key=lambda row: str(row["task_id"])))
).hexdigest(),
"response_set_semantic_sha256": hashlib.sha256(
_canonical(sorted(responses, key=lambda row: str(row.get("task_id"))))
).hexdigest(),
"parser": "flavourbench-selection-set-parser-v3",
"scoring": "exact-selection-lookup-bps-v1; equal-family macro mean",
"inference": None,
"per_task": per_task,
}
report["artifact_sha256"] = hashlib.sha256(_canonical(report)).hexdigest()
return report, per_task
def score_payload_json(tasks: Sequence[Mapping[str, Any]], payload: str) -> dict[str, Any]:
"""Convenience wrapper used by the named Gradio batch endpoint."""
report, _ = score_payload(tasks, payload)
return report