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.
Files changed (73) hide show
  1. vibesys_evaluator-0.3.0rc2/PKG-INFO +39 -0
  2. vibesys_evaluator-0.3.0rc2/README.md +28 -0
  3. vibesys_evaluator-0.3.0rc2/pyproject.toml +28 -0
  4. vibesys_evaluator-0.3.0rc2/setup.cfg +4 -0
  5. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/__init__.py +50 -0
  6. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/__init__.py +1 -0
  7. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/cli.py +98 -0
  8. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/core.py +240 -0
  9. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/files.py +342 -0
  10. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/harness.py +64 -0
  11. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/hdr.py +64 -0
  12. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/http.py +507 -0
  13. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/rand.py +297 -0
  14. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/records.py +130 -0
  15. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/results.py +69 -0
  16. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/run.py +54 -0
  17. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/sample.py +245 -0
  18. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_private/typevars.py +9 -0
  19. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/__init__.py +1 -0
  20. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/buf/__init__.py +1 -0
  21. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/buf/validate/__init__.py +1 -0
  22. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/buf/validate/validate_pb.py +3481 -0
  23. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/__init__.py +1 -0
  24. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/__init__.py +1 -0
  25. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/__init__.py +1 -0
  26. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/core_pb.py +822 -0
  27. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/k8s_pb.py +1208 -0
  28. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/loadgen_pb.py +1561 -0
  29. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/microservices_pb.py +728 -0
  30. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/phase_pb.py +515 -0
  31. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/_proto/vibesys/evaluator/v1/records_pb.py +297 -0
  32. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/__init__.py +12 -0
  33. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_agentx.py +337 -0
  34. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_endpoint.py +39 -0
  35. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_golden.py +524 -0
  36. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/_server.py +99 -0
  37. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/agentx.py +92 -0
  38. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/llm_serving/golden.py +61 -0
  39. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/__init__.py +105 -0
  40. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/__init__.py +38 -0
  41. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/_deployer.py +213 -0
  42. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/_deployment.py +343 -0
  43. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_k8s/_validate.py +120 -0
  44. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/__init__.py +0 -0
  45. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/accuracy.py +304 -0
  46. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/dataset.py +86 -0
  47. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/deploy.py +172 -0
  48. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/entry.py +347 -0
  49. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/local.py +119 -0
  50. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/phase.py +148 -0
  51. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/results.py +20 -0
  52. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/run.py +328 -0
  53. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/_private/workload.py +111 -0
  54. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/microservices/k8s.py +94 -0
  55. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator/py.typed +0 -0
  56. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/PKG-INFO +39 -0
  57. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/SOURCES.txt +71 -0
  58. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/dependency_links.txt +1 -0
  59. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/requires.txt +3 -0
  60. vibesys_evaluator-0.3.0rc2/src/vibesys_evaluator.egg-info/top_level.txt +1 -0
  61. vibesys_evaluator-0.3.0rc2/tests/test_accuracy.py +305 -0
  62. vibesys_evaluator-0.3.0rc2/tests/test_core.py +169 -0
  63. vibesys_evaluator-0.3.0rc2/tests/test_core_fixtures.py +174 -0
  64. vibesys_evaluator-0.3.0rc2/tests/test_dataset.py +65 -0
  65. vibesys_evaluator-0.3.0rc2/tests/test_fixtures.py +150 -0
  66. vibesys_evaluator-0.3.0rc2/tests/test_image_lock.py +56 -0
  67. vibesys_evaluator-0.3.0rc2/tests/test_layering.py +125 -0
  68. vibesys_evaluator-0.3.0rc2/tests/test_pipeline.py +427 -0
  69. vibesys_evaluator-0.3.0rc2/tests/test_plan.py +235 -0
  70. vibesys_evaluator-0.3.0rc2/tests/test_rand.py +147 -0
  71. vibesys_evaluator-0.3.0rc2/tests/test_records.py +227 -0
  72. vibesys_evaluator-0.3.0rc2/tests/test_sample.py +127 -0
  73. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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