Vers3Dynamics-Civil-Reactor-Expert-3B / eval_numeric_correctness.py
Chris
feat: ground numeric answers in the physics engines and measure correctness
9c2a086
Raw
History Blame Contribute Delete
11.9 kB
"""Numeric-correctness evaluation for the civil reactor model.
Why this exists
---------------
The repository's existing benchmarks score assistant-token negative
log-likelihood / perplexity. The project's own independent eval showed that
metric rewards format memorisation that does not transfer: an adapter can score
"53% better" on a template-sharing benchmark while making the model *worse* than
the base on open-ended content. Perplexity is the wrong yardstick for a model
whose whole point is producing *correct numbers*.
This harness measures the thing that actually matters: does the stated number
match what the audited engine computes, within a declared tolerance? Ground
truth is not hand-written; it is computed at run time from the same engines the
answer is supposed to agree with (via ``civil_tools``), so the suite cannot
drift from the physics and there is nothing to keep in sync by hand.
Three modes
-----------
* ``--self-check`` (default): recompute every item's ground truth and confirm
the suite's own ``reference_answer`` contains it within tolerance. Needs no
model and runs in CI; it validates both the suite and the scorer.
* ``--answers FILE``: score a JSONL of model outputs (``{"id", "answer"}``) and
report per-category and overall accuracy.
* ``--endpoint-url URL``: query an OpenAI-compatible local endpoint for each
prompt, then score. Uses only stdlib ``urllib`` — no extra dependency.
A number is scored, not prose. Extraction masks isotope labels (``Xe-135``,
``I-135``, ``U-235``) so a mass number is never mistaken for an answer, then
compares every remaining number in the text against the engine value with
``math.isclose``. A relative tolerance travels with each item, because "within
2%" means different things for a period (~50 s) and an inventory (~1e15 /cm^3).
"""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from civil_safety import read_jsonl
from civil_tools import call_tool
ROOT = Path(__file__).resolve().parent
DEFAULT_SUITE = ROOT / "eval_numeric.jsonl"
# A signed integer/decimal/scientific literal. Kept deliberately strict so it
# does not swallow stray punctuation.
NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
# Element-mass isotope labels (Xe-135, I-135, U-235, Np-239). Masked before
# number extraction so "-135" in "Xe-135" is never read as the answer -135.
ISOTOPE_RE = re.compile(r"[A-Za-z]{1,2}-\d{1,3}\b")
def mask_isotopes(text: str) -> str:
"""Blank out element-mass labels so their mass numbers are not extracted."""
return ISOTOPE_RE.sub(" ", text)
def extract_numbers(text: str) -> list[float]:
"""Every numeric literal in ``text`` (isotope labels masked out first)."""
cleaned = mask_isotopes(text)
values: list[float] = []
for match in NUMBER_RE.finditer(cleaned):
token = match.group()
try:
values.append(float(token))
except ValueError:
continue
return values
def answer_matches(
expected: float, text: str, *, rel_tol: float, abs_tol: float
) -> tuple[bool, float | None]:
"""True iff some number in ``text`` is close to ``expected``.
Returns the matched value too, for reporting. ``abs_tol`` covers values at
or near zero, where a relative tolerance is meaningless.
"""
for value in extract_numbers(text):
if math.isclose(value, expected, rel_tol=rel_tol, abs_tol=abs_tol):
return True, value
return False, None
def ground_truth(item: dict[str, Any]) -> float:
"""Engine-computed reference value for an item's ``check`` spec."""
check = item.get("check")
if not isinstance(check, dict):
raise ValueError(f"{item.get('id', '<no id>')}: missing 'check' object")
tool = check.get("tool")
field_name = check.get("field")
args = check.get("args", {})
result = call_tool(str(tool), args)
if not result.ok:
raise ValueError(
f"{item.get('id')}: ground-truth tool '{tool}' failed: {result.error}"
)
if field_name not in result.values:
raise ValueError(
f"{item.get('id')}: tool '{tool}' has no field '{field_name}' "
f"(has: {', '.join(result.values)})"
)
return result.values[str(field_name)]
@dataclass
class ScoreRow:
id: str
category: str
expected: float
passed: bool
matched: float | None
detail: str = ""
@dataclass
class Report:
rows: list[ScoreRow] = field(default_factory=list)
@property
def total(self) -> int:
return len(self.rows)
@property
def passed(self) -> int:
return sum(1 for r in self.rows if r.passed)
@property
def accuracy(self) -> float:
return self.passed / self.total if self.total else 0.0
def by_category(self) -> dict[str, tuple[int, int]]:
acc: dict[str, list[int]] = {}
for row in self.rows:
bucket = acc.setdefault(row.category, [0, 0])
bucket[0] += int(row.passed)
bucket[1] += 1
return {k: (v[0], v[1]) for k, v in sorted(acc.items())}
def to_dict(self) -> dict[str, Any]:
return {
"total": self.total,
"passed": self.passed,
"accuracy": self.accuracy,
"by_category": {
k: {"passed": p, "total": t} for k, (p, t) in self.by_category().items()
},
"failures": [
{
"id": r.id,
"category": r.category,
"expected": r.expected,
"matched": r.matched,
"detail": r.detail,
}
for r in self.rows
if not r.passed
],
}
def render(self) -> str:
lines = ["Numeric-correctness report", "=" * 34]
for category, (p, t) in self.by_category().items():
lines.append(f" {category:34s} {p:3d}/{t:<3d} {(p / t if t else 0):6.1%}")
lines.append("-" * 34)
lines.append(
f" {'OVERALL':34s} {self.passed:3d}/{self.total:<3d} {self.accuracy:6.1%}"
)
failures = [r for r in self.rows if not r.passed]
if failures:
lines.append("\nFailures:")
for r in failures:
got = "no numeric match" if r.matched is None else f"got {r.matched:g}"
lines.append(
f" - {r.id} ({r.category}): expected {r.expected:g}, {got}. {r.detail}"
)
return "\n".join(lines)
def _tolerances(item: dict[str, Any]) -> tuple[float, float]:
check = item.get("check", {})
return float(check.get("rel_tol", 0.02)), float(check.get("abs_tol", 1e-9))
def score_answers(items: list[dict[str, Any]], answers: dict[str, str]) -> Report:
"""Score model answers (id -> answer text) against engine ground truth."""
report = Report()
for item in items:
item_id = str(item.get("id", "<no id>"))
category = str(item.get("category", "uncategorized"))
expected = ground_truth(item)
rel_tol, abs_tol = _tolerances(item)
text = answers.get(item_id)
if text is None:
report.rows.append(
ScoreRow(item_id, category, expected, False, None, "no answer supplied")
)
continue
passed, matched = answer_matches(
expected, text, rel_tol=rel_tol, abs_tol=abs_tol
)
report.rows.append(ScoreRow(item_id, category, expected, passed, matched))
return report
def self_check(items: list[dict[str, Any]]) -> Report:
"""Confirm each item's reference_answer embeds its engine ground truth."""
answers = {}
for item in items:
ref = item.get("reference_answer")
if not isinstance(ref, str) or not ref.strip():
raise ValueError(
f"{item.get('id')}: self-check needs a 'reference_answer' string"
)
answers[str(item.get("id"))] = ref
return score_answers(items, answers)
def _query_endpoint(url: str, prompt: str, *, system: str, timeout: float) -> str:
"""POST one chat completion to an OpenAI-compatible endpoint (stdlib only)."""
import urllib.error
import urllib.request
payload = json.dumps(
{
"model": "vers3dynamics-civil-reactor-expert",
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"temperature": 0.0,
"max_tokens": 512,
}
).encode("utf-8")
request = urllib.request.Request(
url, data=payload, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError) as exc: # pragma: no cover - network
raise RuntimeError(f"endpoint request failed: {exc}") from exc
return str(body["choices"][0]["message"]["content"])
_ENDPOINT_SYSTEM = (
"You are Vers3Dynamics Civil Reactor Expert. Answer civil-nuclear questions "
"with explicit numbers and units. Stay civil-only."
)
def collect_endpoint_answers(
items: list[dict[str, Any]], url: str, *, timeout: float = 60.0
) -> dict[str, str]:
answers: dict[str, str] = {}
for item in items:
answers[str(item.get("id"))] = _query_endpoint(
url, str(item.get("prompt", "")), system=_ENDPOINT_SYSTEM, timeout=timeout
)
return answers
def _load_answers_file(path: Path) -> dict[str, str]:
answers: dict[str, str] = {}
for row in read_jsonl(path):
answers[str(row.get("id"))] = str(row.get("answer", ""))
return answers
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Numeric-correctness eval against the physics engines."
)
parser.add_argument(
"--suite", type=Path, default=DEFAULT_SUITE, help="Eval suite JSONL."
)
parser.add_argument(
"--self-check", action="store_true", help="Validate suite + scorer (no model)."
)
parser.add_argument(
"--answers", type=Path, help="JSONL of model answers ({id, answer})."
)
parser.add_argument(
"--endpoint-url", help="OpenAI-compatible endpoint to query per prompt."
)
parser.add_argument("--report", type=Path, help="Write the JSON report here.")
parser.add_argument(
"--min-accuracy",
type=float,
default=None,
help="Fail (exit 1) below this accuracy.",
)
args = parser.parse_args(argv)
items = read_jsonl(args.suite)
if not items:
print(f"empty suite: {args.suite}", file=sys.stderr)
return 2
if args.answers:
report = score_answers(items, _load_answers_file(args.answers))
elif args.endpoint_url:
report = score_answers(
items, collect_endpoint_answers(items, args.endpoint_url)
)
else:
report = self_check(items) # default mode
print(report.render())
if args.report:
args.report.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
print(f"\nwrote {args.report}")
if args.min_accuracy is not None and report.accuracy < args.min_accuracy:
print(
f"\nFAIL: accuracy {report.accuracy:.1%} < required {args.min_accuracy:.1%}",
file=sys.stderr,
)
return 1
# In self-check mode, any failure is a broken suite and must be non-zero.
if not args.answers and not args.endpoint_url and report.passed != report.total:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(_main())