vibesys-evaluator 0.3.0rc2__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.
- vibesys_evaluator-0.3.0rc2/PKG-INFO +39 -0
- vibesys_evaluator-0.3.0rc2/README.md +28 -0
- vibesys_evaluator-0.3.0rc2/pyproject.toml +28 -0
- vibesys_evaluator-0.3.0rc2/setup.cfg +4 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/__init__.py +50 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/cli.py +98 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/core.py +240 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/files.py +342 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/harness.py +64 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/hdr.py +64 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/http.py +507 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/rand.py +297 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/records.py +130 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/results.py +69 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/run.py +54 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/sample.py +245 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/typevars.py +9 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/buf/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/buf/validate/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/buf/validate/validate_pb.py +3481 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/__init__.py +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/core_pb.py +822 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/k8s_pb.py +1208 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/loadgen_pb.py +1561 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/microservices_pb.py +728 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/phase_pb.py +515 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/records_pb.py +297 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/__init__.py +12 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_agentx.py +337 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_endpoint.py +39 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_golden.py +524 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_server.py +99 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/agentx.py +92 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/golden.py +61 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/__init__.py +105 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/__init__.py +38 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/_deployer.py +213 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/_deployment.py +343 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/_validate.py +120 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/__init__.py +0 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/accuracy.py +304 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/dataset.py +86 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/deploy.py +172 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/entry.py +347 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/local.py +119 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/phase.py +148 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/results.py +20 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/run.py +328 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/workload.py +111 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/k8s.py +94 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/py.typed +0 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/PKG-INFO +39 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/SOURCES.txt +71 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/dependency_links.txt +1 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/requires.txt +3 -0
- vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/top_level.txt +1 -0
- vibesys_evaluator-0.3.0rc2/tests/test_accuracy.py +305 -0
- vibesys_evaluator-0.3.0rc2/tests/test_core.py +169 -0
- vibesys_evaluator-0.3.0rc2/tests/test_core_fixtures.py +174 -0
- vibesys_evaluator-0.3.0rc2/tests/test_dataset.py +65 -0
- vibesys_evaluator-0.3.0rc2/tests/test_fixtures.py +150 -0
- vibesys_evaluator-0.3.0rc2/tests/test_image_lock.py +56 -0
- vibesys_evaluator-0.3.0rc2/tests/test_layering.py +125 -0
- vibesys_evaluator-0.3.0rc2/tests/test_pipeline.py +427 -0
- vibesys_evaluator-0.3.0rc2/tests/test_plan.py +235 -0
- vibesys_evaluator-0.3.0rc2/tests/test_rand.py +147 -0
- vibesys_evaluator-0.3.0rc2/tests/test_records.py +227 -0
- vibesys_evaluator-0.3.0rc2/tests/test_sample.py +127 -0
- vibesys_evaluator-0.3.0rc2/tests/test_service.py +147 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vibesys-evaluator
|
|
3
|
+
Version: 0.3.0rc2
|
|
4
|
+
Summary: Build VibeSys benchmark evaluators for services: workloads, phases, validation
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: protobuf-py==0.6.0
|
|
9
|
+
Requires-Dist: protovalidate==2.0.0
|
|
10
|
+
Requires-Dist: pyyaml==6.0.3
|
|
11
|
+
|
|
12
|
+
# vibesys-evaluator
|
|
13
|
+
|
|
14
|
+
Build VibeSys benchmark evaluators for services: workloads, phases and validation.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pip install vibesys-evaluator
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The Python package is a thin wrapper over a Go core. The wheels for Linux and macOS (x86_64 and
|
|
21
|
+
arm64) bundle the Go binaries (`vibesys-evaluator`, `loadgen` and `otelcapture`), so one
|
|
22
|
+
`pip install` is all a task needs: no Go toolchain, no `go install`. The Linux wheels are static
|
|
23
|
+
and carry both the manylinux and the musllinux tags.
|
|
24
|
+
|
|
25
|
+
The package looks for each binary in the wheel first, then in an environment variable
|
|
26
|
+
(`VIBESYS_EVALUATOR_BIN`, `VIBESYS_EVALUATOR_LOADGEN`, `VIBESYS_EVALUATOR_OTELCAPTURE`), then on
|
|
27
|
+
`PATH`. A source distribution, or a platform without a wheel, has no bundled binary: build the
|
|
28
|
+
binaries with Go and name them in those variables.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
go install github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go/cmd/vibesys-evaluator@<tag>
|
|
32
|
+
go install github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go/cmd/loadgen@<tag>
|
|
33
|
+
export VIBESYS_EVALUATOR_BIN="$(command -v vibesys-evaluator)"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Go authors import the module `github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go` (tags
|
|
37
|
+
`sdk/vibesys-evaluator/go/v<semver>`, cut with each release) and need no wheel.
|
|
38
|
+
|
|
39
|
+
The design and the public API are in the repository: `sdk/vibesys-evaluator/docs/spec.md`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# vibesys-evaluator
|
|
2
|
+
|
|
3
|
+
Build VibeSys benchmark evaluators for services: workloads, phases and validation.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install vibesys-evaluator
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The Python package is a thin wrapper over a Go core. The wheels for Linux and macOS (x86_64 and
|
|
10
|
+
arm64) bundle the Go binaries (`vibesys-evaluator`, `loadgen` and `otelcapture`), so one
|
|
11
|
+
`pip install` is all a task needs: no Go toolchain, no `go install`. The Linux wheels are static
|
|
12
|
+
and carry both the manylinux and the musllinux tags.
|
|
13
|
+
|
|
14
|
+
The package looks for each binary in the wheel first, then in an environment variable
|
|
15
|
+
(`VIBESYS_EVALUATOR_BIN`, `VIBESYS_EVALUATOR_LOADGEN`, `VIBESYS_EVALUATOR_OTELCAPTURE`), then on
|
|
16
|
+
`PATH`. A source distribution, or a platform without a wheel, has no bundled binary: build the
|
|
17
|
+
binaries with Go and name them in those variables.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
go install github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go/cmd/vibesys-evaluator@<tag>
|
|
21
|
+
go install github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go/cmd/loadgen@<tag>
|
|
22
|
+
export VIBESYS_EVALUATOR_BIN="$(command -v vibesys-evaluator)"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Go authors import the module `github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go` (tags
|
|
26
|
+
`sdk/vibesys-evaluator/go/v<semver>`, cut with each release) and need no wheel.
|
|
27
|
+
|
|
28
|
+
The design and the public API are in the repository: `sdk/vibesys-evaluator/docs/spec.md`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vibesys-evaluator"
|
|
7
|
+
version = "0.3.0rc2"
|
|
8
|
+
description = "Build VibeSys benchmark evaluators for services: workloads, phases, validation"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
# The schema's generated Python types (protoc-gen-py) and their validation, and a YAML
|
|
13
|
+
# parser for the tasks' Kubernetes manifests (microservices.k8s). Nothing else: the package runs in
|
|
14
|
+
# every task image, and the task's own code brings its own clients.
|
|
15
|
+
dependencies = [
|
|
16
|
+
"protobuf-py==0.6.0",
|
|
17
|
+
"protovalidate==2.0.0",
|
|
18
|
+
"pyyaml==6.0.3",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
include = ["vibesys_evaluator*"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
# _bin/ holds the Go binaries of a platform wheel (packaging/evaluator_wheels.py); a development
|
|
27
|
+
# tree has none.
|
|
28
|
+
vibesys_evaluator = ["py.typed", "_bin/*"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""vibesys-evaluator: the sample lifecycle every VibeSys task evaluator shares.
|
|
2
|
+
|
|
3
|
+
The core knows no domain. A task's ``bench.py`` calls ``main`` with its
|
|
4
|
+
metrics and its ``check``, ``benchmark`` and ``profile`` functions; each gets a
|
|
5
|
+
``Run`` (its phases and its report). Domains add what their tasks need on top:
|
|
6
|
+
``vibesys_evaluator.microservices`` (workloads over HTTP, load phases, deploys)
|
|
7
|
+
and ``vibesys_evaluator.llm_serving`` (the candidate server, AgentX replay,
|
|
8
|
+
golden accuracy checks). See the package's ``docs/spec.md`` and ``examples/``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
16
|
+
|
|
17
|
+
from vibesys_evaluator._private import cli
|
|
18
|
+
from vibesys_evaluator._private.rand import Rand
|
|
19
|
+
from vibesys_evaluator._private.results import Metric, Validity
|
|
20
|
+
from vibesys_evaluator._private.run import Run
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
|
|
25
|
+
__all__ = ["Metric", "Rand", "Run", "Validity", "main"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(
|
|
29
|
+
*,
|
|
30
|
+
metrics: Mapping[str, Metric],
|
|
31
|
+
benchmark: Callable[[Run], None],
|
|
32
|
+
check: Callable[[Run], str | None] | None = None,
|
|
33
|
+
profile: Callable[[Run], None] | None = None,
|
|
34
|
+
) -> NoReturn:
|
|
35
|
+
"""Run the task script's command (``check``, ``benchmark`` or ``profile``) and exit.
|
|
36
|
+
|
|
37
|
+
``benchmark --vs-output FILE`` writes result-protocol-2 records: ``hello``
|
|
38
|
+
with ``metrics``, then the result ``benchmark`` reports with
|
|
39
|
+
``run.report`` or an error naming what failed. ``check`` exits 0 when the
|
|
40
|
+
function returns None and 1 with the reason it returns. ``profile`` runs
|
|
41
|
+
the load a profiler records; it is never scored.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
metrics: The metrics ``run.report`` reports, by name.
|
|
45
|
+
benchmark: The scoring definition: runs phases and calls ``run.report``.
|
|
46
|
+
check: The correctness check: None when the system is correct, else what is wrong.
|
|
47
|
+
profile: The standard load to profile the system under.
|
|
48
|
+
"""
|
|
49
|
+
task = cli.Task(metrics=metrics, benchmark=benchmark, check=check, profile=profile)
|
|
50
|
+
sys.exit(cli.run(task, sys.argv[1:], env=os.environ))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Implementation of ``vibesys_evaluator``; nothing here is public API."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The task script's command line when the task brings no system of its own (``main``).
|
|
2
|
+
|
|
3
|
+
bench.py check exit 0 when the check passes
|
|
4
|
+
bench.py benchmark --vs-output FILE [--seed N] one sample, result-protocol-2 records
|
|
5
|
+
bench.py profile [--seed N] the standard profile load, never scored
|
|
6
|
+
|
|
7
|
+
The system under test is already running (vibesys-cli starts the candidate);
|
|
8
|
+
domains reach it through their own helpers. A mode the task did not define
|
|
9
|
+
exits 2 naming it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from vibesys_evaluator._private.run import new_run
|
|
20
|
+
from vibesys_evaluator._private.sample import (
|
|
21
|
+
Mode,
|
|
22
|
+
benchmark_sample,
|
|
23
|
+
log,
|
|
24
|
+
unscored_sample,
|
|
25
|
+
with_signals,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
30
|
+
|
|
31
|
+
from vibesys_evaluator._private.results import Metric
|
|
32
|
+
from vibesys_evaluator._private.run import Run
|
|
33
|
+
from vibesys_evaluator._private.sample import Sample
|
|
34
|
+
|
|
35
|
+
#: Exit status of a usage error (argparse's), also for a mode the task does not define.
|
|
36
|
+
USAGE = 2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, kw_only=True)
|
|
40
|
+
class Task:
|
|
41
|
+
"""Everything the task script passes to ``main``."""
|
|
42
|
+
|
|
43
|
+
metrics: Mapping[str, Metric]
|
|
44
|
+
benchmark: Callable[[Run], None]
|
|
45
|
+
check: Callable[[Run], str | None] | None
|
|
46
|
+
profile: Callable[[Run], None] | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(prog="bench.py", description="A vibesys-evaluator task.")
|
|
51
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
commands.add_parser(Mode.CHECK, help="check the system's correctness (exit 0 = pass)")
|
|
53
|
+
bench = commands.add_parser(Mode.BENCHMARK, help="measure one sample")
|
|
54
|
+
bench.add_argument("--vs-output", type=Path, required=True)
|
|
55
|
+
bench.add_argument("--seed", type=int, default=None, help="replay the sample with this seed")
|
|
56
|
+
profile = commands.add_parser(Mode.PROFILE, help="run the profile load (never scored)")
|
|
57
|
+
profile.add_argument("--seed", type=int, default=None)
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run(task: Task, argv: Sequence[str], *, env: Mapping[str, str]) -> int:
|
|
62
|
+
"""Run one command of the task script; returns the exit status."""
|
|
63
|
+
args = _parser().parse_args(list(argv))
|
|
64
|
+
mode = Mode(args.command)
|
|
65
|
+
if mode is Mode.BENCHMARK:
|
|
66
|
+
return with_signals(
|
|
67
|
+
lambda: benchmark_sample(
|
|
68
|
+
metrics=task.metrics,
|
|
69
|
+
output=args.vs_output,
|
|
70
|
+
seed_flag=args.seed,
|
|
71
|
+
env=env,
|
|
72
|
+
body=lambda sample: task.benchmark(new_run(sample)),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
seed = getattr(args, "seed", None)
|
|
76
|
+
body = _unscored_body(task, mode)
|
|
77
|
+
if body is None:
|
|
78
|
+
log(f"this task defines no {mode}: pass {mode}= to main(...)")
|
|
79
|
+
return USAGE
|
|
80
|
+
return with_signals(lambda: unscored_sample(mode, seed_flag=seed, env=env, body=body))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unscored_body(task: Task, mode: Mode) -> Callable[[Sample], str | None] | None:
|
|
84
|
+
"""What a check or profile sample runs, or None when the task does not define the mode."""
|
|
85
|
+
check, profile = task.check, task.profile
|
|
86
|
+
if mode is Mode.CHECK and check is not None:
|
|
87
|
+
|
|
88
|
+
def checked(sample: Sample) -> str | None:
|
|
89
|
+
return check(new_run(sample))
|
|
90
|
+
|
|
91
|
+
return checked
|
|
92
|
+
if mode is Mode.PROFILE and profile is not None:
|
|
93
|
+
|
|
94
|
+
def profiled(sample: Sample) -> None:
|
|
95
|
+
profile(new_run(sample))
|
|
96
|
+
|
|
97
|
+
return profiled
|
|
98
|
+
return None
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The Go core's command line (``docs/spec.md``, "Wrapper calling convention") behind one helper.
|
|
2
|
+
|
|
3
|
+
``call(group, verb, request)`` runs ``vibesys-evaluator <group> <verb>``, writes the request as
|
|
4
|
+
one JSON message on its stdin and decodes the one JSON message it writes to stdout;
|
|
5
|
+
``stream(group, verb, request, on_event)`` does the same for a long verb, which writes
|
|
6
|
+
``{"event": ...}`` lines before its response. Its stderr
|
|
7
|
+
(progress and logs) passes through to the user. Exit 0 is the response; exit 1 is a domain error
|
|
8
|
+
(``CoreDomainError``, from the response's ``error_kind`` and ``message``); any other exit, a
|
|
9
|
+
reply that is not JSON, or a missing binary is a ``CoreError`` of the core itself.
|
|
10
|
+
|
|
11
|
+
The binary is found in the wheel (``vibesys_evaluator/_bin``, where the platform wheels bundle the
|
|
12
|
+
Go binaries), then ``$VIBESYS_EVALUATOR_BIN``, then ``PATH``. ``loadgen`` and ``otelcapture`` are
|
|
13
|
+
found the same way (``find_binary(name)``), with ``$VIBESYS_EVALUATOR_LOADGEN`` and
|
|
14
|
+
``$VIBESYS_EVALUATOR_OTELCAPTURE``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable, Mapping
|
|
30
|
+
|
|
31
|
+
#: Names the core binary; consulted after the wheel's bundled one.
|
|
32
|
+
BIN_ENV = "VIBESYS_EVALUATOR_BIN"
|
|
33
|
+
BIN_NAME = "vibesys-evaluator"
|
|
34
|
+
#: The environment variable that names each of the wheel's binaries.
|
|
35
|
+
ENV_VARS = {
|
|
36
|
+
BIN_NAME: BIN_ENV,
|
|
37
|
+
"loadgen": "VIBESYS_EVALUATOR_LOADGEN",
|
|
38
|
+
"otelcapture": "VIBESYS_EVALUATOR_OTELCAPTURE",
|
|
39
|
+
}
|
|
40
|
+
_BUNDLED_DIR = Path(__file__).resolve().parent.parent / "_bin"
|
|
41
|
+
_DOMAIN_EXIT = 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CoreError(Exception):
|
|
45
|
+
"""The core itself failed: no binary, a crash, or a reply that breaks the convention."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CoreNotFoundError(CoreError):
|
|
49
|
+
"""No ``vibesys-evaluator`` binary was found."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CoreCrashError(CoreError):
|
|
53
|
+
"""The core exited with a code other than 0 and 1, or wrote a reply that is not JSON."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, *, code: int | None = None) -> None:
|
|
56
|
+
"""Keep the exit code (None when the reply, not the exit, broke the convention)."""
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.code = code
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CoreDomainError(CoreError):
|
|
62
|
+
"""The operation failed on its own terms (exit 1): ``kind`` says how, ``response`` is the reply."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, kind: str, message: str, response: dict[str, Any]) -> None:
|
|
65
|
+
"""Keep the ``error_kind``, the message and the whole reply."""
|
|
66
|
+
super().__init__(f"{kind}: {message}" if kind else message)
|
|
67
|
+
self.kind = kind
|
|
68
|
+
self.message = message
|
|
69
|
+
self.response = response
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_binary(name: str = BIN_NAME) -> str:
|
|
73
|
+
"""The binary ``name``: the wheel's own, then its environment variable, then ``PATH``.
|
|
74
|
+
|
|
75
|
+
``name`` is one of the wheel's binaries (``vibesys-evaluator``, ``loadgen``, ``otelcapture``);
|
|
76
|
+
each has its own variable (``ENV_VARS``).
|
|
77
|
+
"""
|
|
78
|
+
suffix = ".exe" if sys.platform == "win32" else ""
|
|
79
|
+
bundled = _BUNDLED_DIR / (name + suffix)
|
|
80
|
+
if bundled.is_file():
|
|
81
|
+
return str(bundled)
|
|
82
|
+
env = ENV_VARS[name]
|
|
83
|
+
named = os.environ.get(env)
|
|
84
|
+
if named:
|
|
85
|
+
return named
|
|
86
|
+
found = shutil.which(name)
|
|
87
|
+
if found:
|
|
88
|
+
return found
|
|
89
|
+
msg = (
|
|
90
|
+
f"the {name} binary is not bundled in this installation of vibesys-evaluator, ${env} is "
|
|
91
|
+
f"unset and it is not on PATH. The platform wheels bundle it (pip install vibesys-evaluator "
|
|
92
|
+
f"on Linux or macOS, x86_64 or arm64); a source distribution or another platform does not. "
|
|
93
|
+
f"Set ${env} to the binary, or build it: "
|
|
94
|
+
f"`go install github.com/uw-syfi/vibesys/sdk/vibesys-evaluator/go/cmd/{name}@<tag>`"
|
|
95
|
+
)
|
|
96
|
+
raise CoreNotFoundError(msg)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def call(
|
|
100
|
+
group: str,
|
|
101
|
+
verb: str,
|
|
102
|
+
request: Mapping[str, Any] | None = None,
|
|
103
|
+
*,
|
|
104
|
+
timeout_secs: float | None = None,
|
|
105
|
+
terminate_grace_secs: float = 0.0,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Run ``vibesys-evaluator GROUP VERB`` with ``request`` and return its decoded response.
|
|
108
|
+
|
|
109
|
+
When the wait is cut short (a signal handler raising, ``KeyboardInterrupt``), the core is killed
|
|
110
|
+
at once; with ``terminate_grace_secs`` it is sent SIGTERM first and given that long to clean up
|
|
111
|
+
(a verb that owns cluster state deletes it on SIGTERM), then killed.
|
|
112
|
+
"""
|
|
113
|
+
binary = find_binary()
|
|
114
|
+
body = json.dumps({} if request is None else request)
|
|
115
|
+
try:
|
|
116
|
+
process = subprocess.Popen(
|
|
117
|
+
[binary, group, verb],
|
|
118
|
+
stdin=subprocess.PIPE,
|
|
119
|
+
stdout=subprocess.PIPE,
|
|
120
|
+
text=True,
|
|
121
|
+
)
|
|
122
|
+
except FileNotFoundError as error:
|
|
123
|
+
msg = f"cannot run {BIN_NAME} at {binary!r}: {error.strerror}"
|
|
124
|
+
raise CoreNotFoundError(msg) from error
|
|
125
|
+
try:
|
|
126
|
+
stdout, _ = process.communicate(body, timeout=timeout_secs)
|
|
127
|
+
except subprocess.TimeoutExpired as error:
|
|
128
|
+
_stop(process, 0.0)
|
|
129
|
+
msg = f"{BIN_NAME} {group} {verb} ran past {timeout_secs}s"
|
|
130
|
+
raise CoreCrashError(msg) from error
|
|
131
|
+
except BaseException:
|
|
132
|
+
_stop(process, terminate_grace_secs)
|
|
133
|
+
raise
|
|
134
|
+
return _decode(group, verb, process.returncode, stdout)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _stop(process: subprocess.Popen[str], grace_secs: float) -> None:
|
|
138
|
+
"""End the child: SIGTERM and a grace period when asked, then SIGKILL."""
|
|
139
|
+
if grace_secs > 0 and process.poll() is None:
|
|
140
|
+
process.terminate()
|
|
141
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
142
|
+
process.wait(timeout=grace_secs)
|
|
143
|
+
if process.poll() is None:
|
|
144
|
+
process.kill()
|
|
145
|
+
process.wait()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _decode(group: str, verb: str, code: int, stdout: str) -> dict[str, Any]:
|
|
149
|
+
if code not in {0, _DOMAIN_EXIT}:
|
|
150
|
+
msg = f"{BIN_NAME} {group} {verb} exited {code}"
|
|
151
|
+
raise CoreCrashError(msg, code=code)
|
|
152
|
+
try:
|
|
153
|
+
response = json.loads(stdout)
|
|
154
|
+
except ValueError as error:
|
|
155
|
+
msg = f"{BIN_NAME} {group} {verb} wrote a reply that is not JSON: {stdout[:200]!r}"
|
|
156
|
+
raise CoreCrashError(msg, code=code) from error
|
|
157
|
+
if not isinstance(response, dict):
|
|
158
|
+
msg = f"{BIN_NAME} {group} {verb} wrote a reply that is not a JSON object"
|
|
159
|
+
raise CoreCrashError(msg, code=code)
|
|
160
|
+
if code == _DOMAIN_EXIT:
|
|
161
|
+
kind = response.get("error_kind", response.get("errorKind", ""))
|
|
162
|
+
raise CoreDomainError(str(kind), str(response.get("message", "")), response)
|
|
163
|
+
return response
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def stream(
|
|
167
|
+
group: str,
|
|
168
|
+
verb: str,
|
|
169
|
+
request: Mapping[str, Any] | None = None,
|
|
170
|
+
*,
|
|
171
|
+
on_event: Callable[[dict[str, Any]], None],
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Run a long verb: each ``{"event": E}`` line it writes goes to ``on_event(E)`` as it comes.
|
|
174
|
+
|
|
175
|
+
The last line is the response, decoded as ``call`` decodes it. The core is stopped (SIGTERM)
|
|
176
|
+
when this returns early: an exception of ``on_event``, or a signal that raised here.
|
|
177
|
+
"""
|
|
178
|
+
binary = find_binary()
|
|
179
|
+
try:
|
|
180
|
+
process = subprocess.Popen(
|
|
181
|
+
[binary, group, verb], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
|
|
182
|
+
)
|
|
183
|
+
except FileNotFoundError as error:
|
|
184
|
+
msg = f"cannot run {BIN_NAME} at {binary!r}: {error.strerror}"
|
|
185
|
+
raise CoreNotFoundError(msg) from error
|
|
186
|
+
try:
|
|
187
|
+
last = _events(process, f"{BIN_NAME} {group} {verb}", request, on_event)
|
|
188
|
+
code = process.wait()
|
|
189
|
+
finally:
|
|
190
|
+
if process.poll() is None:
|
|
191
|
+
process.terminate()
|
|
192
|
+
process.wait()
|
|
193
|
+
return _reply(f"{BIN_NAME} {group} {verb}", code, last)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _events(
|
|
197
|
+
process: subprocess.Popen[str],
|
|
198
|
+
what: str,
|
|
199
|
+
request: Mapping[str, Any] | None,
|
|
200
|
+
on_event: Callable[[dict[str, Any]], None],
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Write the request, pass each event on, and return the last line that is not an event."""
|
|
203
|
+
stdin, stdout = process.stdin, process.stdout
|
|
204
|
+
if stdin is None or stdout is None:
|
|
205
|
+
msg = f"{what}: no pipes to the core"
|
|
206
|
+
raise CoreCrashError(msg)
|
|
207
|
+
stdin.write(json.dumps({} if request is None else request))
|
|
208
|
+
stdin.close()
|
|
209
|
+
last = ""
|
|
210
|
+
for line in stdout:
|
|
211
|
+
text = line.strip()
|
|
212
|
+
if not text.startswith('{"event":'):
|
|
213
|
+
last = text or last
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
event = json.loads(text)["event"]
|
|
217
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
218
|
+
msg = f"{what} wrote an event that is not JSON: {text[:200]!r}"
|
|
219
|
+
raise CoreCrashError(msg) from error
|
|
220
|
+
on_event(event)
|
|
221
|
+
return last
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _reply(what: str, code: int, last: str) -> dict[str, Any]:
|
|
225
|
+
"""The response line of a streamed verb, or the error it carries."""
|
|
226
|
+
if code not in {0, _DOMAIN_EXIT}:
|
|
227
|
+
msg = f"{what} exited {code}"
|
|
228
|
+
raise CoreCrashError(msg, code=code)
|
|
229
|
+
try:
|
|
230
|
+
response = json.loads(last)
|
|
231
|
+
except ValueError as error:
|
|
232
|
+
msg = f"{what} wrote a reply that is not JSON: {last[:200]!r}"
|
|
233
|
+
raise CoreCrashError(msg, code=code) from error
|
|
234
|
+
if not isinstance(response, dict):
|
|
235
|
+
msg = f"{what} wrote a reply that is not a JSON object"
|
|
236
|
+
raise CoreCrashError(msg, code=code)
|
|
237
|
+
if code == _DOMAIN_EXIT:
|
|
238
|
+
kind = response.get("error_kind", "")
|
|
239
|
+
raise CoreDomainError(str(kind), str(response.get("message", "")), response)
|
|
240
|
+
return response
|