ezvals 0.2.0__py3-none-win_amd64.whl
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.
- ezvals/__init__.py +23 -0
- ezvals/bin/ezvals.exe +0 -0
- ezvals/cli.py +33 -0
- ezvals/context.py +134 -0
- ezvals/evals.py +287 -0
- ezvals/worker.py +66 -0
- ezvals-0.2.0.dist-info/METADATA +94 -0
- ezvals-0.2.0.dist-info/RECORD +10 -0
- ezvals-0.2.0.dist-info/WHEEL +4 -0
- ezvals-0.2.0.dist-info/entry_points.txt +2 -0
ezvals/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any, Callable, TypedDict
|
|
2
|
+
|
|
3
|
+
from ezvals.cli import run
|
|
4
|
+
from ezvals.context import EvalContext, EvalResult, TraceData
|
|
5
|
+
from ezvals.evals import eval, run_evals
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EvalCase(TypedDict, total=False):
|
|
9
|
+
"""An item of @eval(cases=[...]). Fields override the decorator's values for that case."""
|
|
10
|
+
id: str
|
|
11
|
+
input: Any
|
|
12
|
+
reference: Any
|
|
13
|
+
metadata: dict
|
|
14
|
+
dataset: str
|
|
15
|
+
labels: list
|
|
16
|
+
default_score_key: str
|
|
17
|
+
timeout: float
|
|
18
|
+
target: Callable
|
|
19
|
+
evaluators: list
|
|
20
|
+
trials: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["eval", "EvalContext", "EvalResult", "TraceData", "EvalCase", "run_evals", "run"]
|
ezvals/bin/ezvals.exe
ADDED
|
Binary file
|
ezvals/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
BINARY = Path(__file__).parent / "bin" / ("ezvals.exe" if os.name == "nt" else "ezvals")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
"""`ezvals` console script: hand off to the bundled host binary."""
|
|
13
|
+
env = {**os.environ, "EZVALS_PYTHON": sys.executable}
|
|
14
|
+
if os.name == "nt":
|
|
15
|
+
sys.exit(subprocess.run([str(BINARY), *sys.argv[1:]], env=env).returncode)
|
|
16
|
+
os.execve(BINARY, [str(BINARY), *sys.argv[1:]], env)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(path: str, dataset: Optional[str] = None, labels: Optional[list] = None, limit: Optional[int] = None,
|
|
20
|
+
output: Optional[str] = None, concurrency: Optional[int] = None, timeout: Optional[float] = None,
|
|
21
|
+
trials: Optional[int] = None, session: Optional[str] = None, run_name: Optional[str] = None, no_save: bool = False,
|
|
22
|
+
config: Optional[str] = None) -> dict:
|
|
23
|
+
"""Programmatic `ezvals run`. Returns the run (results, totals, ids) plus `saved_path`."""
|
|
24
|
+
flags = {"--dataset": dataset, "--limit": limit, "--output": output, "--concurrency": concurrency,
|
|
25
|
+
"--timeout": timeout, "--trials": trials, "--session": session, "--run-name": run_name, "--config": config}
|
|
26
|
+
args = [str(BINARY), "run", path, "--json"]
|
|
27
|
+
args += [arg for flag, value in flags.items() if value is not None for arg in (flag, str(value))]
|
|
28
|
+
args += [arg for label in labels or [] for arg in ("--label", label)]
|
|
29
|
+
args += ["--no-save"] if no_save else []
|
|
30
|
+
proc = subprocess.run(args, capture_output=True, text=True, env={**os.environ, "EZVALS_PYTHON": sys.executable})
|
|
31
|
+
if proc.returncode not in (0, 4): # 4: no evals matched, which still returns the (empty) run
|
|
32
|
+
raise ValueError(proc.stderr.strip())
|
|
33
|
+
return json.loads(proc.stdout)
|
ezvals/context.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TraceData(dict):
|
|
6
|
+
"""Trace/debug data. A dict with attribute access; `messages` and `trace_url` are always readable."""
|
|
7
|
+
|
|
8
|
+
def __getattr__(self, key):
|
|
9
|
+
if key == "messages":
|
|
10
|
+
return self.setdefault("messages", [])
|
|
11
|
+
if key == "trace_url":
|
|
12
|
+
return self.get("trace_url")
|
|
13
|
+
try:
|
|
14
|
+
return self[key]
|
|
15
|
+
except KeyError:
|
|
16
|
+
raise AttributeError(key) from None
|
|
17
|
+
|
|
18
|
+
def __setattr__(self, key, value):
|
|
19
|
+
self[key] = list(value) if key == "messages" else value
|
|
20
|
+
|
|
21
|
+
def add_messages(self, messages: list) -> "TraceData":
|
|
22
|
+
"""Replace messages (does not append)."""
|
|
23
|
+
self["messages"] = list(messages)
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_score(score: Any, default_key: Optional[str]) -> dict:
|
|
28
|
+
if isinstance(score, bool):
|
|
29
|
+
score = {"passed": score}
|
|
30
|
+
elif isinstance(score, (int, float)):
|
|
31
|
+
score = {"value": score}
|
|
32
|
+
elif not isinstance(score, dict) or score.get("value") is None and score.get("passed") is None:
|
|
33
|
+
raise ValueError(f"Invalid score {score!r}: use True/False, a number, "
|
|
34
|
+
"or {'key': ..., 'passed'/'value': ..., 'notes': ...}")
|
|
35
|
+
score = dict(score)
|
|
36
|
+
if "key" not in score:
|
|
37
|
+
if default_key is None:
|
|
38
|
+
raise ValueError("Must specify score key or set default_score_key")
|
|
39
|
+
score["key"] = default_key
|
|
40
|
+
return score
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_scores(scores: Any, default_key: Optional[str]) -> list:
|
|
44
|
+
"""A score, a list of scores, or None, as a list of score dicts."""
|
|
45
|
+
scores = scores if isinstance(scores, list) else [] if scores is None else [scores]
|
|
46
|
+
normalized = [normalize_score(s, default_key) for s in scores]
|
|
47
|
+
if sum(not (isinstance(s, dict) and "key" in s) for s in scores) > 1:
|
|
48
|
+
raise ValueError(f"Scores {scores!r} would share the key {default_key!r}: give each a 'key'")
|
|
49
|
+
return normalized
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class EvalResult:
|
|
54
|
+
input: Any
|
|
55
|
+
output: Any
|
|
56
|
+
reference: Any = None
|
|
57
|
+
scores: list = field(default_factory=list)
|
|
58
|
+
error: Optional[str] = None
|
|
59
|
+
latency: Optional[float] = None
|
|
60
|
+
metadata: dict = field(default_factory=dict)
|
|
61
|
+
trace_data: TraceData = field(default_factory=TraceData)
|
|
62
|
+
|
|
63
|
+
def __post_init__(self):
|
|
64
|
+
self.scores = normalize_scores(self.scores, "pass")
|
|
65
|
+
self.metadata = self.metadata or {}
|
|
66
|
+
self.trace_data = TraceData(self.trace_data or {})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EvalContext:
|
|
70
|
+
"""Mutable builder for an EvalResult. Injected into any eval parameter annotated as EvalContext."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, input=None, output=None, reference=None, default_score_key: Optional[str] = "pass",
|
|
73
|
+
metadata=None, trace_data=None, latency=None, function_name=None, dataset=None, labels=None,
|
|
74
|
+
**run_info):
|
|
75
|
+
self.input = input
|
|
76
|
+
self.output = output
|
|
77
|
+
self.reference = reference
|
|
78
|
+
self.default_score_key = default_score_key
|
|
79
|
+
self.metadata = dict(metadata or {})
|
|
80
|
+
self.trace_data = TraceData(trace_data or {})
|
|
81
|
+
self.latency = latency
|
|
82
|
+
self.scores: list = []
|
|
83
|
+
self.error: Optional[str] = None
|
|
84
|
+
self.function_name = function_name
|
|
85
|
+
self.dataset = dataset
|
|
86
|
+
self.labels = labels
|
|
87
|
+
self._run = run_info
|
|
88
|
+
|
|
89
|
+
def store(self, input=None, output=None, reference=None, latency=None, scores=None, messages=None,
|
|
90
|
+
trace_url=None, metadata=None, trace_data=None) -> "EvalContext":
|
|
91
|
+
"""Set any fields passed. Scores with an existing key overwrite it; metadata and trace_data merge."""
|
|
92
|
+
if input is not None:
|
|
93
|
+
self.input = input
|
|
94
|
+
if output is not None:
|
|
95
|
+
self.output = output
|
|
96
|
+
if reference is not None:
|
|
97
|
+
self.reference = reference
|
|
98
|
+
if latency is not None:
|
|
99
|
+
self.latency = latency
|
|
100
|
+
if trace_data is not None:
|
|
101
|
+
self.trace_data.update(trace_data)
|
|
102
|
+
if messages is not None:
|
|
103
|
+
self.trace_data.messages = messages
|
|
104
|
+
if trace_url is not None:
|
|
105
|
+
self.trace_data.trace_url = trace_url
|
|
106
|
+
if metadata is not None:
|
|
107
|
+
self.metadata.update(metadata)
|
|
108
|
+
for score in normalize_scores(scores, self.default_score_key):
|
|
109
|
+
keys = [s["key"] for s in self.scores]
|
|
110
|
+
if score["key"] in keys:
|
|
111
|
+
self.scores[keys.index(score["key"])] = score
|
|
112
|
+
else:
|
|
113
|
+
self.scores.append(score)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def build(self) -> EvalResult:
|
|
117
|
+
scores = self.scores or ([] if self.error else [{"key": self.default_score_key or "pass", "passed": True}])
|
|
118
|
+
return EvalResult(input=self.input, output=self.output, reference=self.reference, scores=scores,
|
|
119
|
+
error=self.error, latency=self.latency, metadata=self.metadata, trace_data=self.trace_data)
|
|
120
|
+
|
|
121
|
+
def build_with_error(self, error: str) -> EvalResult:
|
|
122
|
+
self.error = error
|
|
123
|
+
return self.build()
|
|
124
|
+
|
|
125
|
+
def __enter__(self):
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def __exit__(self, *exc):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
for _name in ("run_id", "session_name", "run_name", "eval_path"):
|
|
133
|
+
setattr(EvalContext, _name, property(lambda self, n=_name: self._run.get(n)))
|
|
134
|
+
EvalContext.config = property(lambda self: self._run.get("config") or {})
|
ezvals/evals.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import contextvars
|
|
4
|
+
import functools
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
12
|
+
import typing
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from ezvals.context import EvalContext, EvalResult, normalize_scores
|
|
19
|
+
|
|
20
|
+
# Parameters that file defaults and individual cases can set.
|
|
21
|
+
PARAMS = {"input", "reference", "dataset", "labels", "metadata", "default_score_key", "timeout", "target", "evaluators",
|
|
22
|
+
"trials"}
|
|
23
|
+
_UNSET = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EvalFunction:
|
|
27
|
+
def __init__(self, func: Callable, params: dict):
|
|
28
|
+
self.func = func
|
|
29
|
+
self.params = params
|
|
30
|
+
self.ctx_param = next((name for name, hint in _hints(func).items() if _is_context(hint)), None)
|
|
31
|
+
needs_ctx = {"target", "input_loader", "cases"} & params.keys()
|
|
32
|
+
if needs_ctx and self.ctx_param is None:
|
|
33
|
+
raise ValueError(f"{sorted(needs_ctx)[0]} requires the evaluation function to accept a context parameter")
|
|
34
|
+
if "input_loader" in params and {"input", "reference", "cases"} & params.keys():
|
|
35
|
+
raise ValueError("input_loader cannot be used with input=, reference= or cases=")
|
|
36
|
+
functools.update_wrapper(self, func)
|
|
37
|
+
|
|
38
|
+
def __call__(self):
|
|
39
|
+
return _run_sync(self.call_async())
|
|
40
|
+
|
|
41
|
+
async def call_async(self):
|
|
42
|
+
results = [r for e in expand(self) for r in await e.run()]
|
|
43
|
+
return results[0] if len(results) == 1 else results
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def eval(func=None, *, input=_UNSET, reference=_UNSET, dataset=_UNSET, labels=_UNSET, metadata=_UNSET,
|
|
47
|
+
default_score_key=_UNSET, timeout=_UNSET, target=_UNSET, evaluators=_UNSET, input_loader=_UNSET,
|
|
48
|
+
cases=_UNSET, trials=_UNSET):
|
|
49
|
+
"""Mark a function as an evaluation. Usable as @eval or @eval(...). `ezvals run` runs it `trials` times."""
|
|
50
|
+
params = {k: v for k, v in locals().items() if k != "func" and v is not _UNSET}
|
|
51
|
+
if func is not None:
|
|
52
|
+
return EvalFunction(func, params)
|
|
53
|
+
return lambda f: EvalFunction(f, params)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Eval:
|
|
58
|
+
"""One runnable evaluation: an EvalFunction resolved against file defaults and a single case."""
|
|
59
|
+
id: str
|
|
60
|
+
name: str
|
|
61
|
+
func: Callable
|
|
62
|
+
ctx_param: Optional[str]
|
|
63
|
+
params: dict
|
|
64
|
+
error: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
async def run(self, run_info: Optional[dict] = None, grade: Optional[dict] = None) -> list:
|
|
67
|
+
"""Run the eval. With `grade` (a stored result), skip the target and score that result's output again."""
|
|
68
|
+
run_info = run_info or {}
|
|
69
|
+
p = self.params
|
|
70
|
+
if self.error:
|
|
71
|
+
return [EvalResult(input=None, output=None, error=self.error)]
|
|
72
|
+
ctx = EvalContext(input=p.get("input"), reference=p.get("reference"), metadata=p.get("metadata"),
|
|
73
|
+
default_score_key=p["default_score_key"], function_name=self.name,
|
|
74
|
+
dataset=p["dataset"], labels=p["labels"], **run_info) if self.ctx_param else None
|
|
75
|
+
if grade:
|
|
76
|
+
ctx.store(input=grade.get("input"), output=grade.get("output"), latency=grade.get("latency"),
|
|
77
|
+
metadata=grade.get("metadata"), trace_data=grade.get("trace_data"))
|
|
78
|
+
timeout = run_info.get("timeout") or p.get("timeout")
|
|
79
|
+
start = time.perf_counter()
|
|
80
|
+
try:
|
|
81
|
+
returned = await asyncio.wait_for(self._call_with_target(ctx, skip_target=bool(grade)), timeout)
|
|
82
|
+
if returned is None and ctx is not None:
|
|
83
|
+
returned = ctx
|
|
84
|
+
if isinstance(returned, EvalContext):
|
|
85
|
+
returned = returned.build()
|
|
86
|
+
results = returned if isinstance(returned, list) else [returned]
|
|
87
|
+
if not all(isinstance(r, EvalResult) for r in results):
|
|
88
|
+
raise ValueError("Evaluation function must return EvalResult, List[EvalResult], EvalContext, "
|
|
89
|
+
f"or None (with a context parameter), got {type(returned).__name__}")
|
|
90
|
+
except asyncio.TimeoutError:
|
|
91
|
+
results = [_errored(ctx, f"TimeoutError: Evaluation timed out after {float(timeout)}s")]
|
|
92
|
+
except AssertionError as e:
|
|
93
|
+
if ctx is None:
|
|
94
|
+
results = [_errored(None, describe(e))]
|
|
95
|
+
else: # a bare `assert` has no message, so its source line explains the failure
|
|
96
|
+
notes = str(e) or traceback.extract_tb(e.__traceback__)[-1].line or "Assertion failed"
|
|
97
|
+
ctx.store(scores={"passed": False, "notes": notes})
|
|
98
|
+
results = [ctx.build()]
|
|
99
|
+
except Exception as e:
|
|
100
|
+
results = [_errored(ctx, describe(e))]
|
|
101
|
+
if ctx is not None and inspect.iscoroutine(ctx.output):
|
|
102
|
+
ctx.output.close()
|
|
103
|
+
ctx.output = None
|
|
104
|
+
results = [ctx.build_with_error("TypeError: ctx.output was never awaited. Did you forget await?")]
|
|
105
|
+
try: # evaluators score every finished result, including ones a failed assertion scored
|
|
106
|
+
for i in range(len(results)):
|
|
107
|
+
for evaluator in [] if results[i].error else p.get("evaluators") or []:
|
|
108
|
+
scored = await _call(evaluator, results[i])
|
|
109
|
+
if isinstance(scored, EvalResult):
|
|
110
|
+
results[i] = scored
|
|
111
|
+
elif scored is not None:
|
|
112
|
+
results[i].scores += normalize_scores(scored, "pass")
|
|
113
|
+
except Exception as e:
|
|
114
|
+
results = [_errored(ctx, describe(e))]
|
|
115
|
+
for result in results:
|
|
116
|
+
if not result.scores and not result.error:
|
|
117
|
+
result.scores = [{"key": p["default_score_key"], "passed": True}]
|
|
118
|
+
if result.latency is None:
|
|
119
|
+
result.latency = (time.perf_counter() - start) / len(results)
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
async def _call_with_target(self, ctx, skip_target):
|
|
123
|
+
if self.params.get("target") and not skip_target:
|
|
124
|
+
out = await _call(self.params["target"], ctx)
|
|
125
|
+
if isinstance(out, EvalResult):
|
|
126
|
+
ctx.store(output=out.output, latency=out.latency, trace_data=out.trace_data, metadata=out.metadata)
|
|
127
|
+
elif out is not None and not isinstance(out, EvalContext):
|
|
128
|
+
ctx.store(output=out)
|
|
129
|
+
return await _call(self.func, **({self.ctx_param: ctx} if self.ctx_param else {}))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def expand(fn: EvalFunction, file_defaults: Optional[dict] = None, file: Optional[str] = None) -> list:
|
|
133
|
+
"""Resolve an EvalFunction into runnable Evals: builtins < file defaults < decorator < each case."""
|
|
134
|
+
file = file or inspect.getfile(fn.func)
|
|
135
|
+
unknown = set(file_defaults or {}) - PARAMS
|
|
136
|
+
if unknown:
|
|
137
|
+
raise ValueError(f"Unknown keys in ezvals_defaults: {', '.join(sorted(unknown))}")
|
|
138
|
+
builtins = {"dataset": Path(file).stem, "labels": [], "metadata": {}, "default_score_key": "pass"}
|
|
139
|
+
base = _layer(_layer(builtins, file_defaults or {}, merge_labels=False),
|
|
140
|
+
{k: v for k, v in fn.params.items() if k in PARAMS}, merge_labels=False)
|
|
141
|
+
name = fn.func.__name__
|
|
142
|
+
|
|
143
|
+
def make(case_name, params, error=None):
|
|
144
|
+
return Eval(f"{file}::{case_name}", case_name, fn.func, fn.ctx_param, params, error)
|
|
145
|
+
|
|
146
|
+
if "input_loader" in fn.params:
|
|
147
|
+
try:
|
|
148
|
+
examples = _run_sync(_call(fn.params["input_loader"]))
|
|
149
|
+
except Exception as e:
|
|
150
|
+
return [make(name, base, describe(e, f"Input loader failed: {e}"))]
|
|
151
|
+
cases = [ex if isinstance(ex, dict) else {k: getattr(ex, k) for k in PARAMS | {"id"} if hasattr(ex, k)} for ex in examples]
|
|
152
|
+
elif "cases" in fn.params:
|
|
153
|
+
cases = fn.params["cases"]
|
|
154
|
+
else:
|
|
155
|
+
return [make(name, base)]
|
|
156
|
+
|
|
157
|
+
evals = []
|
|
158
|
+
for i, case in enumerate(cases):
|
|
159
|
+
unknown = set(case) - PARAMS - {"id"}
|
|
160
|
+
if unknown:
|
|
161
|
+
raise ValueError(f"Unknown case keys: {', '.join(sorted(unknown))}")
|
|
162
|
+
overrides = {k: v for k, v in case.items() if k != "id"}
|
|
163
|
+
evals.append(make(f"{name}[{case.get('id', i)}]", _layer(base, overrides, merge_labels=True)))
|
|
164
|
+
return evals
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def discover(files: list) -> list:
|
|
168
|
+
"""Import eval files and return their Evals in source order."""
|
|
169
|
+
evals = []
|
|
170
|
+
for file in files:
|
|
171
|
+
sys.path.insert(0, str(Path(file).resolve().parent))
|
|
172
|
+
spec = importlib.util.spec_from_file_location(Path(file).stem, file)
|
|
173
|
+
module = importlib.util.module_from_spec(spec)
|
|
174
|
+
sys.modules[spec.name] = module
|
|
175
|
+
spec.loader.exec_module(module)
|
|
176
|
+
found = [obj for obj in vars(module).values()
|
|
177
|
+
if isinstance(obj, EvalFunction) and obj.func.__module__ == module.__name__]
|
|
178
|
+
for fn in sorted(found, key=lambda f: f.func.__code__.co_firstlineno):
|
|
179
|
+
evals += expand(fn, getattr(module, "ezvals_defaults", None), file)
|
|
180
|
+
return evals
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run_evals(evals: list, concurrency: int = 1, timeout: Optional[float] = None, dataset: Optional[str] = None,
|
|
184
|
+
labels: Optional[list] = None, limit: Optional[int] = None) -> list:
|
|
185
|
+
"""Run eval functions and/or paths in-process and return their EvalResults (nothing is saved)."""
|
|
186
|
+
resolved = []
|
|
187
|
+
for item in evals:
|
|
188
|
+
resolved += expand(item) if isinstance(item, EvalFunction) else discover(_eval_files(item))
|
|
189
|
+
if dataset:
|
|
190
|
+
resolved = [e for e in resolved if e.params["dataset"] in dataset.split(",")]
|
|
191
|
+
if labels:
|
|
192
|
+
resolved = [e for e in resolved if set(labels) & set(e.params["labels"])]
|
|
193
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
194
|
+
|
|
195
|
+
async def one(e):
|
|
196
|
+
async with semaphore:
|
|
197
|
+
return await e.run({"timeout": timeout})
|
|
198
|
+
|
|
199
|
+
async def all_():
|
|
200
|
+
return await asyncio.gather(*(one(e) for e in resolved[:limit]))
|
|
201
|
+
|
|
202
|
+
return [r for results in _run_sync(all_()) for r in results]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _eval_files(path: str) -> list:
|
|
206
|
+
p = Path(path)
|
|
207
|
+
if p.is_file():
|
|
208
|
+
return [str(p)]
|
|
209
|
+
return [str(f) for f in sorted(p.rglob("*.py"))
|
|
210
|
+
if not f.name.startswith("_") and not any(part.startswith((".", "__")) for part in f.relative_to(p).parts)]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _layer(base: dict, over: dict, merge_labels: bool) -> dict:
|
|
214
|
+
"""Overlay params. Metadata merges (None clears); case labels merge with base labels (None/[] clears)."""
|
|
215
|
+
out = {**base, **over}
|
|
216
|
+
if over.get("metadata") is not None:
|
|
217
|
+
out["metadata"] = {**(base.get("metadata") or {}), **over["metadata"]}
|
|
218
|
+
if merge_labels and "labels" in over:
|
|
219
|
+
out["labels"] = base["labels"] + [l for l in over["labels"] or [] if l not in base["labels"]] if over["labels"] else []
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_INTERNAL = (os.path.dirname(__file__), os.path.dirname(asyncio.__file__), threading.__file__, "<frozen")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def describe(error: BaseException, headline: Optional[str] = None) -> str:
|
|
227
|
+
"""The error, then its traceback through the user's own code only (not ezvals', asyncio's or threading's)."""
|
|
228
|
+
frames = [f for f in traceback.extract_tb(error.__traceback__) if not f.filename.startswith(_INTERNAL)]
|
|
229
|
+
for f in frames:
|
|
230
|
+
if f.filename.startswith(os.getcwd() + os.sep):
|
|
231
|
+
f.filename = os.path.relpath(f.filename)
|
|
232
|
+
where = "Traceback (most recent call last):\n" + "".join(traceback.format_list(frames)) if frames else ""
|
|
233
|
+
return (headline + "\n" if headline else "".join(traceback.format_exception_only(error))) + where
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _errored(ctx: Optional[EvalContext], error: str) -> EvalResult:
|
|
237
|
+
return ctx.build_with_error(error) if ctx else EvalResult(input=None, output=None, error=error)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _call(fn, *args, **kwargs):
|
|
241
|
+
"""Await async callables; run sync ones on a daemon thread so a timeout can abandon them."""
|
|
242
|
+
if inspect.iscoroutinefunction(fn):
|
|
243
|
+
return await fn(*args, **kwargs)
|
|
244
|
+
loop = asyncio.get_running_loop()
|
|
245
|
+
future = loop.create_future()
|
|
246
|
+
call = functools.partial(contextvars.copy_context().run, fn, *args, **kwargs)
|
|
247
|
+
|
|
248
|
+
def settle(result=None, error=None):
|
|
249
|
+
if not future.done():
|
|
250
|
+
future.set_exception(error) if error else future.set_result(result)
|
|
251
|
+
|
|
252
|
+
def target():
|
|
253
|
+
try:
|
|
254
|
+
outcome = (call(), None)
|
|
255
|
+
except BaseException as e:
|
|
256
|
+
outcome = (None, e)
|
|
257
|
+
with contextlib.suppress(RuntimeError): # the loop is gone if this call was abandoned after a timeout
|
|
258
|
+
loop.call_soon_threadsafe(settle, *outcome)
|
|
259
|
+
|
|
260
|
+
threading.Thread(target=target, daemon=True).start()
|
|
261
|
+
return await future
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _run_sync(coro):
|
|
265
|
+
try:
|
|
266
|
+
asyncio.get_running_loop()
|
|
267
|
+
except RuntimeError:
|
|
268
|
+
return asyncio.run(coro)
|
|
269
|
+
with ThreadPoolExecutor(1) as pool:
|
|
270
|
+
return pool.submit(asyncio.run, coro).result()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _hints(func) -> dict:
|
|
274
|
+
params = inspect.signature(func).parameters
|
|
275
|
+
try:
|
|
276
|
+
hints = typing.get_type_hints(func)
|
|
277
|
+
except Exception:
|
|
278
|
+
hints = {}
|
|
279
|
+
return {name: hints.get(name, p.annotation) for name, p in params.items()}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_context(hint) -> bool:
|
|
283
|
+
if hint is EvalContext:
|
|
284
|
+
return True
|
|
285
|
+
if isinstance(hint, (str, typing.ForwardRef)):
|
|
286
|
+
return str(getattr(hint, "__forward_arg__", hint)).split(".")[-1] == "EvalContext"
|
|
287
|
+
return any(_is_context(arg) for arg in typing.get_args(hint))
|
ezvals/worker.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Worker process the ezvals host spawns to run Python evals.
|
|
2
|
+
|
|
3
|
+
Protocol (JSON lines): the worker writes {"type": "evals"} once discovery finishes, then reads
|
|
4
|
+
{"id", "run", "grade"?} requests from stdin and answers each with {"type": "result", "id"}. Evals run
|
|
5
|
+
concurrently; the worker exits once stdin closes and in-flight evals finish. User output goes to stderr.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from ezvals.evals import describe, discover
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
protocol = os.fdopen(os.dup(1), "w", buffering=1)
|
|
18
|
+
os.dup2(2, 1)
|
|
19
|
+
sys.stdout.reconfigure(line_buffering=True) # user prints show up as they happen with --verbose
|
|
20
|
+
|
|
21
|
+
def send(message):
|
|
22
|
+
try:
|
|
23
|
+
line = json.dumps(message, default=_jsonable, allow_nan=False)
|
|
24
|
+
except ValueError: # NaN/Infinity aren't JSON: send them as null
|
|
25
|
+
line = json.dumps(json.loads(json.dumps(message, default=_jsonable), parse_constant=lambda _: None))
|
|
26
|
+
protocol.write(line + "\n")
|
|
27
|
+
|
|
28
|
+
run_info = json.loads(os.environ.get("EZVALS_RUN") or "{}")
|
|
29
|
+
try:
|
|
30
|
+
evals = {e.id: e for e in discover(sys.argv[1:])}
|
|
31
|
+
except Exception as error:
|
|
32
|
+
send({"type": "error", "error": describe(error)})
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
send({"type": "evals", "evals": [{
|
|
35
|
+
"id": e.id, "function": e.name, "dataset": e.params["dataset"], "labels": e.params["labels"],
|
|
36
|
+
"input": e.params.get("input"), "reference": e.params.get("reference"), "metadata": e.params.get("metadata"),
|
|
37
|
+
"trials": e.params.get("trials"), "target": bool(e.params.get("target")),
|
|
38
|
+
} for e in evals.values()]})
|
|
39
|
+
|
|
40
|
+
async def run(request):
|
|
41
|
+
try:
|
|
42
|
+
results = await evals[request["run"]].run(run_info, request.get("grade"))
|
|
43
|
+
send({"type": "result", "id": request["id"], "results": [vars(r) for r in results]})
|
|
44
|
+
except Exception as error: # the host waits for every result, so one that can't be reported becomes an error
|
|
45
|
+
send({"type": "result", "id": request["id"], "results": [{"error": describe(error)}]})
|
|
46
|
+
|
|
47
|
+
async def serve():
|
|
48
|
+
loop = asyncio.get_running_loop()
|
|
49
|
+
tasks = set()
|
|
50
|
+
while line := await loop.run_in_executor(None, sys.stdin.readline):
|
|
51
|
+
tasks.add(asyncio.create_task(run(json.loads(line))))
|
|
52
|
+
await asyncio.gather(*tasks)
|
|
53
|
+
|
|
54
|
+
asyncio.run(serve())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _jsonable(obj):
|
|
58
|
+
if hasattr(obj, "model_dump"):
|
|
59
|
+
return obj.model_dump()
|
|
60
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
61
|
+
return dataclasses.asdict(obj)
|
|
62
|
+
return str(obj)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ezvals
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight, code-first evaluation framework for testing AI agents and LLM applications
|
|
5
|
+
Project-URL: Homepage, https://github.com/camronh/EZVals
|
|
6
|
+
Project-URL: Repository, https://github.com/camronh/EZVals
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agents,benchmark,cli,evaluation,llm,testing
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# EZVals
|
|
17
|
+
|
|
18
|
+
Unit testing for AI agents and LLM apps. Write evals like tests, in Python or TypeScript; EZVals runs them, stores every result, and gives you a web UI to review, annotate and compare runs.
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
EZVals is a development dependency.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add --dev ezvals # or: pip install ezvals
|
|
28
|
+
npm install --save-dev ezvals # TypeScript (Node >= 22.18)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Write an eval
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
# evals/support.py
|
|
35
|
+
from ezvals import eval, EvalContext
|
|
36
|
+
|
|
37
|
+
@eval(input="I want a refund", dataset="customer_service")
|
|
38
|
+
async def test_refund(ctx: EvalContext):
|
|
39
|
+
ctx.output = await run_agent(ctx.input)
|
|
40
|
+
assert "refund" in ctx.output.lower(), "Should acknowledge refund"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// evals/support.eval.ts
|
|
45
|
+
import assert from "node:assert";
|
|
46
|
+
import { evaluate } from "ezvals";
|
|
47
|
+
|
|
48
|
+
evaluate("refund", { input: "I want a refund", dataset: "customer_service" }, async (ctx) => {
|
|
49
|
+
ctx.output = await runAgent(ctx.input);
|
|
50
|
+
assert(String(ctx.output).toLowerCase().includes("refund"), "Should acknowledge refund");
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A failed assertion is a failing score, not an error, and whatever the eval stored before it is kept. Use `cases=` to run one eval over many inputs, `ctx.store(scores=...)` for named or numeric scores, and `target=` to separate calling your agent from scoring it. See the [docs](https://github.com/camronh/EZVals/tree/main/docs) and [examples](https://github.com/camronh/EZVals/tree/main/examples).
|
|
55
|
+
|
|
56
|
+
## Run it
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
ezvals run evals/ # headless; saves to .ezvals/sessions/
|
|
60
|
+
ezvals run evals/ --json # ...and print the results as JSON (handy for agents)
|
|
61
|
+
ezvals serve evals/ # web UI: run, filter, annotate, compare runs
|
|
62
|
+
ezvals run evals/support.py::test_refund -c 8 --timeout 30
|
|
63
|
+
ezvals run evals/ --trials 5 # run each eval 5 times; reports pass@5 and pass^5
|
|
64
|
+
ezvals regrade a1b2c3d4 # re-score a run's outputs after changing a grader, without re-running the agent
|
|
65
|
+
ezvals query "SELECT run_name, total_passed FROM runs" # SQL over every saved run (see: ezvals query --schema)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
TypeScript projects run the same commands through `npx ezvals`. A directory with both languages runs as one.
|
|
69
|
+
|
|
70
|
+
## Agent skill
|
|
71
|
+
|
|
72
|
+
EZVals ships a skill that teaches coding agents to write and analyze evals:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ezvals skills add --claude # or --codex, --cursor, --windsurf, --kiro, --roo, --agents
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
`ezvals` is a single Go binary (`cmd/ezvals/`) that owns the CLI, the web UI (`ui/`, embedded at build time) and storage. To run evals it spawns a small worker from the language SDK (`python/`, `typescript/`) that imports your files and executes evals on request; results stream back and are appended to a per-run event log. Adding a language means writing an SDK against [the SDK spec](https://github.com/camronh/EZVals/blob/main/docs/.spec/EXPERIENCE_SPEC_SDK.md) and passing the shared `conformance/` fixtures.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
Requires Go, Node, and uv.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
make build # UI + host binary (into python/ezvals/bin) + TypeScript SDK
|
|
88
|
+
make test # Go (incl. cross-language conformance), Python and TypeScript tests
|
|
89
|
+
make test-e2e # browser tests for the web UI against a real server
|
|
90
|
+
make storybook # every UI component and page in its states (also run as tests by `make test`)
|
|
91
|
+
uv run --project python ezvals serve examples
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Specs in `docs/.spec/` are the source of truth for behavior.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ezvals/__init__.py,sha256=ANt0TZK8B8JuwGB_gSC1gYTTq7fJ_VNUnWORhhVWHfY,610
|
|
2
|
+
ezvals/cli.py,sha256=XAzIrl7qZ6u88qJlYW2gZjTKACiLQrZUARLW9R90I6M,1775
|
|
3
|
+
ezvals/context.py,sha256=jz_26oBUMu2pI1LXJ0WsjlgALpfssNCpkh_7B646L3Q,5317
|
|
4
|
+
ezvals/evals.py,sha256=2kHIMQuSn_20FpelO1hVElyUIzY6bHBaiB0rmS-dPGw,13096
|
|
5
|
+
ezvals/worker.py,sha256=TuL38t8Jg69zH3IYt4PUKwSX3iHqEEMGdcvuGJ_wCXw,2609
|
|
6
|
+
ezvals/bin/ezvals.exe,sha256=lKAtvh4zDlUPjnrVW6NQ_Ck6RS2F3IbP0MdXv-qsnJk,18387456
|
|
7
|
+
ezvals-0.2.0.dist-info/METADATA,sha256=0O1_3Vm7EcLFLYxhGjduezJASU5Fh9Xsp6uKPKjOSYY,4145
|
|
8
|
+
ezvals-0.2.0.dist-info/WHEEL,sha256=A7YmR1UrJozxyeLCzQFwQUdR7CRUwkFKasEDmnrjVxY,94
|
|
9
|
+
ezvals-0.2.0.dist-info/entry_points.txt,sha256=HgjFjE8IGlHpiVID1_NF3vqwcwxcml_MW4u7OH1cCqo,43
|
|
10
|
+
ezvals-0.2.0.dist-info/RECORD,,
|