verifiers 0.3.2.dev99__py3-none-any.whl → 0.3.2.dev101__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.
- verifiers/v1/agent.py +8 -0
- verifiers/v1/cli/dashboard/eval.py +14 -9
- verifiers/v1/cli/debug.py +1 -1
- verifiers/v1/cli/eval/hint.py +5 -0
- verifiers/v1/cli/eval/main.py +5 -7
- verifiers/v1/cli/eval/runner.py +20 -129
- verifiers/v1/cli/gepa.py +2 -2
- verifiers/v1/cli/init.py +3 -3
- verifiers/v1/cli/replay.py +1 -1
- verifiers/v1/cli/validate.py +2 -2
- verifiers/v1/configs/agent.py +2 -1
- verifiers/v1/configs/cli/eval.py +3 -10
- verifiers/v1/configs/cli/validate.py +1 -1
- verifiers/v1/configs/client.py +1 -1
- verifiers/v1/env.py +9 -2
- verifiers/v1/graph.py +6 -0
- verifiers/v1/harnesses/rlm/harness.py +17 -10
- verifiers/v1/interception/server.py +8 -0
- verifiers/v1/rollout.py +6 -0
- verifiers/v1/serve/__init__.py +2 -0
- verifiers/v1/serve/client.py +75 -34
- verifiers/v1/serve/delta.py +281 -0
- verifiers/v1/serve/pool.py +19 -10
- verifiers/v1/serve/server.py +29 -8
- verifiers/v1/serve/types.py +12 -6
- verifiers/v1/tasksets/harbor/taskset.py +4 -4
- verifiers/v1/trace.py +29 -0
- {verifiers-0.3.2.dev99.dist-info → verifiers-0.3.2.dev101.dist-info}/METADATA +1 -1
- {verifiers-0.3.2.dev99.dist-info → verifiers-0.3.2.dev101.dist-info}/RECORD +32 -30
- verifiers-0.3.2.dev101.dist-info/entry_points.txt +7 -0
- verifiers-0.3.2.dev99.dist-info/entry_points.txt +0 -7
- {verifiers-0.3.2.dev99.dist-info → verifiers-0.3.2.dev101.dist-info}/WHEEL +0 -0
- {verifiers-0.3.2.dev99.dist-info → verifiers-0.3.2.dev101.dist-info}/licenses/LICENSE +0 -0
|
@@ -483,6 +483,7 @@ class InterceptionServer(Interception):
|
|
|
483
483
|
acp=acp,
|
|
484
484
|
)
|
|
485
485
|
)
|
|
486
|
+
session.trace.notify()
|
|
486
487
|
|
|
487
488
|
async def handle_request(
|
|
488
489
|
self, request: web.Request, dialect: Dialect
|
|
@@ -660,6 +661,9 @@ class InterceptionServer(Interception):
|
|
|
660
661
|
return web.json_response(dialect.error_body(str(error)), status=400)
|
|
661
662
|
except RolloutError as error:
|
|
662
663
|
return self._fail(session, dialect, error)
|
|
664
|
+
# The tail is what the harness added since the last turn (tool results, user
|
|
665
|
+
# turns): live watchers see it now rather than with the model's reply.
|
|
666
|
+
session.trace.preview(turn, turn.tail)
|
|
663
667
|
|
|
664
668
|
inspect_response = bool(session.response_interceptors or session.response_stops)
|
|
665
669
|
if relay_streaming:
|
|
@@ -782,6 +786,8 @@ class InterceptionServer(Interception):
|
|
|
782
786
|
error = e
|
|
783
787
|
raise
|
|
784
788
|
finally:
|
|
789
|
+
if node is None:
|
|
790
|
+
turn.abandon()
|
|
785
791
|
# The turn's one per-exchange record: settings, timing, outcome, and
|
|
786
792
|
# the error that ended it (if any).
|
|
787
793
|
self.record_call(
|
|
@@ -1047,6 +1053,8 @@ class InterceptionServer(Interception):
|
|
|
1047
1053
|
error = e
|
|
1048
1054
|
raise
|
|
1049
1055
|
finally:
|
|
1056
|
+
if node is None:
|
|
1057
|
+
turn.abandon()
|
|
1050
1058
|
# The turn's one per-exchange record: settings, timing, outcome, and the
|
|
1051
1059
|
# error that ended it (if any).
|
|
1052
1060
|
self.record_call(
|
verifiers/v1/rollout.py
CHANGED
|
@@ -180,6 +180,7 @@ class Rollout:
|
|
|
180
180
|
proceed; a setup failure is captured onto the trace."""
|
|
181
181
|
self._opened = True
|
|
182
182
|
self.trace.timing.boot.start = time.time()
|
|
183
|
+
self.trace.notify()
|
|
183
184
|
if self._borrowed_runtime is None:
|
|
184
185
|
self.runtime = make_runtime(self.runtime_config, name=self.trace.id)
|
|
185
186
|
elif self._borrowed_runtime is not None and self._borrowed_runtime.stopped:
|
|
@@ -219,6 +220,7 @@ class Rollout:
|
|
|
219
220
|
now = time.time()
|
|
220
221
|
self.trace.timing.boot.end = now
|
|
221
222
|
self.trace.timing.setup.start = now
|
|
223
|
+
self.trace.notify()
|
|
222
224
|
# Task setup and harness provisioning share one setup-stage deadline.
|
|
223
225
|
setup_deadline = (
|
|
224
226
|
None
|
|
@@ -342,6 +344,7 @@ class Rollout:
|
|
|
342
344
|
now = time.time()
|
|
343
345
|
self.trace.timing.setup.end = now
|
|
344
346
|
self.trace.timing.agent.start = now
|
|
347
|
+
self.trace.notify()
|
|
345
348
|
return not self._session.stopped
|
|
346
349
|
|
|
347
350
|
async def step(self, messages: Messages | None = None) -> bool:
|
|
@@ -464,6 +467,7 @@ class Rollout:
|
|
|
464
467
|
finally:
|
|
465
468
|
if trace.timing.agent.start and not trace.timing.agent.end:
|
|
466
469
|
trace.timing.agent.end = time.time()
|
|
470
|
+
trace.notify()
|
|
467
471
|
if not self._failed and self._opened:
|
|
468
472
|
assert runtime is not None
|
|
469
473
|
trace.timing.finalize.start = time.time()
|
|
@@ -479,6 +483,7 @@ class Rollout:
|
|
|
479
483
|
now = time.time()
|
|
480
484
|
trace.timing.finalize.end = now
|
|
481
485
|
trace.timing.scoring.start = now
|
|
486
|
+
trace.notify()
|
|
482
487
|
async with boundary(TaskError, "scoring"):
|
|
483
488
|
# Cross-trace judgement runs later, after the runtime is gone.
|
|
484
489
|
await asyncio.wait_for(
|
|
@@ -489,6 +494,7 @@ class Rollout:
|
|
|
489
494
|
self._timeouts.scoring,
|
|
490
495
|
)
|
|
491
496
|
trace.timing.scoring.end = time.time()
|
|
497
|
+
trace.notify()
|
|
492
498
|
except Exception as e: # noqa: BLE001 - finalize boundary records every rollout failure
|
|
493
499
|
self.fail(e)
|
|
494
500
|
finally:
|
verifiers/v1/serve/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from verifiers.v1.serve.client import EnvClient
|
|
2
|
+
from verifiers.v1.serve.delta import EpisodeAssembly
|
|
2
3
|
from verifiers.v1.serve.pool import EnvServerPool, env_config_data, serve_env
|
|
3
4
|
from verifiers.v1.serve.server import EnvServer
|
|
4
5
|
from verifiers.v1.serve.types import (
|
|
@@ -12,6 +13,7 @@ __all__ = [
|
|
|
12
13
|
"EnvClient",
|
|
13
14
|
"EnvServer",
|
|
14
15
|
"EnvServerPool",
|
|
16
|
+
"EpisodeAssembly",
|
|
15
17
|
"HealthRequest",
|
|
16
18
|
"HealthResponse",
|
|
17
19
|
"RunRequest",
|
verifiers/v1/serve/client.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""ZMQ client for the env server.
|
|
2
2
|
|
|
3
|
-
A DEALER socket + msgpack, with a single receive loop matching
|
|
4
|
-
per-request futures by `request_id
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
A DEALER socket + msgpack, with a single receive loop matching replies to
|
|
4
|
+
per-request futures by `request_id` and routing a run's `delta` frames to its
|
|
5
|
+
assembly (`serve/delta.py`). Speaks the typed pydantic request/response models
|
|
6
|
+
(`serve/types.py`) end-to-end: a request is `model_dump`ed onto the wire; a run's
|
|
7
|
+
traces stream back turn by turn and the assembled record is `model_validate`d into a
|
|
8
|
+
`WireEpisode` once the reply lands — `Trace`s typed as `Trace[WireTaskData]`
|
|
9
|
+
(non-strict task, so env fields survive without importing the env). Health is just
|
|
10
|
+
another request (no dedicated probe thread).
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
import asyncio
|
|
@@ -13,6 +15,7 @@ import contextlib
|
|
|
13
15
|
import logging
|
|
14
16
|
import time
|
|
15
17
|
import uuid
|
|
18
|
+
from collections.abc import Callable
|
|
16
19
|
from typing import TypeVar
|
|
17
20
|
|
|
18
21
|
import msgpack
|
|
@@ -21,6 +24,7 @@ import zmq.asyncio
|
|
|
21
24
|
|
|
22
25
|
from verifiers.v1.configs.client import ClientConfig
|
|
23
26
|
from verifiers.v1.episode import WireEpisode
|
|
27
|
+
from verifiers.v1.serve.delta import EpisodeAssembly, unpack
|
|
24
28
|
from verifiers.v1.serve.types import (
|
|
25
29
|
BaseRequest,
|
|
26
30
|
BaseResponse,
|
|
@@ -47,6 +51,7 @@ class EnvClient:
|
|
|
47
51
|
self.socket.setsockopt(zmq.RCVHWM, 0)
|
|
48
52
|
self.socket.connect(address)
|
|
49
53
|
self._pending: dict[str, asyncio.Future[bytes]] = {}
|
|
54
|
+
self._deltas: dict[str, Callable[[bytes], None]] = {}
|
|
50
55
|
# Strong refs to in-flight fire-and-forget cancels: the loop only
|
|
51
56
|
# holds weak references to tasks, so an unreferenced one can be
|
|
52
57
|
# garbage-collected before it ever sends
|
|
@@ -59,27 +64,48 @@ class EnvClient:
|
|
|
59
64
|
self._receiver = asyncio.create_task(self._receive_loop())
|
|
60
65
|
|
|
61
66
|
async def _receive_loop(self) -> None:
|
|
67
|
+
# The one receiver serves every in-flight request, so a bad frame or a delta
|
|
68
|
+
# handler that raises is logged and skipped rather than allowed to end the loop.
|
|
62
69
|
while True:
|
|
63
70
|
try:
|
|
64
|
-
|
|
71
|
+
frames = await self.socket.recv_multipart()
|
|
65
72
|
except asyncio.CancelledError:
|
|
66
73
|
break
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
try:
|
|
75
|
+
if len(frames) != 3:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"expected [request_id, kind, data], got {len(frames)} frames - "
|
|
78
|
+
"is the env server speaking the same serve protocol?"
|
|
79
|
+
)
|
|
80
|
+
request_id_bytes, kind, data = frames
|
|
81
|
+
request_id = request_id_bytes.decode()
|
|
82
|
+
if kind == b"delta":
|
|
83
|
+
on_delta = self._deltas.get(request_id)
|
|
84
|
+
if on_delta is not None:
|
|
85
|
+
on_delta(data)
|
|
86
|
+
continue
|
|
87
|
+
future = self._pending.pop(request_id, None)
|
|
88
|
+
if future is not None and not future.done():
|
|
89
|
+
future.set_result(data)
|
|
90
|
+
except Exception: # keep receiving for the other requests
|
|
91
|
+
logger.warning("dropping a malformed env-server frame", exc_info=True)
|
|
70
92
|
|
|
71
93
|
async def _request(
|
|
72
94
|
self,
|
|
73
95
|
request: BaseRequest,
|
|
74
96
|
response_type: type[ResponseT],
|
|
75
97
|
timeout: float | None = None,
|
|
98
|
+
on_delta: Callable[[bytes], None] | None = None,
|
|
76
99
|
) -> ResponseT:
|
|
77
100
|
"""Send a typed request and validate the reply into `response_type`. A
|
|
78
|
-
`timeout` is only used for health polling — rollouts run untimed.
|
|
101
|
+
`timeout` is only used for health polling — rollouts run untimed. `on_delta`
|
|
102
|
+
receives each `delta` frame the request streams before its reply."""
|
|
79
103
|
self._ensure_receiver()
|
|
80
104
|
request_id = uuid.uuid4().hex
|
|
81
105
|
future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future()
|
|
82
106
|
self._pending[request_id] = future
|
|
107
|
+
if on_delta is not None:
|
|
108
|
+
self._deltas[request_id] = on_delta
|
|
83
109
|
payload = msgpack.packb(request.model_dump(mode="json"), use_bin_type=True)
|
|
84
110
|
await self.socket.send_multipart(
|
|
85
111
|
[request_id.encode(), request.method.encode(), payload]
|
|
@@ -101,31 +127,30 @@ class EnvClient:
|
|
|
101
127
|
self._cancel_tasks.add(task)
|
|
102
128
|
task.add_done_callback(self._cancel_tasks.discard)
|
|
103
129
|
raise
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Keep large trace replies compact on the loop and expand only one at a time.
|
|
108
|
-
await self._decode_slots.acquire()
|
|
109
|
-
decoding = asyncio.create_task(
|
|
110
|
-
asyncio.to_thread(
|
|
111
|
-
lambda: response_type.model_validate(
|
|
112
|
-
msgpack.unpackb(data, raw=False)
|
|
113
|
-
)
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
# Hold the slot until the worker finishes so cancellation cannot overlap decodes.
|
|
117
|
-
decoding.add_done_callback(lambda _: self._decode_slots.release())
|
|
118
|
-
try:
|
|
119
|
-
response = await asyncio.shield(decoding)
|
|
120
|
-
except asyncio.CancelledError:
|
|
121
|
-
decoding.add_done_callback(
|
|
122
|
-
lambda task: None if task.cancelled() else task.exception()
|
|
123
|
-
)
|
|
124
|
-
raise
|
|
130
|
+
finally:
|
|
131
|
+
self._deltas.pop(request_id, None)
|
|
132
|
+
response = response_type.model_validate(unpack(data))
|
|
125
133
|
if not response.success:
|
|
126
134
|
raise RuntimeError(response.error or "env server request failed")
|
|
127
135
|
return response
|
|
128
136
|
|
|
137
|
+
async def _validate_episode(self, record: dict) -> WireEpisode:
|
|
138
|
+
"""Type the assembled record off the loop, one episode at a time: a long
|
|
139
|
+
trace is a lot of token spans to validate."""
|
|
140
|
+
await self._decode_slots.acquire()
|
|
141
|
+
decoding = asyncio.create_task(
|
|
142
|
+
asyncio.to_thread(WireEpisode.model_validate, record)
|
|
143
|
+
)
|
|
144
|
+
# Hold the slot until the worker finishes so cancellation cannot overlap decodes.
|
|
145
|
+
decoding.add_done_callback(lambda _: self._decode_slots.release())
|
|
146
|
+
try:
|
|
147
|
+
return await asyncio.shield(decoding)
|
|
148
|
+
except asyncio.CancelledError:
|
|
149
|
+
decoding.add_done_callback(
|
|
150
|
+
lambda task: None if task.cancelled() else task.exception()
|
|
151
|
+
)
|
|
152
|
+
raise
|
|
153
|
+
|
|
129
154
|
async def _send_cancel(self, run_request_id: str) -> None:
|
|
130
155
|
"""Best-effort server-side abort of an abandoned run."""
|
|
131
156
|
with contextlib.suppress(Exception):
|
|
@@ -164,10 +189,22 @@ class EnvClient:
|
|
|
164
189
|
model: str,
|
|
165
190
|
sampling: SamplingConfig,
|
|
166
191
|
task_data: dict,
|
|
192
|
+
on_delta: Callable[[dict], None] | None = None,
|
|
167
193
|
) -> WireEpisode:
|
|
168
194
|
"""Run one rollout; return its episode record — flat traces (typed
|
|
169
195
|
`Trace[WireTaskData]`) plus the shared stamp. The server takes the task
|
|
170
|
-
itself (`task_data`, its dumped `TaskData`).
|
|
196
|
+
itself (`task_data`, its dumped `TaskData`). The traces stream in as the
|
|
197
|
+
rollout runs, one delta per turn or phase change (`serve.delta`); `on_delta`
|
|
198
|
+
sees each as it lands, so a caller can relay or persist the stream. The
|
|
199
|
+
episode returned is assembled from the same deltas."""
|
|
200
|
+
assembly = EpisodeAssembly()
|
|
201
|
+
|
|
202
|
+
def apply(data: bytes) -> None:
|
|
203
|
+
delta = unpack(data)
|
|
204
|
+
assembly.apply(delta)
|
|
205
|
+
if on_delta is not None:
|
|
206
|
+
on_delta(delta)
|
|
207
|
+
|
|
171
208
|
response = await self._request(
|
|
172
209
|
RunRequest(
|
|
173
210
|
task_data=task_data,
|
|
@@ -176,8 +213,12 @@ class EnvClient:
|
|
|
176
213
|
sampling=sampling,
|
|
177
214
|
),
|
|
178
215
|
RunResponse,
|
|
216
|
+
on_delta=apply,
|
|
217
|
+
)
|
|
218
|
+
assert response.head is not None
|
|
219
|
+
return await self._validate_episode(
|
|
220
|
+
assembly.finish(response.head, response.traces)
|
|
179
221
|
)
|
|
180
|
-
return response.episode
|
|
181
222
|
|
|
182
223
|
async def close(self) -> None:
|
|
183
224
|
if self._receiver is not None:
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Turn-by-turn episode deltas over the env-serve wire.
|
|
2
|
+
|
|
3
|
+
The worker streams a served episode as it grows. Each trace announces its own changes
|
|
4
|
+
(`Trace.notify`, fired by the rollout at every phase change and by the interception proxy
|
|
5
|
+
after every recorded turn); the `DeltaStreamer` then diffs the run's live traces against
|
|
6
|
+
what it has already sent and ships only the new part — the trace header once, then
|
|
7
|
+
appended nodes / calls / errors, semantic links landing on earlier nodes, the scalar
|
|
8
|
+
fields whose value changed (timing spans, stop condition, rewards, ...), and the
|
|
9
|
+
`pending` preview — the messages of the request in flight that no node holds yet, so a
|
|
10
|
+
watcher sees a tool result before the model has answered it. The `Trace` is
|
|
11
|
+
append-only at turn granularity (a turn's nodes are committed complete, with their
|
|
12
|
+
tokens), so apart from the preview, which a committed turn repeats, every byte of the
|
|
13
|
+
episode crosses the wire once and the stream costs about what a single reply would; the
|
|
14
|
+
reply that ends the run carries only the episode head and per-trace counts the client
|
|
15
|
+
checks its assembly against. A cursor advances only once its delta is on the wire, so a
|
|
16
|
+
send that fails is simply diffed again at the next flush.
|
|
17
|
+
|
|
18
|
+
The client applies deltas into raw dicts (`EpisodeAssembly`) — cheap enough to hand a
|
|
19
|
+
caller after every turn — and validates the assembled record into a `WireEpisode` once,
|
|
20
|
+
when the run's reply lands.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import copy
|
|
27
|
+
import logging
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
30
|
+
|
|
31
|
+
import msgpack
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
|
|
34
|
+
from verifiers.v1.serve.encoding import msgpack_encoder
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from verifiers.v1.env import RunSlot
|
|
38
|
+
from verifiers.v1.trace import Trace
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
HEADER_FIELDS = ("version", "id", "verifiers", "task")
|
|
43
|
+
"""Sent once, when a trace first appears."""
|
|
44
|
+
|
|
45
|
+
LIST_FIELDS = (
|
|
46
|
+
"nodes",
|
|
47
|
+
"calls",
|
|
48
|
+
"errors",
|
|
49
|
+
"extra_usage",
|
|
50
|
+
"request_rewrites",
|
|
51
|
+
"response_rewrites",
|
|
52
|
+
)
|
|
53
|
+
"""Append-only on the worker: each delta carries the items past the sent count."""
|
|
54
|
+
|
|
55
|
+
SCALAR_FIELDS = (
|
|
56
|
+
"agent",
|
|
57
|
+
"tools",
|
|
58
|
+
"mm_token_type_id_map",
|
|
59
|
+
"rewards",
|
|
60
|
+
"metrics",
|
|
61
|
+
"info",
|
|
62
|
+
"root_reply",
|
|
63
|
+
"is_completed",
|
|
64
|
+
"ok",
|
|
65
|
+
"stop_condition",
|
|
66
|
+
"timing",
|
|
67
|
+
)
|
|
68
|
+
"""Re-sent whole whenever their dumped value changes."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pack(payload: Any) -> bytes:
|
|
72
|
+
return msgpack.packb(payload, default=msgpack_encoder, use_bin_type=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def unpack(data: bytes) -> Any:
|
|
76
|
+
# `links` is keyed by node index (int).
|
|
77
|
+
return msgpack.unpackb(data, raw=False, strict_map_key=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def dump(model: BaseModel, **kwargs: Any) -> dict:
|
|
81
|
+
# Task data and agent configs are subclasses of their declared types; dump the
|
|
82
|
+
# runtime type so env-specific fields survive the wire.
|
|
83
|
+
return model.model_dump(mode="python", serialize_as_any=True, **kwargs)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TraceCursor:
|
|
87
|
+
"""What of one trace has been sent."""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self.sent = dict.fromkeys(LIST_FIELDS, 0)
|
|
91
|
+
self.links: list[int] = []
|
|
92
|
+
"""Per sent node, how many of its semantic links went out with or after it."""
|
|
93
|
+
self.scalars: dict[str, bytes] = {}
|
|
94
|
+
self.pending: bytes = pack([])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DeltaStreamer:
|
|
98
|
+
"""Sends a `RunSlot`'s trace changes as deltas, one flush per burst of changes.
|
|
99
|
+
|
|
100
|
+
`watch` subscribes a minted trace (pass it as `run_slot`'s `on_trace`); each
|
|
101
|
+
`Trace.notify` schedules a flush, and changes landing in the same loop iteration
|
|
102
|
+
ride one flush. Use as an async context manager around the rollout: a clean exit
|
|
103
|
+
flushes the final state (the slot then holds the finished episode's traces), a
|
|
104
|
+
cancelled rollout flushes nothing — its client has already gone."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, slot: RunSlot, send: Callable[[bytes], Awaitable[None]]) -> None:
|
|
107
|
+
self.slot = slot
|
|
108
|
+
self.send = send
|
|
109
|
+
self.cursors: dict[str, TraceCursor] = {}
|
|
110
|
+
self._scheduled: asyncio.Handle | None = None
|
|
111
|
+
self._flushes: set[asyncio.Task] = set()
|
|
112
|
+
self._lock = asyncio.Lock()
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self) -> Self:
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
118
|
+
if self._scheduled is not None:
|
|
119
|
+
self._scheduled.cancel()
|
|
120
|
+
self._scheduled = None
|
|
121
|
+
if exc_type is None:
|
|
122
|
+
# let in-flight flushes finish, then send whatever the run left behind
|
|
123
|
+
await asyncio.gather(*self._flushes, return_exceptions=True)
|
|
124
|
+
await self.flush()
|
|
125
|
+
else:
|
|
126
|
+
for task in self._flushes:
|
|
127
|
+
task.cancel()
|
|
128
|
+
|
|
129
|
+
def watch(self, trace: Trace) -> None:
|
|
130
|
+
trace.watch(self._changed)
|
|
131
|
+
self._changed(trace)
|
|
132
|
+
|
|
133
|
+
def _changed(self, trace: Trace) -> None:
|
|
134
|
+
if self._scheduled is not None:
|
|
135
|
+
return
|
|
136
|
+
self._scheduled = asyncio.get_running_loop().call_soon(self._start_flush)
|
|
137
|
+
|
|
138
|
+
def _start_flush(self) -> None:
|
|
139
|
+
self._scheduled = None
|
|
140
|
+
task = asyncio.create_task(self.flush())
|
|
141
|
+
self._flushes.add(task)
|
|
142
|
+
task.add_done_callback(self._flushes.discard)
|
|
143
|
+
|
|
144
|
+
async def flush(self) -> None:
|
|
145
|
+
# Serialized: deltas must leave in diff order, and the reply after the last.
|
|
146
|
+
async with self._lock:
|
|
147
|
+
for trace_id, delta, cursor in self.diff():
|
|
148
|
+
try:
|
|
149
|
+
await self.send(pack(delta))
|
|
150
|
+
except Exception: # the cursor stays put: the next flush diffs it again
|
|
151
|
+
logger.warning(
|
|
152
|
+
"failed to send delta for %s", trace_id, exc_info=True
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
if cursor is None:
|
|
156
|
+
self.cursors.pop(trace_id, None)
|
|
157
|
+
else:
|
|
158
|
+
self.cursors[trace_id] = cursor
|
|
159
|
+
|
|
160
|
+
def diff(self) -> list[tuple[str, dict, TraceCursor | None]]:
|
|
161
|
+
"""Each trace's delta against its sent cursor, with the cursor as it stands once
|
|
162
|
+
that delta is sent (None for a discard). Nothing here is committed: `flush`
|
|
163
|
+
stores a cursor only after its delta left."""
|
|
164
|
+
traces = list(self.slot.traces)
|
|
165
|
+
live = {trace.id for trace in traces}
|
|
166
|
+
deltas: list[tuple[str, dict, TraceCursor | None]] = []
|
|
167
|
+
for trace_id in [trace_id for trace_id in self.cursors if trace_id not in live]:
|
|
168
|
+
# A retried attempt abandons its traces; the client drops them too.
|
|
169
|
+
deltas.append((trace_id, {"trace": trace_id, "discard": True}, None))
|
|
170
|
+
for trace in traces:
|
|
171
|
+
delta: dict[str, Any] = {"trace": trace.id}
|
|
172
|
+
sent = self.cursors.get(trace.id)
|
|
173
|
+
cursor = copy.deepcopy(sent) if sent is not None else TraceCursor()
|
|
174
|
+
if sent is None:
|
|
175
|
+
delta["open"] = dump(trace, include=set(HEADER_FIELDS))
|
|
176
|
+
links: dict[int, list[dict]] = {}
|
|
177
|
+
for index in range(cursor.sent["nodes"]):
|
|
178
|
+
node_links = trace.nodes[index].semantic_parents
|
|
179
|
+
if len(node_links) > cursor.links[index]:
|
|
180
|
+
links[index] = [
|
|
181
|
+
dump(link) for link in node_links[cursor.links[index] :]
|
|
182
|
+
]
|
|
183
|
+
cursor.links[index] = len(node_links)
|
|
184
|
+
if links:
|
|
185
|
+
delta["links"] = links
|
|
186
|
+
for field in LIST_FIELDS:
|
|
187
|
+
items = getattr(trace, field)
|
|
188
|
+
sent = cursor.sent[field]
|
|
189
|
+
if len(items) > sent:
|
|
190
|
+
delta[field] = [dump(item) for item in items[sent:]]
|
|
191
|
+
cursor.sent[field] = len(items)
|
|
192
|
+
if field == "nodes":
|
|
193
|
+
cursor.links.extend(
|
|
194
|
+
len(node.semantic_parents) for node in items[sent:]
|
|
195
|
+
)
|
|
196
|
+
changed: dict[str, Any] = {}
|
|
197
|
+
for field, value in dump(trace, include=set(SCALAR_FIELDS)).items():
|
|
198
|
+
packed = pack(value)
|
|
199
|
+
if cursor.scalars.get(field) != packed:
|
|
200
|
+
cursor.scalars[field] = packed
|
|
201
|
+
changed[field] = value
|
|
202
|
+
if changed:
|
|
203
|
+
delta["set"] = changed
|
|
204
|
+
pending = [dump(message) for message in trace.pending]
|
|
205
|
+
packed = pack(pending)
|
|
206
|
+
if packed != cursor.pending:
|
|
207
|
+
cursor.pending = packed
|
|
208
|
+
# nodes landing clear the preview on the client by themselves
|
|
209
|
+
if pending or "nodes" not in delta:
|
|
210
|
+
delta["pending"] = pending
|
|
211
|
+
if len(delta) > 1:
|
|
212
|
+
deltas.append((trace.id, delta, cursor))
|
|
213
|
+
return deltas
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class EpisodeAssembly:
|
|
217
|
+
"""The client's growing picture of one served episode: raw trace dicts in arrival
|
|
218
|
+
order, each shaped like a dumped `Trace` plus its `pending` preview."""
|
|
219
|
+
|
|
220
|
+
def __init__(self) -> None:
|
|
221
|
+
self.traces: dict[str, dict] = {}
|
|
222
|
+
|
|
223
|
+
def apply(self, delta: dict) -> None:
|
|
224
|
+
trace_id = delta["trace"]
|
|
225
|
+
if delta.get("discard"):
|
|
226
|
+
self.traces.pop(trace_id, None)
|
|
227
|
+
return
|
|
228
|
+
trace = self.traces.get(trace_id)
|
|
229
|
+
if trace is None:
|
|
230
|
+
trace = self.traces[trace_id] = {
|
|
231
|
+
**delta["open"],
|
|
232
|
+
**{field: [] for field in LIST_FIELDS},
|
|
233
|
+
"pending": [],
|
|
234
|
+
}
|
|
235
|
+
for index, links in (delta.get("links") or {}).items():
|
|
236
|
+
trace["nodes"][int(index)]["semantic_parents"].extend(links)
|
|
237
|
+
# a later `links` delta grows a node's semantic_parents in place, so the node
|
|
238
|
+
# is copied: the delta stays as it was when the caller received it
|
|
239
|
+
if "nodes" in delta:
|
|
240
|
+
trace["nodes"].extend(
|
|
241
|
+
{**node, "semantic_parents": list(node.get("semantic_parents") or [])}
|
|
242
|
+
for node in delta["nodes"]
|
|
243
|
+
)
|
|
244
|
+
for field in LIST_FIELDS:
|
|
245
|
+
if field in delta and field != "nodes":
|
|
246
|
+
trace[field].extend(delta[field])
|
|
247
|
+
trace.update(delta.get("set") or {})
|
|
248
|
+
# a committed turn absorbs the preview; an explicit preview replaces it
|
|
249
|
+
if "nodes" in delta:
|
|
250
|
+
trace["pending"] = []
|
|
251
|
+
if "pending" in delta:
|
|
252
|
+
trace["pending"] = delta["pending"]
|
|
253
|
+
|
|
254
|
+
def finish(self, head: dict, summaries: list[TraceSummary]) -> dict:
|
|
255
|
+
"""The full episode record: `head` (the episode without its traces) joined with
|
|
256
|
+
the assembled traces in the server's order. A count mismatch means a delta was
|
|
257
|
+
lost, which the wire never does in a live connection — fail loudly."""
|
|
258
|
+
traces = []
|
|
259
|
+
for summary in summaries:
|
|
260
|
+
trace = self.traces.get(summary.id)
|
|
261
|
+
if trace is None:
|
|
262
|
+
raise RuntimeError(f"served episode is missing trace {summary.id}")
|
|
263
|
+
trace = {key: value for key, value in trace.items() if key != "pending"}
|
|
264
|
+
if (
|
|
265
|
+
len(trace["nodes"]) != summary.nodes
|
|
266
|
+
or len(trace["calls"]) != summary.calls
|
|
267
|
+
):
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
f"served trace {summary.id} assembled {len(trace['nodes'])} nodes / "
|
|
270
|
+
f"{len(trace['calls'])} calls, server reports {summary.nodes} / {summary.calls}"
|
|
271
|
+
)
|
|
272
|
+
traces.append(trace)
|
|
273
|
+
return {**head, "traces": traces}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TraceSummary(BaseModel):
|
|
277
|
+
"""A finished trace's identity and size, for the client's assembly check."""
|
|
278
|
+
|
|
279
|
+
id: str
|
|
280
|
+
nodes: int
|
|
281
|
+
calls: int
|
verifiers/v1/serve/pool.py
CHANGED
|
@@ -193,7 +193,7 @@ class EnvServerPool:
|
|
|
193
193
|
) = await self.frontend.recv_multipart()
|
|
194
194
|
if method == b"health":
|
|
195
195
|
await self.frontend.send_multipart(
|
|
196
|
-
[client_id, request_id, _HEALTH]
|
|
196
|
+
[client_id, request_id, b"reply", _HEALTH]
|
|
197
197
|
)
|
|
198
198
|
elif method == b"cancel":
|
|
199
199
|
# Route the cancel to the worker holding the target run;
|
|
@@ -208,7 +208,7 @@ class EnvServerPool:
|
|
|
208
208
|
target_entry = pending.get(target)
|
|
209
209
|
if target_entry is None:
|
|
210
210
|
await self.frontend.send_multipart(
|
|
211
|
-
[client_id, request_id, _CANCEL_MISS]
|
|
211
|
+
[client_id, request_id, b"reply", _CANCEL_MISS]
|
|
212
212
|
)
|
|
213
213
|
else:
|
|
214
214
|
worker = target_entry["worker"]
|
|
@@ -238,16 +238,25 @@ class EnvServerPool:
|
|
|
238
238
|
self._maybe_scale_up(in_flight)
|
|
239
239
|
for w in self.workers:
|
|
240
240
|
if w["dealer"] in events:
|
|
241
|
-
request_id, data = await w["dealer"].recv_multipart(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
241
|
+
request_id, kind, data = await w["dealer"].recv_multipart(
|
|
242
|
+
copy=False
|
|
243
|
+
)
|
|
244
|
+
# Copy only the routing key; relay the Frames unchanged. A
|
|
245
|
+
# `delta` is one of many for its request — only the `reply`
|
|
246
|
+
# closes the request out.
|
|
247
|
+
if kind.bytes == b"reply":
|
|
248
|
+
entry = pending.pop(request_id.bytes, None)
|
|
249
|
+
if entry is None:
|
|
250
|
+
continue
|
|
251
|
+
entry["worker"]["active"] -= 1
|
|
252
|
+
in_flight -= 1
|
|
253
|
+
else:
|
|
254
|
+
entry = pending.get(request_id.bytes)
|
|
255
|
+
if entry is None:
|
|
256
|
+
continue
|
|
248
257
|
with contextlib.suppress(zmq.ZMQError):
|
|
249
258
|
await self.frontend.send_multipart(
|
|
250
|
-
[entry["client_id"], request_id, data], copy=False
|
|
259
|
+
[entry["client_id"], request_id, kind, data], copy=False
|
|
251
260
|
)
|
|
252
261
|
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
253
262
|
pass
|