jevkit-bench 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,14 @@
1
+ """Score a labeled Jev suite for accuracy and cost, and compare runs.
2
+
3
+ Answers the question you have to defend: for this task, on my data, is Jev good
4
+ enough and what does it cost? Accuracy, tokens and dollars together, because any
5
+ one of them alone is easy to win.
6
+ """
7
+
8
+ from .score import (PRICE_PER_MTOK, QuestionResult, SuiteComparison, SuiteResult,
9
+ compare_suites, score_records)
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["score_records", "compare_suites", "SuiteResult", "SuiteComparison",
14
+ "QuestionResult", "PRICE_PER_MTOK", "__version__"]
jevkit_bench/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ """``jevkit-bench`` command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from jevkit_core import RecordFormatError, read_records
10
+
11
+ from .score import PRICE_PER_MTOK, compare_suites, score_records
12
+
13
+ EXIT_OK = 0
14
+ EXIT_FAILED_GATE = 1
15
+ EXIT_USAGE = 2
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(
20
+ prog="jevkit-bench",
21
+ description="Score a labeled .jevl suite for accuracy and cost, and compare two "
22
+ "runs of it. Never calls the API.",
23
+ )
24
+ parser.add_argument("suite", help="scored .jevl suite")
25
+ parser.add_argument("--baseline", help="a previous run, to compare against")
26
+ parser.add_argument("--question", action="append", dest="questions",
27
+ help="restrict to this question id (repeatable)")
28
+ parser.add_argument("--price-per-mtok", type=float, default=PRICE_PER_MTOK,
29
+ help=f"input price per million tokens (default {PRICE_PER_MTOK})")
30
+ parser.add_argument("--min-accuracy", type=float, help="exit non-zero below this")
31
+ parser.add_argument("--max-regressions", type=int,
32
+ help="exit non-zero above this many regressions (needs --baseline)")
33
+ parser.add_argument("--show-failures", type=int, default=0, metavar="N",
34
+ help="print the N most confident wrong answers")
35
+ parser.add_argument("--format", choices=("text", "json"), default="text")
36
+ args = parser.parse_args(argv)
37
+
38
+ if args.max_regressions is not None and not args.baseline:
39
+ print("jevkit-bench: --max-regressions needs --baseline", file=sys.stderr)
40
+ return EXIT_USAGE
41
+
42
+ try:
43
+ suite = score_records(read_records(args.suite), question_ids=args.questions,
44
+ price_per_mtok=args.price_per_mtok)
45
+ baseline = (
46
+ score_records(read_records(args.baseline), question_ids=args.questions,
47
+ price_per_mtok=args.price_per_mtok)
48
+ if args.baseline else None
49
+ )
50
+ except (OSError, RecordFormatError) as exc:
51
+ print(f"jevkit-bench: {exc}", file=sys.stderr)
52
+ return EXIT_USAGE
53
+
54
+ if not suite.count:
55
+ print("jevkit-bench: nothing scored. Records need a 'label' object keyed by "
56
+ "question id.", file=sys.stderr)
57
+ return EXIT_USAGE
58
+
59
+ comparison = compare_suites(baseline, suite) if baseline else None
60
+
61
+ if args.format == "json":
62
+ payload = suite.to_dict()
63
+ if comparison:
64
+ payload["comparison"] = {
65
+ "baseline_accuracy": comparison.baseline.accuracy,
66
+ "accuracy_delta": comparison.accuracy_delta,
67
+ "cost_delta": comparison.cost_delta,
68
+ "regressions": [list(k) for k in comparison.regressions],
69
+ "fixes": [list(k) for k in comparison.fixes],
70
+ }
71
+ print(json.dumps(payload, indent=2))
72
+ else:
73
+ print(suite.summary())
74
+ if comparison:
75
+ print()
76
+ print(comparison.summary())
77
+ if comparison.regressions:
78
+ print("\nregressions:")
79
+ for rid, qid in comparison.regressions[:20]:
80
+ print(f" {rid[7:19]} {qid}")
81
+ if args.show_failures:
82
+ failures = suite.failures()[: args.show_failures]
83
+ if failures:
84
+ print(f"\nmost confident wrong answers ({len(failures)}):")
85
+ for f in failures:
86
+ # JSON rendering keeps this byte-identical to the
87
+ # JavaScript CLI; repr() would quote with '.
88
+ print(f" {f.request_id[7:19]} {f.question_id}: "
89
+ f"said {json.dumps(f.predicted)}, "
90
+ f"label {json.dumps(f.label)}, p={f.probability:.3f}")
91
+
92
+ if args.min_accuracy is not None and suite.accuracy < args.min_accuracy:
93
+ print(f"jevkit-bench: accuracy {suite.accuracy:.4f} below {args.min_accuracy}",
94
+ file=sys.stderr)
95
+ return EXIT_FAILED_GATE
96
+ if args.max_regressions is not None and comparison is not None:
97
+ if len(comparison.regressions) > args.max_regressions:
98
+ print(f"jevkit-bench: {len(comparison.regressions)} regressions exceed "
99
+ f"{args.max_regressions}", file=sys.stderr)
100
+ return EXIT_FAILED_GATE
101
+ return EXIT_OK
102
+
103
+
104
+ if __name__ == "__main__": # pragma: no cover
105
+ raise SystemExit(main())
jevkit_bench/score.py ADDED
@@ -0,0 +1,233 @@
1
+ """Scoring a labeled `.jevl` suite, and comparing two runs of it.
2
+
3
+ The question this answers is the one you actually have to defend: for this task,
4
+ on my data, is Jev good enough, and what does it cost? That needs accuracy and
5
+ cost and latency together, because any one of them alone is easy to win.
6
+
7
+ Costs are computed from a price per million input tokens, defaulting to Jev's
8
+ published $0.042. Output tokens are free on Jev and are reported but not billed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Iterable, Sequence
16
+
17
+ from jevkit_core import Record, parse_answers
18
+
19
+ __all__ = ["QuestionResult", "SuiteResult", "score_records", "compare_suites",
20
+ "SuiteComparison", "PRICE_PER_MTOK"]
21
+
22
+ PRICE_PER_MTOK = 0.042
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class QuestionResult:
27
+ """One scored question from one record."""
28
+
29
+ request_id: str
30
+ question_id: str
31
+ type: str
32
+ predicted: Any
33
+ label: Any
34
+ correct: bool
35
+ probability: float
36
+ confidence: float | None
37
+ tags: tuple[str, ...] = ()
38
+
39
+
40
+ @dataclass
41
+ class SuiteResult:
42
+ """Everything scored from one run of a suite."""
43
+
44
+ results: list[QuestionResult] = field(default_factory=list)
45
+ input_tokens: int = 0
46
+ output_tokens: int = 0
47
+ records: int = 0
48
+ unlabeled: int = 0
49
+ models: set[str] = field(default_factory=set)
50
+ price_per_mtok: float = PRICE_PER_MTOK
51
+
52
+ @property
53
+ def count(self) -> int:
54
+ return len(self.results)
55
+
56
+ @property
57
+ def correct(self) -> int:
58
+ return sum(1 for r in self.results if r.correct)
59
+
60
+ @property
61
+ def accuracy(self) -> float:
62
+ return self.correct / self.count if self.count else 0.0
63
+
64
+ @property
65
+ def cost(self) -> float:
66
+ """Input-token cost in dollars. Jev bills input only."""
67
+ return self.input_tokens / 1_000_000 * self.price_per_mtok
68
+
69
+ @property
70
+ def cost_per_question(self) -> float:
71
+ return self.cost / self.count if self.count else 0.0
72
+
73
+ def by_tag(self) -> dict[str, tuple[int, float]]:
74
+ """tag -> (n, accuracy). A result with several tags counts under each."""
75
+ buckets: dict[str, list[QuestionResult]] = defaultdict(list)
76
+ for r in self.results:
77
+ for tag in r.tags:
78
+ buckets[tag].append(r)
79
+ return {
80
+ tag: (len(rs), sum(1 for r in rs if r.correct) / len(rs))
81
+ for tag, rs in sorted(buckets.items())
82
+ }
83
+
84
+ def by_question(self) -> dict[str, tuple[int, float]]:
85
+ """question id -> (n, accuracy). Finds the one question dragging the suite."""
86
+ buckets: dict[str, list[QuestionResult]] = defaultdict(list)
87
+ for r in self.results:
88
+ buckets[r.question_id].append(r)
89
+ return {
90
+ qid: (len(rs), sum(1 for r in rs if r.correct) / len(rs))
91
+ for qid, rs in sorted(buckets.items())
92
+ }
93
+
94
+ def failures(self) -> list[QuestionResult]:
95
+ """Wrong answers, most confident first: the most interesting bugs."""
96
+ return sorted(
97
+ (r for r in self.results if not r.correct),
98
+ key=lambda r: -r.probability,
99
+ )
100
+
101
+ def summary(self) -> str:
102
+ lines = [
103
+ f"records: {self.records}"
104
+ + (f" ({self.unlabeled} unlabeled, skipped)" if self.unlabeled else ""),
105
+ f"scored: {self.count} question(s)",
106
+ f"model(s): {', '.join(sorted(self.models)) or '?'}",
107
+ f"accuracy: {self.accuracy:.4f} ({self.correct}/{self.count})",
108
+ f"tokens: {self.input_tokens:,} in, {self.output_tokens:,} out (out is free)",
109
+ f"cost: ${self.cost:.6f} (${self.cost_per_question:.8f}/question "
110
+ f"at ${self.price_per_mtok}/Mtok)",
111
+ ]
112
+ tags = self.by_tag()
113
+ if tags:
114
+ lines.append("by tag:")
115
+ for tag, (n, acc) in tags.items():
116
+ lines.append(f" {tag:<24} {acc:.4f} (n={n})")
117
+ questions = self.by_question()
118
+ if len(questions) > 1:
119
+ lines.append("by question:")
120
+ for qid, (n, acc) in questions.items():
121
+ lines.append(f" {qid:<24} {acc:.4f} (n={n})")
122
+ return "\n".join(lines)
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ return {
126
+ "records": self.records,
127
+ "unlabeled": self.unlabeled,
128
+ "scored": self.count,
129
+ "models": sorted(self.models),
130
+ "accuracy": self.accuracy,
131
+ "correct": self.correct,
132
+ "input_tokens": self.input_tokens,
133
+ "output_tokens": self.output_tokens,
134
+ "cost": self.cost,
135
+ "by_tag": {k: {"n": n, "accuracy": a} for k, (n, a) in self.by_tag().items()},
136
+ "by_question": {k: {"n": n, "accuracy": a} for k, (n, a) in self.by_question().items()},
137
+ }
138
+
139
+
140
+ def score_records(
141
+ records: Iterable[Record],
142
+ *,
143
+ question_ids: Iterable[str] | None = None,
144
+ price_per_mtok: float = PRICE_PER_MTOK,
145
+ ) -> SuiteResult:
146
+ """Score every labeled answer in a suite.
147
+
148
+ Records with no ``label`` are counted and skipped rather than silently
149
+ dropped, so a suite that quietly lost its labels is visible in the summary
150
+ instead of showing a suspiciously perfect score over three records.
151
+ """
152
+ wanted = set(question_ids) if question_ids is not None else None
153
+ suite = SuiteResult(price_per_mtok=price_per_mtok)
154
+
155
+ for record in records:
156
+ suite.records += 1
157
+ suite.models.add(record.model)
158
+ usage = record.usage or {}
159
+ suite.input_tokens += int(usage.get("input_tokens") or 0)
160
+ suite.output_tokens += int(usage.get("output_tokens") or 0)
161
+
162
+ if not record.label:
163
+ suite.unlabeled += 1
164
+ continue
165
+
166
+ answers = parse_answers(record.answers)
167
+ for qid, label in record.label.items():
168
+ if wanted is not None and qid not in wanted:
169
+ continue
170
+ answer = answers.get(qid)
171
+ if answer is None:
172
+ continue
173
+ suite.results.append(QuestionResult(
174
+ request_id=record.request_id,
175
+ question_id=qid,
176
+ type=answer.type,
177
+ predicted=answer.predicted(),
178
+ label=label,
179
+ correct=answer.is_correct(label),
180
+ probability=answer.top_probability,
181
+ confidence=answer.confidence,
182
+ tags=tuple(record.tags),
183
+ ))
184
+ return suite
185
+
186
+
187
+ @dataclass
188
+ class SuiteComparison:
189
+ """Two runs of the same suite, side by side."""
190
+
191
+ baseline: SuiteResult
192
+ candidate: SuiteResult
193
+ regressions: list[tuple[str, str]] = field(default_factory=list)
194
+ fixes: list[tuple[str, str]] = field(default_factory=list)
195
+
196
+ @property
197
+ def accuracy_delta(self) -> float:
198
+ return self.candidate.accuracy - self.baseline.accuracy
199
+
200
+ @property
201
+ def cost_delta(self) -> float:
202
+ return self.candidate.cost - self.baseline.cost
203
+
204
+ def summary(self) -> str:
205
+ return "\n".join([
206
+ f"accuracy: {self.baseline.accuracy:.4f} -> {self.candidate.accuracy:.4f} "
207
+ f"({self.accuracy_delta:+.4f})",
208
+ f"cost: ${self.baseline.cost:.6f} -> ${self.candidate.cost:.6f} "
209
+ f"({self.cost_delta:+.6f})",
210
+ f"regressed: {len(self.regressions)} (right before, wrong now)",
211
+ f"fixed: {len(self.fixes)} (wrong before, right now)",
212
+ ])
213
+
214
+
215
+ def compare_suites(baseline: SuiteResult, candidate: SuiteResult) -> SuiteComparison:
216
+ """Compare two scored runs by (request, question).
217
+
218
+ Aggregate accuracy can hold steady while the *set* of things you get right
219
+ churns underneath, which matters when a specific case is the one you promised
220
+ someone would work. Regressions and fixes are tracked individually for that
221
+ reason.
222
+ """
223
+ def index(suite: SuiteResult) -> dict[tuple[str, str], QuestionResult]:
224
+ return {(r.request_id, r.question_id): r for r in suite.results}
225
+
226
+ before, after = index(baseline), index(candidate)
227
+ shared = set(before) & set(after)
228
+ return SuiteComparison(
229
+ baseline=baseline,
230
+ candidate=candidate,
231
+ regressions=sorted(k for k in shared if before[k].correct and not after[k].correct),
232
+ fixes=sorted(k for k in shared if not before[k].correct and after[k].correct),
233
+ )
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevkit-bench
3
+ Version: 0.1.0
4
+ Summary: Score a labeled TypeSafe Jev suite for accuracy and cost, and compare two runs. Catches regressions aggregate accuracy hides.
5
+ Project-URL: Homepage, https://github.com/pjdurden/jevkit-py
6
+ Project-URL: Issues, https://github.com/pjdurden/jevkit-py/issues
7
+ Author: Prajjwal Chittori
8
+ License-Expression: MIT
9
+ Keywords: accuracy,benchmark,cost,evaluation,jev,system-one,typesafe
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: jevkit-core>=0.2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # jevkit-bench
22
+
23
+ Score a labeled Jev suite for accuracy **and** cost, and compare two runs.
24
+
25
+ Any one number alone is easy to win. Accuracy without cost hides that you spent
26
+ ten times the tokens; cost without accuracy hides that you broke the task. This
27
+ reports them together.
28
+
29
+ > Unofficial and unaffiliated with TypeSafe.
30
+
31
+ ```bash
32
+ pip install jevkit-bench
33
+ ```
34
+
35
+ ## Use
36
+
37
+ ```python
38
+ from jevkit_core import read_records
39
+ from jevkit_bench import compare_suites, score_records
40
+
41
+ suite = score_records(read_records("suite.jevl"))
42
+ print(suite.summary())
43
+
44
+ for failure in suite.failures()[:5]:
45
+ print(failure.question_id, failure.predicted, "should be", failure.label)
46
+ ```
47
+
48
+ `failures()` sorts by probability descending, so the most confident wrong answers
49
+ come first. Those are the interesting bugs: a wrong answer at 0.35 is the model
50
+ telling you it was unsure, while a wrong answer at 0.98 is a question that needs
51
+ rewriting.
52
+
53
+ ## Comparing runs
54
+
55
+ ```python
56
+ comparison = compare_suites(baseline, candidate)
57
+ print(comparison.summary())
58
+ print(comparison.regressions) # right before, wrong now
59
+ ```
60
+
61
+ Aggregate accuracy can hold perfectly steady while the set of things you get
62
+ right churns underneath. That matters when a specific case is the one you
63
+ promised someone would work, so regressions and fixes are tracked individually
64
+ rather than netted off.
65
+
66
+ ## CLI
67
+
68
+ ```bash
69
+ jevkit-bench suite.jevl
70
+ jevkit-bench suite.jevl --baseline last-week.jevl
71
+ jevkit-bench suite.jevl --min-accuracy 0.90 # CI gate
72
+ jevkit-bench suite.jevl --baseline last-week.jevl --max-regressions 0
73
+ jevkit-bench suite.jevl --show-failures 10
74
+ ```
75
+
76
+ ## Cost
77
+
78
+ Computed from input tokens at $0.042 per million, Jev's published price. Output
79
+ tokens are free on Jev, so they are reported but never billed. Override with
80
+ `--price-per-mtok` if your plan differs.
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,7 @@
1
+ jevkit_bench/__init__.py,sha256=8w_dktLYeLkcGuYeZiYBoiauvVEJNM2aK8hVwuBCGQg,570
2
+ jevkit_bench/cli.py,sha256=oYV-fvwXDA46gbA53UrbXRlxRW29vCyqxsTYTTBnBMU,4491
3
+ jevkit_bench/score.py,sha256=4OYFa5jXEERVDgaT8gcIVkcgdIofqqxq1axrSgu8P5I,8404
4
+ jevkit_bench-0.1.0.dist-info/METADATA,sha256=IOS1P56snt3OPNWR6MuIgAZ__R4zS9PDMCgDlPKfRrQ,2724
5
+ jevkit_bench-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ jevkit_bench-0.1.0.dist-info/entry_points.txt,sha256=aed4Mn3V0ohe-g8xztz4k7fJzXmm99OdLk7X_7CuaiM,55
7
+ jevkit_bench-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jevkit-bench = jevkit_bench.cli:main