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.
- jevkit_drift-0.1.0/.gitignore +12 -0
- jevkit_drift-0.1.0/PKG-INFO +87 -0
- jevkit_drift-0.1.0/README.md +67 -0
- jevkit_drift-0.1.0/pyproject.toml +33 -0
- jevkit_drift-0.1.0/src/jevkit_drift/__init__.py +17 -0
- jevkit_drift-0.1.0/src/jevkit_drift/cli.py +70 -0
- jevkit_drift-0.1.0/src/jevkit_drift/compare.py +236 -0
- jevkit_drift-0.1.0/src/jevkit_drift/replay.py +72 -0
- jevkit_drift-0.1.0/tests/test_drift.py +114 -0
|
@@ -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
|