File size: 11,911 Bytes
9c2a086
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
"""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())