benchmaker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ """Correctness / accuracy evaluation as a composable plugin.
2
+
3
+ Layered design (matches the rest of bench-maker):
4
+
5
+ * `EvalWorkloadType` wraps any base WorkloadType. It strips an
6
+ eval-only reference field (default `"reference"`) out of each dict item
7
+ before delegating to the base, and stashes the reference on `Request.meta`
8
+ so post-hooks can read it.
9
+
10
+ * `correctness_hook(scorer, ...)` returns a `PostResponseHook` that extracts
11
+ the model output from the `Response`, calls a `scorer(reference, prediction)`
12
+ function, merges the returned scores into `Sample.extra`, and (optionally)
13
+ fails the sample when a `gate_key` score is <= 0.
14
+
15
+ * Stock scorers — `exact_match`, `contains`, `regex_match`, `json_valid`,
16
+ `multiple_choice`, `judge_llm` — cover the usual graders. Custom scorers
17
+ are just `(reference, prediction) -> dict[str, float]` (sync or async).
18
+
19
+ Because everything lands in `Sample.extra`, the `MetricsAggregator` summarises
20
+ accuracy (`correct.mean`, `judge_score.p50`, ...) generically — no changes to
21
+ the core required.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import re
29
+ from typing import Any, Awaitable, Callable, Optional, Union
30
+
31
+ from benchmaker.types import (
32
+ PostResponseHook,
33
+ Request,
34
+ Response,
35
+ Sample,
36
+ TicketContext,
37
+ maybe_await,
38
+ )
39
+ from benchmaker.workloads.base import WorkloadType
40
+
41
+
42
+ # --------------------------------------------------------------------------- #
43
+ # Reference plumbing: EvalWorkloadType wrapper
44
+ # --------------------------------------------------------------------------- #
45
+
46
+
47
+ class EvalWorkloadType(WorkloadType):
48
+ """Wrap a base WorkloadType to carry eval references through to post-hooks.
49
+
50
+ Items are typically dicts like `{"prompt": "...", "reference": "..."}`.
51
+ The named `reference_key` (and any `extra_meta_keys`) are stripped from the
52
+ item before it reaches the base workload-type — so the base can't accidentally
53
+ forward them to the service — and copied onto `Request.meta` so a downstream
54
+ post-hook (e.g. `correctness_hook`) can score the response against them.
55
+
56
+ The wrapper relies on `WorkloadType.run_ticket`'s default flow
57
+ (one `make_request` -> one fire -> one `make_sample`). Workload-types with
58
+ a custom `run_ticket` (e.g. sandbox lifecycle) should add a bespoke
59
+ post-hook instead of wrapping.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ base: WorkloadType,
65
+ reference_key: str = "reference",
66
+ extra_meta_keys: tuple[str, ...] = (),
67
+ name: Optional[str] = None,
68
+ ):
69
+ self._base = base
70
+ self._reference_key = reference_key
71
+ self._extra_meta_keys = tuple(extra_meta_keys)
72
+ self.name = name or base.name
73
+ self.streaming = base.streaming
74
+
75
+ def _split(self, item: Any) -> tuple[Any, dict[str, Any]]:
76
+ """Return (clean_item, eval_meta). For non-dict items, no split."""
77
+ if not isinstance(item, dict):
78
+ return item, {}
79
+ keys = {self._reference_key, *self._extra_meta_keys}
80
+ eval_meta = {k: item[k] for k in keys if k in item}
81
+ if not eval_meta:
82
+ return item, {}
83
+ clean = {k: v for k, v in item.items() if k not in keys}
84
+ return clean, eval_meta
85
+
86
+ async def make_request(self, item: Any) -> Request:
87
+ clean, eval_meta = self._split(item)
88
+ req = await self._base.make_request(clean)
89
+ for k, v in eval_meta.items():
90
+ req.meta.setdefault(k, v)
91
+ return req
92
+
93
+ async def make_sample(self, item: Any, request: Request, response: Response,
94
+ start_ts: float) -> Sample:
95
+ clean, _ = self._split(item)
96
+ return await self._base.make_sample(clean, request, response, start_ts)
97
+
98
+ async def aclose(self) -> None:
99
+ await self._base.aclose()
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Response -> text extraction
104
+ # --------------------------------------------------------------------------- #
105
+
106
+
107
+ def extract_openai_text(response: Response) -> str:
108
+ """Concatenate assistant text from an OpenAI chat-completions response.
109
+
110
+ Handles both streaming (`stream_chunks` of SSE bytes) and non-streaming
111
+ (`body` containing a single JSON object) forms. Returns "" if nothing
112
+ parses out — callers can fall back to `extract_raw_text` if they prefer
113
+ the raw body.
114
+ """
115
+ parts: list[str] = []
116
+ chunks = response.stream_chunks
117
+ if chunks:
118
+ for raw in chunks:
119
+ for line in raw.splitlines():
120
+ line = line.strip()
121
+ if line.startswith(b"data:"):
122
+ line = line[5:].strip()
123
+ if not line or line == b"[DONE]":
124
+ continue
125
+ try:
126
+ obj = json.loads(line)
127
+ except Exception:
128
+ continue
129
+ _collect_openai_text(obj, parts)
130
+ return "".join(parts)
131
+
132
+ if not response.body:
133
+ return ""
134
+ try:
135
+ obj = json.loads(response.body)
136
+ except Exception:
137
+ return ""
138
+ _collect_openai_text(obj, parts)
139
+ return "".join(parts)
140
+
141
+
142
+ def _collect_openai_text(obj: Any, parts: list[str]) -> None:
143
+ if not isinstance(obj, dict):
144
+ return
145
+ for ch in obj.get("choices") or []:
146
+ delta = ch.get("delta") or {}
147
+ if isinstance(delta.get("content"), str):
148
+ parts.append(delta["content"])
149
+ msg = ch.get("message") or {}
150
+ if isinstance(msg.get("content"), str):
151
+ parts.append(msg["content"])
152
+
153
+
154
+ def extract_raw_text(response: Response) -> str:
155
+ """Decode `response.body` as UTF-8 (best effort)."""
156
+ if not response.body:
157
+ return ""
158
+ return response.body.decode("utf-8", errors="replace")
159
+
160
+
161
+ def extract_text(response: Response) -> str:
162
+ """Default extractor: try OpenAI-chat shape, fall back to raw body."""
163
+ text = extract_openai_text(response)
164
+ if text:
165
+ return text
166
+ return extract_raw_text(response)
167
+
168
+
169
+ Extractor = Callable[[Response], str]
170
+
171
+
172
+ # --------------------------------------------------------------------------- #
173
+ # Scorer protocol + correctness hook
174
+ # --------------------------------------------------------------------------- #
175
+
176
+
177
+ # A scorer takes (reference, prediction) and returns a dict of numeric scores,
178
+ # either sync or async. Reference may be None when the workload didn't supply one.
179
+ Scorer = Callable[
180
+ [Any, str],
181
+ Union[dict[str, float], Awaitable[dict[str, float]]],
182
+ ]
183
+
184
+
185
+ def correctness_hook(
186
+ scorer: Scorer,
187
+ *,
188
+ reference_key: str = "reference",
189
+ extractor: Optional[Extractor] = None,
190
+ gate_key: Optional[str] = "correct",
191
+ prefix: str = "",
192
+ require_reference: bool = True,
193
+ max_prediction_chars: Optional[int] = 2048,
194
+ ) -> PostResponseHook:
195
+ """Build a post-response hook that scores each request's output.
196
+
197
+ Args:
198
+ scorer: `(reference, prediction) -> dict[str, float]` (sync or async).
199
+ The returned scores are merged into `Sample.extra` (with `prefix`
200
+ prepended). Reference comes from `Request.meta[reference_key]`,
201
+ populated by `EvalWorkloadType`.
202
+ reference_key: meta key carrying the gold reference.
203
+ extractor: maps `Response` -> prediction string. Defaults to
204
+ `extract_text` (OpenAI chat first, raw body fallback).
205
+ gate_key: if set and present in the scorer's output, the sample is
206
+ marked `ok=False` when that score is <= 0. Set to None to disable
207
+ gating (correctness still recorded, but doesn't affect goodput).
208
+ prefix: prepended to every extra key (e.g. `"eval_"`).
209
+ require_reference: if True and no reference is on the request, the
210
+ sample is left untouched and a `<prefix>missing_reference=1` flag is
211
+ added. If False, the scorer is still called with reference=None.
212
+ max_prediction_chars: cap on the prediction copy stored in
213
+ `Sample.meta["<prefix>prediction"]` (saved into `samples.jsonl`).
214
+ Default 2048 chars to keep bundles small; set to `None` (or 0) to
215
+ store the full output, or a smaller integer to truncate further.
216
+ """
217
+ extractor = extractor or extract_text
218
+
219
+ async def hook(req: Request, resp: Response, sample: Sample) -> Sample:
220
+ if not resp.ok:
221
+ # Don't grade a failed request — keep the failure visible.
222
+ return sample
223
+ reference = req.meta.get(reference_key)
224
+ if reference is None and require_reference:
225
+ sample.extra[f"{prefix}missing_reference"] = 1.0
226
+ return sample
227
+ try:
228
+ prediction = extractor(resp)
229
+ except Exception as e:
230
+ sample.extra[f"{prefix}extractor_error"] = 1.0
231
+ sample.meta[f"{prefix}extractor_error_msg"] = f"{type(e).__name__}: {e}"
232
+ return sample
233
+ try:
234
+ result = scorer(reference, prediction)
235
+ result = await maybe_await(result)
236
+ except Exception as e:
237
+ sample.extra[f"{prefix}score_error"] = 1.0
238
+ sample.meta[f"{prefix}score_error_msg"] = f"{type(e).__name__}: {e}"
239
+ return sample
240
+ if not isinstance(result, dict):
241
+ sample.extra[f"{prefix}score_error"] = 1.0
242
+ sample.meta[f"{prefix}score_error_msg"] = (
243
+ f"scorer returned {type(result).__name__}, expected dict"
244
+ )
245
+ return sample
246
+ for k, v in result.items():
247
+ try:
248
+ sample.extra[f"{prefix}{k}"] = float(v)
249
+ except (TypeError, ValueError):
250
+ sample.meta[f"{prefix}{k}"] = v
251
+ if gate_key is not None and gate_key in result:
252
+ try:
253
+ if float(result[gate_key]) <= 0.0:
254
+ sample.ok = False
255
+ sample.error = sample.error or f"failed-{gate_key}"
256
+ except (TypeError, ValueError):
257
+ pass
258
+ # Stash the prediction for offline inspection (capped to keep bundles small).
259
+ if max_prediction_chars in (None, 0):
260
+ saved = prediction
261
+ else:
262
+ saved = prediction[:max_prediction_chars]
263
+ sample.meta.setdefault(f"{prefix}prediction", saved)
264
+ return sample
265
+
266
+ return hook
267
+
268
+
269
+ # --------------------------------------------------------------------------- #
270
+ # Stock scorers
271
+ # --------------------------------------------------------------------------- #
272
+
273
+
274
+ def _normalize(s: str, *, strip: bool, case_insensitive: bool) -> str:
275
+ if strip:
276
+ s = s.strip()
277
+ if case_insensitive:
278
+ s = s.lower()
279
+ return s
280
+
281
+
282
+ def exact_match(*, strip: bool = True, case_insensitive: bool = False) -> Scorer:
283
+ """`correct=1` iff prediction == reference (after optional strip/lower)."""
284
+
285
+ def _score(reference: Any, prediction: str) -> dict[str, float]:
286
+ a = _normalize(str(prediction), strip=strip, case_insensitive=case_insensitive)
287
+ b = _normalize(str(reference), strip=strip, case_insensitive=case_insensitive)
288
+ return {"correct": 1.0 if a == b else 0.0}
289
+
290
+ return _score
291
+
292
+
293
+ def contains(*, strip: bool = True, case_insensitive: bool = True) -> Scorer:
294
+ """`correct=1` iff reference substring appears in prediction."""
295
+
296
+ def _score(reference: Any, prediction: str) -> dict[str, float]:
297
+ a = _normalize(str(prediction), strip=strip, case_insensitive=case_insensitive)
298
+ b = _normalize(str(reference), strip=strip, case_insensitive=case_insensitive)
299
+ return {"correct": 1.0 if b and b in a else 0.0}
300
+
301
+ return _score
302
+
303
+
304
+ def regex_match(pattern: str, *, group: int = 0,
305
+ case_insensitive: bool = False) -> Scorer:
306
+ """Match `pattern` against the prediction.
307
+
308
+ If `reference` is None, `correct=1` whenever the pattern matches at all.
309
+ Otherwise `correct=1` only when the captured `group` equals `str(reference)`
310
+ (after stripping). The captured string is also recorded in `extra` as a
311
+ side-channel for debugging.
312
+ """
313
+ flags = re.IGNORECASE if case_insensitive else 0
314
+ rx = re.compile(pattern, flags)
315
+
316
+ def _score(reference: Any, prediction: str) -> dict[str, float]:
317
+ m = rx.search(prediction or "")
318
+ if not m:
319
+ return {"correct": 0.0, "matched": 0.0}
320
+ try:
321
+ captured = m.group(group)
322
+ except IndexError:
323
+ captured = ""
324
+ if reference is None:
325
+ return {"correct": 1.0, "matched": 1.0}
326
+ ok = 1.0 if str(reference).strip() == (captured or "").strip() else 0.0
327
+ return {"correct": ok, "matched": 1.0}
328
+
329
+ return _score
330
+
331
+
332
+ def json_valid(*, required_keys: Optional[tuple[str, ...]] = None) -> Scorer:
333
+ """`valid_json=1` if the prediction parses as JSON.
334
+
335
+ If `required_keys` is given and the parsed value is a dict, `correct=1`
336
+ only when every key is present. When `required_keys` is None, `correct`
337
+ mirrors `valid_json`.
338
+ """
339
+
340
+ def _score(reference: Any, prediction: str) -> dict[str, float]:
341
+ try:
342
+ obj = json.loads(prediction)
343
+ except Exception:
344
+ return {"valid_json": 0.0, "correct": 0.0}
345
+ if required_keys is None:
346
+ return {"valid_json": 1.0, "correct": 1.0}
347
+ if not isinstance(obj, dict):
348
+ return {"valid_json": 1.0, "correct": 0.0}
349
+ for k in required_keys:
350
+ if k not in obj:
351
+ return {"valid_json": 1.0, "correct": 0.0}
352
+ return {"valid_json": 1.0, "correct": 1.0}
353
+
354
+ return _score
355
+
356
+
357
+ def multiple_choice(*, choices: tuple[str, ...] = ("A", "B", "C", "D"),
358
+ case_insensitive: bool = True) -> Scorer:
359
+ """Find the first choice letter mentioned in the prediction; match vs reference.
360
+
361
+ Looks for one of `choices` as a word boundary token. Reference should be
362
+ one of the choices.
363
+ """
364
+ flags = re.IGNORECASE if case_insensitive else 0
365
+ pattern = re.compile(
366
+ r"\b(" + "|".join(re.escape(c) for c in choices) + r")\b", flags
367
+ )
368
+
369
+ def _score(reference: Any, prediction: str) -> dict[str, float]:
370
+ m = pattern.search(prediction or "")
371
+ if not m:
372
+ return {"correct": 0.0, "answered": 0.0}
373
+ chosen = m.group(1)
374
+ ref = str(reference).strip()
375
+ if case_insensitive:
376
+ ok = 1.0 if chosen.lower() == ref.lower() else 0.0
377
+ else:
378
+ ok = 1.0 if chosen == ref else 0.0
379
+ return {"correct": ok, "answered": 1.0}
380
+
381
+ return _score
382
+
383
+
384
+ # --------------------------------------------------------------------------- #
385
+ # LLM-as-judge scorer
386
+ # --------------------------------------------------------------------------- #
387
+
388
+
389
+ JudgeSend = Callable[[str], Awaitable[str]]
390
+ JudgeTemplate = Union[str, Callable[[Any, str], str]]
391
+ JudgeParse = Callable[[str], dict[str, float]]
392
+
393
+
394
+ _DEFAULT_JUDGE_TEMPLATE = (
395
+ "You are grading a model's answer.\n\n"
396
+ "Question reference / expected answer:\n{reference}\n\n"
397
+ "Model answer:\n{prediction}\n\n"
398
+ "Reply with a single integer from 0 to 10 measuring how correct the model "
399
+ "answer is. Output ONLY the integer."
400
+ )
401
+
402
+
403
+ def _default_judge_parse(text: str, *, pass_threshold: int = 7) -> dict[str, float]:
404
+ m = re.search(r"-?\d+", text or "")
405
+ if not m:
406
+ return {"judge_score": 0.0, "correct": 0.0, "judge_parsed": 0.0}
407
+ score = max(0, min(10, int(m.group(0))))
408
+ return {
409
+ "judge_score": float(score),
410
+ "correct": 1.0 if score >= pass_threshold else 0.0,
411
+ "judge_parsed": 1.0,
412
+ }
413
+
414
+
415
+ def judge_llm(
416
+ send: JudgeSend,
417
+ *,
418
+ template: JudgeTemplate = _DEFAULT_JUDGE_TEMPLATE,
419
+ parse: Optional[JudgeParse] = None,
420
+ max_concurrency: int = 4,
421
+ ) -> Scorer:
422
+ """LLM-as-judge scorer.
423
+
424
+ `send(prompt) -> awaitable[str]` is the user's hook into a judge endpoint —
425
+ the caller is responsible for opening / closing any HTTP client. Use the
426
+ convenience constructor below if you just want to talk to an OpenAI-compat
427
+ chat endpoint.
428
+
429
+ `template` is either a format string (with `{reference}` and `{prediction}`)
430
+ or a callable `(reference, prediction) -> str`.
431
+
432
+ `parse(text) -> dict[str, float]` extracts numeric scores from the judge's
433
+ reply. Default: parse a 0..10 integer, set `correct=1` when >= 7.
434
+ """
435
+ parse = parse or _default_judge_parse
436
+ sem = asyncio.Semaphore(max_concurrency)
437
+
438
+ if isinstance(template, str):
439
+ tmpl_str = template
440
+
441
+ def _render(ref: Any, pred: str) -> str:
442
+ return tmpl_str.format(reference=ref, prediction=pred)
443
+ else:
444
+ _render = template # type: ignore[assignment]
445
+
446
+ async def _score(reference: Any, prediction: str) -> dict[str, float]:
447
+ prompt = _render(reference, prediction)
448
+ async with sem:
449
+ reply = await send(prompt)
450
+ return parse(reply)
451
+
452
+ return _score
453
+
454
+
455
+ def openai_chat_judge(
456
+ url: str,
457
+ model: str,
458
+ *,
459
+ api_key: Optional[str] = None,
460
+ temperature: float = 0.0,
461
+ max_tokens: int = 8,
462
+ timeout_s: float = 60.0,
463
+ ) -> tuple[JudgeSend, Callable[[], Awaitable[None]]]:
464
+ """Convenience: returns `(send, aclose)` for an OpenAI-compat chat endpoint.
465
+
466
+ Opens a dedicated aiohttp session on first call. The caller should
467
+ `await aclose()` at the end of the run (or wire it into a workload-type's
468
+ `aclose` chain).
469
+ """
470
+ import aiohttp
471
+
472
+ state: dict[str, Any] = {"session": None}
473
+ headers = {"Content-Type": "application/json"}
474
+ if api_key:
475
+ headers["Authorization"] = f"Bearer {api_key}"
476
+
477
+ async def _send(prompt: str) -> str:
478
+ if state["session"] is None:
479
+ state["session"] = aiohttp.ClientSession(
480
+ timeout=aiohttp.ClientTimeout(total=timeout_s)
481
+ )
482
+ sess: aiohttp.ClientSession = state["session"]
483
+ body = {
484
+ "model": model,
485
+ "messages": [{"role": "user", "content": prompt}],
486
+ "temperature": temperature,
487
+ "max_tokens": max_tokens,
488
+ "stream": False,
489
+ }
490
+ async with sess.post(url, headers=headers, json=body) as resp:
491
+ data = await resp.json()
492
+ choices = data.get("choices") or []
493
+ if not choices:
494
+ return ""
495
+ msg = choices[0].get("message") or {}
496
+ return msg.get("content") or ""
497
+
498
+ async def _aclose() -> None:
499
+ sess = state["session"]
500
+ if sess is not None:
501
+ await sess.close()
502
+ state["session"] = None
503
+
504
+ return _send, _aclose