quantdiff 0.1.0rc1__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.
Files changed (45) hide show
  1. quantdiff/__init__.py +53 -0
  2. quantdiff/__main__.py +5 -0
  3. quantdiff/_http.py +151 -0
  4. quantdiff/_text.py +13 -0
  5. quantdiff/_version.py +1 -0
  6. quantdiff/api.py +340 -0
  7. quantdiff/backends/__init__.py +28 -0
  8. quantdiff/backends/_common.py +342 -0
  9. quantdiff/backends/base.py +91 -0
  10. quantdiff/backends/llamacpp.py +428 -0
  11. quantdiff/backends/ollama.py +359 -0
  12. quantdiff/backends/openai_compat.py +338 -0
  13. quantdiff/cache.py +240 -0
  14. quantdiff/card.py +1664 -0
  15. quantdiff/cli.py +377 -0
  16. quantdiff/discover.py +488 -0
  17. quantdiff/errors.py +45 -0
  18. quantdiff/metrics/__init__.py +36 -0
  19. quantdiff/metrics/codeexec.py +428 -0
  20. quantdiff/metrics/jsonschema.py +610 -0
  21. quantdiff/metrics/logit.py +214 -0
  22. quantdiff/metrics/tasks.py +114 -0
  23. quantdiff/metrics/textsim.py +66 -0
  24. quantdiff/metrics/toolcheck.py +99 -0
  25. quantdiff/png.py +360 -0
  26. quantdiff/preflight.py +365 -0
  27. quantdiff/progress.py +283 -0
  28. quantdiff/py.typed +0 -0
  29. quantdiff/report.py +780 -0
  30. quantdiff/runner.py +492 -0
  31. quantdiff/spec.py +154 -0
  32. quantdiff/stats.py +226 -0
  33. quantdiff/suites/__init__.py +462 -0
  34. quantdiff/suites/data/chat.jsonl +22 -0
  35. quantdiff/suites/data/code.jsonl +32 -0
  36. quantdiff/suites/data/json.jsonl +34 -0
  37. quantdiff/suites/data/scoring.jsonl +41 -0
  38. quantdiff/suites/data/tools.jsonl +32 -0
  39. quantdiff/types.py +322 -0
  40. quantdiff/verdict.py +1513 -0
  41. quantdiff-0.1.0rc1.dist-info/METADATA +514 -0
  42. quantdiff-0.1.0rc1.dist-info/RECORD +45 -0
  43. quantdiff-0.1.0rc1.dist-info/WHEEL +4 -0
  44. quantdiff-0.1.0rc1.dist-info/entry_points.txt +2 -0
  45. quantdiff-0.1.0rc1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,428 @@
1
+ """Run model-written code against a case's assert-based tests in a child process.
2
+
3
+ This is isolation for honest mistakes, not a security sandbox. The code runs in a fresh
4
+ interpreter inside a throwaway directory, with an empty environment (so API keys in the
5
+ parent's environment are not visible), a timeout, and on POSIX some resource limits. On
6
+ timeout every process the code started is killed, not only the interpreter. A deliberately
7
+ hostile program can still read your files or reach the network. quantdiff therefore never
8
+ calls this unless the user passes --allow-code-exec.
9
+
10
+ The verdict is guarded against solutions that try to report a pass for themselves. The
11
+ parent sends the harness a random nonce on stdin before the solution is imported, and only
12
+ accepts a verdict that carries it. The harness writes that verdict to a private duplicate
13
+ of its original stdout, after pointing stdout at the output log, then ends the process
14
+ with os._exit so no exit handler, finally block or finalizer from the solution runs after
15
+ it. Files the solution writes and anything it prints are never read as a verdict. The
16
+ solution shares the harness's interpreter, so code written to dig the nonce out of the
17
+ harness's stack frames can still forge a pass; that takes deliberate effort aimed at
18
+ quantdiff, and SECURITY.md lists it as a known limit.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import json
25
+ import logging
26
+ import math
27
+ import os
28
+ import re
29
+ import secrets
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ import tempfile
34
+ from collections.abc import Callable, Iterator
35
+ from pathlib import Path
36
+ from typing import Final
37
+
38
+ from quantdiff.errors import SuiteError
39
+ from quantdiff.metrics.textsim import strip_reasoning
40
+ from quantdiff.types import CaseOutcome, TaskCase
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ DEFAULT_TIMEOUT_SECONDS: Final = 10.0
45
+ OUTPUT_PREVIEW_CHARS: Final = 2000
46
+
47
+ _FENCE: Final = re.compile(r"```[ \t]*([A-Za-z0-9_+-]*)[^\n]*\n(.*?)```", re.DOTALL)
48
+ _PYTHON_TAGS: Final = frozenset({"python", "python3", "py"})
49
+ _SOLUTION_FILE: Final = "solution.py"
50
+ _TESTS_FILE: Final = "case_tests.py"
51
+ _HARNESS_FILE: Final = "harness.py"
52
+ _VERDICT_FILE: Final = "verdict.jsonl"
53
+ _OUTPUT_FILE: Final = "output.txt"
54
+ _MAX_VERDICT_BYTES: Final = 64 * 1024
55
+ _NONCE_BYTES: Final = 16
56
+
57
+ # The harness runs in the child. Everything that can produce a verdict lives in main()'s
58
+ # locals, and the functions it calls are bound before the solution can replace them.
59
+ _HARNESS: Final = """\
60
+ import json
61
+ import os
62
+ import sys
63
+ import traceback
64
+
65
+ HERE = os.path.dirname(os.path.abspath(__file__))
66
+ TESTS = os.path.join(HERE, {tests_file!r})
67
+ ENTRY_POINT = {entry_point!r}
68
+
69
+
70
+ def one_line(text, limit=200):
71
+ flat = " ".join(str(text).split())
72
+ return flat if len(flat) <= limit else flat[: limit - 3] + "..."
73
+
74
+
75
+ def describe(exc):
76
+ if isinstance(exc, AssertionError):
77
+ line = ""
78
+ for frame in reversed(traceback.extract_tb(exc.__traceback__)):
79
+ if frame.filename == TESTS:
80
+ line = (frame.line or "").strip()
81
+ break
82
+ detail = ": " + one_line(exc) if str(exc) else ""
83
+ return one_line("assertion failed: " + (line or "assert") + detail)
84
+ return one_line(type(exc).__name__ + (": " + str(exc) if str(exc) else ""))
85
+
86
+
87
+ def main():
88
+ nonce = sys.stdin.readline().strip()
89
+ write, exit_now, dumps = os.write, os._exit, json.dumps
90
+ verdict_fd = os.dup(1)
91
+ os.dup2(2, 1)
92
+
93
+ def report(status, reason):
94
+ for stream in (sys.stdout, sys.stderr):
95
+ try:
96
+ stream.flush()
97
+ except BaseException:
98
+ pass
99
+ line = dumps({{"nonce": nonce, "status": status, "reason": reason}}) + "\\n"
100
+ write(verdict_fd, line.encode("utf-8"))
101
+ exit_now(0)
102
+
103
+ sys.path.insert(0, HERE)
104
+ try:
105
+ import solution
106
+ except BaseException as exc:
107
+ report("fail", "solution failed to import: " + describe(exc))
108
+ if not callable(getattr(solution, ENTRY_POINT, None)):
109
+ report("fail", "solution does not define " + ENTRY_POINT)
110
+ namespace = dict(vars(solution))
111
+ try:
112
+ with open(TESTS, encoding="utf-8") as handle:
113
+ source = handle.read()
114
+ exec(compile(source, TESTS, "exec"), namespace)
115
+ except BaseException as exc:
116
+ report("fail", describe(exc))
117
+ report("pass", "")
118
+
119
+
120
+ main()
121
+ """
122
+
123
+
124
+ def extract_code(answer: str) -> str:
125
+ """Return the code in a model answer.
126
+
127
+ Prefers the first ```python fenced block, then the first fenced block of any kind,
128
+ then the whole answer. A leading <think> block is ignored.
129
+ """
130
+ text = strip_reasoning(answer)
131
+ blocks = [(match.group(1).lower(), match.group(2)) for match in _FENCE.finditer(text)]
132
+ for tag, body in blocks:
133
+ if tag in _PYTHON_TAGS:
134
+ return body
135
+ if blocks:
136
+ return blocks[0][1]
137
+ return text.strip()
138
+
139
+
140
+ def run_code_case(
141
+ case: TaskCase, answer: str, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
142
+ ) -> CaseOutcome:
143
+ """Run the code in `answer` against `case.tests` and report pass or fail.
144
+
145
+ Raises SuiteError if the case lacks a valid entry point or tests. Any other problem,
146
+ including one in quantdiff's own process handling, becomes a failed outcome with a
147
+ reason, so one misbehaving answer cannot abort a run.
148
+ """
149
+ entry_point, tests = _require_code_fields(case)
150
+ code = extract_code(answer)
151
+ if not code.strip():
152
+ return _outcome(case, passed=False, reason="answer contains no code")
153
+ try:
154
+ passed, reason = _run_isolated(
155
+ code=code, tests=tests, entry_point=entry_point, timeout_seconds=timeout_seconds
156
+ )
157
+ except Exception as exc:
158
+ logger.exception("could not run code case %s", case.id)
159
+ return _outcome(case, passed=False, reason=f"could not run the tests: {exc!r}")
160
+ return _outcome(case, passed=passed, reason=reason)
161
+
162
+
163
+ def _require_code_fields(case: TaskCase) -> tuple[str, str]:
164
+ if not case.entry_point or not case.entry_point.isidentifier():
165
+ raise SuiteError(f"code case {case.id!r} needs an entry_point that is a Python name")
166
+ if not case.tests or not case.tests.strip():
167
+ raise SuiteError(f"code case {case.id!r} has no tests")
168
+ return case.entry_point, case.tests
169
+
170
+
171
+ def _run_isolated(
172
+ *, code: str, tests: str, entry_point: str, timeout_seconds: float
173
+ ) -> tuple[bool, str]:
174
+ # Not TemporaryDirectory: on CPython 3.10 and 3.11 its ignore_cleanup_errors path
175
+ # recurses without bound when Windows refuses to delete a directory still in use.
176
+ workdir = Path(tempfile.mkdtemp(prefix="quantdiff-code-"))
177
+ try:
178
+ _write_files(workdir, code=code, tests=tests, entry_point=entry_point)
179
+ return _run_harness(workdir, timeout_seconds)
180
+ finally:
181
+ shutil.rmtree(workdir, ignore_errors=True)
182
+
183
+
184
+ def _write_files(workdir: Path, *, code: str, tests: str, entry_point: str) -> None:
185
+ harness = _HARNESS.format(tests_file=_TESTS_FILE, entry_point=entry_point)
186
+ (workdir / _SOLUTION_FILE).write_text(code, encoding="utf-8")
187
+ (workdir / _TESTS_FILE).write_text(tests, encoding="utf-8")
188
+ (workdir / _HARNESS_FILE).write_text(harness, encoding="utf-8")
189
+
190
+
191
+ def _run_harness(workdir: Path, timeout_seconds: float) -> tuple[bool, str]:
192
+ # -I would make Python ignore the PYTHON* variables below. The environment is built
193
+ # from scratch instead, so -E adds nothing; -s and -S keep site-packages out.
194
+ command = [sys.executable, "-s", "-S", str(workdir / _HARNESS_FILE)]
195
+ nonce = secrets.token_hex(_NONCE_BYTES)
196
+ verdict_path = workdir / _VERDICT_FILE
197
+ output_path = workdir / _OUTPUT_FILE
198
+ with verdict_path.open("wb") as verdict, output_path.open("wb") as output:
199
+ process = subprocess.Popen( # noqa: S603 - fixed argv, no shell; running the code is the point
200
+ command,
201
+ cwd=workdir,
202
+ env=_child_environment(),
203
+ stdin=subprocess.PIPE,
204
+ stdout=verdict,
205
+ stderr=output,
206
+ preexec_fn=_resource_limiter(timeout_seconds), # noqa: PLW1509 - only sets rlimits, no locks or threads
207
+ start_new_session=_NEW_SESSION,
208
+ )
209
+ with _contained(process):
210
+ # The harness waits for this line before importing the solution, so the
211
+ # process is already contained by the time any model code runs.
212
+ _send_nonce(process, nonce)
213
+ try:
214
+ returncode = process.wait(timeout=timeout_seconds)
215
+ except subprocess.TimeoutExpired:
216
+ return False, f"timed out after {timeout_seconds:g}s"
217
+
218
+ logger.debug("code case output: %s", _read_preview(output_path))
219
+ return _read_verdict(verdict_path, nonce, returncode)
220
+
221
+
222
+ def _send_nonce(process: subprocess.Popen[bytes], nonce: str) -> None:
223
+ if process.stdin is None:
224
+ raise RuntimeError("harness stdin is not a pipe")
225
+ try:
226
+ process.stdin.write(f"{nonce}\n".encode("ascii"))
227
+ process.stdin.close()
228
+ except OSError:
229
+ # The interpreter died before reading; the missing verdict reports it.
230
+ logger.debug("code harness exited before it read its nonce")
231
+
232
+
233
+ def _child_environment() -> dict[str, str]:
234
+ environment = {
235
+ "PATH": "",
236
+ "PYTHONHASHSEED": "0",
237
+ "PYTHONDONTWRITEBYTECODE": "1",
238
+ "PYTHONIOENCODING": "utf-8",
239
+ }
240
+ if sys.platform == "win32":
241
+ # The Windows C runtime and the interpreter fail to start without it.
242
+ environment["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", r"C:\Windows")
243
+ return environment
244
+
245
+
246
+ def _read_verdict(verdict_path: Path, nonce: str, returncode: int) -> tuple[bool, str]:
247
+ """Return the harness's verdict, ignoring any line that lacks this run's nonce."""
248
+ with verdict_path.open("rb") as handle:
249
+ raw = handle.read(_MAX_VERDICT_BYTES)
250
+ for line in raw.decode("utf-8", errors="replace").splitlines():
251
+ try:
252
+ verdict = json.loads(line)
253
+ except ValueError:
254
+ continue
255
+ if not isinstance(verdict, dict) or verdict.get("nonce") != nonce:
256
+ continue
257
+ if verdict.get("status") not in {"pass", "fail"}:
258
+ return False, "harness reported a malformed result"
259
+ return verdict["status"] == "pass", str(verdict.get("reason", ""))
260
+ return False, f"process exited with code {returncode} before reporting a result"
261
+
262
+
263
+ def _read_preview(path: Path) -> str:
264
+ with path.open("rb") as handle:
265
+ raw = handle.read(OUTPUT_PREVIEW_CHARS * 4)
266
+ return raw.decode("utf-8", errors="replace")[:OUTPUT_PREVIEW_CHARS]
267
+
268
+
269
+ def _outcome(case: TaskCase, *, passed: bool, reason: str) -> CaseOutcome:
270
+ return CaseOutcome(case_id=case.id, kind=case.kind, passed=passed, reason=reason)
271
+
272
+
273
+ if sys.platform == "win32":
274
+ import ctypes
275
+ from ctypes import wintypes
276
+
277
+ _NEW_SESSION: Final = False
278
+ _EXTENDED_LIMIT_INFORMATION: Final = 9
279
+ _KILL_ON_JOB_CLOSE: Final = 0x2000
280
+ _PROCESS_TERMINATE_AND_SET_QUOTA: Final = 0x0001 | 0x0100
281
+ _NOT_INHERITED: Final = 0
282
+
283
+ class _BasicLimits(ctypes.Structure):
284
+ _fields_ = (
285
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
286
+ ("PerJobUserTimeLimit", ctypes.c_int64),
287
+ ("LimitFlags", wintypes.DWORD),
288
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
289
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
290
+ ("ActiveProcessLimit", wintypes.DWORD),
291
+ ("Affinity", ctypes.c_size_t),
292
+ ("PriorityClass", wintypes.DWORD),
293
+ ("SchedulingClass", wintypes.DWORD),
294
+ )
295
+
296
+ class _IoCounters(ctypes.Structure):
297
+ _fields_ = (
298
+ ("ReadOperationCount", ctypes.c_uint64),
299
+ ("WriteOperationCount", ctypes.c_uint64),
300
+ ("OtherOperationCount", ctypes.c_uint64),
301
+ ("ReadTransferCount", ctypes.c_uint64),
302
+ ("WriteTransferCount", ctypes.c_uint64),
303
+ ("OtherTransferCount", ctypes.c_uint64),
304
+ )
305
+
306
+ class _ExtendedLimits(ctypes.Structure):
307
+ _fields_ = (
308
+ ("BasicLimitInformation", _BasicLimits),
309
+ ("IoInfo", _IoCounters),
310
+ ("ProcessMemoryLimit", ctypes.c_size_t),
311
+ ("JobMemoryLimit", ctypes.c_size_t),
312
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
313
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
314
+ )
315
+
316
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
317
+ _kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
318
+ _kernel32.CreateJobObjectW.restype = wintypes.HANDLE
319
+ _kernel32.SetInformationJobObject.argtypes = (
320
+ wintypes.HANDLE,
321
+ ctypes.c_int,
322
+ ctypes.c_void_p,
323
+ wintypes.DWORD,
324
+ )
325
+ _kernel32.SetInformationJobObject.restype = wintypes.BOOL
326
+ _kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
327
+ _kernel32.OpenProcess.restype = wintypes.HANDLE
328
+ _kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
329
+ _kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
330
+ _kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
331
+ _kernel32.TerminateJobObject.restype = wintypes.BOOL
332
+ _kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
333
+ _kernel32.CloseHandle.restype = wintypes.BOOL
334
+
335
+ def _resource_limiter(timeout_seconds: float) -> Callable[[], None] | None:
336
+ return None
337
+
338
+ @contextlib.contextmanager
339
+ def _contained(process: subprocess.Popen[bytes]) -> Iterator[None]:
340
+ """Kill `process` and every process it started when the block exits.
341
+
342
+ A job object follows the whole process tree, which a plain kill does not: a
343
+ grandchild would survive it and keep the working directory in use.
344
+ """
345
+ job = _job_for(process)
346
+ try:
347
+ yield
348
+ finally:
349
+ if job is None:
350
+ process.kill()
351
+ else:
352
+ _kernel32.TerminateJobObject(job, 1)
353
+ _kernel32.CloseHandle(job)
354
+ process.wait()
355
+
356
+ def _job_for(process: subprocess.Popen[bytes]) -> int | None:
357
+ job: int | None = _kernel32.CreateJobObjectW(None, None)
358
+ if not job:
359
+ _warn_uncontained()
360
+ return None
361
+ limits = _ExtendedLimits()
362
+ limits.BasicLimitInformation.LimitFlags = _KILL_ON_JOB_CLOSE
363
+ handle: int | None = _kernel32.OpenProcess(
364
+ _PROCESS_TERMINATE_AND_SET_QUOTA, _NOT_INHERITED, process.pid
365
+ )
366
+ assigned = bool(
367
+ handle
368
+ and _kernel32.SetInformationJobObject(
369
+ job, _EXTENDED_LIMIT_INFORMATION, ctypes.byref(limits), ctypes.sizeof(limits)
370
+ )
371
+ and _kernel32.AssignProcessToJobObject(job, handle)
372
+ )
373
+ if handle:
374
+ _kernel32.CloseHandle(handle)
375
+ if not assigned:
376
+ _warn_uncontained()
377
+ _kernel32.CloseHandle(job)
378
+ return None
379
+ return job
380
+
381
+ def _warn_uncontained() -> None:
382
+ logger.warning(
383
+ "could not put the code harness in a Windows job object (error %d); "
384
+ "processes started by model code may outlive a timeout",
385
+ ctypes.get_last_error(),
386
+ )
387
+
388
+ else:
389
+ import resource
390
+ import signal
391
+
392
+ _NEW_SESSION: Final = True
393
+ _MEMORY_BYTES: Final = 1024**3
394
+ _FILE_BYTES: Final = 16 * 1024**2
395
+ _OPEN_FILES: Final = 64
396
+
397
+ def _resource_limiter(timeout_seconds: float) -> Callable[[], None] | None:
398
+ limits = (
399
+ (resource.RLIMIT_CPU, math.ceil(timeout_seconds) + 1),
400
+ (resource.RLIMIT_AS, _MEMORY_BYTES),
401
+ (resource.RLIMIT_FSIZE, _FILE_BYTES),
402
+ (resource.RLIMIT_NOFILE, _OPEN_FILES),
403
+ )
404
+
405
+ def apply_limits() -> None:
406
+ for which, wanted in limits:
407
+ _, hard = resource.getrlimit(which)
408
+ value = wanted if hard == resource.RLIM_INFINITY else min(wanted, hard)
409
+ try:
410
+ resource.setrlimit(which, (value, value))
411
+ except (ValueError, OSError):
412
+ # Some kernels (macOS for RLIMIT_AS) refuse a limit; the timeout still applies.
413
+ continue
414
+
415
+ return apply_limits
416
+
417
+ @contextlib.contextmanager
418
+ def _contained(process: subprocess.Popen[bytes]) -> Iterator[None]:
419
+ """Kill `process` and every process in its session's group when the block exits."""
420
+ try:
421
+ yield
422
+ finally:
423
+ try:
424
+ os.killpg(process.pid, signal.SIGKILL)
425
+ except OSError:
426
+ # The group is already empty (ESRCH), or only zombies remain (EPERM on macOS).
427
+ logger.debug("code harness process group already gone")
428
+ process.wait()