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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ evalkit = evalkit.cli:main
@@ -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']
@@ -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
@@ -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)
@@ -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))