jevassert 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.
- jevassert/__init__.py +3 -0
- jevassert/__main__.py +3 -0
- jevassert/cli.py +529 -0
- jevassert/client.py +148 -0
- jevassert/compare.py +108 -0
- jevassert/metrics.py +526 -0
- jevassert/packs.py +454 -0
- jevassert/report.py +132 -0
- jevassert/runner.py +159 -0
- jevassert-0.1.0.dist-info/METADATA +194 -0
- jevassert-0.1.0.dist-info/RECORD +14 -0
- jevassert-0.1.0.dist-info/WHEEL +4 -0
- jevassert-0.1.0.dist-info/entry_points.txt +2 -0
- jevassert-0.1.0.dist-info/licenses/LICENSE +201 -0
jevassert/__init__.py
ADDED
jevassert/__main__.py
ADDED
jevassert/cli.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""CLI: ``jevassert record | check | compare``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .client import JevClient, JevError
|
|
15
|
+
from .compare import CompareResult
|
|
16
|
+
from .compare import compare as compare_recordings
|
|
17
|
+
from .metrics import (
|
|
18
|
+
INPUT_USD_PER_MTOK,
|
|
19
|
+
SMALL_N_ITEMS,
|
|
20
|
+
OverallMetrics,
|
|
21
|
+
ThresholdSuggestion,
|
|
22
|
+
build_items,
|
|
23
|
+
compute,
|
|
24
|
+
evaluate_gates,
|
|
25
|
+
suggest_threshold,
|
|
26
|
+
)
|
|
27
|
+
from .packs import Pack, PackError, load_pack
|
|
28
|
+
from .report import render_junit, render_markdown
|
|
29
|
+
from .runner import load_predictions, record, write_predictions
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
parser = _build_parser()
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
try:
|
|
36
|
+
return int(args.func(args))
|
|
37
|
+
except (PackError, FileNotFoundError, ValueError, JevError) as exc:
|
|
38
|
+
print(f"jevassert: error: {exc}", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="jevassert",
|
|
45
|
+
description="Record/replay regression tests for Jev question packs.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
48
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
49
|
+
|
|
50
|
+
record_parser = sub.add_parser(
|
|
51
|
+
"record", help="call Jev for every case and write a predictions JSONL"
|
|
52
|
+
)
|
|
53
|
+
record_parser.add_argument("pack", help="pack directory (containing pack.yaml + cases.jsonl)")
|
|
54
|
+
record_parser.add_argument("-o", "--out", default="predictions.jsonl")
|
|
55
|
+
record_parser.add_argument("--concurrency", type=int, default=8)
|
|
56
|
+
record_parser.add_argument(
|
|
57
|
+
"--limit", type=int, default=None, help="record only the first N cases"
|
|
58
|
+
)
|
|
59
|
+
record_parser.add_argument(
|
|
60
|
+
"--model",
|
|
61
|
+
default=None,
|
|
62
|
+
help="model version to record (default: pack.tested or jev-latest)",
|
|
63
|
+
)
|
|
64
|
+
record_parser.add_argument("--base-url", default=None, help="override TYPESAFE_BASE_URL")
|
|
65
|
+
record_parser.add_argument(
|
|
66
|
+
"--resume",
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="skip cases already recorded successfully in --out (retry errors only)",
|
|
69
|
+
)
|
|
70
|
+
record_parser.add_argument(
|
|
71
|
+
"--rpm", type=int, default=0, help="pace requests per minute (0 = unlimited)"
|
|
72
|
+
)
|
|
73
|
+
record_parser.add_argument(
|
|
74
|
+
"--dry-run",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="estimate tokens and cost from the pack without sending anything",
|
|
77
|
+
)
|
|
78
|
+
record_parser.add_argument(
|
|
79
|
+
"--shuffle-options",
|
|
80
|
+
type=int,
|
|
81
|
+
default=None,
|
|
82
|
+
metavar="SEED",
|
|
83
|
+
help="robustness pass: permute Choice option order with this seed",
|
|
84
|
+
)
|
|
85
|
+
record_parser.add_argument(
|
|
86
|
+
"--repeat",
|
|
87
|
+
type=int,
|
|
88
|
+
default=1,
|
|
89
|
+
metavar="N",
|
|
90
|
+
help="record N independent rounds (extra rounds go to FILE-r2.jsonl, ...); "
|
|
91
|
+
"prints the discordant-decision count across rounds",
|
|
92
|
+
)
|
|
93
|
+
record_parser.set_defaults(func=_cmd_record)
|
|
94
|
+
|
|
95
|
+
check_parser = sub.add_parser("check", help="evaluate a recording against the pack gates")
|
|
96
|
+
check_parser.add_argument("pack")
|
|
97
|
+
check_parser.add_argument("-p", "--predictions", default="predictions.jsonl")
|
|
98
|
+
check_parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
99
|
+
check_parser.add_argument("--junit", default=None, help="write JUnit XML to this path")
|
|
100
|
+
check_parser.add_argument("--report", default=None, help="write a markdown report to this path")
|
|
101
|
+
check_parser.add_argument(
|
|
102
|
+
"--failures", action="store_true", help="list every mismatch after the summary"
|
|
103
|
+
)
|
|
104
|
+
check_parser.add_argument(
|
|
105
|
+
"--bootstrap",
|
|
106
|
+
type=int,
|
|
107
|
+
default=1000,
|
|
108
|
+
help="bootstrap resamples for confidence intervals (0 disables)",
|
|
109
|
+
)
|
|
110
|
+
check_parser.add_argument(
|
|
111
|
+
"--target-precision",
|
|
112
|
+
type=float,
|
|
113
|
+
default=None,
|
|
114
|
+
help="suggest the highest-coverage threshold that reaches this precision",
|
|
115
|
+
)
|
|
116
|
+
check_parser.add_argument(
|
|
117
|
+
"--partition",
|
|
118
|
+
choices=("all", "dev", "test"),
|
|
119
|
+
default="all",
|
|
120
|
+
help="evaluate only the dev or test half (deterministic hash split) — "
|
|
121
|
+
"tune thresholds on dev, verify on test",
|
|
122
|
+
)
|
|
123
|
+
check_parser.add_argument(
|
|
124
|
+
"--partition-seed", type=int, default=0, help="seed for the dev/test split"
|
|
125
|
+
)
|
|
126
|
+
check_parser.add_argument(
|
|
127
|
+
"--partition-ratio",
|
|
128
|
+
type=float,
|
|
129
|
+
default=0.5,
|
|
130
|
+
help="dev share of the deterministic split (default 0.5)",
|
|
131
|
+
)
|
|
132
|
+
check_parser.set_defaults(func=_cmd_check)
|
|
133
|
+
|
|
134
|
+
compare_parser = sub.add_parser("compare", help="paired comparison of two recordings")
|
|
135
|
+
compare_parser.add_argument("pack")
|
|
136
|
+
compare_parser.add_argument("--a", required=True, help="baseline predictions JSONL")
|
|
137
|
+
compare_parser.add_argument("--b", required=True, help="candidate predictions JSONL")
|
|
138
|
+
compare_parser.set_defaults(func=_cmd_compare)
|
|
139
|
+
|
|
140
|
+
return parser
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cmd_record(args: argparse.Namespace) -> int:
|
|
144
|
+
pack = load_pack(args.pack)
|
|
145
|
+
if args.dry_run:
|
|
146
|
+
cases, tokens = _estimate_tokens(pack, args.limit)
|
|
147
|
+
cost = tokens * INPUT_USD_PER_MTOK / 1_000_000
|
|
148
|
+
print(
|
|
149
|
+
f"dry-run: {cases} cases, ~{tokens} input tokens, ~${cost:.4f} "
|
|
150
|
+
"at $0.042/M input tokens (rough estimate, nothing sent)"
|
|
151
|
+
)
|
|
152
|
+
return 0
|
|
153
|
+
if args.repeat < 1:
|
|
154
|
+
print("jevassert: error: --repeat must be >= 1", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
client = JevClient(base_url=args.base_url)
|
|
158
|
+
base_out = Path(args.out)
|
|
159
|
+
total_errors = 0
|
|
160
|
+
try:
|
|
161
|
+
for round_number in range(1, args.repeat + 1):
|
|
162
|
+
round_out = _round_path(base_out, round_number)
|
|
163
|
+
resume_from = None
|
|
164
|
+
if args.resume and round_out.is_file():
|
|
165
|
+
resume_from = load_predictions(round_out)
|
|
166
|
+
|
|
167
|
+
def progress(done: int, total: int, current: int = round_number) -> None:
|
|
168
|
+
prefix = f"round {current}: " if args.repeat > 1 else ""
|
|
169
|
+
print(
|
|
170
|
+
f"\rrecording {prefix}{done}/{total} cases",
|
|
171
|
+
end="",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
flush=True,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
records = record(
|
|
177
|
+
pack,
|
|
178
|
+
client,
|
|
179
|
+
concurrency=args.concurrency,
|
|
180
|
+
limit=args.limit,
|
|
181
|
+
progress=progress,
|
|
182
|
+
model=args.model,
|
|
183
|
+
resume_from=resume_from,
|
|
184
|
+
rpm=args.rpm,
|
|
185
|
+
shuffle_options=args.shuffle_options,
|
|
186
|
+
)
|
|
187
|
+
print(file=sys.stderr)
|
|
188
|
+
write_predictions(round_out, records)
|
|
189
|
+
errors = sum(1 for item in records if item["error"])
|
|
190
|
+
total_errors += errors
|
|
191
|
+
print(
|
|
192
|
+
f"{len(records)} cases -> {round_out}" + (f" ({errors} errors)" if errors else "")
|
|
193
|
+
)
|
|
194
|
+
for item in records:
|
|
195
|
+
if item["error"]:
|
|
196
|
+
print(f" {item['case_id']}: {item['error']}", file=sys.stderr)
|
|
197
|
+
finally:
|
|
198
|
+
client.close()
|
|
199
|
+
|
|
200
|
+
if args.repeat > 1:
|
|
201
|
+
print(_stability_summary(pack, base_out, args.repeat))
|
|
202
|
+
return 1 if total_errors else 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _round_path(base: Path, round_number: int) -> Path:
|
|
206
|
+
if round_number == 1:
|
|
207
|
+
return base
|
|
208
|
+
return base.with_name(f"{base.stem}-r{round_number}{base.suffix}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _stability_summary(pack: Pack, base_out: Path, repeats: int) -> str:
|
|
212
|
+
base = load_predictions(_round_path(base_out, 1))
|
|
213
|
+
flips = 0
|
|
214
|
+
compared = 0
|
|
215
|
+
for round_number in range(2, repeats + 1):
|
|
216
|
+
other_path = _round_path(base_out, round_number)
|
|
217
|
+
if not other_path.is_file():
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
result = compare_recordings(pack, base, load_predictions(other_path))
|
|
221
|
+
except ValueError:
|
|
222
|
+
continue
|
|
223
|
+
flips += result.wins + result.losses
|
|
224
|
+
compared = max(compared, result.n_items)
|
|
225
|
+
if not compared:
|
|
226
|
+
return "stability: no comparable rounds"
|
|
227
|
+
return (
|
|
228
|
+
f"stability: {flips} discordant decisions across {repeats} rounds "
|
|
229
|
+
f"({compared} items compared, {flips / compared:.3f} flip rate)"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _estimate_tokens(pack: Pack, limit: int | None) -> tuple[int, int]:
|
|
234
|
+
cases = pack.cases[:limit] if limit else pack.cases
|
|
235
|
+
question_tokens = len(json.dumps(pack.to_api_questions(), ensure_ascii=False)) // 4 + 1
|
|
236
|
+
tokens = sum(
|
|
237
|
+
len(json.dumps(case.state, ensure_ascii=False)) // 4 + question_tokens for case in cases
|
|
238
|
+
)
|
|
239
|
+
return len(cases), tokens
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
243
|
+
pack = load_pack(args.pack)
|
|
244
|
+
predictions = load_predictions(args.predictions)
|
|
245
|
+
_warn_on_recording_mismatch(pack, predictions)
|
|
246
|
+
if args.partition != "all":
|
|
247
|
+
pack = _partition_pack(pack, args.partition, args.partition_seed, args.partition_ratio)
|
|
248
|
+
case_ids = {case.id for case in pack.cases}
|
|
249
|
+
predictions = {
|
|
250
|
+
case_id: item for case_id, item in predictions.items() if case_id in case_ids
|
|
251
|
+
}
|
|
252
|
+
print(
|
|
253
|
+
f"partition: {args.partition} — {len(pack.cases)} cases "
|
|
254
|
+
f"(seed {args.partition_seed}, ratio {args.partition_ratio})",
|
|
255
|
+
file=sys.stderr,
|
|
256
|
+
)
|
|
257
|
+
overall = compute(pack, predictions, bootstrap=args.bootstrap)
|
|
258
|
+
gates = evaluate_gates(pack, overall)
|
|
259
|
+
suggestion = None
|
|
260
|
+
if args.target_precision is not None:
|
|
261
|
+
items, _, _ = build_items(pack, predictions)
|
|
262
|
+
suggestion = suggest_threshold(items, args.target_precision)
|
|
263
|
+
|
|
264
|
+
if args.junit:
|
|
265
|
+
Path(args.junit).write_text(render_junit(pack, gates), encoding="utf-8")
|
|
266
|
+
if args.report:
|
|
267
|
+
Path(args.report).write_text(
|
|
268
|
+
render_markdown(pack, overall, gates, suggestion, args.target_precision),
|
|
269
|
+
encoding="utf-8",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if args.json:
|
|
273
|
+
print(json.dumps(_payload(pack, overall, gates, suggestion), indent=2, ensure_ascii=False))
|
|
274
|
+
else:
|
|
275
|
+
print(
|
|
276
|
+
_human_summary(
|
|
277
|
+
pack, overall, gates, args.predictions, suggestion, args.target_precision
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
if args.failures:
|
|
281
|
+
print()
|
|
282
|
+
print(_failures(pack, predictions))
|
|
283
|
+
|
|
284
|
+
return 1 if any(gate.ok is False for gate in gates) else 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _warn_on_recording_mismatch(pack: Pack, predictions: dict[str, dict[str, Any]]) -> None:
|
|
288
|
+
"""Never let a stale or partial recording pass silently."""
|
|
289
|
+
pack_ids = {case.id for case in pack.cases}
|
|
290
|
+
extra = sorted(set(predictions) - pack_ids)
|
|
291
|
+
missing = sorted(pack_ids - set(predictions))
|
|
292
|
+
if extra:
|
|
293
|
+
print(
|
|
294
|
+
f"warning: recording has {len(extra)} case(s) not in the pack: {', '.join(extra[:5])}",
|
|
295
|
+
file=sys.stderr,
|
|
296
|
+
)
|
|
297
|
+
if missing:
|
|
298
|
+
print(
|
|
299
|
+
f"warning: recording is missing {len(missing)} case(s): {', '.join(missing[:5])}",
|
|
300
|
+
file=sys.stderr,
|
|
301
|
+
)
|
|
302
|
+
models = {record.get("model") for record in predictions.values() if record.get("model")}
|
|
303
|
+
if pack.tested and models and models != {pack.tested}:
|
|
304
|
+
print(
|
|
305
|
+
f"warning: recording model(s) {sorted(models)} != pack.tested {pack.tested}",
|
|
306
|
+
file=sys.stderr,
|
|
307
|
+
)
|
|
308
|
+
if pack.tested is None:
|
|
309
|
+
print(
|
|
310
|
+
"note: pack.tested is null — pin it to the recorded model version "
|
|
311
|
+
"once evidence is reviewed",
|
|
312
|
+
file=sys.stderr,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _partition_pack(pack: Pack, which: str, seed: int, ratio: float) -> Pack:
|
|
317
|
+
"""Deterministic dev/test half so thresholds are tuned and verified apart."""
|
|
318
|
+
selected = tuple(case for case in pack.cases if _in_partition(case.id, which, seed, ratio))
|
|
319
|
+
return dataclasses.replace(pack, cases=selected)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _in_partition(case_id: str, which: str, seed: int, ratio: float) -> bool:
|
|
323
|
+
digest = hashlib.blake2s(f"{seed}:{case_id}".encode(), digest_size=8).digest()
|
|
324
|
+
is_dev = int.from_bytes(digest, "big") / 2**64 < ratio
|
|
325
|
+
return is_dev if which == "dev" else not is_dev
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _failures(pack: Pack, predictions: dict[str, dict[str, Any]]) -> str:
|
|
329
|
+
items, _, _ = build_items(pack, predictions)
|
|
330
|
+
misses = [item for item in items if not item.correct]
|
|
331
|
+
states = {case.id: case.state for case in pack.cases}
|
|
332
|
+
lines = [f"failures: {len(misses)}/{len(items)} items"]
|
|
333
|
+
for item in misses:
|
|
334
|
+
lines.append(
|
|
335
|
+
f" {item.case_id} {item.qid}: expected={item.expected!r} got={item.got!r} "
|
|
336
|
+
f"p={item.decision_prob:.2f}"
|
|
337
|
+
)
|
|
338
|
+
lines.append(f" state: {json.dumps(states[item.case_id], ensure_ascii=False)[:200]}")
|
|
339
|
+
return "\n".join(lines)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _cmd_compare(args: argparse.Namespace) -> int:
|
|
343
|
+
pack = load_pack(args.pack)
|
|
344
|
+
result = compare_recordings(pack, load_predictions(args.a), load_predictions(args.b))
|
|
345
|
+
print(_compare_summary(pack, result, args.a, args.b))
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _payload(
|
|
350
|
+
pack: Pack,
|
|
351
|
+
overall: OverallMetrics,
|
|
352
|
+
gates: list[Any],
|
|
353
|
+
suggestion: ThresholdSuggestion | None = None,
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
return {
|
|
356
|
+
"pack": pack.id,
|
|
357
|
+
"version": pack.version,
|
|
358
|
+
"record_model": pack.record_model,
|
|
359
|
+
"tested": pack.tested,
|
|
360
|
+
"n_cases": overall.n_cases,
|
|
361
|
+
"n_items": overall.n_items,
|
|
362
|
+
"case_errors": overall.case_errors,
|
|
363
|
+
"missing_items": overall.missing_items,
|
|
364
|
+
"accuracy": overall.accuracy,
|
|
365
|
+
"accuracy_ci": list(overall.accuracy_ci) if overall.accuracy_ci else None,
|
|
366
|
+
"ece": overall.ece,
|
|
367
|
+
"ece_ci": list(overall.ece_ci) if overall.ece_ci else None,
|
|
368
|
+
"suggestion": (
|
|
369
|
+
{
|
|
370
|
+
"threshold": suggestion.threshold,
|
|
371
|
+
"coverage": suggestion.coverage,
|
|
372
|
+
"precision": suggestion.precision,
|
|
373
|
+
"n_accepted": suggestion.n_accepted,
|
|
374
|
+
}
|
|
375
|
+
if suggestion
|
|
376
|
+
else None
|
|
377
|
+
),
|
|
378
|
+
"mean_decision_prob": overall.mean_decision_prob,
|
|
379
|
+
"cost_per_case_usd": overall.cost_per_case_usd,
|
|
380
|
+
"total_cost_usd": overall.total_cost_usd,
|
|
381
|
+
"p50_latency_ms": overall.p50_latency_ms,
|
|
382
|
+
"p95_latency_ms": overall.p95_latency_ms,
|
|
383
|
+
"per_question": {
|
|
384
|
+
qid: {
|
|
385
|
+
"type": metrics.qtype,
|
|
386
|
+
"n": metrics.n,
|
|
387
|
+
"missing": metrics.missing,
|
|
388
|
+
"accuracy": metrics.accuracy,
|
|
389
|
+
"mean_decision_prob": metrics.mean_decision_prob,
|
|
390
|
+
"ece": metrics.ece,
|
|
391
|
+
"brier": metrics.brier,
|
|
392
|
+
}
|
|
393
|
+
for qid, metrics in overall.per_question.items()
|
|
394
|
+
},
|
|
395
|
+
"coverage": [
|
|
396
|
+
{
|
|
397
|
+
"threshold": row.threshold,
|
|
398
|
+
"coverage": row.coverage,
|
|
399
|
+
"precision": row.precision,
|
|
400
|
+
}
|
|
401
|
+
for row in overall.coverage
|
|
402
|
+
],
|
|
403
|
+
"threshold_coverage": (
|
|
404
|
+
{
|
|
405
|
+
"n_total": overall.threshold_coverage.n_total,
|
|
406
|
+
"n_auto": overall.threshold_coverage.n_auto,
|
|
407
|
+
"coverage": overall.threshold_coverage.coverage,
|
|
408
|
+
"precision": overall.threshold_coverage.precision,
|
|
409
|
+
}
|
|
410
|
+
if overall.threshold_coverage
|
|
411
|
+
else None
|
|
412
|
+
),
|
|
413
|
+
"gates": [{"gate": gate.gate, "ok": gate.ok, "detail": gate.detail} for gate in gates],
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _human_summary(
|
|
418
|
+
pack: Pack,
|
|
419
|
+
overall: OverallMetrics,
|
|
420
|
+
gates: list[Any],
|
|
421
|
+
predictions_path: str,
|
|
422
|
+
suggestion: ThresholdSuggestion | None = None,
|
|
423
|
+
target_precision: float | None = None,
|
|
424
|
+
) -> str:
|
|
425
|
+
lines: list[str] = []
|
|
426
|
+
tested = f", tested {pack.tested}" if pack.tested else ", provisional"
|
|
427
|
+
lines.append(
|
|
428
|
+
f"jevassert — {pack.id} v{pack.version} (record model {pack.record_model}{tested})"
|
|
429
|
+
)
|
|
430
|
+
lines.append(
|
|
431
|
+
f"recording: {predictions_path} — {overall.n_cases} cases, "
|
|
432
|
+
f"{overall.case_errors} errors, {overall.missing_items} missing, {overall.n_items} items"
|
|
433
|
+
)
|
|
434
|
+
lines.append("")
|
|
435
|
+
lines.append(
|
|
436
|
+
f"{'question':<22} {'type':<7} {'n':>3} {'acc':>7} {'p(dec)':>7} {'ece':>7} {'brier':>7}"
|
|
437
|
+
)
|
|
438
|
+
for qid, metrics in overall.per_question.items():
|
|
439
|
+
brier = f"{metrics.brier:.3f}" if metrics.brier is not None else "—"
|
|
440
|
+
lines.append(
|
|
441
|
+
f"{qid:<22} {metrics.qtype:<7} {metrics.n:>3} {metrics.accuracy:>7.3f} "
|
|
442
|
+
f"{metrics.mean_decision_prob:>7.3f} {metrics.ece:>7.3f} {brier:>7}"
|
|
443
|
+
)
|
|
444
|
+
lines.append(
|
|
445
|
+
f"{'overall':<22} {'':<7} {overall.n_items:>3} {overall.accuracy:>7.3f} "
|
|
446
|
+
f"{overall.mean_decision_prob:>7.3f} {overall.ece:>7.3f}"
|
|
447
|
+
)
|
|
448
|
+
if overall.accuracy_ci is not None:
|
|
449
|
+
low, high = overall.accuracy_ci
|
|
450
|
+
ece_part = ""
|
|
451
|
+
if overall.ece_ci is not None:
|
|
452
|
+
ece_part = f", ECE CI {overall.ece_ci[0]:.3f}–{overall.ece_ci[1]:.3f}"
|
|
453
|
+
lines.append(f"bootstrap 95%: accuracy CI {low:.3f}–{high:.3f}{ece_part}")
|
|
454
|
+
if overall.n_items < SMALL_N_ITEMS:
|
|
455
|
+
lines.append(
|
|
456
|
+
f"note: {overall.n_items} items only — accuracy CI is wide and ECE is coarse "
|
|
457
|
+
"(see README: Reading the numbers)"
|
|
458
|
+
)
|
|
459
|
+
if overall.cost_per_case_usd is not None:
|
|
460
|
+
lines.append(
|
|
461
|
+
f"cost/case ${overall.cost_per_case_usd:.6f} "
|
|
462
|
+
f"(total ${overall.total_cost_usd:.4f} at ${0.042}/M input tokens)"
|
|
463
|
+
)
|
|
464
|
+
if overall.p95_latency_ms is not None:
|
|
465
|
+
lines.append(
|
|
466
|
+
f"latency p50 {overall.p50_latency_ms:.0f}ms, p95 {overall.p95_latency_ms:.0f}ms"
|
|
467
|
+
)
|
|
468
|
+
lines.append("")
|
|
469
|
+
lines.append("coverage (accept when p >= threshold):")
|
|
470
|
+
for row in overall.coverage:
|
|
471
|
+
precision = f"{row.precision:.3f}" if row.precision is not None else "—"
|
|
472
|
+
lines.append(
|
|
473
|
+
f" >= {row.threshold:.2f}: coverage {row.coverage:.3f}, precision {precision}"
|
|
474
|
+
)
|
|
475
|
+
if overall.threshold_coverage is not None:
|
|
476
|
+
tc = overall.threshold_coverage
|
|
477
|
+
precision = f"{tc.precision:.3f}" if tc.precision is not None else "—"
|
|
478
|
+
lines.append("")
|
|
479
|
+
lines.append("author thresholds (pack floors; labels without a floor go to review):")
|
|
480
|
+
lines.append(
|
|
481
|
+
f" auto-accepted {tc.n_auto}/{tc.n_total} ({tc.coverage:.3f}), precision {precision}"
|
|
482
|
+
)
|
|
483
|
+
if suggestion is not None and target_precision is not None:
|
|
484
|
+
lines.append("")
|
|
485
|
+
lines.append(
|
|
486
|
+
f"threshold suggestion (precision >= {target_precision:.2f}): "
|
|
487
|
+
f"p >= {suggestion.threshold:.2f} → coverage {suggestion.coverage:.3f}, "
|
|
488
|
+
f"precision {suggestion.precision:.3f} (n={suggestion.n_accepted})"
|
|
489
|
+
)
|
|
490
|
+
lines.append("")
|
|
491
|
+
lines.append("gates:")
|
|
492
|
+
if not gates:
|
|
493
|
+
lines.append(" (none declared in pack.yaml)")
|
|
494
|
+
for gate in gates:
|
|
495
|
+
mark = "PASS" if gate.ok else ("SKIP" if gate.ok is None else "FAIL")
|
|
496
|
+
lines.append(f" {mark} {gate.gate}: {gate.detail}")
|
|
497
|
+
return "\n".join(lines)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _compare_summary(pack: Pack, result: CompareResult, path_a: str, path_b: str) -> str:
|
|
501
|
+
lines: list[str] = []
|
|
502
|
+
lines.append(f"jevassert compare — {pack.id} v{pack.version}")
|
|
503
|
+
lines.append(f" A: {path_a}")
|
|
504
|
+
lines.append(f" B: {path_b}")
|
|
505
|
+
lines.append(
|
|
506
|
+
f" items {result.n_items}: accuracy A {result.accuracy_a:.3f} "
|
|
507
|
+
f"-> B {result.accuracy_b:.3f} (delta {result.delta:+.3f})"
|
|
508
|
+
)
|
|
509
|
+
lines.append(
|
|
510
|
+
f" discordant: A-only correct {result.wins}, B-only correct {result.losses} "
|
|
511
|
+
f"— McNemar exact p = {result.p_value:.4f}"
|
|
512
|
+
)
|
|
513
|
+
if result.cost_per_case_a_usd is not None and result.cost_per_case_b_usd is not None:
|
|
514
|
+
lines.append(
|
|
515
|
+
f" cost/case A ${result.cost_per_case_a_usd:.6f} "
|
|
516
|
+
f"-> B ${result.cost_per_case_b_usd:.6f}"
|
|
517
|
+
)
|
|
518
|
+
lines.append("")
|
|
519
|
+
lines.append(f"{'question':<22} {'n':>3} {'acc A':>7} {'acc B':>7} {'delta':>7}")
|
|
520
|
+
for delta in result.per_question.values():
|
|
521
|
+
lines.append(
|
|
522
|
+
f"{delta.qid:<22} {delta.n:>3} {delta.accuracy_a:>7.3f} "
|
|
523
|
+
f"{delta.accuracy_b:>7.3f} {delta.delta:>+7.3f}"
|
|
524
|
+
)
|
|
525
|
+
return "\n".join(lines)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
if __name__ == "__main__": # pragma: no cover
|
|
529
|
+
raise SystemExit(main())
|
jevassert/client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""HTTP transport for the TypeSafe System One endpoint.
|
|
2
|
+
|
|
3
|
+
One small client with retries and an injectable transport so tests run
|
|
4
|
+
without network access and so any Jev-compatible endpoint (TypeSafe API,
|
|
5
|
+
gateway, local replica) can be used via ``base_url``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.typesafe.ai"
|
|
21
|
+
ENDPOINT = "/v1/systemone"
|
|
22
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504, 529}
|
|
23
|
+
|
|
24
|
+
# transport(method, url, headers, json_body) -> (status, body, retry_after_seconds)
|
|
25
|
+
Transport = Callable[[str, str, dict[str, str], dict[str, Any]], tuple[int, Any, float | None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JevError(RuntimeError):
|
|
29
|
+
"""A System One call failed (after retries, if retryable)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status = status
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_auth_error(self) -> bool:
|
|
37
|
+
return self.status in (401, 403)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _ResponseLike(Protocol): # pragma: no cover - typing helper
|
|
41
|
+
status_code: int
|
|
42
|
+
headers: Any
|
|
43
|
+
|
|
44
|
+
def json(self) -> Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _httpx_transport(client: httpx.Client) -> Transport:
|
|
48
|
+
def transport(
|
|
49
|
+
method: str, url: str, headers: dict[str, str], json_body: dict[str, Any]
|
|
50
|
+
) -> tuple[int, Any, float | None]:
|
|
51
|
+
response = client.request(method, url, headers=headers, json=json_body)
|
|
52
|
+
retry_after: float | None = None
|
|
53
|
+
raw = response.headers.get("retry-after")
|
|
54
|
+
if raw:
|
|
55
|
+
try:
|
|
56
|
+
retry_after = float(raw)
|
|
57
|
+
except ValueError:
|
|
58
|
+
retry_after = None
|
|
59
|
+
try:
|
|
60
|
+
body = response.json()
|
|
61
|
+
except ValueError:
|
|
62
|
+
body = response.text
|
|
63
|
+
return response.status_code, body, retry_after
|
|
64
|
+
|
|
65
|
+
return transport
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class JevClient:
|
|
69
|
+
"""Thin client for ``POST {base_url}/v1/systemone``.
|
|
70
|
+
|
|
71
|
+
Reads ``TYPESAFE_API_KEY`` and ``TYPESAFE_BASE_URL`` from the environment
|
|
72
|
+
when not given explicitly. The API key is never logged or written to disk.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str | None = None,
|
|
78
|
+
base_url: str | None = None,
|
|
79
|
+
transport: Transport | None = None,
|
|
80
|
+
timeout: float = 60.0,
|
|
81
|
+
max_retries: int = 3,
|
|
82
|
+
backoff_seconds: float = 0.5,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.api_key = api_key or os.environ.get("TYPESAFE_API_KEY")
|
|
85
|
+
self.base_url = (
|
|
86
|
+
base_url or os.environ.get("TYPESAFE_BASE_URL") or DEFAULT_BASE_URL
|
|
87
|
+
).rstrip("/")
|
|
88
|
+
self.max_retries = max_retries
|
|
89
|
+
self.backoff_seconds = backoff_seconds
|
|
90
|
+
self._http = None if transport else httpx.Client(timeout=timeout)
|
|
91
|
+
self._transport: Transport = transport or _httpx_transport(self._http) # type: ignore[arg-type]
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> JevClient:
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
if self._http is not None:
|
|
101
|
+
self._http.close()
|
|
102
|
+
self._http = None
|
|
103
|
+
|
|
104
|
+
def system_one(
|
|
105
|
+
self, state: Any, questions: dict[str, Any], model: str = "jev-latest"
|
|
106
|
+
) -> tuple[dict[str, Any], float]:
|
|
107
|
+
"""Evaluate ``questions`` against ``state``; returns (response, latency_ms)."""
|
|
108
|
+
if not self.api_key:
|
|
109
|
+
raise JevError("TYPESAFE_API_KEY is not set (env var or api_key=)")
|
|
110
|
+
payload = {"state": state, "model": model, "questions": questions}
|
|
111
|
+
headers = {
|
|
112
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"User-Agent": f"jevassert/{__version__}",
|
|
115
|
+
}
|
|
116
|
+
url = f"{self.base_url}{ENDPOINT}"
|
|
117
|
+
|
|
118
|
+
for attempt in range(self.max_retries + 1):
|
|
119
|
+
started = time.perf_counter()
|
|
120
|
+
try:
|
|
121
|
+
status, body, retry_after = self._transport("POST", url, headers, payload)
|
|
122
|
+
except httpx.TransportError as exc:
|
|
123
|
+
if attempt >= self.max_retries:
|
|
124
|
+
raise JevError(f"request failed after {attempt + 1} attempts: {exc}") from exc
|
|
125
|
+
self._sleep(attempt, None)
|
|
126
|
+
continue
|
|
127
|
+
latency_ms = (time.perf_counter() - started) * 1000
|
|
128
|
+
|
|
129
|
+
if status == 200 and isinstance(body, dict):
|
|
130
|
+
return body, latency_ms
|
|
131
|
+
if status in RETRYABLE_STATUS and attempt < self.max_retries:
|
|
132
|
+
self._sleep(attempt, retry_after)
|
|
133
|
+
continue
|
|
134
|
+
raise JevError(f"HTTP {status}: {_excerpt(body)}", status=status)
|
|
135
|
+
|
|
136
|
+
raise JevError("unreachable") # pragma: no cover
|
|
137
|
+
|
|
138
|
+
def _sleep(self, attempt: int, retry_after: float | None) -> None:
|
|
139
|
+
delay = self.backoff_seconds * (2**attempt) + random.uniform(0, 0.25)
|
|
140
|
+
if retry_after:
|
|
141
|
+
delay = max(delay, retry_after)
|
|
142
|
+
time.sleep(delay)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _excerpt(body: Any, limit: int = 300) -> str:
|
|
146
|
+
text = body if isinstance(body, str) else str(body)
|
|
147
|
+
text = " ".join(text.split())
|
|
148
|
+
return text[:limit] + ("…" if len(text) > limit else "")
|