Skip to content

Commit e69c5b9

Browse files
committed
use unix socket for communication and split data generation to its own runner
1 parent 2d87ab5 commit e69c5b9

8 files changed

Lines changed: 1374 additions & 1124 deletions

File tree

scripts/data-runner.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import asyncio
6+
import json
7+
import os
8+
import socket
9+
import sys
10+
import time
11+
from dataclasses import dataclass
12+
from typing import Any
13+
14+
try:
15+
from braintrust.util import eprint
16+
from runner_common import call_evaluator_data, load_evaluators, to_async_iterator
17+
except Exception as exc: # pragma: no cover - runtime guard
18+
print(
19+
"Unable to import the braintrust package. Please install it in your Python environment.",
20+
file=sys.stderr,
21+
)
22+
print(str(exc), file=sys.stderr)
23+
sys.exit(1)
24+
25+
26+
@dataclass
27+
class PullChannel:
28+
sock: socket.socket
29+
30+
def send(self, payload: Any) -> None:
31+
self.sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
32+
33+
async def lines(self):
34+
buffer = ""
35+
while True:
36+
chunk = await asyncio.to_thread(self.sock.recv, 4096)
37+
if not chunk:
38+
break
39+
buffer += chunk.decode("utf-8")
40+
while True:
41+
newline = buffer.find("\n")
42+
if newline == -1:
43+
break
44+
line = buffer[:newline].strip()
45+
buffer = buffer[newline + 1 :]
46+
if line:
47+
yield line
48+
49+
trailing = buffer.strip()
50+
if trailing:
51+
yield trailing
52+
53+
def close(self) -> None:
54+
try:
55+
self.sock.shutdown(socket.SHUT_RDWR)
56+
except OSError:
57+
pass
58+
self.sock.close()
59+
60+
61+
def create_pull_channel() -> PullChannel:
62+
sock_path = os.getenv("BT_EVAL_PULL_SOCK")
63+
if not sock_path:
64+
raise ValueError("Missing BT_EVAL_PULL_SOCK")
65+
66+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
67+
sock.connect(sock_path)
68+
return PullChannel(sock)
69+
70+
71+
def parse_start_request(raw: str) -> str:
72+
parsed = json.loads(raw)
73+
if not isinstance(parsed, dict):
74+
raise ValueError("Start request must be a JSON object.")
75+
if parsed.get("type") != "start":
76+
raise ValueError("Expected initial start command.")
77+
name = parsed.get("name")
78+
if not isinstance(name, str) or not name:
79+
raise ValueError("Start request must include a non-empty evaluator name.")
80+
return name
81+
82+
83+
def build_parser() -> argparse.ArgumentParser:
84+
parser = argparse.ArgumentParser(description="Stream eval rows over a unix socket for bt.")
85+
parser.add_argument("files", nargs="*", help="Eval files or directories to load.")
86+
return parser
87+
88+
89+
async def run(files: list[str]) -> int:
90+
evaluators, _reporters = load_evaluators(files)
91+
channel = create_pull_channel()
92+
93+
try:
94+
line_iter = channel.lines()
95+
try:
96+
start_line = await anext(line_iter)
97+
except StopAsyncIteration:
98+
return 0
99+
100+
try:
101+
target_name = parse_start_request(start_line)
102+
except Exception as exc:
103+
channel.send({"type": "error", "message": str(exc)})
104+
return 1
105+
106+
evaluator_instance = next(
107+
(candidate for candidate in evaluators if candidate.evaluator.eval_name == target_name),
108+
None,
109+
)
110+
if evaluator_instance is None:
111+
channel.send({"type": "error", "message": f"Evaluator '{target_name}' not found"})
112+
return 1
113+
114+
evaluator = evaluator_instance.evaluator
115+
raw_data, _base_experiment_name = await call_evaluator_data(evaluator.data)
116+
data_iterator = to_async_iterator(raw_data)
117+
iterator = data_iterator.__aiter__()
118+
119+
trial_count = getattr(evaluator, "trial_count", 1)
120+
try:
121+
trial_count = int(trial_count)
122+
except Exception:
123+
trial_count = 1
124+
if trial_count < 1:
125+
trial_count = 1
126+
127+
max_concurrency = getattr(evaluator, "max_concurrency", None)
128+
try:
129+
max_concurrency = int(max_concurrency) if max_concurrency is not None else 10
130+
except Exception:
131+
max_concurrency = 10
132+
if max_concurrency < 1:
133+
max_concurrency = 1
134+
135+
experiment_name = getattr(evaluator, "experiment_name", None)
136+
if not isinstance(experiment_name, str) or not experiment_name:
137+
experiment_name = f"{evaluator.eval_name}-{int(time.time() * 1000)}"
138+
139+
channel.send(
140+
{
141+
"type": "ready",
142+
"evaluator_name": evaluator.eval_name,
143+
"max_concurrency": max_concurrency,
144+
"experiment_name": experiment_name,
145+
}
146+
)
147+
148+
current_datum = None
149+
trial_index = 0
150+
async for line in line_iter:
151+
parsed = json.loads(line)
152+
command_type = parsed.get("type") if isinstance(parsed, dict) else None
153+
if command_type == "close":
154+
break
155+
if command_type != "next":
156+
channel.send(
157+
{
158+
"type": "error",
159+
"message": f"Unsupported pull command '{command_type}'",
160+
}
161+
)
162+
return 1
163+
164+
if current_datum is None:
165+
try:
166+
current_datum = await iterator.__anext__()
167+
trial_index = 0
168+
except StopAsyncIteration:
169+
channel.send({"type": "eof"})
170+
continue
171+
172+
channel.send(
173+
{
174+
"type": "row",
175+
"datum": current_datum,
176+
"trial_index": trial_index,
177+
}
178+
)
179+
trial_index += 1
180+
if trial_index >= trial_count:
181+
current_datum = None
182+
183+
return 0
184+
finally:
185+
channel.close()
186+
187+
188+
def main(argv: list[str] | None = None) -> int:
189+
parser = build_parser()
190+
args = parser.parse_args(argv)
191+
files = args.files or ["."]
192+
193+
try:
194+
return asyncio.run(run(files))
195+
except Exception as exc:
196+
eprint(str(exc))
197+
return 1
198+
199+
200+
if __name__ == "__main__":
201+
sys.exit(main())

0 commit comments

Comments
 (0)