oryxeval 26.11.6__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.
- oryxeval-26.11.6/LICENSE +21 -0
- oryxeval-26.11.6/PKG-INFO +163 -0
- oryxeval-26.11.6/README.md +128 -0
- oryxeval-26.11.6/oryxeval/__init__.py +107 -0
- oryxeval-26.11.6/oryxeval/baseline.py +260 -0
- oryxeval-26.11.6/oryxeval/cases.py +267 -0
- oryxeval-26.11.6/oryxeval/cli.py +364 -0
- oryxeval-26.11.6/oryxeval/metric.py +121 -0
- oryxeval-26.11.6/oryxeval/sidebyside.py +180 -0
- oryxeval-26.11.6/oryxeval/stats.py +389 -0
- oryxeval-26.11.6/oryxeval/sweep.py +1257 -0
- oryxeval-26.11.6/oryxeval/task.py +785 -0
- oryxeval-26.11.6/oryxeval.egg-info/PKG-INFO +163 -0
- oryxeval-26.11.6/oryxeval.egg-info/SOURCES.txt +28 -0
- oryxeval-26.11.6/oryxeval.egg-info/dependency_links.txt +1 -0
- oryxeval-26.11.6/oryxeval.egg-info/requires.txt +12 -0
- oryxeval-26.11.6/oryxeval.egg-info/top_level.txt +1 -0
- oryxeval-26.11.6/pyproject.toml +35 -0
- oryxeval-26.11.6/setup.cfg +4 -0
- oryxeval-26.11.6/tests/test_baseline.py +226 -0
- oryxeval-26.11.6/tests/test_cases.py +279 -0
- oryxeval-26.11.6/tests/test_cli.py +478 -0
- oryxeval-26.11.6/tests/test_frame_truncation.py +126 -0
- oryxeval-26.11.6/tests/test_metric.py +176 -0
- oryxeval-26.11.6/tests/test_reexports.py +22 -0
- oryxeval-26.11.6/tests/test_source_hash_golden.py +24 -0
- oryxeval-26.11.6/tests/test_split.py +267 -0
- oryxeval-26.11.6/tests/test_stats.py +312 -0
- oryxeval-26.11.6/tests/test_sweep.py +968 -0
- oryxeval-26.11.6/tests/test_task.py +335 -0
oryxeval-26.11.6/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oryx Intelligence LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oryxeval
|
|
3
|
+
Version: 26.11.6
|
|
4
|
+
Summary: Cheap, durable LLM evals: free and local, every arm cached so re-runs pay only for what changed, with confidence intervals and a verdict.
|
|
5
|
+
Author-email: Oryx Intelligence LLC <dev@oryxintel.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/oryxintel/oryxeval
|
|
8
|
+
Project-URL: Documentation, https://docs.oryxflow.dev/docs/llm-evals/
|
|
9
|
+
Project-URL: Source, https://github.com/oryxintel/oryxeval
|
|
10
|
+
Project-URL: Changelog, https://github.com/oryxintel/oryxeval/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: llm,evals,llm-evaluation,prompt-engineering,pydantic-evals,llm-as-a-judge,a-b-testing,confidence-intervals,caching,oryxflow
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Science/Research
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: oryxflow>=26.11.6
|
|
24
|
+
Requires-Dist: pydantic-evals>=2
|
|
25
|
+
Requires-Dist: scipy
|
|
26
|
+
Requires-Dist: typer
|
|
27
|
+
Requires-Dist: tenacity
|
|
28
|
+
Requires-Dist: jinja2
|
|
29
|
+
Requires-Dist: pyyaml
|
|
30
|
+
Requires-Dist: pandas
|
|
31
|
+
Requires-Dist: numpy
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# oryxeval
|
|
37
|
+
|
|
38
|
+
**Cheap, durable LLM evals. Free, local, and you never pay twice for the same answer.**
|
|
39
|
+
|
|
40
|
+
Every arm of an eval matrix (prompt x model x ...) is a cached cell, so a re-run pays only for what
|
|
41
|
+
changed. You get a confidence interval and a plain-English verdict on whether the change is
|
|
42
|
+
actually better. [pydantic-evals](https://pydantic.dev/docs/ai/evals/) does the scoring; oryxeval
|
|
43
|
+
adds the matrix, the cache, the intervals and the verdict. Built on
|
|
44
|
+
[oryxflow](https://github.com/oryxintel/oryxflow). Python 3.10+.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install oryxeval
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How it's different
|
|
51
|
+
|
|
52
|
+
- **Free, and it runs where your code runs.** A Python library: no platform, no account, no
|
|
53
|
+
per-trace or per-score metering, no retention window. Your only bill is your model provider's,
|
|
54
|
+
and the cache keeps that small. Results are files on your disk.
|
|
55
|
+
- **The cache is keyed on your code, not on the API request.** Most eval tools cache raw provider
|
|
56
|
+
responses. oryxeval caches the whole arm: the function under test, the cases it read and the
|
|
57
|
+
settings. So an agent that makes five model calls and a tool call per case is cached as one
|
|
58
|
+
unit, an edited function re-runs on its own, and nothing expires after a couple of weeks.
|
|
59
|
+
- **Re-score for free.** Model outputs and scores are cached separately. Change a scorer or a
|
|
60
|
+
judge rubric and only the scoring re-runs, over outputs you already paid for.
|
|
61
|
+
- **A verdict, not a dashboard.** Paired differences with intervals, a guardrail that must not
|
|
62
|
+
get worse, a judge-vs-human agreement check, and a last line that says "inside noise" when it
|
|
63
|
+
is.
|
|
64
|
+
- **It tests your production code as-is.** The thing under test is an ordinary async Python
|
|
65
|
+
function that calls your real code. No YAML config, no rewriting it into a framework's
|
|
66
|
+
pipeline format.
|
|
67
|
+
|
|
68
|
+
## The problem: prompt changes ship on vibes
|
|
69
|
+
|
|
70
|
+
- **Noise passes for a win.** The same prompt can score 69% and then 64% an hour later. Without an
|
|
71
|
+
interval, a 5-point "improvement" is a coin flip you're about to ship.
|
|
72
|
+
- **The headline metric hides the regression.** A prompt that answers *everything* scores 100% on
|
|
73
|
+
"did it answer", and also makes things up. A prompt that stays silent on hard cases looks great
|
|
74
|
+
on quality.
|
|
75
|
+
- **Nobody checked the judge.** An LLM judge with no human labels behind it is an opinion with a
|
|
76
|
+
percentage printed next to it.
|
|
77
|
+
- **Every re-run re-bills everything.** Tweak one prompt, or fix a typo in a judge rubric, and a
|
|
78
|
+
hand-rolled script calls the model for every case and every arm again. So people run fewer
|
|
79
|
+
evals, on fewer cases, and trust them less.
|
|
80
|
+
- **Stale results look fresh.** A hand-rolled cache keyed on the wrong thing quietly serves
|
|
81
|
+
last week's outputs for this week's prompt.
|
|
82
|
+
|
|
83
|
+
## What else you get
|
|
84
|
+
|
|
85
|
+
- **Guardrails and coverage built in.** Name the metric that must not get worse (with a budget) and
|
|
86
|
+
a winner that breaks it is reported as *not a clean win*. A quality rate on a filtered subset is
|
|
87
|
+
shown next to how much it covers.
|
|
88
|
+
- **A bill before you spend.** Every run prints the projected call count (and cost, if you give
|
|
89
|
+
it a per-call price) and asks first. One keystroke spends a single call to check credentials
|
|
90
|
+
before committing to the rest.
|
|
91
|
+
- **Baselines from git.** Compare your working tree against the prompt as it was at any commit,
|
|
92
|
+
without copying files around.
|
|
93
|
+
- **Where to look next.** Slice the best arm by any metadata column, worst first, and write a
|
|
94
|
+
side-by-side of every case where the arms disagree, straight from the stored outputs.
|
|
95
|
+
|
|
96
|
+
## Quick example
|
|
97
|
+
|
|
98
|
+
Write the async function you want to compare. Any keyword holding a list is an axis: two prompt
|
|
99
|
+
versions make two arms.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import oryxeval as ev
|
|
103
|
+
from pydantic import BaseModel
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Turn(BaseModel):
|
|
107
|
+
question: str
|
|
108
|
+
phrasing: str = 'plain'
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def run_turn(inputs, prompt_version):
|
|
112
|
+
return await my_assistant(inputs.question, prompt=prompt_version) # your code
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
r = ev.sweep(run_turn,
|
|
116
|
+
cases=ev.load_cases('cases.csv', inputs=Turn),
|
|
117
|
+
prompt_version=['prod', 'preship'],
|
|
118
|
+
repeats=3,
|
|
119
|
+
metric=ev.Metric('accuracy', 'correct'),
|
|
120
|
+
guardrail=ev.Metric('false positives', 'false_positive',
|
|
121
|
+
higher_is_better=False, budget=0.05),
|
|
122
|
+
slices=('phrasing',))
|
|
123
|
+
r.verdict()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Before anything is billed:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
run_turn · 20 cases × 2 arms × 3 reps = 120 calls
|
|
130
|
+
cached 0 · new 120 (+1 preflight call per new arm)
|
|
131
|
+
|
|
132
|
+
Run 120 new calls for run_turn? (y/n/c=check one call first)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Then the verdict, with intervals. Run it again and it costs nothing:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
run_turn · 20 cases × 2 arms × 3 reps = 120 calls
|
|
139
|
+
cached 120 · new 0
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Let your coding agent run it
|
|
143
|
+
|
|
144
|
+
The [oryxeval Claude Code plugin](https://github.com/oryxintel/oryxeval-claude-plugin) takes an
|
|
145
|
+
agent from "I changed this prompt" to a verdict in four commands: plan the eval from your diff,
|
|
146
|
+
scaffold it, harvest real cases from the repo, and run it. It always shows the bill and waits
|
|
147
|
+
before spending.
|
|
148
|
+
|
|
149
|
+
## Docs
|
|
150
|
+
|
|
151
|
+
- [LLM evals quickstart](https://docs.oryxflow.dev/docs/llm-evals-quickstart/): a complete eval
|
|
152
|
+
in one file, with what each line does and what it prints.
|
|
153
|
+
- [The guide](https://docs.oryxflow.dev/docs/llm-evals/): guardrails, judges, baselines, slices
|
|
154
|
+
and the method behind the verdict.
|
|
155
|
+
|
|
156
|
+
## Coming from `oryxflow.evals`
|
|
157
|
+
|
|
158
|
+
oryxeval was split out of `oryxflow.evals`. To keep an existing eval's cache, change only its
|
|
159
|
+
import lines and no function body: the steps are in [CHANGELOG.md](CHANGELOG.md) (`26.11.6`).
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# oryxeval
|
|
2
|
+
|
|
3
|
+
**Cheap, durable LLM evals. Free, local, and you never pay twice for the same answer.**
|
|
4
|
+
|
|
5
|
+
Every arm of an eval matrix (prompt x model x ...) is a cached cell, so a re-run pays only for what
|
|
6
|
+
changed. You get a confidence interval and a plain-English verdict on whether the change is
|
|
7
|
+
actually better. [pydantic-evals](https://pydantic.dev/docs/ai/evals/) does the scoring; oryxeval
|
|
8
|
+
adds the matrix, the cache, the intervals and the verdict. Built on
|
|
9
|
+
[oryxflow](https://github.com/oryxintel/oryxflow). Python 3.10+.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install oryxeval
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## How it's different
|
|
16
|
+
|
|
17
|
+
- **Free, and it runs where your code runs.** A Python library: no platform, no account, no
|
|
18
|
+
per-trace or per-score metering, no retention window. Your only bill is your model provider's,
|
|
19
|
+
and the cache keeps that small. Results are files on your disk.
|
|
20
|
+
- **The cache is keyed on your code, not on the API request.** Most eval tools cache raw provider
|
|
21
|
+
responses. oryxeval caches the whole arm: the function under test, the cases it read and the
|
|
22
|
+
settings. So an agent that makes five model calls and a tool call per case is cached as one
|
|
23
|
+
unit, an edited function re-runs on its own, and nothing expires after a couple of weeks.
|
|
24
|
+
- **Re-score for free.** Model outputs and scores are cached separately. Change a scorer or a
|
|
25
|
+
judge rubric and only the scoring re-runs, over outputs you already paid for.
|
|
26
|
+
- **A verdict, not a dashboard.** Paired differences with intervals, a guardrail that must not
|
|
27
|
+
get worse, a judge-vs-human agreement check, and a last line that says "inside noise" when it
|
|
28
|
+
is.
|
|
29
|
+
- **It tests your production code as-is.** The thing under test is an ordinary async Python
|
|
30
|
+
function that calls your real code. No YAML config, no rewriting it into a framework's
|
|
31
|
+
pipeline format.
|
|
32
|
+
|
|
33
|
+
## The problem: prompt changes ship on vibes
|
|
34
|
+
|
|
35
|
+
- **Noise passes for a win.** The same prompt can score 69% and then 64% an hour later. Without an
|
|
36
|
+
interval, a 5-point "improvement" is a coin flip you're about to ship.
|
|
37
|
+
- **The headline metric hides the regression.** A prompt that answers *everything* scores 100% on
|
|
38
|
+
"did it answer", and also makes things up. A prompt that stays silent on hard cases looks great
|
|
39
|
+
on quality.
|
|
40
|
+
- **Nobody checked the judge.** An LLM judge with no human labels behind it is an opinion with a
|
|
41
|
+
percentage printed next to it.
|
|
42
|
+
- **Every re-run re-bills everything.** Tweak one prompt, or fix a typo in a judge rubric, and a
|
|
43
|
+
hand-rolled script calls the model for every case and every arm again. So people run fewer
|
|
44
|
+
evals, on fewer cases, and trust them less.
|
|
45
|
+
- **Stale results look fresh.** A hand-rolled cache keyed on the wrong thing quietly serves
|
|
46
|
+
last week's outputs for this week's prompt.
|
|
47
|
+
|
|
48
|
+
## What else you get
|
|
49
|
+
|
|
50
|
+
- **Guardrails and coverage built in.** Name the metric that must not get worse (with a budget) and
|
|
51
|
+
a winner that breaks it is reported as *not a clean win*. A quality rate on a filtered subset is
|
|
52
|
+
shown next to how much it covers.
|
|
53
|
+
- **A bill before you spend.** Every run prints the projected call count (and cost, if you give
|
|
54
|
+
it a per-call price) and asks first. One keystroke spends a single call to check credentials
|
|
55
|
+
before committing to the rest.
|
|
56
|
+
- **Baselines from git.** Compare your working tree against the prompt as it was at any commit,
|
|
57
|
+
without copying files around.
|
|
58
|
+
- **Where to look next.** Slice the best arm by any metadata column, worst first, and write a
|
|
59
|
+
side-by-side of every case where the arms disagree, straight from the stored outputs.
|
|
60
|
+
|
|
61
|
+
## Quick example
|
|
62
|
+
|
|
63
|
+
Write the async function you want to compare. Any keyword holding a list is an axis: two prompt
|
|
64
|
+
versions make two arms.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import oryxeval as ev
|
|
68
|
+
from pydantic import BaseModel
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Turn(BaseModel):
|
|
72
|
+
question: str
|
|
73
|
+
phrasing: str = 'plain'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def run_turn(inputs, prompt_version):
|
|
77
|
+
return await my_assistant(inputs.question, prompt=prompt_version) # your code
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
r = ev.sweep(run_turn,
|
|
81
|
+
cases=ev.load_cases('cases.csv', inputs=Turn),
|
|
82
|
+
prompt_version=['prod', 'preship'],
|
|
83
|
+
repeats=3,
|
|
84
|
+
metric=ev.Metric('accuracy', 'correct'),
|
|
85
|
+
guardrail=ev.Metric('false positives', 'false_positive',
|
|
86
|
+
higher_is_better=False, budget=0.05),
|
|
87
|
+
slices=('phrasing',))
|
|
88
|
+
r.verdict()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Before anything is billed:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
run_turn · 20 cases × 2 arms × 3 reps = 120 calls
|
|
95
|
+
cached 0 · new 120 (+1 preflight call per new arm)
|
|
96
|
+
|
|
97
|
+
Run 120 new calls for run_turn? (y/n/c=check one call first)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then the verdict, with intervals. Run it again and it costs nothing:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
run_turn · 20 cases × 2 arms × 3 reps = 120 calls
|
|
104
|
+
cached 120 · new 0
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Let your coding agent run it
|
|
108
|
+
|
|
109
|
+
The [oryxeval Claude Code plugin](https://github.com/oryxintel/oryxeval-claude-plugin) takes an
|
|
110
|
+
agent from "I changed this prompt" to a verdict in four commands: plan the eval from your diff,
|
|
111
|
+
scaffold it, harvest real cases from the repo, and run it. It always shows the bill and waits
|
|
112
|
+
before spending.
|
|
113
|
+
|
|
114
|
+
## Docs
|
|
115
|
+
|
|
116
|
+
- [LLM evals quickstart](https://docs.oryxflow.dev/docs/llm-evals-quickstart/): a complete eval
|
|
117
|
+
in one file, with what each line does and what it prints.
|
|
118
|
+
- [The guide](https://docs.oryxflow.dev/docs/llm-evals/): guardrails, judges, baselines, slices
|
|
119
|
+
and the method behind the verdict.
|
|
120
|
+
|
|
121
|
+
## Coming from `oryxflow.evals`
|
|
122
|
+
|
|
123
|
+
oryxeval was split out of `oryxflow.evals`. To keep an existing eval's cache, change only its
|
|
124
|
+
import lines and no function body: the steps are in [CHANGELOG.md](CHANGELOG.md) (`26.11.6`).
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""LLM evaluation harness: one cached cell per arm of an eval matrix.
|
|
2
|
+
|
|
3
|
+
Install it with::
|
|
4
|
+
|
|
5
|
+
pip install oryxeval
|
|
6
|
+
|
|
7
|
+
It is a separate package from oryxflow, which it builds on. Import it explicitly::
|
|
8
|
+
|
|
9
|
+
import oryxeval as ev
|
|
10
|
+
|
|
11
|
+
SEAM: the per-case DataFrame is the interface. Everything downstream of a run --
|
|
12
|
+
``Metric``, the slices, the intervals, the verdict, the report -- reads only that
|
|
13
|
+
frame and never touches a pydantic-evals object. The two override points on
|
|
14
|
+
``TaskEval`` are the exit: ``_evaluate()`` returns a report, so swap it for a
|
|
15
|
+
different runner, and ``_to_frame(report)`` returns the rows, so swap it for a
|
|
16
|
+
different report shape. Nothing else has to change. The property that buys is
|
|
17
|
+
available today rather than later: every metric, verdict and report test is
|
|
18
|
+
written against a hand-built DataFrame, so everything downstream of a run is
|
|
19
|
+
testable without pydantic-evals present.
|
|
20
|
+
|
|
21
|
+
``cli`` and ``build_app`` are resolved on first use rather than at import, so
|
|
22
|
+
``import oryxeval`` does not pay for Typer -- a sweep run from a notebook
|
|
23
|
+
never needs a command line.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import importlib
|
|
27
|
+
|
|
28
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = _version("oryxeval")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
__version__ = "unknown"
|
|
34
|
+
|
|
35
|
+
from oryxflow import (Parameter, IntParameter, FloatParameter, BoolParameter,
|
|
36
|
+
ChoiceParameter, ListParameter, DictParameter, DateParameter,
|
|
37
|
+
EnumParameter, hash_files)
|
|
38
|
+
|
|
39
|
+
from oryxeval.baseline import (
|
|
40
|
+
BaselineError,
|
|
41
|
+
PromptArm,
|
|
42
|
+
Variant,
|
|
43
|
+
VariantError,
|
|
44
|
+
git_sha,
|
|
45
|
+
git_tree,
|
|
46
|
+
)
|
|
47
|
+
from oryxeval.cases import RESERVED_METADATA, CaseList, load_cases
|
|
48
|
+
from oryxeval.metric import Metric
|
|
49
|
+
from oryxeval.stats import (DeltaCI, JudgeAlignment, RateCI, cluster_labels,
|
|
50
|
+
corrected_rate, delta_ci, judge_alignment,
|
|
51
|
+
rate_ci)
|
|
52
|
+
from oryxeval.sweep import EvalResult, sweep
|
|
53
|
+
from oryxeval.task import EvalFailureRateError, TaskEval
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
'__version__',
|
|
57
|
+
'BoolParameter',
|
|
58
|
+
'ChoiceParameter',
|
|
59
|
+
'DateParameter',
|
|
60
|
+
'DictParameter',
|
|
61
|
+
'EnumParameter',
|
|
62
|
+
'FloatParameter',
|
|
63
|
+
'IntParameter',
|
|
64
|
+
'ListParameter',
|
|
65
|
+
'Parameter',
|
|
66
|
+
'hash_files',
|
|
67
|
+
'BaselineError',
|
|
68
|
+
'CaseList',
|
|
69
|
+
'DeltaCI',
|
|
70
|
+
'EvalFailureRateError',
|
|
71
|
+
'EvalResult',
|
|
72
|
+
'JudgeAlignment',
|
|
73
|
+
'Metric',
|
|
74
|
+
'PromptArm',
|
|
75
|
+
'RESERVED_METADATA',
|
|
76
|
+
'RateCI',
|
|
77
|
+
'TaskEval',
|
|
78
|
+
'Variant',
|
|
79
|
+
'VariantError',
|
|
80
|
+
'build_app',
|
|
81
|
+
'cli',
|
|
82
|
+
'cluster_labels',
|
|
83
|
+
'corrected_rate',
|
|
84
|
+
'delta_ci',
|
|
85
|
+
'git_sha',
|
|
86
|
+
'git_tree',
|
|
87
|
+
'judge_alignment',
|
|
88
|
+
'load_cases',
|
|
89
|
+
'rate_ci',
|
|
90
|
+
'sweep',
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
_LAZY = ('cli', 'build_app')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def __getattr__(name):
|
|
97
|
+
"""Import the Typer-backed names only when one of them is actually used."""
|
|
98
|
+
if name in _LAZY:
|
|
99
|
+
module = importlib.import_module('oryxeval.cli')
|
|
100
|
+
value = module if name == 'cli' else getattr(module, name)
|
|
101
|
+
globals()[name] = value
|
|
102
|
+
return value
|
|
103
|
+
raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def __dir__():
|
|
107
|
+
return sorted(set(list(globals()) + list(__all__)))
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The two ways to build a comparison arm, deliberately distinct.
|
|
2
|
+
|
|
3
|
+
A baseline is *something that existed*, so it is checked out of git: byte-exact by
|
|
4
|
+
construction, immune to the live files moving under it, and -- the reason this module
|
|
5
|
+
does not simply ship a string-replacement helper -- able to restore a section that was
|
|
6
|
+
DELETED. A probe is a rewrite that has not shipped anywhere, so there is no ref to check
|
|
7
|
+
out and an anchored ``(old -> new)`` replacement is all there is.
|
|
8
|
+
|
|
9
|
+
Both fail loudly. A comparison arm that silently becomes a no-op reports the old version
|
|
10
|
+
as having scored well, which is indistinguishable from a real result and therefore the
|
|
11
|
+
worst failure a comparison can have.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaselineError(RuntimeError):
|
|
25
|
+
"""A baseline arm could not be built, or was built empty.
|
|
26
|
+
|
|
27
|
+
A ``RuntimeError``, because an empty or wrong baseline is not a recoverable
|
|
28
|
+
condition: whatever the arm went on to measure would not be the old version.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VariantError(ValueError):
|
|
33
|
+
"""A variant's anchor text was not found in the rendered text.
|
|
34
|
+
|
|
35
|
+
A ``ValueError``, because the arguments no longer describe the text they are
|
|
36
|
+
applied to -- the anchor has been edited away, and applying the variant anyway
|
|
37
|
+
would report the unchanged prompt's numbers as the variant's.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _git(args, repo=None, binary=False):
|
|
42
|
+
"""Run a git command, returning stdout (bytes when ``binary``). Raise on failure."""
|
|
43
|
+
cwd = str(repo) if repo is not None else os.getcwd()
|
|
44
|
+
proc = subprocess.run(['git'] + list(args), cwd=cwd,
|
|
45
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
46
|
+
if proc.returncode != 0:
|
|
47
|
+
raise BaselineError("git {} failed in {}: {}".format(
|
|
48
|
+
' '.join(args), cwd, proc.stderr.decode('utf-8', 'replace').strip()))
|
|
49
|
+
return proc.stdout if binary else proc.stdout.decode('utf-8', 'replace')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def git_sha(ref, repo=None):
|
|
53
|
+
"""Resolve ``ref`` to its full commit SHA.
|
|
54
|
+
|
|
55
|
+
An arm built from a git ref must report *this* as its ``code_version()``, never the
|
|
56
|
+
ref string: ``HEAD~1`` names different content every time something is committed, and
|
|
57
|
+
a cache key that moves under a stable-looking name reads stale numbers as fresh ones.
|
|
58
|
+
A resolved SHA is immutable, so the arm's cache key is stable forever.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
out = _git(['rev-parse', '--verify', '{}^{{commit}}'.format(ref)], repo=repo)
|
|
62
|
+
except BaselineError:
|
|
63
|
+
out = _git(['rev-parse', '--verify', str(ref)], repo=repo)
|
|
64
|
+
sha = out.strip()
|
|
65
|
+
if not sha:
|
|
66
|
+
raise BaselineError("ref '{}' did not resolve to a commit".format(ref))
|
|
67
|
+
return sha
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as_list(paths):
|
|
71
|
+
if paths is None:
|
|
72
|
+
return []
|
|
73
|
+
if isinstance(paths, (str, bytes, os.PathLike)):
|
|
74
|
+
return [paths]
|
|
75
|
+
return list(paths)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ls_tree(ref, paths, repo=None):
|
|
79
|
+
"""Filenames under ``paths`` at ``ref``, recursively, git-style forward slashes."""
|
|
80
|
+
args = ['ls-tree', '-r', '-z', '--name-only', str(ref), '--']
|
|
81
|
+
args += [str(p).replace('\\', '/') for p in paths]
|
|
82
|
+
out = _git(args, repo=repo, binary=True).decode('utf-8', 'surrogateescape')
|
|
83
|
+
return [n for n in out.split('\0') if n]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_dest(sha, repo=None):
|
|
87
|
+
base = Path(repo) if repo is not None else Path.cwd()
|
|
88
|
+
root = base / '.oryxflow' / 'baseline'
|
|
89
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
# The materialized tree is generated, never source: ignore it wherever it lands,
|
|
91
|
+
# so a user whose repo predates this never has to notice it.
|
|
92
|
+
ignore = root / '.gitignore'
|
|
93
|
+
if not ignore.exists():
|
|
94
|
+
ignore.write_text('*\n')
|
|
95
|
+
return root / sha[:12]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def git_tree(ref, paths, dest=None, expect_absent=None, repo=None):
|
|
99
|
+
"""Materialize files from a git ref into a directory, for a BASELINE arm.
|
|
100
|
+
|
|
101
|
+
Use this for a prompt/template tree as it was before a change: it is
|
|
102
|
+
byte-exact by construction, cannot decay as the live files move, and -- the
|
|
103
|
+
reason it exists -- it restores DELETED files, which a string-replacement
|
|
104
|
+
variant cannot do at all.
|
|
105
|
+
|
|
106
|
+
``ref`` is anything git resolves ('abc123^', 'v2.1', 'main~3'). Because a
|
|
107
|
+
resolved ref is immutable, an arm built from one has a cache key that is
|
|
108
|
+
stable forever.
|
|
109
|
+
|
|
110
|
+
Raises RuntimeError when the ref yields no files under ``paths`` -- a
|
|
111
|
+
silently empty baseline reads as "the old version already scored well",
|
|
112
|
+
which is the worst possible failure for a comparison.
|
|
113
|
+
|
|
114
|
+
``expect_absent=`` names files that must NOT exist at this ref; their
|
|
115
|
+
presence means the ref is wrong and the arm is measuring nothing.
|
|
116
|
+
|
|
117
|
+
Returns the directory the files were written to. Point your own loader at it --
|
|
118
|
+
how your templates are loaded is yours to know, so nothing here is patched or
|
|
119
|
+
installed on your behalf. The default location is keyed by the resolved SHA,
|
|
120
|
+
beside the eval and gitignored, so a ref is materialized once and reused.
|
|
121
|
+
Pass ``repo=`` to run git somewhere other than the working directory, and use
|
|
122
|
+
``git_sha(ref)`` for the arm's ``code_version()``.
|
|
123
|
+
"""
|
|
124
|
+
paths = _as_list(paths)
|
|
125
|
+
if not paths:
|
|
126
|
+
raise BaselineError(
|
|
127
|
+
"git_tree(ref={!r}) needs at least one path; materializing nothing would "
|
|
128
|
+
"give an empty baseline".format(ref))
|
|
129
|
+
sha = git_sha(ref, repo=repo)
|
|
130
|
+
|
|
131
|
+
for name in _as_list(expect_absent):
|
|
132
|
+
found = _ls_tree(sha, [name], repo=repo)
|
|
133
|
+
if found:
|
|
134
|
+
raise BaselineError(
|
|
135
|
+
"ref '{}' ({}) still has {} -- expect_absent said it should not exist "
|
|
136
|
+
"there, so this is not the ref you meant and the arm would measure "
|
|
137
|
+
"nothing.".format(ref, sha[:10], ', '.join(sorted(found))))
|
|
138
|
+
|
|
139
|
+
names = _ls_tree(sha, paths, repo=repo)
|
|
140
|
+
if not names:
|
|
141
|
+
raise BaselineError(
|
|
142
|
+
"ref '{}' ({}) has no files under {} -- an empty baseline reads as 'the old "
|
|
143
|
+
"version already scored well', so it is refused rather than measured.".format(
|
|
144
|
+
ref, sha[:10], ', '.join(repr(str(p)) for p in paths)))
|
|
145
|
+
|
|
146
|
+
dest = Path(dest) if dest is not None else _default_dest(sha, repo=repo)
|
|
147
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
for name in names:
|
|
149
|
+
target = dest.joinpath(*name.split('/'))
|
|
150
|
+
blob = _git(['show', '{}:{}'.format(sha, name)], repo=repo, binary=True)
|
|
151
|
+
if target.exists() and target.read_bytes() == blob:
|
|
152
|
+
continue
|
|
153
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
target.write_bytes(blob)
|
|
155
|
+
manifest = dest / '_oryxflow_baseline.json'
|
|
156
|
+
manifest.write_text(json.dumps(
|
|
157
|
+
{'ref': str(ref), 'sha': sha, 'paths': [str(p) for p in paths], 'files': names},
|
|
158
|
+
indent=2, sort_keys=True))
|
|
159
|
+
return dest
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class Variant:
|
|
164
|
+
"""An exact (old -> new) replacement applied to already-rendered text, for a
|
|
165
|
+
PROBE arm -- a rewrite that has not shipped, so there is no ref to check out.
|
|
166
|
+
|
|
167
|
+
Raises when ``old`` is not present: a variant whose anchor text has been
|
|
168
|
+
edited away silently becomes a no-op, and then reports the unchanged prompt's
|
|
169
|
+
numbers as the variant's.
|
|
170
|
+
|
|
171
|
+
NOT for baselines. Use ``git_tree`` there: a replacement cannot restore a
|
|
172
|
+
deleted section, and cannot put one back in its original position.
|
|
173
|
+
"""
|
|
174
|
+
old: str
|
|
175
|
+
new: str
|
|
176
|
+
|
|
177
|
+
def apply(self, text):
|
|
178
|
+
"""Return ``text`` with ``old`` replaced by ``new``. Raise ``VariantError``
|
|
179
|
+
(a ``ValueError``) when ``old`` is absent, rather than returning the text
|
|
180
|
+
unchanged."""
|
|
181
|
+
if self.old not in text:
|
|
182
|
+
raise VariantError(
|
|
183
|
+
"variant anchor not found in the rendered text (anchor {!r}...): the "
|
|
184
|
+
"text it was written against has changed, so this variant would be a "
|
|
185
|
+
"no-op and its arm would report the unchanged prompt's numbers.".format(
|
|
186
|
+
self.old[:80]))
|
|
187
|
+
return text.replace(self.old, self.new)
|
|
188
|
+
|
|
189
|
+
def __call__(self, text):
|
|
190
|
+
return self.apply(text)
|
|
191
|
+
|
|
192
|
+
def code_version(self):
|
|
193
|
+
"""A token that changes when this replacement changes, for an arm's cache key."""
|
|
194
|
+
h = hashlib.md5()
|
|
195
|
+
h.update(self.old.encode('utf-8'))
|
|
196
|
+
h.update(b'\0')
|
|
197
|
+
h.update(self.new.encode('utf-8'))
|
|
198
|
+
return 'variant-' + h.hexdigest()[:10]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class PromptArm:
|
|
203
|
+
"""Where one arm reads its prompt files, AND the cache key that matches them.
|
|
204
|
+
|
|
205
|
+
The two have to agree, and nothing else makes them. Written by hand it is two
|
|
206
|
+
places -- a ``prompt_root(arm)`` that returns a directory and a
|
|
207
|
+
``code_version()`` that returns a token -- and the failure when they drift is
|
|
208
|
+
silent and bad: a baseline arm reading a checked-out tree but keyed on a hash
|
|
209
|
+
of the LIVE files re-runs when a live prompt is edited (merely wasteful) and
|
|
210
|
+
does NOT re-run when you point it at a different ref (stale, and reported as
|
|
211
|
+
fresh). One object owns both, so it cannot happen.
|
|
212
|
+
|
|
213
|
+
ARMS = {
|
|
214
|
+
'live': ev.PromptArm(globs=['prompts/*.jinja2'], root=ROOT),
|
|
215
|
+
'baseline': ev.PromptArm(ref='v2.1', paths=['prompts/'], root=ROOT),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# in the agent: ARMS[arm].dir()
|
|
219
|
+
# in the task: def code_version(self): return ARMS[self.arm].code_version()
|
|
220
|
+
|
|
221
|
+
``ref=None`` means the working tree, keyed on the CONTENT of ``globs``. A
|
|
222
|
+
``ref`` means that commit, materialized with :py:func:`git_tree` and keyed on
|
|
223
|
+
the resolved sha -- immutable, so a baseline arm's cell stays valid for good.
|
|
224
|
+
|
|
225
|
+
``subdir`` is applied to both, so the two arms hand the loader the same
|
|
226
|
+
layout and it never has to know which one it got.
|
|
227
|
+
"""
|
|
228
|
+
ref: str = None
|
|
229
|
+
paths: tuple = ()
|
|
230
|
+
globs: tuple = ()
|
|
231
|
+
root: str = None
|
|
232
|
+
subdir: str = ''
|
|
233
|
+
expect_absent: tuple = ()
|
|
234
|
+
dest: str = None
|
|
235
|
+
|
|
236
|
+
def __post_init__(self):
|
|
237
|
+
if self.ref is None and not self.globs:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
'PromptArm: the working-tree arm needs globs= (the files whose CONTENT '
|
|
240
|
+
'is its cache key) -- without them the arm is keyed on nothing and an '
|
|
241
|
+
'edited prompt is served from cache')
|
|
242
|
+
if self.ref is not None and not self.paths:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
'PromptArm: a ref arm needs paths= (what to materialize from {!r})'.format(
|
|
245
|
+
self.ref))
|
|
246
|
+
|
|
247
|
+
def dir(self):
|
|
248
|
+
"""The directory this arm reads its prompts from."""
|
|
249
|
+
base = Path(self.root) if self.root is not None else Path.cwd()
|
|
250
|
+
if self.ref is not None:
|
|
251
|
+
base = git_tree(self.ref, self.paths, dest=self.dest,
|
|
252
|
+
expect_absent=self.expect_absent, repo=self.root)
|
|
253
|
+
return base.joinpath(*self.subdir.split('/')) if self.subdir else base
|
|
254
|
+
|
|
255
|
+
def code_version(self):
|
|
256
|
+
"""The cache token for the files :py:meth:`dir` will hand out."""
|
|
257
|
+
if self.ref is not None:
|
|
258
|
+
return git_sha(self.ref, repo=self.root)
|
|
259
|
+
from oryxflow.utils import hash_files
|
|
260
|
+
return hash_files(*self.globs, root=self.root)
|