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.
@@ -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)