evalcore 0.1.0__py3-none-any.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.
- evalcore-0.1.0.dist-info/METADATA +828 -0
- evalcore-0.1.0.dist-info/RECORD +34 -0
- evalcore-0.1.0.dist-info/WHEEL +4 -0
- evalcore-0.1.0.dist-info/entry_points.txt +2 -0
- evalcore-0.1.0.dist-info/licenses/LICENSE +28 -0
- evalkit/__init__.py +39 -0
- evalkit/adapters/__init__.py +15 -0
- evalkit/adapters/_env.py +24 -0
- evalkit/adapters/base.py +40 -0
- evalkit/adapters/http.py +96 -0
- evalkit/adapters/replay.py +41 -0
- evalkit/cli.py +655 -0
- evalkit/compare.py +137 -0
- evalkit/graders/__init__.py +18 -0
- evalkit/graders/base.py +77 -0
- evalkit/graders/classification.py +107 -0
- evalkit/graders/deterministic.py +127 -0
- evalkit/graders/judge.py +635 -0
- evalkit/graders/numeric.py +91 -0
- evalkit/loader.py +129 -0
- evalkit/models.py +390 -0
- evalkit/pairwise.py +324 -0
- evalkit/py.typed +0 -0
- evalkit/rating.py +1323 -0
- evalkit/refs.py +71 -0
- evalkit/report.py +186 -0
- evalkit/reporters/__init__.py +33 -0
- evalkit/reporters/base.py +159 -0
- evalkit/reporters/html.py +426 -0
- evalkit/reporters/markdown.py +74 -0
- evalkit/retry.py +85 -0
- evalkit/runner.py +283 -0
- evalkit/store.py +286 -0
- evalkit/sweep.py +93 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
evalkit/__init__.py,sha256=_1rkqqBO1YKj8ARwj0rza_CiDg4GoneGnkiKXifTE8o,910
|
|
2
|
+
evalkit/cli.py,sha256=-PXWKp9wNHY7Dbc0Z6NMGiiEH7Y57TVoYYNCUsqEGBs,22065
|
|
3
|
+
evalkit/compare.py,sha256=VJveGmXcOOiJafFm2tun_kH1WYoL3niBZKBcIAyj1-Q,4487
|
|
4
|
+
evalkit/loader.py,sha256=kCBeSHsXdIS4dsNqFeTP71z42Kpw2Y5DboxS35j8Ppg,4768
|
|
5
|
+
evalkit/models.py,sha256=OHW2BDj1TR0uPv3RM3ksUzJER4MQwMb5nj3c8t_r_cQ,12555
|
|
6
|
+
evalkit/pairwise.py,sha256=A_F-SbOC8YZNjZgdQl7O4mLAJIReJBsyPH8BRRxkwwo,10957
|
|
7
|
+
evalkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
evalkit/rating.py,sha256=Rn0U1Wb2vqz0prH3bxtyJOT66nRGV6Lo4zZCVW_seMg,56585
|
|
9
|
+
evalkit/refs.py,sha256=WQNQqnYV640DbCBLydddU8MhpkdZ_S2-Wp_ZJ1NyABU,2611
|
|
10
|
+
evalkit/report.py,sha256=yYD5DjWNYV1sE2u8WMV8iyH-NbRpk25bvO7DNWUtQD8,6845
|
|
11
|
+
evalkit/retry.py,sha256=UtiAIobz8DLlvxPLXmG0zf7R_C_X8_IgwOiLKoyCwOo,3029
|
|
12
|
+
evalkit/runner.py,sha256=3yzqVTPtjz5k5xon5pw6nwfSTU1MP473zIcjFvlM6CI,10691
|
|
13
|
+
evalkit/store.py,sha256=afqMchrH77gecGAMuseU4Ir9Yfr6GHaRs7Trg7h3oUQ,10805
|
|
14
|
+
evalkit/sweep.py,sha256=GqXTCYM3R33y5RXau5aVVlrgUrXgwUzPgFNFh_EW1BA,2984
|
|
15
|
+
evalkit/adapters/__init__.py,sha256=7KwMEQp3ExS4hyfv6eH4IOJC9U8_iG9uTRat8bsqjc4,681
|
|
16
|
+
evalkit/adapters/_env.py,sha256=_VYsz7XFE4hk2xXaPXR_NvkkzRK_YcitToBQSfMaduM,833
|
|
17
|
+
evalkit/adapters/base.py,sha256=o0IFRohtPepvH9dEtiayVf8FEazsDl8aWhTct7CXzac,1090
|
|
18
|
+
evalkit/adapters/http.py,sha256=X_JpV2_I2ovOZvE2_gVBA-12Agq9YAtkFk4NDC5a0PM,3407
|
|
19
|
+
evalkit/adapters/replay.py,sha256=MZo2-zI23YeKv6jf3FIsgTttdvJULstf4ziPG4HuaDI,1467
|
|
20
|
+
evalkit/graders/__init__.py,sha256=jBVRJiIPk5Sl_mJbnS_UCRaGDuBWfLIM7ba1CU_zkYY,784
|
|
21
|
+
evalkit/graders/base.py,sha256=xzH3igBdwaTLrSNKRgz6DZCuBDXcXSE8-9PJYMg41T8,2269
|
|
22
|
+
evalkit/graders/classification.py,sha256=W6-vo51FU3nPNn8WEwi0zu-kwQT6HmQ_cbdDhrqhoSw,3872
|
|
23
|
+
evalkit/graders/deterministic.py,sha256=kwqIRq0ihC0VrBT72xc6YuOeT3oPwYf1K2TBAR-X63c,4047
|
|
24
|
+
evalkit/graders/judge.py,sha256=8az97kI3BL5hMmAoVjutL8bo6N0lHIiBpR2iaeH4jsw,23864
|
|
25
|
+
evalkit/graders/numeric.py,sha256=LWGrvnXhuE6cJFlZ6eMYs4oICOIyhCWMZACWOshsFaA,3187
|
|
26
|
+
evalkit/reporters/__init__.py,sha256=PRwuWfX054aDU6VQcYlMbout5Nmtu6Yao7b8yv8gpr8,839
|
|
27
|
+
evalkit/reporters/base.py,sha256=vHD5QZ63Y_bxef9DhanbwVkofnOoe3d8WhfY2nakvjI,5660
|
|
28
|
+
evalkit/reporters/html.py,sha256=6jo4REOKcSRNIDzFbqISlAjc43fgZ00umVWRMIV40oE,17899
|
|
29
|
+
evalkit/reporters/markdown.py,sha256=acXtxIRskPOE28RIpZWX83Gewav3ypcnVhBjl5yVcp0,2844
|
|
30
|
+
evalcore-0.1.0.dist-info/METADATA,sha256=shSdhgGK2gyY4SotUxGsQjY2xmuApayelaqpkcTbGgc,38374
|
|
31
|
+
evalcore-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
32
|
+
evalcore-0.1.0.dist-info/entry_points.txt,sha256=qW9PtJgBdWRQAMp2kDN3AxqNna_nBk0sA35u6jEa0dY,45
|
|
33
|
+
evalcore-0.1.0.dist-info/licenses/LICENSE,sha256=k5Kb0VE7QFc6o_kU42n1Q5EGQqbGBl6HhprgjDYN_jw,1507
|
|
34
|
+
evalcore-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AWeber Communications
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
16
|
+
may be used to endorse or promote products derived from this software
|
|
17
|
+
without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
evalkit/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""evalkit - a generic, consumer-agnostic evaluation engine.
|
|
2
|
+
|
|
3
|
+
The engine knows nothing about any particular system under test. A consumer
|
|
4
|
+
supplies four things as data/plug-ins:
|
|
5
|
+
|
|
6
|
+
1. an **adapter** config (how to call its system + the knobs a variant sets),
|
|
7
|
+
2. **datasets** (cases with opaque ``input``/``expected`` blobs),
|
|
8
|
+
3. **graders** (generic ones here + any custom ones it registers), and
|
|
9
|
+
4. a **suite + thresholds** config.
|
|
10
|
+
|
|
11
|
+
Everything else - runner, comparison/regression engine, results store, and
|
|
12
|
+
reporting - lives here and is reused unchanged across consumers.
|
|
13
|
+
|
|
14
|
+
See ``docs/evals/platform-design.md`` for the full design.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from evalkit import (
|
|
18
|
+
adapters,
|
|
19
|
+
compare,
|
|
20
|
+
graders,
|
|
21
|
+
loader,
|
|
22
|
+
models,
|
|
23
|
+
refs,
|
|
24
|
+
report,
|
|
25
|
+
runner,
|
|
26
|
+
store,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
'adapters',
|
|
31
|
+
'compare',
|
|
32
|
+
'graders',
|
|
33
|
+
'loader',
|
|
34
|
+
'models',
|
|
35
|
+
'refs',
|
|
36
|
+
'report',
|
|
37
|
+
'runner',
|
|
38
|
+
'store',
|
|
39
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Target adapters - the one seam to the system under test.
|
|
2
|
+
|
|
3
|
+
An adapter turns a (case, variant) into a normalized ``Output``. The built-in
|
|
4
|
+
``http`` adapter (POST a templated body to an endpoint, extract fields from the
|
|
5
|
+
JSON response) covers most request/response APIs; ``replay`` returns recorded
|
|
6
|
+
outputs so the whole pipeline runs offline and in tests. Consumers register
|
|
7
|
+
their own adapters for anything else - e.g. a browser-driven adapter or one
|
|
8
|
+
that reads results a deployed system already logged to an observability store.
|
|
9
|
+
|
|
10
|
+
Importing this package registers the built-in adapter types.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from evalkit.adapters import base, http, replay
|
|
14
|
+
|
|
15
|
+
__all__ = ['base', 'http', 'replay']
|
evalkit/adapters/_env.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""``${ENV_VAR}`` expansion shared by adapters.
|
|
2
|
+
|
|
3
|
+
Adapters keep secrets and environment-specific URLs out of the suite file by
|
|
4
|
+
writing ``${VAR}`` placeholders that expand from the process environment at
|
|
5
|
+
invoke time. Kept here (not in any one adapter) so every adapter can reuse it
|
|
6
|
+
without importing a sibling adapter's internals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
_ENV_RE = re.compile(r'\$\{([A-Z0-9_]+)\}')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _expand_env(value: typing.Any) -> typing.Any:
|
|
17
|
+
"""Expand ``${VAR}`` in strings (recursively through dict/list)."""
|
|
18
|
+
if isinstance(value, str):
|
|
19
|
+
return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ''), value)
|
|
20
|
+
if isinstance(value, dict):
|
|
21
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
22
|
+
if isinstance(value, list):
|
|
23
|
+
return [_expand_env(v) for v in value]
|
|
24
|
+
return value
|
evalkit/adapters/base.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Adapter protocol and registry."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from evalkit import models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@typing.runtime_checkable
|
|
9
|
+
class TargetAdapter(typing.Protocol):
|
|
10
|
+
"""Invoke the system under test for one case under one variant."""
|
|
11
|
+
|
|
12
|
+
async def invoke(
|
|
13
|
+
self, case: models.Case, variant: models.Variant
|
|
14
|
+
) -> models.Output: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_REGISTRY: dict[str, type] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(type_name: str) -> typing.Callable[[type], type]:
|
|
21
|
+
"""Class decorator registering an adapter under a config ``type``."""
|
|
22
|
+
|
|
23
|
+
def _decorate(cls: type) -> type:
|
|
24
|
+
if type_name in _REGISTRY:
|
|
25
|
+
raise ValueError(f'adapter type {type_name!r} already registered')
|
|
26
|
+
_REGISTRY[type_name] = cls
|
|
27
|
+
return cls
|
|
28
|
+
|
|
29
|
+
return _decorate
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_adapter(spec: dict) -> TargetAdapter:
|
|
33
|
+
"""Instantiate an adapter from a ``{type, ...}`` config spec."""
|
|
34
|
+
spec = dict(spec)
|
|
35
|
+
type_name = spec.pop('type')
|
|
36
|
+
if type_name not in _REGISTRY:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f'unknown adapter type {type_name!r}; known: {sorted(_REGISTRY)}'
|
|
39
|
+
)
|
|
40
|
+
return _REGISTRY[type_name](**spec)
|
evalkit/adapters/http.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""HTTP target adapter - POST a templated body, extract response fields.
|
|
2
|
+
|
|
3
|
+
The request ``body`` is a template whose ``$ref`` strings are resolved against
|
|
4
|
+
the case and variant (e.g. ``$input.content``, ``$variant.model``). ``extract``
|
|
5
|
+
maps output field names to dotted paths into the JSON response. ``base_url``
|
|
6
|
+
and header values support ``${ENV_VAR}`` expansion so secrets/URLs stay out of
|
|
7
|
+
the suite file.
|
|
8
|
+
|
|
9
|
+
``httpx`` is imported lazily so offline/replay runs need no network stack.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from evalkit import models, refs
|
|
15
|
+
from evalkit.adapters import base
|
|
16
|
+
from evalkit.adapters._env import _expand_env
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@base.register('http')
|
|
20
|
+
class HTTPAdapter:
|
|
21
|
+
"""POST ``body`` to ``base_url + path`` and extract fields from JSON."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str,
|
|
26
|
+
path: str,
|
|
27
|
+
body: dict,
|
|
28
|
+
extract: dict[str, str],
|
|
29
|
+
method: str = 'POST',
|
|
30
|
+
headers: dict[str, str] | None = None,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
):
|
|
33
|
+
self.base_url = base_url
|
|
34
|
+
self.path = path
|
|
35
|
+
self.body_template = body
|
|
36
|
+
self.extract = extract
|
|
37
|
+
self.method = method
|
|
38
|
+
self.headers = headers or {}
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
|
|
41
|
+
async def invoke(
|
|
42
|
+
self, case: models.Case, variant: models.Variant
|
|
43
|
+
) -> models.Output:
|
|
44
|
+
import httpx
|
|
45
|
+
|
|
46
|
+
context = {
|
|
47
|
+
'input': case.input,
|
|
48
|
+
'expected': case.expected or {},
|
|
49
|
+
'variant': variant.knobs,
|
|
50
|
+
'case': case.model_dump(),
|
|
51
|
+
}
|
|
52
|
+
payload = refs.build_value(self.body_template, context)
|
|
53
|
+
url = _expand_env(self.base_url).rstrip('/') + self.path
|
|
54
|
+
# Drop headers whose ${ENV} expanded to empty (e.g. unset auth) so we
|
|
55
|
+
# never send a blank Authorization that a proxy might 400 on.
|
|
56
|
+
headers = {k: v for k, v in _expand_env(self.headers).items() if v}
|
|
57
|
+
|
|
58
|
+
start = time.monotonic()
|
|
59
|
+
try:
|
|
60
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
61
|
+
response = await client.request(
|
|
62
|
+
self.method, url, json=payload, headers=headers
|
|
63
|
+
)
|
|
64
|
+
except httpx.HTTPError as exc:
|
|
65
|
+
# Network-level failures (timeouts, connection resets) are
|
|
66
|
+
# transient - worth a retry.
|
|
67
|
+
return models.Output(
|
|
68
|
+
error=f'{type(exc).__name__}: {exc}',
|
|
69
|
+
retryable=True,
|
|
70
|
+
latency_ms=(time.monotonic() - start) * 1000,
|
|
71
|
+
)
|
|
72
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
73
|
+
|
|
74
|
+
if response.status_code >= 400:
|
|
75
|
+
# 429 (rate limit) and 5xx (server) are transient; other 4xx are
|
|
76
|
+
# the request's fault and won't fix on retry.
|
|
77
|
+
code = response.status_code
|
|
78
|
+
return models.Output(
|
|
79
|
+
error=f'HTTP {code}',
|
|
80
|
+
retryable=code == 429 or code >= 500,
|
|
81
|
+
raw=response.text[:2000],
|
|
82
|
+
latency_ms=latency_ms,
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
body = response.json()
|
|
86
|
+
except ValueError:
|
|
87
|
+
return models.Output(
|
|
88
|
+
error='non-JSON response',
|
|
89
|
+
raw=response.text[:2000],
|
|
90
|
+
latency_ms=latency_ms,
|
|
91
|
+
)
|
|
92
|
+
fields = {
|
|
93
|
+
name: refs.resolve_path(body, path)
|
|
94
|
+
for name, path in self.extract.items()
|
|
95
|
+
}
|
|
96
|
+
return models.Output(fields=fields, raw=body, latency_ms=latency_ms)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Replay adapter - return recorded outputs instead of calling a service.
|
|
2
|
+
|
|
3
|
+
Lets the whole pipeline (runner -> graders -> compare -> report) run offline
|
|
4
|
+
in unit tests with no network, no API keys, and no deployed service. The
|
|
5
|
+
fixtures file maps ``case_id`` to per-variant recorded outputs::
|
|
6
|
+
|
|
7
|
+
obfuscated_eval:
|
|
8
|
+
baseline: {verdict: ok} # a recorded miss
|
|
9
|
+
candidate: {verdict: malicious}
|
|
10
|
+
|
|
11
|
+
Each recorded value becomes ``Output.fields`` for that (case, variant).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from evalkit import loader, models
|
|
15
|
+
from evalkit.adapters import base
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@base.register('replay')
|
|
19
|
+
class ReplayAdapter:
|
|
20
|
+
"""Serve recorded ``Output.fields`` keyed by ``case.id`` and variant."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, fixtures: str | dict):
|
|
23
|
+
if isinstance(fixtures, str):
|
|
24
|
+
self._fixtures = loader.load_data_file(fixtures)
|
|
25
|
+
else:
|
|
26
|
+
self._fixtures = fixtures
|
|
27
|
+
|
|
28
|
+
async def invoke(
|
|
29
|
+
self, case: models.Case, variant: models.Variant
|
|
30
|
+
) -> models.Output:
|
|
31
|
+
per_case = self._fixtures.get(case.id)
|
|
32
|
+
if per_case is None:
|
|
33
|
+
return models.Output(error=f'no fixture for case {case.id!r}')
|
|
34
|
+
recorded = per_case.get(variant.name)
|
|
35
|
+
if recorded is None:
|
|
36
|
+
return models.Output(
|
|
37
|
+
error=f'no fixture for {case.id!r}/{variant.name!r}'
|
|
38
|
+
)
|
|
39
|
+
if 'error' in recorded:
|
|
40
|
+
return models.Output(error=recorded['error'])
|
|
41
|
+
return models.Output(fields=dict(recorded))
|