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 ADDED
@@ -0,0 +1,3 @@
1
+ """jevassert — record/replay regression tests for Jev question packs."""
2
+
3
+ __version__ = "0.1.0"
jevassert/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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 "")