initial commit

This commit is contained in:
Jiashi Li committed 2026-10-20 12:09:42 +08:00
commit d585e6542c
345 files changed
+63326

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
outputs/
data/
+221
View File
@@ -0,0 +1,221 @@
# Benchmark evaluation
Offline batch evaluation for AIME 2026, HLE, and GPQA Diamond, built on the
DeepInfer offline batch API.
Generation and scoring are separate stages. The generation stage submits every
request to the engine in a single batch and writes the raw model outputs to disk;
the scoring stage reads those outputs and produces the final metrics. Scoring can
therefore be repeated (with a different judge, for example) without regenerating.
The reported metric is **Pass@1**.
| Benchmark | Tasks | Samples per task | How Pass@1 is computed |
| :--- | ---: | ---: | :--- |
| [AIME 2026](aime/run.sh) | 30 | 32 | mean accuracy over 32 samples per task |
| [HLE](hle/run.sh) | 2500 (2158 text, 342 image) | 1 | single-sample accuracy |
| [GPQA Diamond](gpqa/run.sh) | 198 | 8 | mean accuracy over 8 samples per task |
---
## 1. Layout
```
eval/
|-- run_eval.py # generation entry point (offline batch)
|-- score.py # scoring entry point
|-- common/ # shared utilities: paths, JSONL I/O, Pass@k, loader, judge, encoder
|-- aime/ # one directory per benchmark
| |-- run.sh # two-stage driver: inference, then evaluation
| `-- bench.py # prompt construction, answer extraction, scoring
|-- hle/
|-- gpqa/
|-- data/ # datasets (or point DATA_FILE elsewhere)
`-- outputs/ # results
```
---
## 2. Requirements
Complete the backend-specific installation described in the top-level [README.md](../README.md):
`./build.sh` installs the requirements and builds the vendored operators and the in-tree
DeepInfer extension. Keep `DEEPINFER_ACCELERATOR` exported when running evaluation, because it
selects the platform build of the extension. No additional build step is needed for evaluation.
Chat prompts are rendered by the encoder distributed with the checkpoint
(`<checkpoint>/encoding/encoding.py`), which the scripts load at runtime. The
checkpoint directory must contain that file.
---
## 3. Data preparation
The evaluation scripts do not download datasets. Each dataset must be obtained
from its upstream source and converted to JSONL.
| Benchmark | Source | Fields |
| :--- | :--- | :--- |
| AIME 2026 | [MathArena/aime_2026](https://huggingface.co/datasets/MathArena/aime_2026), or the official AIME 2026 I/II problems | `task_id`, `question`, `answer` (an integer string in the range 0-999) |
| HLE | [cais/hle](https://huggingface.co/datasets/cais/hle) (gated; accept the terms on Hugging Face first) | `task_id`, `question`, `answer`, optional `category`; image questions are described below |
| GPQA Diamond | [Idavidrein/gpqa](https://huggingface.co/datasets/Idavidrein/gpqa) (gated) or the public mirror [hendrydong/gpqa_diamond_mc](https://huggingface.co/datasets/hendrydong/gpqa_diamond_mc) | `task_id`, `question`, `answer` (a single letter, A-D) |
### 3.1 Record format
Each dataset is a JSONL file with the fields shown above. Place it at the default path
given in Section 3.3, or point `DATA_FILE` at it.
```jsonc
// AIME 2026
{"task_id": "aime_2026_0", "question": "Patrick started walking ...", "answer": "277"}
// HLE, text question
{"task_id": "hle_0001", "question": "...", "answer": "...", "category": "Math"}
// GPQA Diamond: The question must contain the four choices inline, labelled (A)-(D).
{"task_id": "gpqa_diamond_0", "question": "Two quantum states ...\n\n(A) 10^-11 eV\n(B) 10^-8 eV\n(C) 10^-9 eV\n(D) 10^-4 eV", "answer": "D", "category": "Physics"}
```
### 3.2 Image questions
An HLE image question may reference its image in any of the following ways:
```jsonc
// 1. Path to a local file
{"task_id": "hle_0500", "question": "...", "answer": "...", "category": "Biology/Medicine",
"image": "/path/to/500.png"}
// 2. Inline base64
{"task_id": "hle_0501", "question": "...", "answer": "...", "category": "Physics",
"image": "<base64-string>"}
// 3. multi_modal_data, as consumed by the engine (optionally with image dimensions)
{"task_id": "hle_0502", "question": "...<image>", "answer": "...", "category": "Chemistry",
"multi_modal_data": {"image_urls": ["/path/to/502.jpg"], "image_sizes": [[1080, 1078]]}}
```
The question must contain an `<image>` placeholder wherever the image belongs;
generation replaces it with the image token the engine understands.
### 3.3 Locating the data file
Each driver script defines a default `DATA_FILE`:
| Benchmark | Default `DATA_FILE` |
| :--- | :--- |
| aime | `eval/data/aime_2026.jsonl` |
| hle | `eval/data/hle_test.jsonl` |
| gpqa | `eval/data/gpqa_diamond.jsonl` |
Place the dataset at the corresponding path, or point `DATA_FILE` at it:
```bash
cd <repo-root>
bash eval/aime/run.sh
bash eval/gpqa/run.sh
DATA_FILE=/data/my_aime.jsonl bash eval/aime/run.sh
DATA_FILE=/data/my_hle.jsonl bash eval/hle/run.sh
DATA_FILE=/data/my_gpqa.jsonl bash eval/gpqa/run.sh
```
---
## 4. Running an evaluation
Each driver script runs two stages:
```
[1/2] inference -> [2/2] evaluate
```
Defaults can be overridden with environment variables:
```bash
EP=8 N_SAMPLE=4 TAG=smoke bash eval/aime/run.sh # smoke test
bash eval/aime/run.sh # full run with the default model
MODEL=/path/to/DeepSeek-V4.1-Flash-1017 bash eval/aime/run.sh # local HF directory
```
Available overrides: `MODEL`, `EP`, `N_SAMPLE`, `REASONING_EFFORT`, `TAG`,
`DATA_FILE`.
For HLE the sampling configuration is not adjustable through environment
variables: `n_sample=1` is set in the driver script, and `temperature=1.0` is the
per-benchmark default in `run_eval.py`. To run multiple samples, edit `--n-sample`
in the script. For AIME and GPQA the sample count is configurable through
`N_SAMPLE`, defaulting to 32 and 8 respectively.
Scoring uses an LLM judge service by default; see the next section. A
deterministic rule-based judge is also provided.
---
## 5. Scoring
Two scoring modes are available. The default is the LLM judge, which is also the
protocol used by the reference evaluation.
| Mode | Method | Dependencies | Determinism |
| :--- | :--- | :--- | :--- |
| `llm` (default) | A judge model decides whether the extracted answer is equivalent to the reference answer. | A judge service | Subject to the judge model's own sampling |
| `rule` | The answer is extracted by fixed rules and compared exactly: the integer inside `\boxed{}` for AIME, the letter following `ANSWER:` for GPQA. | None | Fully deterministic |
HLE supports the LLM judge only. More than half of its answers are free-form
text, so exact matching would mark equivalent answers as wrong and systematically
understate the score; `score.py` therefore rejects `--judge rule` for HLE.
To use the LLM judge, provide a judge service:
```bash
export EVAL_JUDGE_BASE_URL=https://api.deepseek.com
export EVAL_JUDGE_API_KEY=<key>
export EVAL_JUDGE_MODEL=deepseek-v4-flash
bash eval/aime/run.sh
```
To score without a judge service, uncomment the rule-based command in stage 2/2 of
the driver script, or re-score existing output directly; no regeneration is
required:
```bash
python eval/score.py --out-dir eval/outputs/aime/<TAG> --judge rule
```
When the LLM judge is used, `summary.json` additionally reports
`rule_vs_llm_agreement`, the fraction of rollouts on which both modes agree. On
AIME 2026 the two modes agreed on all 960 rollouts.
---
## 6. Outputs
Results are written to `eval/outputs/<benchmark>/<TAG>/`:
| File | Contents |
| :--- | :--- |
| `summary.json` | Aggregate metrics. `Pass@1` is the headline figure; `Pass@k`, `num_tasks`, `num_rollouts` and `coverage_complete` are also reported. |
| `scored.jsonl` | One row per rollout: raw model output, extracted answer, verdict. |
| `raw_rank*.jsonl` | Raw model outputs as written by each data-parallel rank. |
| `meta.json` | The complete configuration of the run, for reproducibility. |
A `Pass@1` figure is only valid when `coverage_complete` in `summary.json` is
`true`; otherwise one or more ranks or samples are missing.
---
## 7. Reference results
The following Pass@1 values are reference figures for the V4.1 model under the
default configuration described above.
| Benchmark | Pass@1 |
| :--- | ---: |
| AIME 2026 | 99.9% |
| GPQA Diamond | 93.4% |
| HLE-Full | 42.6% |
Due to the stochastic nature of LLM generation, repeated runs may differ by a small margin.
These figures are therefore indicative rather than exact, and serve as a sanity check
that a deployment is configured correctly.
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
EVAL_DIR = Path(__file__).resolve().parent.parent # eval/
if str(EVAL_DIR) not in sys.path:
sys.path.insert(0, str(EVAL_DIR))
from common.io import require_file # noqa: E402
EN_BOXED_INSTR = "{question}\nPlease reason step by step, and put your final answer within \\boxed{{}}."
@dataclass(frozen=True)
class Problem:
task_id: str
question: str
answer: str
# --------------------------------------------------------------------------- #
# Data
# --------------------------------------------------------------------------- #
def load(data_file: str | os.PathLike) -> list[Problem]:
path = Path(data_file)
require_file(path, "AIME 2026 download: https://huggingface.co/datasets/MathArena/aime_2026")
problems: list[Problem] = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
row = json.loads(line)
problems.append(
Problem(
task_id=row["task_id"],
question=row["question"],
answer=str(row["answer"]),
)
)
assert len({p.task_id for p in problems}) == len(problems), "duplicate task_id"
return problems
# --------------------------------------------------------------------------- #
# prompt
# --------------------------------------------------------------------------- #
def build_question(problem: Problem) -> str:
return EN_BOXED_INSTR.format(question=problem.question.strip())
def build_messages(problem: Problem) -> list[dict[str, Any]]:
"""Single-turn user message; rendered into the model chat template by the checkpoint encoder."""
return [{"role": "user", "content": build_question(problem)}]
# --------------------------------------------------------------------------- #
# Answer extraction and scoring
# --------------------------------------------------------------------------- #
def extract_boxed(text: str) -> str | None:
r"""Return the content of the last \boxed{...}, supporting nested braces (e.g. \boxed{\frac{1}{2}})."""
token = "\\boxed"
starts = [match.start() for match in re.finditer(re.escape(token), text)]
for start in reversed(starts):
open_brace = text.find("{", start + len(token))
if open_brace < 0:
continue
depth = 0
for pos in range(open_brace, len(text)):
if text[pos] == "{":
depth += 1
elif text[pos] == "}":
depth -= 1
if depth == 0:
return text[open_brace + 1 : pos]
return None
_NUMBER_RE = re.compile(r"(?<![\w.])(\d{1,3})(?![\w.])")
def normalize_answer(ans: str | None) -> str | None:
"""Normalize an answer into a comparable string: strip $, braces, whitespace, thousands separators and leading +/0."""
if ans is None:
return None
text = ans.strip()
text = text.strip("$").strip()
text = text.replace("\\left", "").replace("\\right", "")
text = text.replace("\\!", "").replace("\\,", "").replace("\\;", "")
text = text.replace("{,}", ",") if "{,}" in text else text # LaTeX thousands separator: \boxed{1{,}234}
text = text.replace(",", "").replace(" ", "")
text = text.rstrip(".")
if text.startswith("+"):
text = text[1:]
if re.fullmatch(r"-?\d+", text):
return str(int(text))
return text
# The fallback scans only the tail: reasoning text holds many numbers, so scanning the whole
# text would easily pick up a value from an intermediate draft.
_TAIL_CHARS = 400
def extract_answer_with_source(text: str) -> tuple[str | None, str]:
r"""Extract the answer and report which path produced it.
1. `boxed`: the content of the last \boxed{...}. This is the format the prompt requests, so it
is the most reliable path;
2. `tail_number`: the last 0-999 integer after `</think>` (or within the final 400 characters).
A fallback for malformed output, **higher risk**; score.py reports its share separately;
3. `none`: nothing found.
"""
boxed = extract_boxed(text)
if boxed is not None:
norm = normalize_answer(boxed)
if norm:
return norm, "boxed"
tail = text.rsplit("</think>", 1)[-1] if "</think>" in text else text[-_TAIL_CHARS:]
nums = _NUMBER_RE.findall(tail)
if nums:
return normalize_answer(nums[-1]), "tail_number"
return None, "none"
def is_correct(pred: str | None, gold: str) -> bool:
"""Exact integer match; non-integer answers fall back to case- and whitespace-insensitive string equality."""
if pred is None:
return False
pred_norm, gold_norm = normalize_answer(pred), normalize_answer(gold)
if pred_norm is None or gold_norm is None:
return False
if re.fullmatch(r"-?\d+", pred_norm) and re.fullmatch(r"-?\d+", gold_norm):
return int(pred_norm) == int(gold_norm)
return pred_norm.lower() == gold_norm.lower()
# --------------------------------------------------------------------------- #
# Entry points used by run_eval.py / score.py
# --------------------------------------------------------------------------- #
def build_records(problems: list[Problem], *, limit: int | None = None) -> list[dict[str, Any]]:
records = []
for problem in problems:
records.append(
{
"task_id": problem.task_id,
"question": problem.question,
"answer": problem.answer,
"judge_question": build_question(problem),
"messages": build_messages(problem),
}
)
if limit is not None and len(records) >= limit:
break
return records
def score_rollout(rollout: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]:
"""Score one rollout. The rollout must carry raw_text (the model output)."""
pred, source = extract_answer_with_source(rollout.get("raw_text", ""))
row = dict(rollout)
row["pred"] = pred
row["extract_source"] = source
row["gold"] = record["answer"]
row["correct"] = is_correct(pred, record["answer"])
return row
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
EVAL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "${EVAL_DIR}/.." && pwd)"
cd "${REPO}"
MODEL="${MODEL:-deepseek-ai/DeepSeek-V4.1-Flash-1017}"
EP="${EP:-8}"
N_SAMPLE="${N_SAMPLE:-32}"
REASONING_EFFORT="${REASONING_EFFORT:-100}"
TAG="${TAG:-${MODEL//\//_}-n${N_SAMPLE}-j${REASONING_EFFORT}}"
DATA_FILE="${DATA_FILE:-${EVAL_DIR}/data/aime_2026.jsonl}"
OUT="${EVAL_DIR}/outputs/aime/${TAG}"
echo "[aime] model=${MODEL} ep=${EP} n_sample=${N_SAMPLE} effort=${REASONING_EFFORT} -> ${OUT}"
# 1) inference
python eval/run_eval.py --bench aime --model-name "${MODEL}" --ep "${EP}" \
--n-sample "${N_SAMPLE}" --reasoning-effort "${REASONING_EFFORT}" \
--tag "${TAG}" --data-file "${DATA_FILE}"
# 2) evaluate
# LLM judging: need EVAL_JUDGE_BASE_URL / EVAL_JUDGE_API_KEY / EVAL_JUDGE_MODEL
python eval/score.py --out-dir "${OUT}" --judge llm --judge-concurrency 16
# rule judging: use this instead when no LLM judge is available
# python eval/score.py --out-dir "${OUT}" --judge rule
echo "done: ${OUT}/summary.json"
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
def load_encoder(model_name: str) -> ModuleType:
"""Import the chat encoder that ships inside the checkpoint directory."""
from examples._config import resolve_model_path
path = Path(resolve_model_path(model_name)) / "encoding" / "encoding.py"
if not path.is_file():
raise SystemExit(f"checkpoint has no encoding/encoding.py: {path}")
mod_name = "ckpt_encoding"
if mod_name in sys.modules:
return sys.modules[mod_name]
spec = importlib.util.spec_from_file_location(mod_name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = module
spec.loader.exec_module(module)
return module
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import os
from collections.abc import Iterable
from pathlib import Path
from typing import Any
def read_jsonl(path: str | os.PathLike) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def write_jsonl(path: str | os.PathLike, rows: Iterable[dict[str, Any]]) -> int:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
count = 0
with open(path, "w") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
count += 1
return count
def require_file(path: Path, hint: str) -> Path:
"""Raise an actionable error when the data file is missing (this directory ships no datasets)."""
if not path.is_file():
raise FileNotFoundError(
f"data file not found: {path}\n"
"The evaluation scripts do not download datasets. Obtain it and place it at this path, "
f"or set DATA_FILE / --data-file.\n{hint}"
)
return path
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
import json
import os
import re
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import requests
ANSWER_EXACT_MATCH_PROMPT = """
# Role: Deterministic Autograder
You are a precise, automated grading system. Your sole function is to evaluate whether a student's final answer matches the reference answer. You must NOT grade the reasoning, steps, or methodology—only the final extracted result.
# Input Data
<|Start of Question|>
{question}
<|End of Question|>
<|Start of Reference Answer|>
{answer}
<|End of Reference Answer|>
<|Start of Student's Answer|>
{output}
<|End of Student's Answer|>
# Grading Guidelines (Equivalence Rules)
Equivalence is mandatory for a `correct` grade. You must rigorously verify if the answers represent the exact same value, meaning, or expression, even if formatted differently. Actively try to prove equivalence before marking an answer as wrong.
1. **Algebraic Equivalence:** Expressions like `n(n+1)/2` and `n^2/2 + n/2` or `x/sqrt(2)` and `x*sqrt(2)/2` are equivalent. You must mathematically simplify expressions to check for a match.
2. **Numerical Equivalence:** Fractions, decimals, and percentages that hold the same value are equivalent (e.g., `1/2` == `0.5` == `50%`).
3. **Set/List Equivalence:** Unless the question specifically asks for an ordered tuple/vector, the order of elements does not matter (e.g., `{{1, 2}}` is equivalent to `{{2, 1}}`).
4. **Unit Equivalence:** Ignore minor unit formatting variations. If a student includes correct units and the reference does not (or vice versa), do not penalize them.
5. **Text Equivalence:** Ignore case, minor typos, and punctuation differences unless they completely change the mathematical/logical meaning.
6. **Multiple-Choice Equivalence**: For multiple-choice questions, the student's answer is correct if it matches either the option letter (e.g., "A") or the content of the correct option (e.g., answering "100" when the correct option is "A: 100").
7. **No Partial Credit:** If the student's final answer is incomplete, contains multiple contradictory answers, or is partially incorrect, it is `wrong`.
8. **No Answer:** If no clear, unambiguous final answer can be extracted from the student's text, it is `wrong`.
# Instructions
- Compare the two answers using the Grading Guidelines, and verify their equivalence.
- You must NOT grade the reasoning or steps, only the final answer.
- If the student’s final answer is correct according to the reference answer, output `correct`.
- Be concise in your thinking, do NOT comment on any background to the problem, do NOT attempt to solve the problem, do NOT argue for any answer different than the reference answer, focus only on whether the answers match.
Output exactly one of `correct` and `wrong`, without any additional text.
""".strip()
HLE_JUDGE_PROMPT = r"""
Judge whether the following [response] to [question] is correct or not based on the precise and unambiguous [correct_answer] below.
[question]: {question}
[response]: {response}
Your judgement must be in the format and criteria specified below:
extracted_final_answer: The final exact answer extracted from the [response]. Put the extracted answer as 'None' if there is no exact, final answer to extract from the response.
[correct_answer]: {correct_answer}
reasoning: Explain why the extracted_final_answer is correct or incorrect based on [correct_answer], focusing only on if there are meaningful differences between [correct_answer] and the extracted_final_answer. Do not comment on any background to the problem, do not attempt to solve the problem, do not argue for any answer different than [correct_answer], focus only on whether the answers match.
correct: Answer 'yes' if extracted_final_answer matches the [correct_answer] given above, or is within a small margin of error for numerical problems. Answer 'no' otherwise, i.e. if there if there is any inconsistency, ambiguity, non-equivalency, or if the extracted answer is incorrect.
confidence: The extracted confidence score between 0|\%| and 100|\%| from [response]. Put 100 if there is no confidence score available.
""".strip()
# Two judge protocols: answer equivalence (general / short math answers) and the official HLE protocol
JUDGE_KINDS = ("equivalence", "hle")
# HLE verdict parsing. The judge is a thinking model rather than the official o3-mini with
# structured output, so the verdict field drifts. Accepting only a strict `correct:` line
# silently scores drifting answers as wrong, so fall back through:
# `correct:` field (tolerating quotes/brackets/markdown, last occurrence wins)
# -> line-initial `Answer: yes|no`
# -> wording of the explanation (skip lines that carry the graded model's own answer,
# neutralise double negatives, then let a negation outrank an affirmation)
# If none of the three match, the sample counts as wrong and is flagged unparsed.
_HLE_CORRECT_RE = re.compile(r"(?<![a-z])correct[\"'\]\*_]*\s*:\s*[\"'\*_ ]*(yes|no)\b")
_HLE_ANSWER_FALLBACK_RE = re.compile(r"^[\*_ ]*answer[\*_ ]*:\s*[\"'\*_ ]*(yes|no)\b", re.MULTILINE)
# Under the drift these lines carry the graded model's answer (possibly containing words such as
# "is correct" or "none"), so they must not take part in the wording decision; if an
# explanation:/reasoning: section follows on the same line, keep that part.
_HLE_WORDING_SKIP_LINE_RE = re.compile(
r"^[\*_ \"]*(?:extracted_final_answer|answer|confidence)[\*_\"]*\s*:(?:(?!explanation:|reasoning:).)*",
re.MULTILINE,
)
_HLE_WORDING_NEUTRAL_RE = re.compile(
# double negatives: no meaningful difference / not different / does not contradict ...
r"\bno\s+(?:\w+\s+){0,2}(?:differences?|discrepanc(?:y|ies)|inconsistenc(?:y|ies)|mismatch(?:es)?|conflicts?|deviations?|contradictions?|non-equivalenc(?:e|y))\b(?:\s+or\s+\w+)?"
r"|\bnot\s+(?:\w+\s+)?(?:different|inconsistent|incorrect|wrong)\b"
r"|\b(?:does|do)\s+not\s+(?:\w+\s+)?(?:contradict|create|constitute|introduce|represent|affect|alter|change|make|render|amount|rise)\b[^\n.;]{0,80}"
r"|\bdiffer(?:s|ing)?\s+only\s+in\b|\bonly\s+differ(?:s|ing|ence)?\s+(?:in|is)\b"
r"|\bdiffers?\s+(?:from[^\n]{0,80}?)?\bby\s+(?:only|just|a\s+negligible|a\s+small|less\s+than|about|roughly|approximately)\b"
r"|\bwithout\s+(?:any\s+)?(?:\w+\s+)?(?:differences?|inconsistenc(?:y|ies)|discrepanc(?:y|ies))\b"
r"|\bnot\s+(?:a\s+)?(?:meaningful|significant|substantive)\b"
# the judge echoing the instructions it was given
r"|\bcorrect\s+or\s+incorrect\b|\bfocusing\s+only\s+on\s+if\s+there\s+are\s+meaningful\s+differences\b"
r"|\bif\s+there\s+(?:if\s+there\s+)?is\s+any\s+inconsistency[^.\n]*"
r"|\banswer\s+'?no'?\s+otherwise\b|\bor\s+if\s+the\s+extracted\s+answer\s+is\s+incorrect\b"
r"|\bwhy\s+the\s+extracted_final_answer\s+is\b"
)
_HLE_WORDING_NO_RE = re.compile(
r"\b(?:does|do|did)\s+not\s+(?:\w+\s+)?(?:match|equal|correspond|agree|coincide|align)"
r"|\bdoesn'?t\s+(?:\w+\s+)?(?:match|equal|correspond|agree)"
r"|\bnot\s+(?:\w+\s+){0,2}(?:match(?:ing|ed)?|equivalent|equal|identical|the\s+same|consistent|correct)\b"
r"|\bnon-equivalent\b|\bnot\s+within\s+(?:a\s+)?(?:small\s+)?(?:acceptable\s+)?margin\b"
r"|\bmismatch|\bdiffers?\b|\bdisagrees?\b|\bcontradict|\binconsistent\s+with\b"
r"|\b(?:is|are|was|were)\s+(?:\w+\s+)?(?:different|incorrect|wrong|inconsistent)\b"
r"|\bdifferent\s+(?:\w+\s+)?(?:from|answers?|values?|numbers?|choices?|options?|letters?|terms?|compounds?|results?|sequences?|quantit|express|formul|structure|molecule|name)"
r"|\b(?:meaningful|significant|substantive|noticeabl\w+)\s+(?:\w+\s+)?(?:discrepancy|difference|mismatch|deviation|inconsistency|non-equivalence)\b"
r"|\binconsistency(?:/ambiguity)?\s+with\b"
r"|\bdifference\s+is\s+(?:meaningful|significant|substantive)\b"
r"|\bno\s+(?:\w+\s+){0,2}match\b|\bfails?\s+to\s+match\b"
r"|\bcannot\s+be\s+considered\s+(?:equivalent|correct|a\s+match)"
)
_HLE_WORDING_YES_RE = re.compile(
r"\bmatch(?:es|ed|ing)?\b"
r"|\bidentical\b|\bequivalent\b|\bsame\s+as\b|\bconsistent\s+with\b|\bagrees?\s+with\b"
r"|\b(?:is|are)\s+(?:\w+\s+)?correct\b"
r"|\bequals?\s+(?:to\s+)?(?:the|\d)"
r"|\bcorrectly\b|\bcorresponds?\s+to\b"
)
def _hle_wording_verdict(text: str) -> str | None:
text = _HLE_WORDING_SKIP_LINE_RE.sub(" ", text)
text = _HLE_WORDING_NEUTRAL_RE.sub(" ", text)
if _HLE_WORDING_NO_RE.search(text):
return "no"
if _HLE_WORDING_YES_RE.search(text):
return "yes"
return None
def parse_hle_judge(response: str) -> tuple[bool, bool]:
"""Return (is_correct, verdict_unparsed) for an HLE judge reply."""
verdict = response.split("</think>")[-1].strip().lower()
matches = _HLE_CORRECT_RE.findall(verdict)
if not matches:
matches = _HLE_ANSWER_FALLBACK_RE.findall(verdict)
if matches:
return matches[-1] == "yes", False
wording = _hle_wording_verdict(verdict)
return wording == "yes", wording is None
# Per-request timeout and attempt count: a thinking judge occasionally takes a long time, and one
# slow request must never invalidate a whole batch (2500 items).
DEFAULT_TIMEOUT = float(os.environ.get("EVAL_JUDGE_TIMEOUT", "600"))
MAX_REQUEST_ATTEMPTS = int(os.environ.get("EVAL_JUDGE_ATTEMPTS", "3"))
DEFAULT_MAX_TOKENS = 1024 * 64
def truncate_output(text: str, max_paragraphs: int = 10, max_chars: int = 4000) -> str:
"""Truncate the model output before judging: last 10 paragraphs, last 4000 characters."""
tail = text.split("</think>")[-1].strip()
return "\n\n".join(tail.split("\n\n")[-max_paragraphs:])[-max_chars:]
def parse_judge_is_correct(response: str) -> bool:
"""Parse a judge reply: only the last section after `</think>`."""
verdict = response.split("</think>")[-1].strip()
return verdict.strip().lower() == "correct"
def resolve_model(model: str | None = None) -> str:
"""The judge model to use: the explicit argument wins, then EVAL_JUDGE_MODEL."""
resolved = model or os.environ.get("EVAL_JUDGE_MODEL") or ""
if not resolved:
raise RuntimeError("missing judge model: set EVAL_JUDGE_MODEL")
return resolved
class LLMJudge:
"""Judge client for an OpenAI-compatible /chat/completions endpoint."""
def __init__(
self,
*,
base_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
temperature: float = 1.0,
top_p: float = 0.95,
max_tokens: int = DEFAULT_MAX_TOKENS,
timeout: float = DEFAULT_TIMEOUT,
concurrency: int = 8,
) -> None:
self.base_url = (base_url or os.environ.get("EVAL_JUDGE_BASE_URL") or "").rstrip("/")
self.api_key = api_key or os.environ.get("EVAL_JUDGE_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or ""
self.model = resolve_model(model)
self.temperature = temperature
self.top_p = top_p
self.max_tokens = max_tokens
self.timeout = timeout
self.concurrency = concurrency
if not self.base_url:
raise RuntimeError("missing judge base URL: set EVAL_JUDGE_BASE_URL")
if not self.api_key:
raise RuntimeError("missing judge API key: set EVAL_JUDGE_API_KEY (or DEEPSEEK_API_KEY)")
def _judge_one(
self,
question: str,
answer: str,
output: str,
kind: str = "equivalence",
max_tokens: int | None = None,
) -> dict[str, Any]:
if kind == "hle":
content = HLE_JUDGE_PROMPT.format(question=question, correct_answer=answer, response=output)
parser = None
elif kind == "equivalence":
content = ANSWER_EXACT_MATCH_PROMPT.format(question=question, answer=answer, output=output)
parser = parse_judge_is_correct
else:
raise ValueError(f"unknown judge protocol: {kind!r}, expected one of {JUDGE_KINDS}")
payload = {
"model": self.model,
"messages": [{"role": "user", "content": content}],
"temperature": self.temperature,
"top_p": self.top_p,
"max_tokens": max_tokens or self.max_tokens,
"stream": False,
}
last_err = ""
for attempt in range(1, MAX_REQUEST_ATTEMPTS + 1):
try:
response = requests.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
data=json.dumps(payload),
timeout=self.timeout,
)
response.raise_for_status()
choice = response.json()["choices"][0]
message = choice.get("message") or {}
# Read only `content`: a thinking model puts its draft in `reasoning_content`,
# and a phrase such as "correct: no" there must not be taken as the verdict.
text = (message.get("content") or "").strip()
if kind == "hle":
judge_correct, verdict_unparsed = parse_hle_judge(text) if text else (False, True)
return {
"judge_correct": judge_correct,
"judge_raw": text,
"judge_parsed": bool(text),
"judge_verdict_unparsed": verdict_unparsed,
"judge_finish_reason": choice.get("finish_reason"),
}
return {
"judge_correct": bool(text) and parser(text),
"judge_raw": text,
"judge_parsed": bool(text),
"judge_finish_reason": choice.get("finish_reason"),
}
except Exception as exc: # network / timeout / 5xx land here; must not abort the batch
last_err = f"{type(exc).__name__}: {exc}"
if attempt < MAX_REQUEST_ATTEMPTS:
time.sleep(min(2**attempt, 10))
return {
"judge_correct": False,
"judge_raw": "",
"judge_parsed": False,
"judge_error": last_err,
}
def judge_many(
self,
items: list[tuple[str, str, str]],
kind: str = "equivalence",
max_tokens: int | None = None,
) -> list[dict[str, Any]]:
"""items = [(question, gold_answer, model_output), ...], judged concurrently."""
def _safe(item: tuple[str, str, str]) -> dict[str, Any]:
try:
return self._judge_one(*item, kind=kind, max_tokens=max_tokens)
except Exception as exc: # last resort: an unexpected failure must not abort the batch
return {
"judge_correct": False,
"judge_raw": "",
"judge_parsed": False,
"judge_error": f"{type(exc).__name__}: {exc}",
}
with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
return list(pool.map(_safe, items))
def judge_answers(
items: list[tuple[str, str, str]],
*,
kind: str = "equivalence",
concurrency: int = 8,
) -> list[dict[str, Any]]:
"""Judge every (question, gold_answer, model_output) triple; one verdict per item."""
judge = LLMJudge(concurrency=concurrency)
verdicts = judge.judge_many(items, kind=kind)
failed = [verdict for verdict in verdicts if not verdict.get("judge_parsed")]
if failed:
errors = Counter(verdict["judge_error"] for verdict in failed if verdict.get("judge_error"))
print(f"[warn] {len(failed)}/{len(items)} judge replies carried no verdict; counted as wrong", flush=True)
for err, count in errors.most_common(5):
print(f"[warn] {count} request(s) failed: {err}", flush=True)
if not errors:
print("[warn] replies were empty; raise the judge max_tokens", flush=True)
return verdicts
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import importlib.util
import sys
from types import ModuleType
from common.paths import EVAL_DIR
SUPPORTED = ("aime", "hle", "gpqa")
def load_bench(name: str) -> ModuleType:
if name not in SUPPORTED:
raise SystemExit(f"benchmark not wired up: {name} (supported: {' / '.join(SUPPORTED)})")
path = EVAL_DIR / name / "bench.py"
if not path.is_file():
raise SystemExit(f"benchmark implementation not found: {path}")
mod_name = f"eval_bench_{name}"
if mod_name in sys.modules:
return sys.modules[mod_name]
spec = importlib.util.spec_from_file_location(mod_name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = module
spec.loader.exec_module(module)
return module
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import math
from collections import defaultdict
from typing import Any
def estimator(num_samples: int, num_correct: int, k: int) -> float:
"""Unbiased Pass@k estimator: 1 - C(n-c, k) / C(n, k).
(The Codex/HumanEval estimator.)
* for k=1 it degenerates to c/n, the mean per-sample accuracy, i.e. the usual avg@n / Pass@1;
* when n-c < k (too few wrong samples for a k-subset to be all-correct) it returns 1.0.
"""
assert 0 <= num_correct <= num_samples, f"invalid num_correct={num_correct} num_samples={num_samples}"
assert 0 <= k <= num_samples, f"invalid k={k} num_samples={num_samples}"
if num_samples - num_correct < k:
return 1.0
return float(1.0 - math.prod(1.0 - k / i for i in range(num_samples - num_correct + 1, num_samples + 1)))
def pass_at_k_by_task(
results: list[dict[str, Any]],
k_list: list[int],
*,
task_key: str = "task_id",
correct_key: str = "correct",
) -> dict[str, float]:
"""Mean Pass@k over tasks, with tasks grouped by `task_key`.
When a task has fewer than k samples (judging failure, truncated sampling), approximate with
min(k, n) instead of dropping the task: dropping it would compute Pass@k only over the "clean"
tasks, and those are usually the easier ones, which biases the result upward.
"""
grouped: dict[str, list[bool]] = defaultdict(list)
for row in results:
grouped[row[task_key]].append(bool(row[correct_key]))
metrics: dict[str, float] = {}
for k in k_list:
per_task = [estimator(len(flags), sum(flags), min(k, len(flags))) for flags in grouped.values() if flags]
metrics[f"Pass@{k}"] = sum(per_task) / len(per_task) if per_task else 0.0
return metrics
def default_k_list(n_sample: int) -> list[int]:
"""Default k values: 1, 2, 4, ... (powers of two), with n_sample appended."""
k_list = [2**i for i in range(max(math.ceil(math.log2(n_sample)), 1))]
if n_sample not in k_list:
k_list.append(n_sample)
return k_list
def summarize(
results: list[dict[str, Any]],
*,
n_sample: int,
k_list: list[int] | None = None,
task_key: str = "task_id",
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""One rollout per row: {task_id, correct, ...}."""
k_list = k_list or default_k_list(n_sample)
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in results:
grouped[row[task_key]].append(row)
summary: dict[str, Any] = {
"num_tasks": len(grouped),
"num_rollouts": len(results),
"n_sample": n_sample,
"score": sum(1 for row in results if row["correct"]) / len(results) if results else 0.0,
"task_accuracy": {
task_id: sum(1 for row in task_rows if row["correct"]) / len(task_rows) for task_id, task_rows in sorted(grouped.items())
},
}
summary.update(pass_at_k_by_task(results, k_list, task_key=task_key))
if extra:
summary.update(extra)
return summary
def print_report(summary: dict[str, Any], *, title: str = "") -> None:
print("\n" + "=" * 72)
if title:
print(title)
print(f"tasks={summary['num_tasks']} rollouts={summary['num_rollouts']} n_sample={summary['n_sample']}")
pass_keys = sorted(
(metric for metric in summary if metric.startswith("Pass@")),
key=lambda k: int(k.split("@")[1]),
)
for key in pass_keys:
print(f" {key:<10s} = {summary[key] * 100:6.2f}")
note = "" if abs(summary["score"] - summary.get("Pass@1", 0.0)) < 1e-9 else " (ragged sample counts, differs from Pass@1)"
print(
f" {'score':<10s} = {summary['score'] * 100:6.2f} (= mean per-sample accuracy, equals Pass@1 when sample counts are even){note}"
)
print("-" * 72)
print("per-task accuracy:")
for tid, acc in summary["task_accuracy"].items():
bar = "#" * int(round(acc * 20))
print(f" {tid:<20s} {acc * 100:6.2f} {bar}")
print("=" * 72)
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
COMMON_DIR = Path(__file__).resolve().parent
EVAL_DIR = COMMON_DIR.parent
DATA_DIR = EVAL_DIR / "data"
DEFAULT_OUT_ROOT = EVAL_DIR / "outputs"
def _looks_like_repo(path: Path) -> bool:
return (path / "examples" / "_config.py").is_file() and (path / "deepinfer").is_dir()
def resolve_repo() -> Path:
"""Locate the repository root.
By default it is inferred from the location of eval/; set `DEEPINFER_REPO=<repo root>` when the
directory layout is unusual.
"""
candidates: list[Path] = []
if os.environ.get("DEEPINFER_REPO"):
candidates.append(Path(os.environ["DEEPINFER_REPO"]).expanduser())
candidates += [EVAL_DIR.parent, Path.cwd()]
for cand in candidates:
try:
if _looks_like_repo(cand):
return cand.resolve()
except OSError:
continue
print(
f"[eval] warning: could not infer the repository root from {EVAL_DIR} (no examples/ "
"and deepinfer/); set DEEPINFER_REPO=<absolute repo root>",
file=sys.stderr,
)
return EVAL_DIR.parent
def ensure_sys_path() -> Path:
"""Put the repository root and the eval directory on sys.path; return the repository root."""
repo = resolve_repo()
for entry in (str(repo), str(EVAL_DIR)):
if entry not in sys.path:
sys.path.insert(0, entry)
return repo
def resolve_out_dir(out_dir: str | None, *, bench: str, tag: str) -> str:
return out_dir or str(DEFAULT_OUT_ROOT / bench / tag)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
EVAL_DIR = Path(__file__).resolve().parent.parent # eval/
if str(EVAL_DIR) not in sys.path:
sys.path.insert(0, str(EVAL_DIR))
from common.io import require_file # noqa: E402
GPQA_PROMPT = (
"Answer the following multiple choice question. The last line of your response should be "
"of the following format: 'ANSWER: $LETTER' (without quotes) where LETTER is one of ABCD. "
"Think step by step before answering.\n\n{question}"
)
@dataclass(frozen=True)
class Problem:
task_id: str
question: str
answer: str # a single letter A-D
category: str = "unknown"
# --------------------------------------------------------------------------- #
# Data
# --------------------------------------------------------------------------- #
# The public mirror appends an answer-format instruction to each question, which conflicts with our
# prompt template, so it is stripped uniformly.
_TRAILING_INSTR_RE = re.compile(r"\n*\s*Please write your final answer in the form of.*$", re.I | re.S)
def _strip_trailing_instruction(question: str) -> str:
return _TRAILING_INSTR_RE.sub("", question).strip()
def _normalize_row(row: dict[str, Any], index: int) -> Problem | None:
"""Accept both the parquet-derived format and the public-mirror format."""
question = row.get("question") or row.get("problem")
raw_answer = row.get("answer") or row.get("solution")
if question is None or raw_answer is None:
return None
# \boxed{D} / ANSWER: D / a bare D are all normalized to a single letter.
# Do not search for the first A-D: the "B" in "BOXED" inside \boxed{D} would match.
raw = str(raw_answer).strip()
match = (
re.search(r"\\boxed\{\s*\(?([A-D])\)?\s*\}", raw, re.I)
or re.search(r"(?i)answer\s*[::]\s*\(?([A-D])\b", raw)
or re.fullmatch(r"\s*\(?([A-D])\)?\s*", raw)
)
if not match:
return None
task_id = str(row.get("task_id") or row.get("id") or f"gpqa_diamond_{index}")
category = str(row.get("category") or row.get("domain") or "unknown")
return Problem(
task_id=task_id,
question=_strip_trailing_instruction(str(question)),
answer=match.group(1),
category=category,
)
def load(data_file: str | os.PathLike) -> list[Problem]:
path = Path(data_file)
require_file(
path,
"GPQA Diamond download: https://huggingface.co/datasets/Idavidrein/gpqa (gated)"
" or the public mirror https://huggingface.co/datasets/hendrydong/gpqa_diamond_mc",
)
problems: list[Problem] = []
skipped = 0
with open(path) as f:
for line_no, line in enumerate(f):
line = line.strip()
if not line:
continue
problem = _normalize_row(json.loads(line), line_no)
if problem is None:
skipped += 1
continue
problems.append(problem)
assert problems, f"no problem parsed from {path}"
assert len({p.task_id for p in problems}) == len(problems), "duplicate task_id"
if skipped:
print(f"[gpqa] skipped {skipped} lines (missing fields, or an answer that is not A-D)", flush=True)
return problems
def build_question(problem: Problem) -> str:
return GPQA_PROMPT.format(question=problem.question.strip())
def build_messages(problem: Problem) -> list[dict[str, Any]]:
return [{"role": "user", "content": build_question(problem)}]
# --------------------------------------------------------------------------- #
# Answer extraction and scoring
# --------------------------------------------------------------------------- #
_ANSWER_LINE_RE = re.compile(r"(?im)^\s*ANSWER\s*[::]\s*\(?([A-D])\b")
_BOXED_RE = re.compile(r"\\boxed\{\s*\(?([A-D])\)?\s*\}")
_STANDALONE_RE = re.compile(r"(?<![A-Za-z])([A-D])(?![A-Za-z])")
def extract_answer_with_source(text: str) -> tuple[str | None, str]:
"""Extract the answer letter by priority and report which path matched."""
if not text:
return None, "none"
matches = _ANSWER_LINE_RE.findall(text)
if matches:
return matches[-1].upper(), "answer_line"
matches = _BOXED_RE.findall(text)
if matches:
return matches[-1].upper(), "boxed"
tail = text.rsplit("</think>", 1)[-1] if "</think>" in text else text[-400:]
matches = _STANDALONE_RE.findall(tail)
if matches:
return matches[-1].upper(), "tail_letter"
return None, "none"
def is_correct(pred: str | None, gold: str) -> bool:
return pred is not None and pred.upper() == str(gold).strip().upper()
# --------------------------------------------------------------------------- #
# Entry points used by run_eval.py / score.py
# --------------------------------------------------------------------------- #
def build_records(problems: list[Problem], *, limit: int | None = None) -> list[dict[str, Any]]:
records = []
for problem in problems:
records.append(
{
"task_id": problem.task_id,
"question": problem.question,
"answer": problem.answer,
"category": problem.category,
"judge_question": build_question(problem),
"messages": build_messages(problem),
}
)
if limit is not None and len(records) >= limit:
break
return records
def score_rollout(rollout: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]:
pred, source = extract_answer_with_source(rollout.get("raw_text", ""))
row = dict(rollout)
row["pred"] = pred
row["extract_source"] = source
row["gold"] = record["answer"]
row["correct"] = is_correct(pred, record["answer"])
return row
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
EVAL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "${EVAL_DIR}/.." && pwd)"
cd "${REPO}"
MODEL="${MODEL:-deepseek-ai/DeepSeek-V4.1-Flash-1017}"
EP="${EP:-8}"
N_SAMPLE="${N_SAMPLE:-8}"
REASONING_EFFORT="${REASONING_EFFORT:-100}"
TAG="${TAG:-${MODEL//\//_}-n${N_SAMPLE}-j${REASONING_EFFORT}}"
DATA_FILE="${DATA_FILE:-${EVAL_DIR}/data/gpqa_diamond.jsonl}"
OUT="${EVAL_DIR}/outputs/gpqa/${TAG}"
echo "[gpqa] model=${MODEL} ep=${EP} n_sample=${N_SAMPLE} effort=${REASONING_EFFORT} -> ${OUT}"
# 1) inference
python eval/run_eval.py --bench gpqa --model-name "${MODEL}" --ep "${EP}" \
--n-sample "${N_SAMPLE}" --reasoning-effort "${REASONING_EFFORT}" \
--tag "${TAG}" --data-file "${DATA_FILE}"
# 2) evaluate
# LLM judging: need EVAL_JUDGE_BASE_URL / EVAL_JUDGE_API_KEY / EVAL_JUDGE_MODEL
python eval/score.py --out-dir "${OUT}" --judge llm --judge-concurrency 16
# rule judging: use this instead when no LLM judge is available
# python eval/score.py --out-dir "${OUT}" --judge rule
echo "done: ${OUT}/summary.json"
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
EVAL_DIR = Path(__file__).resolve().parent.parent # eval/
if str(EVAL_DIR) not in sys.path:
sys.path.insert(0, str(EVAL_DIR))
from common.io import require_file # noqa: E402
from common.paths import DATA_DIR # noqa: E402
# The official three-part answer format from hle_eval
HLE_ANSWER_FORMAT = (
"{question}\n"
"Your response should be in the following format:\n"
"Explanation: {{your explanation for your final answer}}\n"
"Answer: {{your succinct, final answer}}\n"
"Confidence: {{your confidence score between 0% and 100% for your answer}}"
)
@dataclass(frozen=True)
class Problem:
task_id: str
question: str
answer: str
category: str = "unknown"
has_image: bool = False
image_paths: tuple[str, ...] = ()
image_sizes: tuple[tuple[int, int], ...] = ()
# --------------------------------------------------------------------------- #
# Data
# --------------------------------------------------------------------------- #
_IMAGE_MAGIC = (b"\xff\xd8\xff", b"\x89PNG", b"GIF8", b"RIFF", b"BM")
def _resolve_images(row: dict[str, Any]) -> tuple[tuple[str, ...], tuple[tuple[int, int], ...]]:
"""Resolve a row's image into (local file path, (W, H)).
Three representations are supported:
1. multi_modal_data.image_urls + image_sizes (the full-dataset JSONL uses this form)
2. an `image` field holding a local path
3. an `image` field holding base64 (common in the official cais/hle parquet), decoded and
cached under data/hle_images/
"""
import base64
multi_modal = row.get("multi_modal_data") or {}
urls = [str(url) for url in (multi_modal.get("image_urls") or [])]
sizes = [(int(size[0]), int(size[1])) for size in (multi_modal.get("image_sizes") or []) if len(size) >= 2]
raw = row.get("image")
if not urls and raw:
if isinstance(raw, (bytes, bytearray)):
urls = [_write_cached_image(row, bytes(raw), 0)]
else:
text = str(raw).strip()
if os.path.isfile(text):
urls = [text]
elif text:
try:
data = base64.b64decode(text, validate=False)
except Exception:
data = b""
if any(data.startswith(magic) for magic in _IMAGE_MAGIC):
urls = [_write_cached_image(row, data, 0)]
if urls and not sizes:
from PIL import Image # PIL is only needed to recover the dimensions from a file
sizes = [Image.open(path).size for path in urls]
return tuple(urls), tuple(sizes)
def _write_cached_image(row: dict[str, Any], data: bytes, index: int) -> str:
cache = DATA_DIR / "hle_images"
cache.mkdir(parents=True, exist_ok=True)
stem = str(row.get("task_id") or row.get("id") or "hle")
path = cache / f"{stem}_{index}.img"
if not path.is_file() or path.stat().st_size != len(data):
path.write_bytes(data)
return str(path)
def _has_image(row: dict[str, Any]) -> bool:
"""Accept several common representations: an explicit modality field, multi_modal_data.image_urls, or a single image field."""
if str(row.get("modality") or "").lower() == "image":
return True
multi_modal = row.get("multi_modal_data") or {}
if multi_modal.get("image_urls"):
return True
img = row.get("image")
if img is None:
return False
if isinstance(img, (bytes, bytearray)):
return len(img) > 0
return bool(str(img).strip())
def load(data_file: str | os.PathLike) -> list[Problem]:
path = Path(data_file)
require_file(path, "HLE download: https://huggingface.co/datasets/cais/hle (gated; accept the terms on Hugging Face first)")
problems: list[Problem] = []
with open(path) as f:
for line_no, line in enumerate(f):
line = line.strip()
if not line:
continue
row = json.loads(line)
has_image = _has_image(row)
image_paths, image_sizes = _resolve_images(row) if has_image else ((), ())
problems.append(
Problem(
task_id=str(row.get("task_id") or row.get("id") or f"hle_{line_no:04d}"),
question=str(row["question"]),
answer=str(row["answer"]),
category=str(row.get("category") or row.get("raw_subject") or "unknown"),
has_image=has_image,
image_paths=image_paths,
image_sizes=image_sizes,
)
)
assert len({p.task_id for p in problems}) == len(problems), "duplicate task_id"
return problems
def build_question(problem: Problem) -> str:
return HLE_ANSWER_FORMAT.format(question=problem.question.strip())
def build_messages(problem: Problem) -> list[dict[str, Any]]:
return [{"role": "user", "content": build_question(problem)}]
# --------------------------------------------------------------------------- #
# Answer extraction and (rule-based) scoring
# --------------------------------------------------------------------------- #
_ANSWER_LINE_RE = re.compile(r"(?im)^\s*(?:\*\*)?answer(?:\*\*)?\s*[::]\s*(.+?)\s*$")
def extract_answer(text: str) -> str | None:
"""Return the content of the last `Answer:` line (the official three-part format)."""
if not text:
return None
matches = _ANSWER_LINE_RE.findall(text)
if not matches:
return None
ans = matches[-1].strip()
ans = ans.strip("*").strip()
ans = re.sub(r"^\$+|\$+$", "", ans).strip()
return ans or None
def normalize_answer(ans: str | None) -> str | None:
if ans is None:
return None
text = ans.strip()
text = text.strip("$").strip()
text = text.replace("\\left", "").replace("\\right", "")
text = text.replace("\\!", "").replace("\\,", "").replace("\\;", "")
text = text.replace("{,}", ",") if "{,}" in text else text
text = re.sub(r"\s+", " ", text).strip().lower()
text = text.rstrip(".").strip()
if re.fullmatch(r"-?\d+", text):
return str(int(text))
return text
def is_correct(pred: str | None, gold: str) -> bool:
"""Rule-based scoring: normalized string equality. **Smoke tests only**; report LLM-judge numbers."""
pred_norm, gold_norm = normalize_answer(pred), normalize_answer(gold)
return pred_norm is not None and gold_norm is not None and pred_norm == gold_norm
# --------------------------------------------------------------------------- #
# Entry points used by run_eval.py / score.py
# --------------------------------------------------------------------------- #
def build_records(
problems: list[Problem],
*,
limit: int | None = None,
) -> list[dict[str, Any]]:
records = []
for problem in problems:
records.append(
{
"task_id": problem.task_id,
"question": problem.question,
"answer": problem.answer,
"category": problem.category,
"modality": "image" if problem.has_image else "text",
"image_paths": list(problem.image_paths),
"image_sizes": [list(size) for size in problem.image_sizes],
"judge_question": problem.question,
"messages": build_messages(problem),
}
)
if limit is not None and len(records) >= limit:
break
return records
def score_rollout(rollout: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]:
row = dict(rollout)
row["pred"] = extract_answer(rollout.get("raw_text", ""))
row["gold"] = record["answer"]
row["correct"] = is_correct(row["pred"], record["answer"])
return row
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
EVAL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "${EVAL_DIR}/.." && pwd)"
cd "${REPO}"
MODEL="${MODEL:-deepseek-ai/DeepSeek-V4.1-Flash-1017}"
EP="${EP:-8}"
REASONING_EFFORT="${REASONING_EFFORT:-100}"
TAG="${TAG:-${MODEL//\//_}-j${REASONING_EFFORT}}"
DATA_FILE="${DATA_FILE:-${EVAL_DIR}/data/hle_test.jsonl}"
OUT="${EVAL_DIR}/outputs/hle/${TAG}"
echo "[hle] model=${MODEL} ep=${EP} effort=${REASONING_EFFORT} -> ${OUT}"
# 1) inference (single sample per problem, temperature from BENCH_DEFAULTS)
python eval/run_eval.py --bench hle --model-name "${MODEL}" --ep "${EP}" \
--n-sample 1 --reasoning-effort "${REASONING_EFFORT}" \
--tag "${TAG}" --data-file "${DATA_FILE}"
# 2) evaluate (LLM judge only)
# need EVAL_JUDGE_BASE_URL / EVAL_JUDGE_API_KEY / EVAL_JUDGE_MODEL
python eval/score.py --out-dir "${OUT}" --judge llm --judge-concurrency 16
echo "done: ${OUT}/summary.json"
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Any
EVAL_DIR = Path(__file__).resolve().parent
if str(EVAL_DIR) not in sys.path:
sys.path.insert(0, str(EVAL_DIR))
from common.io import write_jsonl # noqa: E402
from common.loader import SUPPORTED, load_bench # noqa: E402
from common.paths import ensure_sys_path, resolve_out_dir # noqa: E402
REPO = ensure_sys_path() # put repo root on sys.path so examples / deepinfer can be imported
from examples._config import DEFAULT_MODEL_NAME # noqa: E402
BENCH_DEFAULTS = {
"aime": {"n_sample": 32, "temperature": 1.0, "top_p": 1.0},
"hle": {"n_sample": 1, "temperature": 1.0, "top_p": 1.0},
"gpqa": {"n_sample": 8, "temperature": 1.0, "top_p": 1.0},
}
_REASONING_EFFORT_LEVELS = ("low", "high", "max")
def _reasoning_effort(value: str) -> int | str:
"""Accept an integer 1-100, or a named level low/high/max."""
if value in _REASONING_EFFORT_LEVELS:
return value
try:
effort = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"expected an integer 1-100 or one of {_REASONING_EFFORT_LEVELS}, got {value!r}") from None
if not 1 <= effort <= 100:
raise argparse.ArgumentTypeError(f"expected 1-100, got {effort}")
return effort
parser = argparse.ArgumentParser(description="eval benchmark")
# benchmark setting
parser.add_argument("--bench", default="aime", choices=list(SUPPORTED))
parser.add_argument("--model-name", default=DEFAULT_MODEL_NAME, help="HF model id or HF ckpt directory")
parser.add_argument("--data-file", required=True, help="problem jsonl")
parser.add_argument("--limit", type=int, default=None, help="run only the first N problems")
parser.add_argument("--out-dir", default=None)
parser.add_argument("--tag", default=None, help="output dir suffix, to compare settings of the same model")
# sample param
parser.add_argument("--n-sample", type=int, default=None, help="samples per problem (default: aime 32 / hle 1 / gpqa 8)")
parser.add_argument("--temperature", type=float, default=None, help="default 1.0 for every benchmark")
parser.add_argument("--top-p", type=float, default=None, help="default 1.0")
parser.add_argument(
"--reasoning-effort", type=_reasoning_effort, default=100, help="integer 1-100, or low/high/max; higher means more thinking"
)
parser.add_argument("--max-len", type=int, default=1024 * 144, help="max prompt + output length per request")
# engine
parser.add_argument("--ep", type=int, default=8, help="expert parallel size = total GPUs in this job")
parser.add_argument("--cp", type=int, default=1)
parser.add_argument("--dp", type=int, default=None, help="defaults to ep // cp")
parser.add_argument("--max-num-seqs", type=int, default=384)
parser.add_argument("--max-num-batched-tokens", type=int, default=32768)
parser.add_argument("--gpu-memory-utilization", type=float, default=0.9)
parser.add_argument(
"--env", action="append", default=[], metavar="KEY=VALUE", help="extra env vars, repeatable; applied before the engine is built"
)
parser.add_argument("--decode-blacklist", default="0,2,129262..129264", help="tokens blocked during generation; empty string disables it")
parser.add_argument(
"--batch-invariant", action="store_true", help="DEEPINFER_BATCH_INVARIANT=1: results independent of batch composition (slower)"
)
args = parser.parse_args()
# fill in per-benchmark defaults (only for args not passed explicitly)
for _key, _default in BENCH_DEFAULTS[args.bench].items():
if getattr(args, _key) is None:
setattr(args, _key, _default)
REASONING_EFFORT = args.reasoning_effort
OUT_DIR = resolve_out_dir(
args.out_dir,
bench=args.bench,
tag=args.tag or f"{args.model_name.replace('/', '_')}-n{args.n_sample}-j{args.reasoning_effort}",
)
META_PATH = os.path.join(OUT_DIR, "meta.json")
# --------------------------------------------------------------------------- #
# generation
# --------------------------------------------------------------------------- #
def _render_prompts(encoder: ModuleType, records: list[dict]) -> list[str]:
return [
encoder.encode_messages(
rec["messages"],
thinking_mode="thinking",
reasoning_effort=REASONING_EFFORT,
)
for rec in records
]
def _multi_modal_entry(record: dict) -> dict:
"""One multi_modal_data entry: image bytes plus dimensions."""
return {
"image_bytes": [Path(path).read_bytes() for path in record.get("image_paths") or []],
"image_sizes": [tuple(size) for size in record.get("image_sizes") or []],
}
def _setup_env() -> None:
# MTP / DFlash speculative decoding, set before setup_common_env (which only setdefaults it).
os.environ["DEEPINFER_USE_NEXTN_DECODE"] = "5"
os.environ["DEEPINFER_ENABLE_MTP_VARLEN"] = "1"
from examples._config import setup_common_env
setup_common_env(model_name=args.model_name, sampler_force_topk=0, set_override_experts=True, enable_eplb=True)
for item in args.env:
key, _, value = item.partition("=")
assert key and "=" in item, f"--env expects KEY=VALUE, got {item!r}"
os.environ[key] = value
if args.batch_invariant:
os.environ["DEEPINFER_BATCH_INVARIANT"] = "1"
if args.decode_blacklist:
os.environ["DEEPINFER_DECODE_TOKEN_BLACKLIST"] = args.decode_blacklist
def _seed(global_index: int, sample_index: int) -> int:
"""Per-sample seed: identical seeds produce identical output, so every sample needs its own."""
return (global_index * args.n_sample + sample_index) % (2**64)
def _rows_from_outputs(tokenizer: Any, plan: list, records: list[dict], outputs: list) -> list[dict]:
rows = []
for (problem, sample), generated in zip(plan, outputs, strict=True):
assert generated, f"{records[problem]['task_id']}#{sample}: generated no tokens"
text = tokenizer.decode([token.token_id for token in generated], skip_special_tokens=False, clean_up_tokenization_spaces=False)
rows.append(
{
"task_id": records[problem]["task_id"],
"sample_idx": sample,
"seq_seed": _seed(problem, sample),
"prompt_len": generated[0].prompt_len,
"num_output_tokens": len(generated),
"finish_reason": generated[-1].finish_reason,
"raw_text": text,
}
)
return rows
def run_rank(dp_size: int, dp_rank: int) -> None:
_setup_env()
from common.encoder import load_encoder
from examples._config import create_llm
encoder = load_encoder(args.model_name)
bench = load_bench(args.bench)
problems = bench.load(args.data_file)
records = bench.build_records(problems, limit=args.limit)
# Shard by sample so every rank gets an equal share and no rank inherits a problem's long tail.
plan = [(problem, sample) for problem in range(len(records)) for sample in range(args.n_sample)][dp_rank::dp_size]
llm = create_llm(
args.model_name,
dp_rank,
ep_size=args.ep,
cp_size=args.cp,
data_parallel_size=dp_size,
max_num_batched_tokens=args.max_num_batched_tokens,
# The engine's runtime_max_model_len is max_model_len + 1, and every request's
# prompt + max_tokens is already clamped to <= max_len, so a tiny margin is enough.
max_model_len=args.max_len + 64,
max_num_seqs=args.max_num_seqs,
gpu_memory_utilization=args.gpu_memory_utilization,
lookahead_tokens=min(1, int(os.environ.get("DEEPINFER_USE_NEXTN_DECODE", "0"))),
decode_sequence=False,
)
try:
# Import after create_llm: deepinfer imports torch, which must not happen before the engine
# has set up the accelerator devices.
from deepinfer.sampling_params import SamplingParams
tokenizer = llm.get_tokenizer()
if not plan:
# dp_size > number of requests leaves some ranks with an empty shard: write an empty
# file and stop, do not build a batch.
os.makedirs(OUT_DIR, exist_ok=True)
write_jsonl(os.path.join(OUT_DIR, f"raw_rank{dp_rank:03d}.jsonl"), [])
print(f"[rank {dp_rank}] empty shard (dp_size={dp_size} > request count), skipping", flush=True)
return
# Render and tokenize each needed prompt once: a rank may use the same prompt for several
# samples of the same problem.
needed = sorted({problem for problem, _ in plan})
texts = dict(zip(needed, _render_prompts(encoder, [records[problem] for problem in needed]), strict=True))
has_images = any(records[problem].get("image_paths") for problem in needed)
multi_modal: dict[int, dict] = {}
if has_images:
# Replace the <image> placeholder with the image token the engine understands. Image
# questions then take the string path and the engine expands the token.
from examples._prompts import get_engine_image_token, replace_image_tokens
image_token_str, _ = get_engine_image_token(llm)
texts = dict(zip(needed, replace_image_tokens([texts[problem] for problem in needed], image_token_str), strict=True))
# The engine consumes multi_modal_data in place, so every request gets its own copy.
multi_modal = {problem: _multi_modal_entry(records[problem]) for problem in needed}
token_ids = {problem: tokenizer.encode(text, add_special_tokens=False) for problem, text in texts.items()}
# max_tokens is clamped to the remaining budget: max_len - prompt_len, at least 1.
sampling_params = [
SamplingParams(
temperature=args.temperature,
top_p=args.top_p,
max_tokens=max(args.max_len - len(token_ids[problem]), 1),
ignore_eos=False,
seq_seed=_seed(problem, sample),
)
for problem, sample in plan
]
lens = [len(token_ids[problem]) for problem, _ in plan]
print(
f"[rank {dp_rank}] problems={len(needed)} requests={len(plan)} "
f"prompt_tokens[min/avg/max]={min(lens)}/{sum(lens) // len(lens)}/{max(lens)}"
+ (f" multimodal={len(plan)}" if has_images else ""),
flush=True,
)
if has_images:
outputs = llm.generate(
prompts=[texts[problem] for problem, _ in plan],
multi_modal_data=[dict(multi_modal[problem]) for problem, _ in plan],
sampling_params=sampling_params,
use_tqdm=False,
)
else:
outputs = llm.generate(
prompts=None,
prompt_token_ids=[token_ids[problem] for problem, _ in plan],
sampling_params=sampling_params,
use_tqdm=False,
)
rows = _rows_from_outputs(tokenizer, plan, records, outputs)
os.makedirs(OUT_DIR, exist_ok=True)
path = os.path.join(OUT_DIR, f"raw_rank{dp_rank:03d}.jsonl")
write_jsonl(path, rows)
print(f"[rank {dp_rank}] wrote {path} rows={len(rows)}", flush=True)
finally:
llm.close()
def main() -> int:
dp_size = max(args.ep // args.cp, 1) if args.dp is None else args.dp
os.makedirs(OUT_DIR, exist_ok=True)
meta = {
"bench": args.bench,
"model_name": args.model_name,
"ep": args.ep,
"cp": args.cp,
"dp": dp_size,
"data_file": args.data_file,
"limit": args.limit,
"n_sample": args.n_sample,
"temperature": args.temperature,
"top_p": args.top_p,
"reasoning_effort": REASONING_EFFORT,
"max_len": args.max_len,
"mtp": 5,
"mtp_varlen": True,
"batch_invariant": args.batch_invariant,
"decode_blacklist": args.decode_blacklist,
"max_num_seqs": args.max_num_seqs,
"max_num_batched_tokens": args.max_num_batched_tokens,
"env": args.env,
}
for old in Path(OUT_DIR).glob("raw_rank*.jsonl"):
old.unlink()
with open(META_PATH, "w") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
print(f"REPO={REPO}\nOUT_DIR={OUT_DIR}\nmeta={json.dumps(meta, ensure_ascii=False)}", flush=True)
from examples._launch import launch_dp
launch_dp(run_rank, dp_size)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
from collections import Counter
from pathlib import Path
EVAL_DIR = Path(__file__).resolve().parent
if str(EVAL_DIR) not in sys.path:
sys.path.insert(0, str(EVAL_DIR))
from common.io import read_jsonl, write_jsonl # noqa: E402
from common.loader import load_bench # noqa: E402
from common.passk import default_k_list, print_report, summarize # noqa: E402
from common.paths import ensure_sys_path # noqa: E402
ensure_sys_path()
# Default judge per benchmark: all three use the LLM judge, matching the reference protocol.
BENCH_DEFAULT_JUDGE = {"aime": "llm", "hle": "llm", "gpqa": "llm"}
def load_raw(out_dir: str) -> list[dict]:
"""Merge every rank's raw output; for a given (task_id, sample_idx) the last write wins."""
merged: dict[tuple, dict] = {}
order: list[tuple] = []
paths = sorted(glob.glob(os.path.join(out_dir, "raw_rank*.jsonl")))
if not paths:
raise FileNotFoundError(f"no raw_rank*.jsonl under {out_dir} (did generation finish?)")
for path in paths:
for row in read_jsonl(path):
key = (row["task_id"], row["sample_idx"])
if key not in merged:
order.append(key)
merged[key] = row
return [merged[key] for key in order]
def _judge(results: list[dict], by_id: dict, bench_name: str, judge: str, concurrency: int) -> str | None:
"""Score `results` in place. Returns the judge model name for the LLM judge, else None."""
if judge == "rule" and bench_name == "hle":
raise SystemExit(
"HLE does not support rule judging: the official protocol requires an LLM judge.\n"
"Most HLE answers are free text, so normalized exact match marks equivalent-but-"
"differently-written answers wrong and systematically understates the score.\n"
"Set EVAL_JUDGE_BASE_URL / EVAL_JUDGE_API_KEY / EVAL_JUDGE_MODEL and use --judge llm."
)
if judge != "llm":
for row in results:
row["rule_correct"] = row["correct"]
return None
from common.judge import judge_answers, resolve_model, truncate_output
judge_kind = "hle" if bench_name == "hle" else "equivalence"
def judge_input(text: str) -> str:
# HLE official protocol: drop the thinking section, send the rest in full, no truncation.
if judge_kind == "hle":
return text.split("</think>")[-1].strip()
return truncate_output(text)
items = [
(
by_id[row["task_id"]].get("judge_question") or by_id[row["task_id"]]["question"],
by_id[row["task_id"]]["answer"],
judge_input(row["raw_text"]),
)
for row in results
]
for row, verdict in zip(results, judge_answers(items, kind=judge_kind, concurrency=concurrency), strict=True):
row["rule_correct"] = row["correct"]
row["correct"] = bool(verdict["judge_correct"])
row["judge_raw"] = verdict.get("judge_raw", "")
row["judge_parsed"] = bool(verdict.get("judge_parsed", True))
if "judge_verdict_unparsed" in verdict:
row["judge_verdict_unparsed"] = bool(verdict["judge_verdict_unparsed"])
if verdict.get("judge_error"):
row["judge_error"] = verdict["judge_error"]
return resolve_model()
def score_run(
out_dir: str,
*,
judge: str = "auto",
judge_concurrency: int = 8,
data_file: str | None = None,
verbose: bool = True,
) -> int:
meta_path = os.path.join(out_dir, "meta.json")
meta: dict = {}
if os.path.isfile(meta_path):
with open(meta_path) as f:
meta = json.load(f)
bench_name = meta.get("bench", "aime")
bench = load_bench(bench_name)
if judge == "auto":
judge = BENCH_DEFAULT_JUDGE.get(bench_name, "rule")
data_path = data_file or meta.get("data_file")
if not data_path:
raise SystemExit(f"{meta_path} has no data_file; pass --data-file explicitly")
problems = bench.load(data_path)
records = bench.build_records(problems, limit=meta.get("limit"))
by_id = {r["task_id"]: r for r in records}
rollouts = load_raw(out_dir)
unknown = [r["task_id"] for r in rollouts if r["task_id"] not in by_id]
assert not unknown, f"raw output has task_ids that are not in the dataset: {sorted(set(unknown))[:5]}"
results: list[dict] = []
for rollout in rollouts:
record = by_id[rollout["task_id"]]
row = bench.score_rollout(rollout, record)
results.append(row)
judge_model = _judge(results, by_id, bench_name, judge, judge_concurrency)
if not results:
raise SystemExit(f"no rollout at all in {out_dir}/raw_rank*.jsonl; refusing to emit an empty score")
# Coverage: missing rank files, problems or samples must be surfaced, never silently ignored.
seen = {row["task_id"] for row in results}
n_sample = int(meta.get("n_sample", max((row["sample_idx"] for row in results), default=0) + 1))
missing = sorted(set(by_id) - seen)
expected_rollouts = len(records) * n_sample
coverage_complete = not missing and len(results) == expected_rollouts
if not coverage_complete:
print(
f"[warn] incomplete coverage: {len(seen)}/{len(records)} tasks, {len(results)}/{expected_rollouts} "
"rollouts; this is not a complete result",
flush=True,
)
# Truncated rollouts and fallback extraction paths both depress the score, so surface them.
finish_reasons = dict(Counter(str(row["finish_reason"]) for row in results if row.get("finish_reason")))
extract_sources = dict(Counter(str(row["extract_source"]) for row in results if row.get("extract_source")))
truncated = sum(finish_reasons.get(reason, 0) for reason in ("length", "prompt_length_exceeded"))
invalid = sum(finish_reasons.get(reason, 0) for reason in ("ignored", "aborted", "error", "preempted"))
summary = summarize(
results,
n_sample=n_sample,
k_list=meta.get("k_list") or default_k_list(n_sample),
extra={
"bench": meta.get("bench", "aime"),
"model_name": meta.get("model_name"),
"judge": judge,
"judge_model": judge_model,
"temperature": meta.get("temperature"),
"top_p": meta.get("top_p"),
"reasoning_effort": meta.get("reasoning_effort"),
"max_len": meta.get("max_len"),
"extract_failed": sum(1 for row in results if row.get("extract_failed", row.get("pred") is None)) / len(results),
"expected_tasks": len(records),
"expected_rollouts": expected_rollouts,
"coverage_complete": coverage_complete,
"missing_tasks": len(missing),
"ragged_samples": len({sum(1 for row in results if row["task_id"] == task_id) for task_id in seen}) > 1,
"finish_reasons": finish_reasons,
"extract_sources": extract_sources,
"truncated_rollouts": truncated,
"invalid_rollouts": invalid,
},
)
groups: dict[str, list[dict]] = {}
for row in results:
if key := str(by_id[row["task_id"]].get("modality") or ""):
groups.setdefault(key, []).append(row)
if len(groups) > 1:
summary["by_modality"] = {
modality: {
"num_tasks": len({row["task_id"] for row in rows}),
"num_rollouts": len(rows),
"Pass@1": sum(1 for row in rows if row["correct"]) / len(rows),
}
for modality, rows in sorted(groups.items())
}
if judge == "llm":
agree = sum(1 for row in results if bool(row["rule_correct"]) == bool(row["correct"]))
summary["rule_vs_llm_agreement"] = agree / len(results)
unparsed = sum(1 for row in results if not row.get("judge_parsed", True))
summary["judge_unparsed"] = unparsed / len(results)
verdict_unparsed = sum(1 for row in results if row.get("judge_verdict_unparsed"))
summary["judge_verdict_unparsed"] = verdict_unparsed / len(results)
if verdict_unparsed:
print(
f"[warn] {verdict_unparsed}/{len(results)} judge replies had no extractable verdict; "
"all three fallbacks missed, they count as wrong",
flush=True,
)
if unparsed:
print(
f"[warn] {unparsed}/{len(results)} judge calls returned no verdict and count as wrong; "
"this depresses the score -- raise the judge max_tokens and re-run",
flush=True,
)
os.makedirs(out_dir, exist_ok=True)
write_jsonl(os.path.join(out_dir, "scored.jsonl"), results)
with open(os.path.join(out_dir, "summary.json"), "w") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
if verbose:
print_report(summary, title=f"{summary['bench']} {summary.get('model_name')} judge={judge}")
print(
f"coverage: expected {summary['expected_rollouts']} rollouts / "
f"{summary['expected_tasks']} tasks, got {summary['num_rollouts']} / {summary['num_tasks']}, "
f"complete={summary['coverage_complete']}"
)
print(f"finish_reason={summary['finish_reasons']}")
for key, stat in (summary.get("by_modality") or {}).items():
print(f" Pass@1[{key}] = {stat['Pass@1'] * 100:6.2f} (tasks={stat['num_tasks']})")
if summary["extract_sources"]:
print(f"extract_source={summary['extract_sources']}")
if summary["truncated_rollouts"] or summary["invalid_rollouts"]:
print(
f"[warn] {summary['truncated_rollouts']} truncated / "
f"{summary['invalid_rollouts']} invalid rollouts, all counted as wrong; "
"this depresses the score"
)
print(f"extract_failed={summary['extract_failed'] * 100:.2f}%")
if "rule_vs_llm_agreement" in summary:
print(f"rule vs llm agreement = {summary['rule_vs_llm_agreement'] * 100:.2f}%")
print(f"wrote {os.path.join(out_dir, 'scored.jsonl')} / summary.json")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="score a benchmark run and summarize Pass@k")
parser.add_argument("--out-dir", required=True)
parser.add_argument(
"--judge", choices=["auto", "rule", "llm"], default="auto", help="auto: per-benchmark default (all three use the LLM judge)"
)
parser.add_argument("--judge-concurrency", type=int, default=8)
parser.add_argument("--data-file", default=None, help="override meta.json's data_file")
args = parser.parse_args()
return score_run(
args.out_dir,
judge=args.judge,
judge_concurrency=args.judge_concurrency,
data_file=args.data_file,
)
if __name__ == "__main__":
raise SystemExit(main())