jevkit-bench 0.1.0__tar.gz

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,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .ruff_cache/
12
+ uv.lock
@@ -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,64 @@
1
+ # jevkit-bench
2
+
3
+ Score a labeled Jev suite for accuracy **and** cost, and compare two runs.
4
+
5
+ Any one number alone is easy to win. Accuracy without cost hides that you spent
6
+ ten times the tokens; cost without accuracy hides that you broke the task. This
7
+ reports them together.
8
+
9
+ > Unofficial and unaffiliated with TypeSafe.
10
+
11
+ ```bash
12
+ pip install jevkit-bench
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```python
18
+ from jevkit_core import read_records
19
+ from jevkit_bench import compare_suites, score_records
20
+
21
+ suite = score_records(read_records("suite.jevl"))
22
+ print(suite.summary())
23
+
24
+ for failure in suite.failures()[:5]:
25
+ print(failure.question_id, failure.predicted, "should be", failure.label)
26
+ ```
27
+
28
+ `failures()` sorts by probability descending, so the most confident wrong answers
29
+ come first. Those are the interesting bugs: a wrong answer at 0.35 is the model
30
+ telling you it was unsure, while a wrong answer at 0.98 is a question that needs
31
+ rewriting.
32
+
33
+ ## Comparing runs
34
+
35
+ ```python
36
+ comparison = compare_suites(baseline, candidate)
37
+ print(comparison.summary())
38
+ print(comparison.regressions) # right before, wrong now
39
+ ```
40
+
41
+ Aggregate accuracy can hold perfectly steady while the set of things you get
42
+ right churns underneath. That matters when a specific case is the one you
43
+ promised someone would work, so regressions and fixes are tracked individually
44
+ rather than netted off.
45
+
46
+ ## CLI
47
+
48
+ ```bash
49
+ jevkit-bench suite.jevl
50
+ jevkit-bench suite.jevl --baseline last-week.jevl
51
+ jevkit-bench suite.jevl --min-accuracy 0.90 # CI gate
52
+ jevkit-bench suite.jevl --baseline last-week.jevl --max-regressions 0
53
+ jevkit-bench suite.jevl --show-failures 10
54
+ ```
55
+
56
+ ## Cost
57
+
58
+ Computed from input tokens at $0.042 per million, Jev's published price. Output
59
+ tokens are free on Jev, so they are reported but never billed. Override with
60
+ `--price-per-mtok` if your plan differs.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "jevkit-bench"
3
+ version = "0.1.0"
4
+ description = "Score a labeled TypeSafe Jev suite for accuracy and cost, and compare two runs. Catches regressions aggregate accuracy hides."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Prajjwal Chittori" }]
9
+ keywords = ["jev", "typesafe", "system-one", "benchmark", "evaluation", "accuracy", "cost"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = ["jevkit-core>=0.2.0"]
20
+
21
+ [project.scripts]
22
+ jevkit-bench = "jevkit_bench.cli:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/pjdurden/jevkit-py"
26
+ Issues = "https://github.com/pjdurden/jevkit-py/issues"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/jevkit_bench"]
@@ -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__"]
@@ -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())
@@ -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,79 @@
1
+ import pytest
2
+ from jevkit_core import Record
3
+ from jevkit_bench import compare_suites, score_records
4
+
5
+
6
+ def rec(choice, label, probs=None, state="s", usage=None, tags=()):
7
+ return Record(
8
+ model="jev-1.13.0", state=state,
9
+ questions={"team": {"type": "choice", "instructions": "Which team"}},
10
+ answers={"team": {"type": "choice", "choice": choice,
11
+ "probabilities": probs or {choice: 1.0}, "confidence": 0.9}},
12
+ label={"team": label}, usage=usage, tags=list(tags),
13
+ )
14
+
15
+
16
+ def test_accuracy_counts_matches():
17
+ suite = score_records([rec("a", "a"), rec("b", "a", state="t")])
18
+ assert suite.count == 2 and suite.correct == 1
19
+ assert suite.accuracy == 0.5
20
+
21
+
22
+ def test_unlabeled_records_are_counted_and_skipped():
23
+ unlabeled = Record(model="m", state="u", questions={"q": {"type": "noul"}},
24
+ answers={"q": {"type": "noul", "noul": 0.9}})
25
+ suite = score_records([rec("a", "a"), unlabeled])
26
+ assert suite.records == 2 and suite.unlabeled == 1 and suite.count == 1
27
+
28
+
29
+ def test_cost_uses_input_tokens_only():
30
+ suite = score_records([rec("a", "a", usage={"input_tokens": 1_000_000,
31
+ "output_tokens": 500_000})])
32
+ assert suite.input_tokens == 1_000_000
33
+ assert suite.output_tokens == 500_000
34
+ assert suite.cost == pytest.approx(0.042)
35
+
36
+
37
+ def test_price_override_is_respected():
38
+ suite = score_records([rec("a", "a", usage={"input_tokens": 1_000_000})],
39
+ price_per_mtok=1.0)
40
+ assert suite.cost == pytest.approx(1.0)
41
+
42
+
43
+ def test_failures_are_sorted_most_confident_first():
44
+ suite = score_records([
45
+ rec("b", "a", probs={"b": 0.6, "a": 0.4}, state="one"),
46
+ rec("b", "a", probs={"b": 0.99, "a": 0.01}, state="two"),
47
+ ])
48
+ assert [round(f.probability, 2) for f in suite.failures()] == [0.99, 0.6]
49
+
50
+
51
+ def test_by_tag_buckets_results():
52
+ suite = score_records([rec("a", "a", tags=["routing"]),
53
+ rec("b", "a", state="t", tags=["routing"])])
54
+ assert suite.by_tag()["routing"] == (2, 0.5)
55
+
56
+
57
+ def test_by_question_finds_the_weak_question():
58
+ good = Record(model="m", state="s",
59
+ questions={"a": {"type": "noul"}, "b": {"type": "noul"}},
60
+ answers={"a": {"type": "noul", "noul": 0.9},
61
+ "b": {"type": "noul", "noul": 0.9}},
62
+ label={"a": True, "b": False})
63
+ by_q = score_records([good]).by_question()
64
+ assert by_q["a"] == (1, 1.0) and by_q["b"] == (1, 0.0)
65
+
66
+
67
+ def test_comparison_tracks_regressions_and_fixes_separately():
68
+ baseline = score_records([rec("a", "a", state="one"), rec("b", "a", state="two")])
69
+ candidate = score_records([rec("b", "a", state="one"), rec("a", "a", state="two")])
70
+ comparison = compare_suites(baseline, candidate)
71
+ # Accuracy is unchanged at 0.5, but the set of correct answers swapped entirely.
72
+ assert comparison.accuracy_delta == 0.0
73
+ assert len(comparison.regressions) == 1
74
+ assert len(comparison.fixes) == 1
75
+
76
+
77
+ def test_empty_suite_reports_zero_rather_than_dividing_by_zero():
78
+ suite = score_records([])
79
+ assert suite.accuracy == 0.0 and suite.cost_per_question == 0.0