jevkit-drift 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,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevkit-drift
3
+ Version: 0.1.0
4
+ Summary: Detect when a new TypeSafe Jev model version flips decisions your code depends on. Replays a golden set and diffs the answers.
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: drift,jev,regression,system-one,testing,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-drift
22
+
23
+ TypeSafe's docs warn that `jev-latest` moves when a new version ships, and that
24
+ confidence thresholds tuned against one version do not automatically hold on the
25
+ next. `jevkit-drift` answers the question that warning implies: for the requests
26
+ you actually care about, what changed?
27
+
28
+ > Unofficial and unaffiliated with TypeSafe.
29
+
30
+ ```bash
31
+ pip install jevkit-drift
32
+ ```
33
+
34
+ ## Two kinds of change
35
+
36
+ A **flip** is a changed decision. Your code takes a different branch. This is
37
+ what breaks things.
38
+
39
+ A **shift** is movement in the probability distribution with the same decision
40
+ still on top. Harmless on its own, but it is what walks an answer toward a
41
+ threshold, so a large shift is an early warning before anything has flipped.
42
+
43
+ Both are reported separately, because conflating them is how a drift report ends
44
+ up either too noisy to read or too quiet to help.
45
+
46
+ ## Use
47
+
48
+ ```python
49
+ from jevkit_core import read_records, write_records
50
+ from jevkit_drift import compare_sets, replay
51
+
52
+ baseline = list(read_records("golden.jevl"))
53
+ candidate = replay(baseline, lambda s, q: client.system_one(s, q, model="jev-1.14.0"))
54
+ write_records("candidate.jevl", candidate)
55
+
56
+ report = compare_sets(baseline, candidate)
57
+ print(report.summary())
58
+
59
+ for request_id, delta in report.flips:
60
+ print(request_id, delta.describe())
61
+ ```
62
+
63
+ ## CLI
64
+
65
+ ```bash
66
+ jevkit-drift golden.jevl candidate.jevl
67
+ jevkit-drift golden.jevl candidate.jevl --max-flips 3
68
+ jevkit-drift golden.jevl candidate.jevl --max-shift 0.15
69
+ jevkit-drift golden.jevl candidate.jevl --format json
70
+ ```
71
+
72
+ Exit codes: `0` within tolerance, `1` drifted, `2` bad usage.
73
+
74
+ ## How records are paired
75
+
76
+ On `request_id`, the digest of state plus questions with the model deliberately
77
+ excluded. That is the whole reason the record format keeps two digests: the full
78
+ `id` changes when the model changes, so it cannot pair a baseline with its
79
+ replay, while `request_id` can.
80
+
81
+ Change the state or a question and the records stop pairing, which is correct.
82
+ They are no longer the same request, and comparing them would be meaningless.
83
+ The CLI says so rather than reporting zero drift.
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,67 @@
1
+ # jevkit-drift
2
+
3
+ TypeSafe's docs warn that `jev-latest` moves when a new version ships, and that
4
+ confidence thresholds tuned against one version do not automatically hold on the
5
+ next. `jevkit-drift` answers the question that warning implies: for the requests
6
+ you actually care about, what changed?
7
+
8
+ > Unofficial and unaffiliated with TypeSafe.
9
+
10
+ ```bash
11
+ pip install jevkit-drift
12
+ ```
13
+
14
+ ## Two kinds of change
15
+
16
+ A **flip** is a changed decision. Your code takes a different branch. This is
17
+ what breaks things.
18
+
19
+ A **shift** is movement in the probability distribution with the same decision
20
+ still on top. Harmless on its own, but it is what walks an answer toward a
21
+ threshold, so a large shift is an early warning before anything has flipped.
22
+
23
+ Both are reported separately, because conflating them is how a drift report ends
24
+ up either too noisy to read or too quiet to help.
25
+
26
+ ## Use
27
+
28
+ ```python
29
+ from jevkit_core import read_records, write_records
30
+ from jevkit_drift import compare_sets, replay
31
+
32
+ baseline = list(read_records("golden.jevl"))
33
+ candidate = replay(baseline, lambda s, q: client.system_one(s, q, model="jev-1.14.0"))
34
+ write_records("candidate.jevl", candidate)
35
+
36
+ report = compare_sets(baseline, candidate)
37
+ print(report.summary())
38
+
39
+ for request_id, delta in report.flips:
40
+ print(request_id, delta.describe())
41
+ ```
42
+
43
+ ## CLI
44
+
45
+ ```bash
46
+ jevkit-drift golden.jevl candidate.jevl
47
+ jevkit-drift golden.jevl candidate.jevl --max-flips 3
48
+ jevkit-drift golden.jevl candidate.jevl --max-shift 0.15
49
+ jevkit-drift golden.jevl candidate.jevl --format json
50
+ ```
51
+
52
+ Exit codes: `0` within tolerance, `1` drifted, `2` bad usage.
53
+
54
+ ## How records are paired
55
+
56
+ On `request_id`, the digest of state plus questions with the model deliberately
57
+ excluded. That is the whole reason the record format keeps two digests: the full
58
+ `id` changes when the model changes, so it cannot pair a baseline with its
59
+ replay, while `request_id` can.
60
+
61
+ Change the state or a question and the records stop pairing, which is correct.
62
+ They are no longer the same request, and comparing them would be meaningless.
63
+ The CLI says so rather than reporting zero drift.
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "jevkit-drift"
3
+ version = "0.1.0"
4
+ description = "Detect when a new TypeSafe Jev model version flips decisions your code depends on. Replays a golden set and diffs the answers."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Prajjwal Chittori" }]
9
+ keywords = ["jev", "typesafe", "system-one", "drift", "regression", "testing"]
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-drift = "jevkit_drift.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_drift"]
@@ -0,0 +1,17 @@
1
+ """Detect when a new Jev model version changes decisions you depend on.
2
+
3
+ TypeSafe's docs warn that ``jev-latest`` moves and that tuned confidence
4
+ thresholds move with it. This package replays a golden set against a new version
5
+ and reports what actually changed, separating flipped decisions from probability
6
+ shifts that have not flipped anything yet.
7
+ """
8
+
9
+ from .compare import (DriftReport, QuestionDelta, RecordDelta, compare_answers,
10
+ compare_records, compare_sets, total_variation)
11
+ from .replay import replay
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = ["compare_sets", "compare_records", "compare_answers", "replay",
16
+ "DriftReport", "RecordDelta", "QuestionDelta", "total_variation",
17
+ "__version__"]
@@ -0,0 +1,70 @@
1
+ """``jevkit-drift`` 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 .compare import compare_sets
12
+
13
+ EXIT_OK = 0
14
+ EXIT_DRIFTED = 1
15
+ EXIT_USAGE = 2
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(
20
+ prog="jevkit-drift",
21
+ description="Compare two .jevl runs of the same requests and report which "
22
+ "decisions flipped. Never calls the API.",
23
+ )
24
+ parser.add_argument("baseline", help="the golden set")
25
+ parser.add_argument("candidate", help="the same requests answered by another model")
26
+ parser.add_argument("--max-flips", type=int, default=0,
27
+ help="tolerated number of flipped decisions (default 0)")
28
+ parser.add_argument("--max-shift", type=float, default=None,
29
+ help="fail if any distribution moves more than this (0..1)")
30
+ parser.add_argument("--format", choices=("text", "json"), default="text")
31
+ parser.add_argument("--quiet", action="store_true", help="only print the summary")
32
+ args = parser.parse_args(argv)
33
+
34
+ try:
35
+ before = list(read_records(args.baseline))
36
+ after = list(read_records(args.candidate))
37
+ except (OSError, RecordFormatError) as exc:
38
+ print(f"jevkit-drift: {exc}", file=sys.stderr)
39
+ return EXIT_USAGE
40
+
41
+ try:
42
+ report = compare_sets(before, after)
43
+ except ValueError as exc:
44
+ print(f"jevkit-drift: {exc}", file=sys.stderr)
45
+ return EXIT_USAGE
46
+
47
+ if args.format == "json":
48
+ print(json.dumps(report.to_dict(), indent=2))
49
+ else:
50
+ print(report.summary())
51
+ if report.flips and not args.quiet:
52
+ print("\nflipped decisions:")
53
+ for request_id, delta in report.flips:
54
+ print(f" {request_id[7:19]} {delta.describe()}")
55
+
56
+ if not report.deltas and (before or after):
57
+ print("jevkit-drift: no requests matched between the two files. Records pair on "
58
+ "state and questions, so a change to either makes them incomparable.",
59
+ file=sys.stderr)
60
+ return EXIT_USAGE
61
+
62
+ if len(report.flips) > args.max_flips:
63
+ return EXIT_DRIFTED
64
+ if args.max_shift is not None and report.max_shift > args.max_shift:
65
+ return EXIT_DRIFTED
66
+ return EXIT_OK
67
+
68
+
69
+ if __name__ == "__main__": # pragma: no cover
70
+ raise SystemExit(main())
@@ -0,0 +1,236 @@
1
+ """Comparing two answers to the same request.
2
+
3
+ TypeSafe's docs warn that ``jev-latest`` moves when a new version ships, and
4
+ that confidence thresholds tuned against one version do not automatically hold
5
+ on the next. This module answers the question that warning implies: for a set of
6
+ requests you already care about, what actually changed?
7
+
8
+ Two kinds of change are tracked separately, because they mean different things:
9
+
10
+ - A **flip** is a changed decision. Your code takes a different branch. This is
11
+ what breaks things.
12
+ - A **shift** is movement in the probability distribution with the same decision
13
+ on top. Harmless on its own, but it is what moves an answer toward a threshold,
14
+ so a large shift is an early warning even when nothing flipped yet.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ from jevkit_core import Answer, Record, parse_answers
24
+
25
+ __all__ = ["QuestionDelta", "RecordDelta", "DriftReport", "compare_answers",
26
+ "compare_records", "compare_sets"]
27
+
28
+
29
+ def total_variation(a: dict[str, float], b: dict[str, float]) -> float:
30
+ """Total variation distance between two distributions, on 0..1.
31
+
32
+ Outcomes present in one distribution and not the other count in full, which
33
+ is what makes this meaningful when a new model version changes the option
34
+ set. Distributions are not renormalized: if the API returns something that
35
+ does not sum to 1, that is reported rather than hidden.
36
+ """
37
+ keys = set(a) | set(b)
38
+ return sum(abs(a.get(k, 0.0) - b.get(k, 0.0)) for k in keys) / 2.0
39
+
40
+
41
+ @dataclass
42
+ class QuestionDelta:
43
+ """What changed for one question between two runs."""
44
+
45
+ question_id: str
46
+ type: str
47
+ before: Any
48
+ after: Any
49
+ distribution_shift: float
50
+ confidence_before: float | None
51
+ confidence_after: float | None
52
+ score_before: float | None = None
53
+ score_after: float | None = None
54
+
55
+ @property
56
+ def flipped(self) -> bool:
57
+ """The selected outcome changed."""
58
+ return self.before != self.after
59
+
60
+ @property
61
+ def confidence_delta(self) -> float | None:
62
+ if self.confidence_before is None or self.confidence_after is None:
63
+ return None
64
+ return self.confidence_after - self.confidence_before
65
+
66
+ def describe(self) -> str:
67
+ # JSON rendering rather than repr(), so the Python and JavaScript CLIs
68
+ # emit byte-identical output. repr() quotes with ', JSON with ".
69
+ if self.flipped:
70
+ head = (f"{self.question_id}: FLIP {json.dumps(self.before)} -> "
71
+ f"{json.dumps(self.after)}")
72
+ else:
73
+ head = f"{self.question_id}: stable ({json.dumps(self.before)})"
74
+ parts = [f"shift={self.distribution_shift:.3f}"]
75
+ delta = self.confidence_delta
76
+ if delta is not None:
77
+ parts.append(f"conf {self.confidence_before:.3f} -> "
78
+ f"{self.confidence_after:.3f} ({delta:+.3f})")
79
+ return f"{head} [{', '.join(parts)}]"
80
+
81
+
82
+ @dataclass
83
+ class RecordDelta:
84
+ """What changed for one request between two runs."""
85
+
86
+ request_id: str
87
+ model_before: str
88
+ model_after: str
89
+ questions: list[QuestionDelta] = field(default_factory=list)
90
+
91
+ @property
92
+ def flips(self) -> list[QuestionDelta]:
93
+ return [q for q in self.questions if q.flipped]
94
+
95
+ @property
96
+ def max_shift(self) -> float:
97
+ return max((q.distribution_shift for q in self.questions), default=0.0)
98
+
99
+
100
+ def compare_answers(qid: str, before: Answer, after: Answer) -> QuestionDelta:
101
+ if before.type != after.type:
102
+ raise ValueError(
103
+ f"question {qid!r}: type changed from {before.type!r} to {after.type!r}. "
104
+ f"These are not the same question and cannot be compared."
105
+ )
106
+ return QuestionDelta(
107
+ question_id=qid,
108
+ type=before.type,
109
+ before=before.predicted(),
110
+ after=after.predicted(),
111
+ distribution_shift=total_variation(before.probabilities, after.probabilities),
112
+ confidence_before=before.confidence,
113
+ confidence_after=after.confidence,
114
+ score_before=before.score,
115
+ score_after=after.score,
116
+ )
117
+
118
+
119
+ def compare_records(before: Record, after: Record) -> RecordDelta:
120
+ if before.request_id != after.request_id:
121
+ raise ValueError(
122
+ "records describe different requests and cannot be compared "
123
+ f"({before.request_id} vs {after.request_id})"
124
+ )
125
+ old = parse_answers(before.answers)
126
+ new = parse_answers(after.answers)
127
+
128
+ missing = sorted(set(old) - set(new))
129
+ added = sorted(set(new) - set(old))
130
+ if missing or added:
131
+ detail = []
132
+ if missing:
133
+ detail.append(f"missing in the new run: {', '.join(missing)}")
134
+ if added:
135
+ detail.append(f"only in the new run: {', '.join(added)}")
136
+ raise ValueError(
137
+ f"answer sets differ for request {before.request_id}: {'; '.join(detail)}"
138
+ )
139
+
140
+ return RecordDelta(
141
+ request_id=before.request_id,
142
+ model_before=before.model,
143
+ model_after=after.model,
144
+ questions=[compare_answers(qid, old[qid], new[qid]) for qid in sorted(old)],
145
+ )
146
+
147
+
148
+ @dataclass
149
+ class DriftReport:
150
+ """Drift across a whole golden set."""
151
+
152
+ deltas: list[RecordDelta] = field(default_factory=list)
153
+ unmatched_before: list[str] = field(default_factory=list)
154
+ unmatched_after: list[str] = field(default_factory=list)
155
+
156
+ @property
157
+ def total_questions(self) -> int:
158
+ return sum(len(d.questions) for d in self.deltas)
159
+
160
+ @property
161
+ def flips(self) -> list[tuple[str, QuestionDelta]]:
162
+ return [(d.request_id, q) for d in self.deltas for q in d.flips]
163
+
164
+ @property
165
+ def flip_rate(self) -> float:
166
+ total = self.total_questions
167
+ return len(self.flips) / total if total else 0.0
168
+
169
+ @property
170
+ def max_shift(self) -> float:
171
+ return max((d.max_shift for d in self.deltas), default=0.0)
172
+
173
+ @property
174
+ def mean_shift(self) -> float:
175
+ shifts = [q.distribution_shift for d in self.deltas for q in d.questions]
176
+ return sum(shifts) / len(shifts) if shifts else 0.0
177
+
178
+ def models(self) -> tuple[set[str], set[str]]:
179
+ return ({d.model_before for d in self.deltas}, {d.model_after for d in self.deltas})
180
+
181
+ def summary(self) -> str:
182
+ before, after = self.models()
183
+ lines = [
184
+ f"compared {len(self.deltas)} request(s), {self.total_questions} question(s)",
185
+ f" {', '.join(sorted(before)) or '?'} -> {', '.join(sorted(after)) or '?'}",
186
+ f" flips: {len(self.flips)} ({self.flip_rate:.1%})",
187
+ f" mean shift: {self.mean_shift:.4f}",
188
+ f" max shift: {self.max_shift:.4f}",
189
+ ]
190
+ if self.unmatched_before:
191
+ lines.append(f" {len(self.unmatched_before)} record(s) in the baseline had no "
192
+ f"counterpart and were not compared")
193
+ if self.unmatched_after:
194
+ lines.append(f" {len(self.unmatched_after)} record(s) in the new run had no "
195
+ f"counterpart and were not compared")
196
+ return "\n".join(lines)
197
+
198
+ def to_dict(self) -> dict[str, Any]:
199
+ return {
200
+ "requests": len(self.deltas),
201
+ "questions": self.total_questions,
202
+ "flips": len(self.flips),
203
+ "flip_rate": self.flip_rate,
204
+ "mean_shift": self.mean_shift,
205
+ "max_shift": self.max_shift,
206
+ "unmatched_before": self.unmatched_before,
207
+ "unmatched_after": self.unmatched_after,
208
+ "details": [
209
+ {
210
+ "request_id": rid,
211
+ "question_id": q.question_id,
212
+ "before": q.before,
213
+ "after": q.after,
214
+ "distribution_shift": q.distribution_shift,
215
+ "confidence_delta": q.confidence_delta,
216
+ }
217
+ for rid, q in self.flips
218
+ ],
219
+ }
220
+
221
+
222
+ def compare_sets(before: list[Record], after: list[Record]) -> DriftReport:
223
+ """Pair two sets of records by request id and compare each pair.
224
+
225
+ Records are paired on ``request_id``, the digest of state plus questions with
226
+ the model excluded, which is exactly what makes a baseline comparable to its
227
+ replay on a different model version.
228
+ """
229
+ old = {r.request_id: r for r in before}
230
+ new = {r.request_id: r for r in after}
231
+ shared = sorted(set(old) & set(new))
232
+ return DriftReport(
233
+ deltas=[compare_records(old[rid], new[rid]) for rid in shared],
234
+ unmatched_before=sorted(set(old) - set(new)),
235
+ unmatched_after=sorted(set(new) - set(old)),
236
+ )
@@ -0,0 +1,72 @@
1
+ """Replaying a golden set against a model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Iterable
6
+
7
+ from jevkit_core import Record
8
+
9
+ __all__ = ["replay", "SystemOneCallable"]
10
+
11
+ SystemOneCallable = Callable[[Any, dict[str, Any]], Any]
12
+
13
+
14
+ def _extract(response: Any) -> tuple[str, dict[str, Any], dict[str, Any] | None]:
15
+ """Pull (model, answers, usage) out of an SDK response or a plain dict."""
16
+ if isinstance(response, dict):
17
+ model = response.get("model", "")
18
+ answers = response.get("answers", {})
19
+ usage = response.get("usage")
20
+ else:
21
+ model = getattr(response, "model", "")
22
+ answers = getattr(response, "answers", {})
23
+ usage = getattr(response, "usage", None)
24
+
25
+ plain: dict[str, Any] = {}
26
+ for qid, answer in (answers or {}).items():
27
+ if isinstance(answer, dict):
28
+ plain[qid] = answer
29
+ elif hasattr(answer, "model_dump"):
30
+ plain[qid] = answer.model_dump()
31
+ elif hasattr(answer, "__dict__"):
32
+ plain[qid] = {k: v for k, v in vars(answer).items() if not k.startswith("_")}
33
+ else:
34
+ raise TypeError(f"answer {qid!r}: cannot convert {type(answer).__name__} to a dict")
35
+
36
+ if usage is not None and not isinstance(usage, dict):
37
+ if hasattr(usage, "model_dump"):
38
+ usage = usage.model_dump()
39
+ elif hasattr(usage, "__dict__"):
40
+ usage = {k: v for k, v in vars(usage).items() if not k.startswith("_")}
41
+
42
+ return str(model), plain, usage
43
+
44
+
45
+ def replay(
46
+ records: Iterable[Record],
47
+ system_one: SystemOneCallable,
48
+ *,
49
+ tags: Iterable[str] | None = None,
50
+ ) -> list[Record]:
51
+ """Re-send each record's request and return the new answers as new records.
52
+
53
+ ``system_one`` is any callable taking ``(state, questions)`` and returning a
54
+ response, which is the shape both official SDKs already expose. Labels and
55
+ tags carry over from the baseline so a replayed set stays scoreable.
56
+ """
57
+ extra_tags = list(tags or [])
58
+ out: list[Record] = []
59
+ for record in records:
60
+ response = system_one(record.state, record.questions)
61
+ model, answers, usage = _extract(response)
62
+ out.append(Record(
63
+ model=model or record.model,
64
+ state=record.state,
65
+ questions=record.questions,
66
+ answers=answers,
67
+ usage=usage,
68
+ label=record.label,
69
+ tags=list(record.tags) + extra_tags,
70
+ meta={**record.meta, "replayed_from": record.id},
71
+ ))
72
+ return out
@@ -0,0 +1,114 @@
1
+ import pytest
2
+ from jevkit_core import Record
3
+ from jevkit_drift import compare_records, compare_sets, replay, total_variation
4
+
5
+ QUESTIONS = {"team": {"type": "choice", "instructions": "Which team"}}
6
+
7
+
8
+ def rec(model, choice, probs, confidence=0.5, state="s"):
9
+ return Record(model=model, state=state, questions=QUESTIONS,
10
+ answers={"team": {"type": "choice", "choice": choice,
11
+ "probabilities": probs, "confidence": confidence}})
12
+
13
+
14
+ def test_total_variation_of_identical_distributions_is_zero():
15
+ assert total_variation({"a": 0.5, "b": 0.5}, {"a": 0.5, "b": 0.5}) == 0.0
16
+
17
+
18
+ def test_total_variation_of_disjoint_distributions_is_one():
19
+ assert total_variation({"a": 1.0}, {"b": 1.0}) == pytest.approx(1.0)
20
+
21
+
22
+ def test_total_variation_counts_outcomes_missing_from_one_side():
23
+ assert total_variation({"a": 1.0}, {"a": 0.5, "b": 0.5}) == pytest.approx(0.5)
24
+
25
+
26
+ def test_a_changed_decision_is_a_flip():
27
+ delta = compare_records(
28
+ rec("jev-1.13.0", "billing", {"billing": 0.7, "technical": 0.3}),
29
+ rec("jev-1.14.0", "technical", {"billing": 0.3, "technical": 0.7}),
30
+ )
31
+ assert delta.flips and delta.questions[0].flipped
32
+ assert delta.questions[0].before == "billing"
33
+ assert delta.questions[0].after == "technical"
34
+
35
+
36
+ def test_a_moved_distribution_with_the_same_decision_is_not_a_flip():
37
+ delta = compare_records(
38
+ rec("jev-1.13.0", "billing", {"billing": 0.9, "technical": 0.1}),
39
+ rec("jev-1.14.0", "billing", {"billing": 0.6, "technical": 0.4}),
40
+ )
41
+ assert not delta.flips
42
+ assert delta.max_shift == pytest.approx(0.3)
43
+
44
+
45
+ def test_confidence_delta_is_reported():
46
+ delta = compare_records(
47
+ rec("jev-1.13.0", "billing", {"billing": 0.9}, confidence=0.8),
48
+ rec("jev-1.14.0", "billing", {"billing": 0.9}, confidence=0.5),
49
+ )
50
+ assert delta.questions[0].confidence_delta == pytest.approx(-0.3)
51
+
52
+
53
+ def test_records_for_different_requests_cannot_be_compared():
54
+ with pytest.raises(ValueError, match="different requests"):
55
+ compare_records(rec("m", "a", {"a": 1.0}, state="one"),
56
+ rec("m", "a", {"a": 1.0}, state="two"))
57
+
58
+
59
+ def test_a_changed_answer_type_is_refused():
60
+ before = Record(model="m", state="s", questions=QUESTIONS,
61
+ answers={"team": {"type": "choice", "choice": "a", "probabilities": {"a": 1.0}}})
62
+ after = Record(model="m", state="s", questions=QUESTIONS,
63
+ answers={"team": {"type": "noul", "noul": 1.0}})
64
+ with pytest.raises(ValueError, match="type changed"):
65
+ compare_records(before, after)
66
+
67
+
68
+ def test_a_missing_answer_is_refused_rather_than_ignored():
69
+ before = rec("m", "billing", {"billing": 1.0})
70
+ after = Record(model="m", state="s", questions=QUESTIONS, answers={})
71
+ with pytest.raises(ValueError, match="missing in the new run"):
72
+ compare_records(before, after)
73
+
74
+
75
+ def test_sets_pair_on_request_id_across_model_versions():
76
+ report = compare_sets(
77
+ [rec("jev-1.13.0", "billing", {"billing": 0.7, "technical": 0.3})],
78
+ [rec("jev-1.14.0", "technical", {"billing": 0.3, "technical": 0.7})],
79
+ )
80
+ assert len(report.deltas) == 1
81
+ assert len(report.flips) == 1
82
+ assert report.flip_rate == 1.0
83
+
84
+
85
+ def test_unmatched_records_are_reported_not_silently_dropped():
86
+ report = compare_sets(
87
+ [rec("m", "a", {"a": 1.0}, state="one")],
88
+ [rec("m", "a", {"a": 1.0}, state="two")],
89
+ )
90
+ assert report.deltas == []
91
+ assert len(report.unmatched_before) == 1
92
+ assert len(report.unmatched_after) == 1
93
+
94
+
95
+ def test_replay_carries_labels_and_calls_through():
96
+ baseline = [Record(model="jev-1.13.0", state="s", questions=QUESTIONS,
97
+ answers={"team": {"type": "choice", "choice": "billing",
98
+ "probabilities": {"billing": 1.0}}},
99
+ label={"team": "billing"}, tags=["routing"])]
100
+ seen = []
101
+
102
+ def fake(state, questions):
103
+ seen.append((state, questions))
104
+ return {"model": "jev-1.14.0",
105
+ "answers": {"team": {"type": "choice", "choice": "technical",
106
+ "probabilities": {"technical": 1.0}}},
107
+ "usage": {"input_tokens": 5}}
108
+
109
+ out = replay(baseline, fake)
110
+ assert len(seen) == 1
111
+ assert out[0].model == "jev-1.14.0"
112
+ assert out[0].label == {"team": "billing"}
113
+ assert "routing" in out[0].tags
114
+ assert out[0].request_id == baseline[0].request_id