char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Georgi Gerganovllama-eval : fix crash when answer is None in HTML dump (#25435)da46e59cb

char/aliceai-t5-moe
59.7 KiB1591 linesraw
1#!/usr/bin/env python3
2
3import argparse
4import json
5import os
6import re
7import subprocess
8import sys
9import threading
10import time
11from abc import ABC, abstractmethod
12from concurrent.futures import ThreadPoolExecutor, as_completed
13from dataclasses import dataclass, asdict, field
14from pathlib import Path
15from queue import Queue
16from typing import Dict, List, Optional, Any, Tuple
17import requests
18from tqdm import tqdm
19import random
20from math import sqrt
21
22
23@dataclass
24class ServerConfig:
25    url: str
26    threads: int
27    name: str = ""
28
29def wilson_interval(correct: int, total: int, z: float = 1.96) -> Tuple[float, float]:
30    """Wilson score confidence interval for a proportion."""
31    if total == 0:
32        return (0.0, 1.0)
33    p = correct / total
34    z2 = z * z / total
35    center = (p + z2 / 2) / (1 + z2)
36    margin = z * sqrt((p * (1 - p) + z2 / 4) / total) / (1 + z2)
37    return (center - margin, center + margin)
38
39cache_dir = Path.home() / ".cache" / "huggingface" / "datasets"
40cache_dir.mkdir(parents=True, exist_ok=True)
41os.environ["HF_DATASETS_CACHE"] = str(cache_dir)
42os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
43
44GRADER_PATTERNS = {
45    "aime": r'\boxed{(\d+)}|\b(\d+)\b',
46    "aime2025": r'\boxed{(\d+)}|\b(\d+)\b',
47    "aime2026": r'\boxed{(\d+)}|\b(\d+)\b',
48    "gsm8k": r'\b(\d+)\b',
49}
50
51SAMPLE_ANSWERS = {
52    "aime": [
53        "42",
54        "-123",
55        "999"
56    ],
57    "aime2025": [
58        "42",
59        "-123",
60        "999"
61    ],
62    "aime2026": [
63        "42",
64        "-123",
65        "999"
66    ],
67    "gsm8k": [
68        "42",
69        "-123",
70        "999"
71    ],
72    "gpqa": [
73        "A",
74        "D",
75        "C"
76    ],
77}
78
79TEMPLATE_REGISTRY = {
80    "aime": """Solve the following math problem step by step. Put your answer inside \\boxed{{}}.
81
82{question}
83
84Remember to put your answer inside \\boxed{{}}.
85""",
86    "aime2025": """Solve the following math problem step by step. Put your answer inside \\boxed{{}}.
87
88{question}
89
90Remember to put your answer inside \\boxed{{}}.
91""",
92    "aime2026": """Solve the following math problem step by step. Put your answer inside \\boxed{{}}.
93
94{question}
95
96Remember to put your answer inside \\boxed{{}}.
97""",
98    "gsm8k": """{question}
99Please reason step by step, and put your final numeric answer within \\boxed{{}} without any extra characters.
100""",
101    "gpqa": """Answer the following multiple choice question. The last line of your response should be in the following format: 'Answer: A/B/C/D' (e.g. 'Answer: A').
102
103{Question}
104
105A) {A}
106B) {B}
107C) {C}
108D) {D}
109""",
110}
111
112
113class BaseDataset(ABC):
114    questions: List[Dict]
115
116    @abstractmethod
117    def get_question(self, index: int) -> Dict:
118        pass
119
120    @abstractmethod
121    def get_question_text(self, question: Dict) -> str:
122        pass
123
124    @abstractmethod
125    def get_answer(self, question: Dict) -> str:
126        pass
127
128    @abstractmethod
129    def get_prompt(self, question: Dict) -> str:
130        pass
131
132    def __len__(self) -> int:
133        return len(self.questions)
134
135
136@dataclass
137class TaskState:
138    task_id: str
139    prompt: str
140    expected: str
141    question_text: str = ""
142    response: Optional[str] = None
143    answer: Optional[str] = None
144    grader_log: Dict[str, Any] = field(default_factory=dict)
145    correct: bool = False
146    status: str = "pending"
147    tokens: Optional[int] = None
148    tps_gen: Optional[float] = None
149    t_gen_ms: Optional[float] = None
150    reasoning_content: Optional[str] = None
151    server_name: Optional[str] = None
152    chunk_idx: int = 0
153    problem_idx: int = 0
154
155
156class EvalState:
157    def __init__(
158        self,
159        dataset_type: str,
160        sampling_config: Dict[str, Any],
161        output_file: Path = Path("llama-eval-state.json"),
162        model_name: Optional[str] = None
163    ):
164        self.dataset_type = dataset_type
165        self.sampling_config = sampling_config
166        self.output_file = output_file
167        self.model_name = model_name
168        self.dataset: Optional[BaseDataset] = None
169        self.tasks: List[Tuple[int, str]] = []
170        self.all_tasks: List[Tuple[int, str]] = []
171        self.task_states: Dict[str, Any] = {}
172        self.total = 0
173        self.correct = 0
174        self.processed = 0
175        self.total_time: float = 0.0
176        self._lock = threading.Lock()
177
178    def load_dataset(self, seed: int = 1234):
179        if self.dataset_type == "aime":
180            self.dataset = AimeDataset()
181        elif self.dataset_type == "aime2025":
182            self.dataset = Aime2025Dataset()
183        elif self.dataset_type == "aime2026":
184            self.dataset = Aime2026Dataset()
185        elif self.dataset_type == "gsm8k":
186            self.dataset = Gsm8kDataset()
187        elif self.dataset_type == "gpqa":
188            self.dataset = GpqaDataset(variant="diamond", seed=seed)
189        else:
190            raise ValueError(f"Unknown dataset type: {self.dataset_type}")
191
192    def setup_tasks(self, n_cases: Optional[int] = None, seed: int = 1234):
193        if self.dataset is None:
194            raise ValueError("Dataset not loaded. Call load_dataset() first.")
195
196        if n_cases is None:
197            n_cases = len(self.dataset)
198
199        dataset_size = len(self.dataset)
200        rng = random.Random(seed)
201
202        self.tasks = []
203        for chunk_idx in range((n_cases + dataset_size - 1) // dataset_size):
204            chunk_size = min(dataset_size, n_cases - chunk_idx * dataset_size)
205            indices = list(range(dataset_size))
206            rng.shuffle(indices)
207            chunk_indices = indices[:chunk_size]
208
209            for i in chunk_indices:
210                task_id = f"{self.dataset_type}_{chunk_idx:03d}_{i:03d}"
211                self.tasks.append((i, task_id))
212
213        self.all_tasks = list(self.tasks)
214
215    def get_case(self, index: int) -> Tuple[str, str, str]:
216        if self.dataset is None:
217            raise ValueError("Dataset not loaded.")
218        question = self.dataset.get_question(index)
219        question_text = self.dataset.get_question_text(question)
220        prompt = self.dataset.get_prompt(question)
221        expected = self.dataset.get_answer(question)
222        return question_text, prompt, expected
223
224    def add_result(
225        self,
226        task_id: str,
227        prompt: str,
228        expected: str,
229        response: Optional[str],
230        answer: Optional[str],
231        grader_log: Dict[str, Any],
232        correct: bool,
233        status: str,
234        tokens: Optional[int] = None,
235        tps_gen: Optional[float] = None,
236        t_gen_ms: Optional[float] = None,
237        reasoning_content: Optional[str] = None,
238        server_name: Optional[str] = None,
239        chunk_idx: int = 0,
240        problem_idx: int = 0,
241    ):
242        with self._lock:
243            if "cases" not in self.task_states:
244                self.task_states["cases"] = {}
245
246            self.task_states["cases"][task_id] = {
247                "task_id": task_id,
248                "prompt": prompt,
249                "expected": expected,
250                "response": response,
251                "answer": answer,
252                "grader_log": grader_log,
253                "correct": correct,
254                "status": status,
255                "tokens": tokens,
256                "tps_gen": tps_gen,
257                "t_gen_ms": t_gen_ms,
258                "reasoning_content": reasoning_content,
259                "server_name": server_name,
260                "chunk_idx": chunk_idx,
261                "problem_idx": problem_idx,
262            }
263
264            self.correct = sum(1 for c in self.task_states.get("cases", {}).values() if c.get("correct", False))
265
266    def print_progress(self, task_state: TaskState, total_tasks: int, n_correct: int = 0):
267        display_answer = task_state.answer if task_state.answer else "N/A"
268        display_tokens = str(task_state.tokens) if task_state.tokens is not None else "N/A"
269        display_tps = f"{task_state.tps_gen:.1f}" if task_state.tps_gen is not None else "N/A"
270        display_t_gen = f"{task_state.t_gen_ms/1000:.1f}" if task_state.t_gen_ms is not None else "N/A"
271        display_server = task_state.server_name if task_state.server_name else "N/A"
272        success_ratio = n_correct / self.processed if self.processed > 0 else 0.0
273        first_line = task_state.question_text.split('\n')[0]
274        truncated_question = first_line[:43]
275        if len(first_line) > 43:
276            truncated_question += "..."
277        else:
278            truncated_question = truncated_question.ljust(43) + "..."
279        print(f"{self.processed:3}/{total_tasks:3}  {task_state.task_id:<20} {self.dataset_type.upper()}   {truncated_question:<40}    {task_state.expected:<10} {display_answer:<10} {display_tokens:<6} {display_tps:<6} {display_t_gen:<8} {'✓' if task_state.correct else '✗'}  [{n_correct:3}/{self.processed:3}, {success_ratio:.3f}]  {display_server}")
280
281    def print_summary(self):
282        if self.total == 0:
283            print(f"\n{'='*60}")
284            print(f"Results: 0/0 correct (0.0%)")
285            print(f"{'='*60}")
286        else:
287            ci_lower, ci_upper = self.accuracy_ci()
288            print(f"\n{'='*60}")
289            print(f"Results: {self.correct}/{self.total} correct ({self.correct/self.total*100:.1f}%) [{ci_lower*100:.1f}%, {ci_upper*100:.1f}%]")
290            print(f"{'='*60}")
291
292    def dump(self):
293        with self._lock:
294            tasks_to_save = self.all_tasks if self.all_tasks else self.tasks
295            all_cases = {}
296            for i, task_id in tasks_to_save:
297                question_text, prompt, expected = self.get_case(i)
298                # Extract chunk_idx from task_id for pending cases
299                _parts = task_id.rsplit("_", 2)
300                _chunk_idx = int(_parts[-2]) if len(_parts) >= 3 else 0
301                if task_id in self.task_states.get("cases", {}):
302                    all_cases[task_id] = self.task_states["cases"][task_id]
303                else:
304                    all_cases[task_id] = {
305                        "task_id": task_id,
306                        "prompt": prompt,
307                        "expected": expected,
308                        "question_text": question_text,
309                        "response": None,
310                        "answer": None,
311                        "grader_log": {},
312                        "correct": False,
313                        "status": "pending",
314                        "tokens": None,
315                        "tps_gen": None,
316                        "t_gen_ms": None,
317                        "reasoning_content": None,
318                        "server_name": None,
319                        "chunk_idx": _chunk_idx,
320                        "problem_idx": i,
321                    }
322
323            ci_lower, ci_upper = self.accuracy_ci()
324            data = {
325                "id": self.dataset_type,
326                "model_name": self.model_name,
327                "tasks": [tid for _, tid in tasks_to_save],
328                "task_states": {
329                    "total": self.total,
330                    "correct": self.correct,
331                    "total_time": self.total_time,
332                    "ci_lower": ci_lower,
333                    "ci_upper": ci_upper,
334                    "cases": all_cases,
335                },
336                "sampling_config": self.sampling_config
337            }
338            with open(self.output_file, "w") as f:
339                json.dump(data, f, indent=2)
340
341            self.dump_html(tasks_to_save, all_cases)
342
343    def dump_html(self, tasks_to_save: List[Tuple[int, str]], all_cases: Dict[str, Any]):
344        html_file = Path(str(self.output_file) + ".html")
345
346        cases = all_cases
347        completed = {tid: c for tid, c in cases.items() if c.get("status") == "ok"}
348        n_correct = sum(1 for c in completed.values() if c.get("correct", False))
349        n_incorrect = len(completed) - n_correct
350        n_pending = len(tasks_to_save) - len(completed)
351        accuracy = n_correct / len(completed) * 100 if completed else 0.0
352        ci_lower, ci_upper = wilson_interval(n_correct, len(completed)) if completed else (0.0, 1.0)
353
354        sampling_parts = []
355        for k, v in self.sampling_config.items():
356            if v is not None:
357                sampling_parts.append(f"{k}={v}")
358        sampling_str = ", ".join(sampling_parts) if sampling_parts else "default"
359
360        rows = []
361        for i, task_id in tasks_to_save:
362            case = cases.get(task_id, {})
363            status = case.get("status", "pending")
364            expected = case.get("expected", "")
365            answer = case.get("answer") or "" if status == "ok" else ""
366            is_correct = case.get("correct", False) if status == "ok" else False
367            response = case.get("response", "") or ""
368            prompt = case.get("prompt", "") or ""
369            grader_log = case.get("grader_log", {})
370
371            if status == "ok":
372                status_class = "correct" if is_correct else "incorrect"
373                status_text = "✓" if is_correct else "✗"
374            elif status == "pending":
375                status_class = "pending"
376                status_text = "–"
377            else:
378                status_class = "error"
379                status_text = "!"
380
381            tokens = case.get("tokens")
382            tokens_str = str(tokens) if tokens is not None else ""
383            tps_gen = case.get("tps_gen")
384            tps_str = f"{tps_gen:.1f}" if tps_gen is not None else ""
385            t_gen_ms = case.get("t_gen_ms")
386            t_gen_str = f"{t_gen_ms/1000:.1f}" if t_gen_ms is not None else ""
387            reasoning_content = case.get("reasoning_content", "") or ""
388            server_name = case.get("server_name", "") or ""
389
390            escaped_response = self._escape_html(response)
391            escaped_prompt = self._escape_html(prompt)
392            escaped_reasoning = self._escape_html(reasoning_content)
393            grader_log_str = self._escape_html(json.dumps(grader_log, indent=2))
394            escaped_server = self._escape_html(server_name)
395
396            answer_class = status_class if status == "ok" else ""
397            rows.append(f"""<tr class="task-row" onclick="toggleDetails('{task_id}')">
398                <td>{task_id}</td>
399                <td class="{status_class}">{status_text}</td>
400                <td>{self._escape_html(expected)}</td>
401                <td class="{answer_class}">{self._escape_html(answer)}</td>
402                <td>{tokens_str}</td>
403                <td>{tps_str}</td>
404                <td>{t_gen_str}</td>
405                <td>{escaped_server}</td>
406            </tr>
407            <tr id="details-{task_id}" class="details-row">
408                <td colspan="8">
409                    <div class="details-content">
410                        <b>Prompt</b><pre>{escaped_prompt}</pre>
411                        <b>Response</b><pre>{escaped_response}</pre>
412                        {f'<b>Reasoning</b><pre>{escaped_reasoning}</pre>' if escaped_reasoning else ''}
413                        <b>Grader</b><pre>{grader_log_str}</pre>
414                    </div>
415                </td>
416            </tr>""")
417
418        rows_html = "\n".join(rows)
419
420        # ---- per-problem summary table ----
421        problem_groups: Dict[int, List[Dict[str, Any]]] = {}
422        for _tid, _case in cases.items():
423            if _case.get("status") != "ok":
424                continue
425            _pidx = _case.get("problem_idx")
426            if _pidx is None:
427                _p_parts = _tid.rsplit("_", 2)
428                _pidx = int(_p_parts[-1]) if len(_p_parts) >= 3 else 0
429            problem_groups.setdefault(_pidx, []).append(_case)
430
431        summary_rows_html = ""
432        if problem_groups:
433            def _stat(v, fmt=".1f", avg_fmt=None):
434                if not v:
435                    return ("–", "–", "–")
436                af = fmt if avg_fmt is None else avg_fmt
437                return (f"{min(v):{fmt}}", f"{sum(v)/len(v):{af}}", f"{max(v):{fmt}}")
438
439            summary_data = []
440            for pidx, g in problem_groups.items():
441                runs = len(g)
442                n_ok = sum(1 for c in g if c.get("correct", False))
443                toks = [c["tokens"] for c in g if c.get("tokens") is not None]
444                tps = [c["tps_gen"] for c in g if c.get("tps_gen") is not None]
445                tg = [c["t_gen_ms"] / 1000 for c in g if c.get("t_gen_ms") is not None]
446                summary_data.append((
447                    pidx, runs, n_ok,
448                    _stat(toks, "d", ".0f"),
449                    _stat(tps),
450                    _stat(tg),
451                ))
452
453            summary_data.sort(key=lambda r: r[0])  # sort by problem index ascending
454
455            summary_rows_html = "\n".join(
456                f"""<tr class="summary-row">
457                    <td>{p:03d}</td>
458                    <td>{r}</td>
459                    <td>{n}/{r}</td>
460                    <td>{tk[0]}</td><td>{tk[1]}</td><td>{tk[2]}</td>
461                    <td>{tp[0]}</td><td>{tp[1]}</td><td>{tp[2]}</td>
462                    <td>{tg[0]}</td><td>{tg[1]}</td><td>{tg[2]}</td>
463                </tr>"""
464                for p, r, n, tk, tp, tg in summary_data
465            )
466
467        html_content = f"""<!DOCTYPE html>
468<html>
469<head>
470<meta charset="UTF-8">
471<title>{self.dataset_type.upper()} Eval</title>
472<style>
473        body {{ font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #fff; color: #222; }}
474        .bar {{ padding: 8px 0; font-size: 13px; color: #555; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; display: grid; grid-template-columns: auto 1fr auto 1fr; gap: 2px 12px; align-items: baseline; }}
475        .bar .label {{ color: #888; }}
476        .bar .value {{ color: #222; }}
477        table {{ width: 100%; border-collapse: collapse; font-size: 13px; font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; }}
478        th {{ text-align: left; padding: 6px 8px; border-bottom: 2px solid #ccc; font-weight: 600; }}
479        td {{ padding: 4px 8px; border-bottom: 1px solid #eee; vertical-align: top; }}
480        .task-row {{ cursor: pointer; }}
481        .task-row:hover {{ background: #f5f5f5; }}
482        .correct {{ color: #1a7f37; }}
483        .incorrect {{ color: #cf222e; }}
484        .pending {{ color: #888; }}
485        .error {{ color: #9a6700; }}
486        .details-row {{ display: none; }}
487        .details-row.open {{ display: table-row; }}
488        .details-content {{ padding: 8px 16px; background: #f6f8fa; font-size: 12px; }}
489        .details-content b {{ color: #555; }}
490        .details-content pre {{ background: #fff; border: 1px solid #e1e4e8; padding: 8px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; margin: 4px 0 8px; }}
491        .summary-table {{ margin-bottom: 16px; font-size: 13px; width: 100%; }}
492        .summary-row {{ background: #fafbfc; }}
493        .summary-row:hover {{ background: #f5f5f5; }}
494        .summary-table th {{ text-align: right; font-weight: 600; }}
495        .summary-table th:first-child {{ text-align: left; }}
496        .summary-table th[colspan] {{ text-align: center; }}
497        .summary-table td {{ text-align: right; }}
498        .summary-table td:first-child {{ text-align: left; }}
499        .tabs {{ display: flex; border-bottom: 2px solid #ddd; margin: 12px 0 0; }}
500        .tab-btn {{ padding: 6px 16px; border: none; background: none; font-size: 13px; cursor: pointer; color: #555; border-bottom: 2px solid transparent; margin-bottom: -2px; font-weight: 500; }}
501        .tab-btn:hover {{ color: #222; }}
502        .tab-btn.active {{ color: #222; border-bottom-color: #222; font-weight: 600; }}
503        .tab-content {{ display: none; }}
504        .tab-content.active {{ display: block; }}
505</style>
506</head>
507<body>
508    <div class="bar">
509        <div class="label">Dataset</div><div class="value"><b>{self.dataset_type.upper()}</b></div>
510        <div class="label">Model</div><div class="value"><b>{self.model_name or 'N/A'}</b></div>
511        <div class="label">Accuracy</div><div class="value"><b>{accuracy:.1f}%</b> [{ci_lower*100:.1f}%, {ci_upper*100:.1f}%]</div>
512        <div class="label">Correct</div><div class="value"><span class="correct">{n_correct}</span> / {len(completed)}</div>
513        <div class="label">Pending</div><div class="value">{n_pending}</div>
514        <div class="label">Time</div><div class="value">{self.total_time:.1f}s</div>
515        <div class="label">Sampling</div><div class="value">{sampling_str}</div>
516    </div>
517    <div class="tabs">
518        <button class="tab-btn active" data-tab="detailed" onclick="switchTab(this)">Detailed</button>
519        <button class="tab-btn" data-tab="summary" onclick="switchTab(this)">Summary</button>
520    </div>
521    <div id="tab-detailed" class="tab-content active">
522        <table>
523            <thead>
524                <tr>
525                    <th>ID</th>
526                    <th></th>
527                    <th>Gold</th>
528                    <th>Answer</th>
529                    <th>Tokens</th>
530                    <th>T/s</th>
531                    <th>Gen s</th>
532                    <th>Server</th>
533                </tr>
534            </thead>
535            <tbody>
536                {rows_html}
537            </tbody>
538        </table>
539    </div>
540    <div id="tab-summary" class="tab-content">
541        <table class="summary-table">
542            <thead>
543                <tr>
544                    <th>Problem</th>
545                    <th>Runs</th>
546                    <th>Correct</th>
547                    <th colspan="3">Tokens</th>
548                    <th colspan="3">T/s</th>
549                    <th colspan="3">Gen s</th>
550                </tr>
551                <tr>
552                    <th></th>
553                    <th></th>
554                    <th></th>
555                    <th>min</th><th>avg</th><th>max</th>
556                    <th>min</th><th>avg</th><th>max</th>
557                    <th>min</th><th>avg</th><th>max</th>
558                </tr>
559            </thead>
560            <tbody>
561                {summary_rows_html}
562            </tbody>
563        </table>
564    </div>
565    <script>
566        function toggleDetails(id) {{ document.getElementById('details-'+id).classList.toggle('open'); }}
567        function switchTab(btn) {{
568            document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
569            document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
570            btn.classList.add('active');
571            document.getElementById('tab-'+btn.dataset.tab).classList.add('active');
572        }}
573    </script>
574</body>
575</html>"""
576
577        with open(html_file, "w") as f:
578            f.write(html_content)
579
580    def _escape_html(self, s: str) -> str:
581        return (s.replace("&", "&amp;")
582                   .replace("<", "&lt;")
583                   .replace(">", "&gt;")
584                   .replace('"', "&quot;")
585                   .replace("'", "&#39;"))
586
587    @classmethod
588    def load(cls, path: Path) -> "EvalState":
589        with open(path, "r") as f:
590            data = json.load(f)
591
592        eval_state = cls(
593            dataset_type=data["id"],
594            sampling_config=data["sampling_config"],
595            output_file=path,
596            model_name=data.get("model_name")
597        )
598        eval_state.load_dataset()
599
600        eval_state.tasks = []
601        eval_state.all_tasks = []
602        for task_id in data.get("tasks", []):
603            parts = task_id.rsplit("_", 2)
604            if len(parts) >= 3:
605                idx = int(parts[-1])
606            else:
607                idx = 0
608            eval_state.tasks.append((idx, task_id))
609            eval_state.all_tasks.append((idx, task_id))
610
611        eval_state.task_states = data.get("task_states", {})
612
613        cases = eval_state.task_states.get("cases", {})
614        eval_state.total = eval_state.task_states.get("total", 0)
615        eval_state.correct = eval_state.task_states.get("correct", 0)
616        eval_state.total_time = eval_state.task_states.get("total_time", 0.0)
617
618        if eval_state.total == 0:
619            eval_state.total = len(cases)
620            eval_state.correct = sum(1 for c in cases.values() if c.get("correct", False))
621
622        return eval_state
623
624    def is_complete(self) -> bool:
625        if not self.all_tasks:
626            return False
627        cases = self.task_states.get("cases", {})
628        completed = {tid for tid in self.task_states.get("cases", {}).keys() if cases.get(tid, {}).get("status") == "ok"}
629        return len(completed) == len(self.all_tasks)
630
631    def get_pending_tasks(self) -> List[Tuple[int, str]]:
632        cases = self.task_states.get("cases", {})
633        pending = []
634        for i, task_id in self.all_tasks:
635            status = cases.get(task_id, {}).get("status", "pending")
636            if status != "ok":
637                pending.append((i, task_id))
638        return pending
639
640    def print_all_tasks(self):
641        cases = self.task_states.get("cases", {})
642        tasks_to_show = self.all_tasks if self.all_tasks else self.tasks
643        print()
644        print("Tasks:")
645        print("  Task ID             Dataset  Prompt (first 40 chars)                        Expected    Answer       Tokens  T/s     Gen s  Status")
646        for i, task_id in tasks_to_show:
647            question, prompt, expected = self.get_case(i)
648            case = cases.get(task_id, {})
649            status = case.get("status", "pending")
650            answer = case.get("answer") or "N/A" if status == "ok" else "N/A"
651            tokens = case.get("tokens")
652            tokens_str = str(tokens) if tokens is not None else "N/A"
653            tps_gen = case.get("tps_gen")
654            tps_str = f"{tps_gen:.1f}" if tps_gen is not None else "N/A"
655            t_gen_ms = case.get("t_gen_ms")
656            t_gen_str = f"{t_gen_ms/1000:.1f}" if t_gen_ms is not None else "N/A"
657            server_name = case.get("server_name", "") or ""
658            is_correct = case.get("correct", False) if status == "ok" else False
659            symbol = "✓ " if is_correct else ("✗ " if status == "ok" else "")
660            first_line = question.split('\n')[0]
661            question_trunc = first_line[:43]
662            if len(first_line) > 43:
663                question_trunc += "..."
664            else:
665                question_trunc = question_trunc.ljust(43) + "..."
666            print(f"  {task_id:<20} {self.dataset_type.upper()}   {question_trunc:<40}    {expected:<10} {answer:<10} {tokens_str:<6} {tps_str:<6} {t_gen_str:<8} {symbol}{status}  {server_name}")
667        print()
668
669    def print_existing_summary(self):
670        cases = self.task_states.get("cases", {})
671        completed_cases = {tid: c for tid, c in cases.items() if c.get("status") == "ok"}
672        correct = sum(1 for c in completed_cases.values() if c.get("correct", False))
673        total = len(completed_cases)
674        if total == 0:
675            print(f"{'='*60}")
676            print(f"Results: 0/0 correct (0.0%)")
677            print(f"{'='*60}")
678        else:
679            ci_lower, ci_upper = self.accuracy_ci()
680            print(f"{'='*60}")
681            print(f"Results: {correct}/{total} correct ({correct/total*100:.1f}%) [{ci_lower*100:.1f}%, {ci_upper*100:.1f}%]")
682            print(f"{'='*60}")
683
684    def accuracy_ci(self) -> Tuple[float, float]:
685        """Compute Wilson score confidence interval from completed cases."""
686        cases = self.task_states.get("cases", {})
687        completed = {tid: c for tid, c in cases.items() if c.get("status") == "ok"}
688        correct = sum(1 for c in completed.values() if c.get("correct", False))
689        total = len(completed)
690        return wilson_interval(correct, total)
691
692def normalize_number(s: str) -> Optional[int]:
693    match = re.match(r"\d+", s)  # match digits from the start
694    if not match:
695        return None
696    return int(match.group(0))
697
698class AimeDataset(BaseDataset):
699    def __init__(self, split: str = "train"):
700        self.split = split
701        self.questions = []
702        self._load_dataset()
703
704    def _load_dataset(self):
705        print(f"Loading AIME dataset (split: {self.split})...")
706        from datasets import load_dataset
707
708        cache_path = cache_dir / "AI-MO___aimo-validation-aime" / "default" / "0.0.0"
709        if cache_path.exists():
710            print(f"Using cached dataset from {cache_path}")
711            ds = load_dataset("AI-MO/aimo-validation-aime", split=self.split, cache_dir=str(cache_path))
712        else:
713            ds = load_dataset("AI-MO/aimo-validation-aime", split=self.split)
714
715        self.questions = []
716        for row in ds:
717            question = dict(row)
718            question["dataset_type"] = "aime"
719            self.questions.append(question)
720
721        print(f"AIME dataset loaded: {len(self.questions)} questions")
722
723    def get_question(self, index: int) -> Dict:
724        """Get question by index"""
725        return self.questions[index]
726
727    def get_question_text(self, question: Dict) -> str:
728        """Get question string"""
729        return question["problem"] if "problem" in question else question["question"]
730
731    def get_answer(self, question: Dict) -> str:
732        answer = question["answer"]
733        if isinstance(answer, str):
734            normalized = normalize_number(answer)
735            return str(normalized) if normalized is not None else answer
736        return str(answer)
737
738    def get_prompt(self, question: Dict) -> str:
739        """Get formatted prompt for the question"""
740        return TEMPLATE_REGISTRY[question["dataset_type"]].format(
741            question=self.get_question_text(question),
742        )
743
744class Aime2025Dataset(BaseDataset):
745    def __init__(self):
746        self.questions = []
747        self._load_dataset()
748
749    def _load_dataset(self):
750        print(f"Loading AIME2025 dataset...")
751        from datasets import load_dataset
752
753        config_name = "AIME2025-I"
754        cache_path = cache_dir / "opencompass___AIME2025" / "default" / "0.0.0"
755        if cache_path.exists():
756            print(f"Using cached dataset from {cache_path}")
757            ds = load_dataset("opencompass/AIME2025", config_name, split="test", cache_dir=str(cache_path))
758        else:
759            ds = load_dataset("opencompass/AIME2025", config_name, split="test")
760
761        self.questions = []
762        for row in ds:
763            question = dict(row)
764            question["dataset_type"] = "aime2025"
765            self.questions.append(question)
766
767        print(f"AIME2025 dataset loaded: {len(self.questions)} questions")
768
769        print(f"Loading AIME2025 dataset (part 2)...")
770        config_name_2 = "AIME2025-II"
771        cache_path_2 = cache_dir / "opencompass___AIME2025" / "default" / "0.0.0"
772        if cache_path_2.exists():
773            print(f"Using cached dataset from {cache_path_2}")
774            ds_2 = load_dataset("opencompass/AIME2025", config_name_2, split="test", cache_dir=str(cache_path_2))
775        else:
776            ds_2 = load_dataset("opencompass/AIME2025", config_name_2, split="test")
777
778        for row in ds_2:
779            question = dict(row)
780            question["dataset_type"] = "aime2025"
781            self.questions.append(question)
782
783        print(f"AIME2025 dataset loaded: {len(self.questions)} questions (total)")
784
785    def get_question(self, index: int) -> Dict:
786        """Get question by index"""
787        return self.questions[index]
788
789    def get_question_text(self, question: Dict) -> str:
790        """Get question string"""
791        return question["question"]
792
793    def get_answer(self, question: Dict) -> str:
794        answer = question["answer"]
795        if isinstance(answer, str):
796            normalized = normalize_number(answer)
797            return str(normalized) if normalized is not None else answer
798        return str(answer)
799
800    def get_prompt(self, question: Dict) -> str:
801        """Get formatted prompt for the question"""
802        return TEMPLATE_REGISTRY["aime2025"].format(
803            question=self.get_question_text(question),
804        )
805
806class Aime2026Dataset(BaseDataset):
807    def __init__(self):
808        self.questions = []
809        self._load_dataset()
810
811    def _load_dataset(self):
812        print(f"Loading AIME2026 dataset...")
813        from datasets import load_dataset
814
815        cache_path = cache_dir / "MathArena___aime_2026" / "default" / "0.0.0"
816        if cache_path.exists():
817            print(f"Using cached dataset from {cache_path}")
818            ds = load_dataset("MathArena/aime_2026", "default", split="train", cache_dir=str(cache_path))
819        else:
820            ds = load_dataset("MathArena/aime_2026", "default", split="train")
821
822        self.questions = []
823        for row in ds:
824            question = dict(row)
825            question["dataset_type"] = "aime2026"
826            self.questions.append(question)
827
828        print(f"AIME2026 dataset loaded: {len(self.questions)} questions")
829
830    def get_question(self, index: int) -> Dict:
831        """Get question by index"""
832        return self.questions[index]
833
834    def get_question_text(self, question: Dict) -> str:
835        """Get question string"""
836        return question["problem"]
837
838    def get_answer(self, question: Dict) -> str:
839        return str(question["answer"])
840
841    def get_prompt(self, question: Dict) -> str:
842        """Get formatted prompt for the question"""
843        return TEMPLATE_REGISTRY["aime2026"].format(
844            question=self.get_question_text(question),
845        )
846
847class Gsm8kDataset(BaseDataset):
848    def __init__(self, split: str = "test"):
849        self.split = split
850        self.questions = []
851        self._load_dataset()
852
853    def _load_dataset(self):
854        print(f"Loading GSM8K dataset (split: {self.split})...")
855        from datasets import load_dataset
856
857        cache_path = cache_dir / "openai___gsm8k" / "default" / "0.0.0"
858        if cache_path.exists():
859            print(f"Using cached dataset from {cache_path}")
860            ds = load_dataset("openai/gsm8k", "main", split=self.split, cache_dir=str(cache_path))
861        else:
862            ds = load_dataset("openai/gsm8k", "main", split=self.split)
863
864        self.questions = []
865        for row in ds:
866            question = dict(row)
867            question["dataset_type"] = "gsm8k"
868
869            # Extract numeric answer from the answer field (already has #### prefix)
870            gold = question["answer"]
871            # Split by #### and take the last part
872            parts = gold.split("####")
873            if len(parts) > 1:
874                gold = parts[-1].strip()
875            # Extract the first number from the remaining text
876            normalized = normalize_number(gold)
877            question["gold"] = str(normalized) if normalized is not None else gold
878
879            self.questions.append(question)
880
881        print(f"GSM8K dataset loaded: {len(self.questions)} questions")
882
883    def get_question(self, index: int) -> Dict:
884        """Get question by index"""
885        return self.questions[index]
886
887    def get_question_text(self, question: Dict) -> str:
888        """Get question string"""
889        return question["problem"] if "problem" in question else question["question"]
890
891    def get_answer(self, question: Dict) -> str:
892        # GSM8K has pre-extracted gold field, AIME uses answer field
893        if "gold" in question:
894            return question["gold"]
895        answer = question["answer"]
896        if isinstance(answer, str):
897            normalized = normalize_number(answer)
898            return str(normalized) if normalized is not None else answer
899        return str(answer)
900
901    def get_prompt(self, question: Dict) -> str:
902        """Get formatted prompt for the question"""
903        return TEMPLATE_REGISTRY[question["dataset_type"]].format(
904            question=self.get_question_text(question),
905        )
906
907class GpqaDataset(BaseDataset):
908    def __init__(self, variant: str = "diamond", seed: int = 1234):
909        self.variant = variant
910        self.seed = seed
911        self.questions = []
912        self._load_dataset()
913
914    def _load_dataset(self):
915        print(f"Loading GPQA dataset (variant: {self.variant})...")
916        import pandas as pd
917
918        url = f"https://openaipublic.blob.core.windows.net/simple-evals/gpqa_{self.variant}.csv"
919        df = pd.read_csv(url)
920
921        rng = random.Random(self.seed)
922
923        self.questions = []
924        for _, row in df.iterrows():
925            question = row.to_dict()
926            question["dataset_type"] = "gpqa"
927
928            # Shuffle the answer options
929            correct_answer = question["Correct Answer"]
930            incorrect_answers = [
931                question["Incorrect Answer 1"],
932                question["Incorrect Answer 2"],
933                question["Incorrect Answer 3"]
934            ]
935
936            # Create list of (answer, is_correct) tuples
937            options = [(ans, ans == correct_answer) for ans in incorrect_answers]
938            options.append((correct_answer, True))
939
940            # Shuffle the options
941            rng.shuffle(options)
942
943            # Extract shuffled answers and determine correct letter
944            shuffled_answers = [ans for ans, _ in options]
945            correct_letter = chr(ord('A') + options.index((correct_answer, True)))
946
947            # Store shuffled answers and correct letter
948            question["shuffled_answers"] = shuffled_answers
949            question["correct_letter"] = correct_letter
950
951            self.questions.append(question)
952
953        print(f"GPQA dataset loaded: {len(self.questions)} questions")
954
955    def get_question(self, index: int) -> Dict:
956        """Get question by index"""
957        return self.questions[index]
958
959    def get_question_text(self, question: Dict) -> str:
960        """Get question string"""
961        return question["Question"]
962
963    def get_answer(self, question: Dict) -> str:
964        # GPQA returns the correct letter (A, B, C, or D)
965        return question["correct_letter"]
966
967    def get_prompt(self, question: Dict) -> str:
968        """Get formatted prompt for the question"""
969        return TEMPLATE_REGISTRY["gpqa"].format(
970            Question=self.get_question_text(question),
971            A=question["shuffled_answers"][0],
972            B=question["shuffled_answers"][1],
973            C=question["shuffled_answers"][2],
974            D=question["shuffled_answers"][3]
975        )
976
977class Grader:
978    def __init__(
979        self,
980        grader_type: str = "llm",
981        grader_script: Optional[str] = None,
982        grader_model_name: Optional[str] = None,
983        grader_server_url: str = "",
984        dataset_type: str = "aime"
985    ):
986        self.grader_type = grader_type
987        self.grader_script = grader_script
988        self.grader_model_name = grader_model_name
989        self.grader_server_url = grader_server_url
990        self.dataset_type = dataset_type
991        self.pattern = self._get_pattern()
992
993    def _get_pattern(self) -> Optional[str]:
994        if self.grader_type == "regex":
995            return GRADER_PATTERNS.get(self.dataset_type)  # Use dataset_type as key
996        return None
997
998    def _extract_answer_regex(self, pred: str) -> Optional[str]:
999        """Extract answer using regex pattern"""
1000        if not self.pattern:
1001            return None
1002
1003        # For AIME datasets, prioritize boxed answers
1004        if self.dataset_type in ["aime", "aime2025"]:
1005            boxed_pattern = r'\\boxed{([^}]+)}'
1006            boxed_matches = re.findall(boxed_pattern, pred, re.IGNORECASE)
1007            if boxed_matches:
1008                # Return the last boxed answer found (most likely the final answer)
1009                return boxed_matches[-1].strip()
1010
1011        # For other datasets, search for numbers from the end of the text
1012        # This prioritizes numbers that appear later in the response
1013        matches = re.findall(self.pattern, pred, re.IGNORECASE)
1014        if not matches:
1015            return None
1016
1017        # Process matches from end to start
1018        for match in reversed(matches):
1019            if isinstance(match, tuple):
1020                match = match[0] if match[0] else match[1]
1021            answer = match.strip()
1022            if answer:
1023                return answer
1024        return None
1025
1026    def _grade_regex(self, gold: str, pred: str) -> Tuple[bool, Optional[str]]:
1027        """Grade using regex pattern matching"""
1028        answer = self._extract_answer_regex(pred)
1029        if answer is None:
1030            return False, None
1031        is_correct = answer.strip() == gold.strip()
1032        return is_correct, answer
1033
1034    def _grade_cli(self, gold: str, pred: str) -> Tuple[bool, Optional[str]]:
1035        """Grade using external CLI script"""
1036        if not self.grader_script:
1037            raise ValueError("CLI grader requires --grader-script")
1038
1039        script_path = Path(self.grader_script)
1040        if not script_path.exists():
1041            raise FileNotFoundError(f"Grader script not found: {self.grader_script}")
1042
1043        try:
1044            result = subprocess.run(
1045                [str(script_path), "--answer", pred, "--expected", gold],
1046                capture_output=True,
1047                text=True,
1048                timeout=30
1049            )
1050            is_correct = result.returncode == 0
1051            answer = pred if is_correct else None
1052            return is_correct, answer
1053        except subprocess.TimeoutExpired:
1054            return False, None
1055        except Exception as e:
1056            return False, None
1057
1058    def _grade_llm(self, gold: str, pred: str, problem: str) -> Tuple[bool, Optional[str]]:
1059        """Grade using LLM-based extraction with few-shot examples"""
1060        sample_answers = SAMPLE_ANSWERS.get(self.dataset_type, [])
1061        sample_examples = "\n".join([
1062            f"Example {i+1}: {ans}" for i, ans in enumerate(sample_answers)
1063        ])
1064
1065        system_prompt = f"""You are an answer extraction system. Your task is to extract the answer from the model's response.
1066
1067Here are some examples of extracted answers to demonstrate what you are supposed to output:
1068
1069{sample_examples}
1070
1071When extracting the answer, provide only the extracted answer itself, nothing else. If there is no clear answer that can be extracted from the response, reply with 'no answer'."""
1072
1073        user_prompt = f"""Extract the answer from the following response:
1074
1075"{pred}"
1076
1077Please provide only the extracted answer, nothing else. If there is no clear answer that can be extracted from the response, reply with 'no answer'."""
1078
1079        url = f"{self.grader_server_url}/v1/chat/completions"
1080        headers = {"Content-Type": "application/json"}
1081        data = {
1082            "model": self.grader_model_name,
1083            "messages": [
1084                {"role": "system", "content": system_prompt},
1085                {"role": "user", "content": user_prompt}
1086            ],
1087            "temperature": 0,
1088        }
1089        #print(json.dumps(data, indent=2))
1090
1091        try:
1092            response = requests.post(url, headers=headers, json=data)
1093            response.raise_for_status()
1094            answer = response.json()["choices"][0]["message"]["content"].strip()
1095            is_correct = answer.strip().lower() == gold.strip().lower()
1096            return is_correct, answer
1097        except Exception as e:
1098            return False, None
1099
1100    def _truncate_response(self, response: str, max_lines: int = 6) -> str:
1101        """Keep only last N lines of response"""
1102        lines = response.split('\n')
1103        return '\n'.join(lines[-max_lines:]) if len(lines) > max_lines else response
1104
1105    def grade(self, gold: str, pred: str, problem: str = "") -> Tuple[bool, Optional[str]]:
1106        """Grade the response"""
1107        if self.grader_type == "regex":
1108            return self._grade_regex(gold, pred)
1109        elif self.grader_type == "cli":
1110            return self._grade_cli(gold, pred)
1111        elif self.grader_type == "llm":
1112            return self._grade_llm(gold, pred, problem)
1113        else:
1114            raise ValueError(f"Unknown grader type: {self.grader_type}")
1115
1116class Processor:
1117    def __init__(
1118        self,
1119        server_configs: List[ServerConfig],
1120        grader: Grader,
1121        model_name: Optional[str] = None,
1122        n_predict: int = -1
1123    ):
1124        self.server_configs = server_configs
1125        self.grader = grader
1126        self.model_name = model_name
1127        self.n_predict = n_predict
1128
1129    @staticmethod
1130    def _check_server(server_config: ServerConfig) -> List[str]:
1131        url = f"{server_config.url}/v1/models"
1132        try:
1133            response = requests.get(url)
1134            response.raise_for_status()
1135            models = [m["id"] for m in response.json().get("data", [])]
1136            return models
1137        except Exception as e:
1138            print(f"Error: Cannot reach server {server_config.name} ({server_config.url}): {e}", file=sys.stderr)
1139            sys.exit(1)
1140
1141    def _make_request(
1142        self, server_config: ServerConfig, eval_state: EvalState, prompt: str
1143    ) -> Tuple[Dict[str, Any], int, Optional[float], Optional[float], str]:
1144        url = f"{server_config.url}/v1/chat/completions"
1145        headers = {"Content-Type": "application/json"}
1146        data = {
1147            "model": self.model_name if self.model_name else "llama",
1148            "messages": [{"role": "user", "content": prompt}],
1149            "n_predict": self.n_predict
1150        }
1151        if eval_state.sampling_config.get("temperature") is not None:
1152            data["temperature"] = eval_state.sampling_config["temperature"]
1153        if eval_state.sampling_config.get("top_k") is not None:
1154            data["top_k"] = eval_state.sampling_config["top_k"]
1155        if eval_state.sampling_config.get("top_p") is not None:
1156            data["top_p"] = eval_state.sampling_config["top_p"]
1157        if eval_state.sampling_config.get("min_p") is not None:
1158            data["min_p"] = eval_state.sampling_config["min_p"]
1159
1160        response = requests.post(url, headers=headers, json=data)
1161        response.raise_for_status()
1162        result = response.json()
1163        tokens = result.get("usage", {}).get("completion_tokens", 0)
1164        timings = result.get("timings", {})
1165        tps_gen = timings.get("predicted_per_second") if timings else None
1166        t_gen_ms = timings.get("predicted_ms") if timings else None
1167        finish_reason = result.get("choices", [{}])[0].get("finish_reason", "stop")
1168        return result, tokens, tps_gen, t_gen_ms, finish_reason
1169
1170    def _process_single_case(
1171        self, server_config: ServerConfig, eval_state: EvalState, i: int, task_id: str
1172    ) -> TaskState:
1173        question_text, prompt, expected = eval_state.get_case(i)
1174
1175        # Extract chunk_idx from task_id: "{dataset_type}_{chunk_idx:03d}_{index:03d}"
1176        _parts = task_id.rsplit("_", 2)
1177        chunk_idx = int(_parts[-2]) if len(_parts) >= 3 else 0
1178        problem_idx = i
1179
1180        task_state = TaskState(
1181            task_id=task_id,
1182            prompt=prompt,
1183            expected=expected,
1184            question_text=question_text,
1185            server_name=server_config.name,
1186            chunk_idx=chunk_idx,
1187            problem_idx=problem_idx,
1188        )
1189
1190        try:
1191            response, tokens, tps_gen, t_gen_ms, finish_reason = self._make_request(server_config, eval_state, prompt)
1192            result = response["choices"][0]["message"]["content"]
1193            reasoning_content = response["choices"][0].get("message", {}).get("reasoning_content")
1194            task_state.response = result
1195            task_state.tokens = tokens
1196            task_state.tps_gen = tps_gen
1197            task_state.t_gen_ms = t_gen_ms
1198            task_state.reasoning_content = reasoning_content
1199
1200            if finish_reason != "stop":
1201                task_state.status = f"error: finish_reason={finish_reason}"
1202                eval_state.add_result(
1203                    task_id, prompt, expected, result, None,
1204                    {"finish_reason": finish_reason}, False, task_state.status,
1205                    tokens, tps_gen, t_gen_ms, reasoning_content, server_config.name,
1206                    chunk_idx, problem_idx,
1207                )
1208                eval_state.dump()
1209                return task_state
1210
1211            result_truncated = self.grader._truncate_response(result, max_lines=10)
1212            is_correct, answer = self.grader.grade(expected, result_truncated, prompt)
1213
1214            grader_log = {
1215                "pred": result_truncated,
1216                "grader_type": self.grader.grader_type
1217            }
1218            if self.grader.grader_type == "regex" and self.grader.pattern:
1219                grader_log["pattern"] = self.grader.pattern
1220
1221            task_state.correct = is_correct
1222            task_state.answer = answer
1223            task_state.grader_log = grader_log
1224            task_state.status = "ok"
1225
1226            eval_state.add_result(
1227                task_id, prompt, expected, result, answer,
1228                grader_log, is_correct, "ok",
1229                tokens, tps_gen, t_gen_ms, reasoning_content, server_config.name,
1230                chunk_idx, problem_idx,
1231            )
1232
1233            eval_state.dump()
1234
1235        except Exception as e:
1236            task_state.status = f"error: {str(e)}"
1237
1238        return task_state
1239
1240    @staticmethod
1241    def _worker(
1242        server_config: ServerConfig,
1243        processor: "Processor",
1244        eval_state: EvalState,
1245        task_queue: Queue,
1246        results_queue: Queue,
1247    ):
1248        """Worker that pulls tasks from a shared queue and sends them to its server."""
1249        while True:
1250            task = task_queue.get()
1251            if task is None:  # sentinel
1252                task_queue.task_done()
1253                break
1254            try:
1255                i, task_id = task
1256                result = processor._process_single_case(server_config, eval_state, i, task_id)
1257                results_queue.put(result)
1258            finally:
1259                task_queue.task_done()
1260
1261    def evaluate(self, eval_state: EvalState, verbose: bool = False, resume: bool = False):
1262        total_tasks = len(eval_state.tasks)
1263        eval_state.total = len(eval_state.all_tasks) if eval_state.all_tasks else total_tasks
1264        eval_state.processed = 0
1265        start_time = time.time()
1266
1267        # Check servers and list models
1268        server_models = [self._check_server(sc) for sc in self.server_configs]
1269
1270        # Print server info
1271        print(f"\nProcessing {len(eval_state.tasks)} {eval_state.dataset_type.upper()} tasks ...")
1272        print(f"Servers ({len(self.server_configs)}):")
1273        for i, sc in enumerate(self.server_configs):
1274            models_str = ", ".join(server_models[i]) if server_models[i] else "(none)"
1275            print(f"  {i+1}. {sc.name} — {sc.url} ({sc.threads} threads) [{models_str}]")
1276        print(f"Model: {self.model_name}")
1277        print(f"Grader: {self.grader.grader_type}")
1278        print(f"Sampling: temp={eval_state.sampling_config.get('temperature', 'skip')}, top-k={eval_state.sampling_config.get('top_k', 'skip')}, top-p={eval_state.sampling_config.get('top_p', 'skip')}, min-p={eval_state.sampling_config.get('min_p', 'skip')}")
1279        print()
1280
1281        # Shared task queue: all workers compete for tasks
1282        task_queue: Queue = Queue()
1283        for i, task_id in eval_state.tasks:
1284            task_queue.put((i, task_id))
1285
1286        # Results queue: workers push completed TaskStates here
1287        results_queue: Queue = Queue()
1288
1289        # Total worker threads across all servers
1290        total_threads = sum(sc.threads for sc in self.server_configs)
1291
1292        # Add one sentinel per worker so every worker exits cleanly
1293        for _ in range(total_threads):
1294            task_queue.put(None)
1295
1296        # Launch workers: one ThreadPoolExecutor per server
1297        executors: List[ThreadPoolExecutor] = []
1298        worker_futures: List[Any] = []
1299        for server_config in self.server_configs:
1300            executor = ThreadPoolExecutor(max_workers=server_config.threads)
1301            executors.append(executor)
1302            for _ in range(server_config.threads):
1303                future = executor.submit(
1304                    self._worker, server_config, self, eval_state,
1305                    task_queue, results_queue
1306                )
1307                worker_futures.append(future)
1308
1309        # Drain results as they complete
1310        n_correct = 0
1311        session_time = 0.0
1312        completed_count = 0
1313
1314        while completed_count < total_tasks:
1315            task_state = results_queue.get()
1316            eval_state.processed += 1
1317            completed_count += 1
1318            if task_state.correct:
1319                n_correct += 1
1320            elapsed = time.time() - start_time
1321            eval_state.total_time += elapsed
1322            session_time += elapsed
1323            start_time = time.time()
1324            eval_state.print_progress(task_state, total_tasks, n_correct)
1325
1326            if verbose:
1327                print(f"\nCase {eval_state.processed}: {task_state.correct}")
1328                print(f"  Expected: {task_state.expected}")
1329                if task_state.response:
1330                    print(f"  Response: {task_state.response}")
1331                if task_state.answer:
1332                    print(f"  Answer: {task_state.answer}")
1333                print(f"  Status: {task_state.status}")
1334
1335        # Wait for all workers to finish and shut down executors
1336        for future in worker_futures:
1337            future.result()
1338        for executor in executors:
1339            executor.shutdown(wait=True)
1340
1341        print(f"\nSession time: {session_time:.1f}s | Total accumulated time: {eval_state.total_time:.1f}s")
1342        eval_state.print_summary()
1343        eval_state.dump()
1344
1345def main():
1346    parser = argparse.ArgumentParser(
1347        description="Simplified evaluation tool for llama.cpp"
1348    )
1349    parser.add_argument(
1350        "--server",
1351        type=str,
1352        default="http://localhost:8033",
1353        help="Comma-separated llama-server URLs (default: http://localhost:8033)"
1354    )
1355    parser.add_argument(
1356        "--server-name",
1357        type=str,
1358        default="",
1359        help="Comma-separated display names for servers (default: use URLs)"
1360    )
1361    parser.add_argument(
1362        "--dataset",
1363        type=str,
1364        default="aime",
1365        choices=["aime", "aime2025", "aime2026", "gsm8k", "gpqa"],
1366        help="Dataset type (default: aime)"
1367    )
1368    parser.add_argument(
1369        "--n_cases",
1370        type=int,
1371        default=None,
1372        help="Number of cases to evaluate (default: all)"
1373    )
1374    parser.add_argument(
1375        "--seed",
1376        type=int,
1377        default=1234,
1378        help="Random seed for shuffling (default: 1234)"
1379    )
1380    parser.add_argument(
1381        "--n_predict",
1382        type=int,
1383        default=-1,
1384        help="Max tokens to predict per prompt (default: -1, infinite)"
1385    )
1386    parser.add_argument(
1387        "--temperature",
1388        type=float,
1389        default=None,
1390        help="Sampling temperature (default: not passed)"
1391    )
1392    parser.add_argument(
1393        "--top-k",
1394        type=int,
1395        default=None,
1396        help="Top K sampling (default: not passed)"
1397    )
1398    parser.add_argument(
1399        "--top-p",
1400        type=float,
1401        default=None,
1402        help="Top P sampling (default: not passed)"
1403    )
1404    parser.add_argument(
1405        "--min-p",
1406        type=float,
1407        default=None,
1408        help="Min P sampling (default: not passed)"
1409    )
1410    parser.add_argument(
1411        "--threads",
1412        type=str,
1413        default="32",
1414        help="Comma-separated thread counts per server (default: 32)"
1415    )
1416    parser.add_argument(
1417        "--model",
1418        type=str,
1419        default=None,
1420        help="Model name to append as query parameter (e.g., gpt-oss-20b-hf)"
1421    )
1422    parser.add_argument(
1423        "--verbose",
1424        action="store_true",
1425        help="Show detailed output for each case"
1426    )
1427    parser.add_argument(
1428        "--output",
1429        type=Path,
1430        default=Path("llama-eval-state.json"),
1431        help="Output file for eval state (default: llama-eval-state.json)"
1432    )
1433    parser.add_argument(
1434        "--grader-type",
1435        type=str,
1436        default="llm",
1437        choices=["regex", "cli", "llm"],
1438        help="Grader type: regex, cli, or llm (default: llm)"
1439    )
1440    parser.add_argument(
1441        "--grader-script",
1442        type=str,
1443        default=None,
1444        help="CLI grader script path (required for --grader-type cli)"
1445    )
1446    parser.add_argument(
1447        "--grader-server",
1448        type=str,
1449        default="",
1450        help="Server URL for LLM grader (default: same as main server)"
1451    )
1452    parser.add_argument(
1453        "--grader-model",
1454        type=str,
1455        default="",
1456        help="Model name for LLM grader (default: same as main model)"
1457    )
1458    parser.add_argument(
1459        "--resume",
1460        action="store_true",
1461        help="Resume from existing eval state"
1462    )
1463
1464    args = parser.parse_args()
1465
1466    # Parse server URLs and thread counts
1467    server_urls = [u.strip() for u in args.server.split(",") if u.strip()]
1468    thread_counts = [int(t.strip()) for t in args.threads.split(",") if t.strip()]
1469
1470    if len(server_urls) != len(thread_counts):
1471        print(f"Error: --server ({len(server_urls)} URLs) and --threads ({len(thread_counts)} values) must have the same count")
1472        sys.exit(1)
1473
1474    # Parse server names (optional, defaults to URLs)
1475    if args.server_name:
1476        server_names = [n.strip() for n in args.server_name.split(",") if n.strip()]
1477        if len(server_names) != len(server_urls):
1478            print(f"Error: --server-name ({len(server_names)} names) and --server ({len(server_urls)} URLs) must have the same count")
1479            sys.exit(1)
1480    else:
1481        server_names = server_urls  # fallback to URLs
1482
1483    server_configs = [
1484        ServerConfig(url=url, threads=threads, name=name)
1485        for url, threads, name in zip(server_urls, thread_counts, server_names)
1486    ]
1487
1488    if args.dataset == "gpqa" and args.grader_type != "llm":
1489        print("Error: GPQA dataset requires --grader-type llm")
1490        parser.print_help()
1491        sys.exit(1)
1492
1493    if args.output.exists():
1494        print(f"Loading existing eval state from {args.output}")
1495        eval_state = EvalState.load(args.output)
1496
1497        # Verify model matches
1498        if eval_state.model_name is not None and args.model != eval_state.model_name:
1499            print(f"Error: Model mismatch. State has '{eval_state.model_name}', but --model is '{args.model}'")
1500            sys.exit(1)
1501
1502        eval_state.print_all_tasks()
1503        eval_state.print_existing_summary()
1504
1505        if eval_state.is_complete():
1506            return
1507
1508        print()
1509
1510        if not args.resume:
1511            print(f"Evaluation incomplete. Run with --resume to continue.")
1512            return
1513
1514        pending_tasks = eval_state.get_pending_tasks()
1515        print(f"Resuming from {len(pending_tasks)} pending tasks")
1516
1517        existing_cases = eval_state.task_states.get("cases", {})
1518
1519        eval_state.tasks = pending_tasks
1520        eval_state.task_states["cases"] = existing_cases
1521
1522        grader_server_url = args.grader_server if args.grader_server else server_configs[0].url
1523        grader_model_name = args.grader_model if args.grader_model else args.model
1524        if args.grader_type == "llm" and not grader_model_name:
1525            print("Error: --grader-type llm requires --grader-model or --model")
1526            sys.exit(1)
1527        grader = Grader(
1528            grader_type=args.grader_type,
1529            grader_script=args.grader_script,
1530            grader_model_name=grader_model_name,
1531            grader_server_url=grader_server_url,
1532            dataset_type=eval_state.dataset_type
1533        )
1534        resume = True
1535    else:
1536        if args.resume:
1537            print("Error: No existing eval state found to resume")
1538            sys.exit(1)
1539
1540        grader_server_url = args.grader_server if args.grader_server else server_configs[0].url
1541        grader_model_name = args.grader_model if args.grader_model else args.model
1542        if args.grader_type == "llm" and not grader_model_name:
1543            print("Error: --grader-type llm requires --grader-model or --model")
1544            sys.exit(1)
1545
1546        grader = Grader(
1547            grader_type=args.grader_type,
1548            grader_script=args.grader_script,
1549            grader_model_name=grader_model_name,
1550            grader_server_url=grader_server_url,
1551            dataset_type=args.dataset
1552        )
1553
1554        if args.grader_type == "llm" and not args.grader_server:
1555            print("Warning: Using same server for LLM grader (no --grader-server specified)")
1556
1557        sampling_config = {}
1558        if args.temperature is not None:
1559            sampling_config["temperature"] = args.temperature
1560        if args.top_k is not None:
1561            sampling_config["top_k"] = args.top_k
1562        if args.top_p is not None:
1563            sampling_config["top_p"] = args.top_p
1564        if args.min_p is not None:
1565            sampling_config["min_p"] = args.min_p
1566
1567        eval_state = EvalState(
1568            dataset_type=args.dataset,
1569            sampling_config=sampling_config,
1570            output_file=args.output,
1571            model_name=args.model
1572        )
1573        eval_state.load_dataset(seed=args.seed)
1574        eval_state.setup_tasks(n_cases=args.n_cases, seed=args.seed)
1575        eval_state.dump()
1576        resume = False
1577
1578        eval_state.print_all_tasks()
1579
1580    processor = Processor(
1581        server_configs=server_configs,
1582        grader=grader,
1583        model_name=args.model,
1584        n_predict=args.n_predict
1585    )
1586
1587    processor.evaluate(eval_state, verbose=args.verbose, resume=resume)
1588    print(f"\nEval state dumped to {args.output}")
1589
1590if __name__ == "__main__":
1591    main()