collimate-rl 0.3.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.
- collimate_rl/__init__.py +107 -0
- collimate_rl/burst.py +243 -0
- collimate_rl/client.py +496 -0
- collimate_rl/fleet.py +514 -0
- collimate_rl/gateway.py +751 -0
- collimate_rl/harness.py +581 -0
- collimate_rl/launch_view.py +606 -0
- collimate_rl/local_backend.py +339 -0
- collimate_rl/openenv/__init__.py +21 -0
- collimate_rl/openenv/collimate_provider.py +364 -0
- collimate_rl/rewards.py +370 -0
- collimate_rl/rl_view.py +463 -0
- collimate_rl/sandbox.py +448 -0
- collimate_rl/seeds.py +90 -0
- collimate_rl/skyrl/__init__.py +13 -0
- collimate_rl/skyrl/collimate_code_env.py +273 -0
- collimate_rl/slime/__init__.py +18 -0
- collimate_rl/slime/reward.py +264 -0
- collimate_rl/template.py +456 -0
- collimate_rl/transport.py +101 -0
- collimate_rl-0.3.0.dist-info/METADATA +256 -0
- collimate_rl-0.3.0.dist-info/RECORD +26 -0
- collimate_rl-0.3.0.dist-info/WHEEL +5 -0
- collimate_rl-0.3.0.dist-info/entry_points.txt +3 -0
- collimate_rl-0.3.0.dist-info/licenses/LICENSE +202 -0
- collimate_rl-0.3.0.dist-info/top_level.txt +1 -0
collimate_rl/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Copyright 2026 Omer Gero (Gero Consulting)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Collimate RL integration toolkit.
|
|
4
|
+
|
|
5
|
+
Thin, dependency-free Python building blocks for using a Collimate control
|
|
6
|
+
plane (the ``serve.rs`` daemon: ``/v1/sessions`` + ``/v1/exec``) as the
|
|
7
|
+
sandboxed *reward grader* in an RL post-training loop.
|
|
8
|
+
|
|
9
|
+
This is the "exec-grader" integration mode: the environment logic lives in the
|
|
10
|
+
trainer, and Collimate runs each candidate rollout in a forked microVM to
|
|
11
|
+
produce an isolated, reproducible reward. It needs nothing from the guest
|
|
12
|
+
beyond the existing exec API — no host->guest ingress — so it runs against the
|
|
13
|
+
control plane that exists today.
|
|
14
|
+
|
|
15
|
+
Public surface:
|
|
16
|
+
|
|
17
|
+
from collimate_rl import (
|
|
18
|
+
connect, # ONE entry point, either transport (see below)
|
|
19
|
+
CollimateClient, # the daemon /v1 HTTP client (stdlib urllib only)
|
|
20
|
+
GatewayClient, # the GA gateway client (Bearer col_* key, any tier)
|
|
21
|
+
CollimateDrainingError, # node draining (retry-safe; route elsewhere)
|
|
22
|
+
PropertyReward, # property-based / fuzz grader (CRN via seed)
|
|
23
|
+
UnitTestReward, # explicit unit-test grader (deterministic)
|
|
24
|
+
grade, grade_batch, # run a candidate (or a GRPO group) -> reward
|
|
25
|
+
GradeResult,
|
|
26
|
+
extract_code, # pull code out of an LLM completion
|
|
27
|
+
fresh_seed, group_seeds, DET_SEED, # common-random-numbers helpers
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
``connect()`` returns the right client for the target you name (a daemon URL /
|
|
31
|
+
UDS, or a ``col_*`` API key of ANY tier → the gateway) — and both clients
|
|
32
|
+
expose the same session surface, so ``grade``/``grade_batch``, ``Sandbox`` and
|
|
33
|
+
every framework adapter run over either transport unchanged.
|
|
34
|
+
|
|
35
|
+
The framework adapters (``collimate_rl.skyrl``, ``collimate_rl.openenv``,
|
|
36
|
+
``collimate_rl.slime``) are thin shims over this.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from .client import CollimateClient, CollimateDrainingError, CollimateError
|
|
40
|
+
from .fleet import Fleet, RolloutHandle
|
|
41
|
+
from .gateway import DemoTierLimit, GatewayClient, GatewayError
|
|
42
|
+
from .transport import SessionClient, connect
|
|
43
|
+
# template.py installs GatewayClient.session and re-exports the high-level flow;
|
|
44
|
+
# importing it here wires that method and keeps the public surface discoverable.
|
|
45
|
+
from .template import (
|
|
46
|
+
Ready,
|
|
47
|
+
Session,
|
|
48
|
+
Template,
|
|
49
|
+
TemplateHandle,
|
|
50
|
+
TemplateStatus,
|
|
51
|
+
open_session,
|
|
52
|
+
)
|
|
53
|
+
from .rewards import (
|
|
54
|
+
GradeResult,
|
|
55
|
+
PropertyReward,
|
|
56
|
+
RewardSpec,
|
|
57
|
+
UnitTestReward,
|
|
58
|
+
extract_code,
|
|
59
|
+
grade,
|
|
60
|
+
grade_batch,
|
|
61
|
+
reward_spec_from_ground_truth,
|
|
62
|
+
)
|
|
63
|
+
from .sandbox import CollimateTaskError, ExecResult, Sandbox, rollout_once
|
|
64
|
+
from .seeds import DET_SEED, fresh_seed, group_seeds, is_valid_seed, seed_from_key
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
# transport selection (one interface, any key tier)
|
|
68
|
+
"connect",
|
|
69
|
+
"SessionClient",
|
|
70
|
+
"CollimateClient",
|
|
71
|
+
"CollimateError",
|
|
72
|
+
"CollimateDrainingError",
|
|
73
|
+
# GA gateway surface (Bearer col_* keys) + high-level bake/session flow
|
|
74
|
+
"GatewayClient",
|
|
75
|
+
"GatewayError",
|
|
76
|
+
"DemoTierLimit",
|
|
77
|
+
"Ready",
|
|
78
|
+
"Template",
|
|
79
|
+
"TemplateHandle",
|
|
80
|
+
"TemplateStatus",
|
|
81
|
+
"Session",
|
|
82
|
+
"open_session",
|
|
83
|
+
# fleet / control-plane surface
|
|
84
|
+
"Fleet",
|
|
85
|
+
"RolloutHandle",
|
|
86
|
+
# in-pod sandbox hot path
|
|
87
|
+
"Sandbox",
|
|
88
|
+
"ExecResult",
|
|
89
|
+
"CollimateTaskError",
|
|
90
|
+
"rollout_once",
|
|
91
|
+
# reward grading
|
|
92
|
+
"RewardSpec",
|
|
93
|
+
"PropertyReward",
|
|
94
|
+
"UnitTestReward",
|
|
95
|
+
"GradeResult",
|
|
96
|
+
"grade",
|
|
97
|
+
"grade_batch",
|
|
98
|
+
"reward_spec_from_ground_truth",
|
|
99
|
+
"extract_code",
|
|
100
|
+
"fresh_seed",
|
|
101
|
+
"group_seeds",
|
|
102
|
+
"seed_from_key",
|
|
103
|
+
"is_valid_seed",
|
|
104
|
+
"DET_SEED",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
__version__ = "0.3.0"
|
collimate_rl/burst.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Lifecycle load burst: start -> exec -> stop, N times at concurrency C.
|
|
2
|
+
|
|
3
|
+
The classic load-tool view (histogram + latency distribution + summary), applied
|
|
4
|
+
to the full sandbox lifecycle instead of a bare HTTP endpoint: every sample is a
|
|
5
|
+
CREATE (a live fork off the warm pool), one EXEC inside it, and a DELETE. Each
|
|
6
|
+
phase is timed separately so the report can attribute the milliseconds honestly.
|
|
7
|
+
|
|
8
|
+
Zero dependencies beyond the optional ``rich`` extra the harness already uses.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from .gateway import GatewayClient, GatewayError
|
|
19
|
+
|
|
20
|
+
#: Default exec payload — deliberately the same *class* of work as the classic
|
|
21
|
+
#: "hello sandbox" launch demos (a trivial interpreter one-liner), so lifecycle
|
|
22
|
+
#: numbers compare apples-to-apples. The flex is what the sandbox IS (a warm
|
|
23
|
+
#: fork of the full template, deps resident), not what the one-liner does.
|
|
24
|
+
DEFAULT_EXEC = 'python3 -c "import uuid, time; print(str(uuid.uuid4()) + \' \' + str(time.time()))"'
|
|
25
|
+
|
|
26
|
+
PERCENTILES = (10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0, 99.9, 100.0)
|
|
27
|
+
HISTOGRAM_BUCKETS = 11
|
|
28
|
+
BAR_WIDTH = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
# pure helpers (unit-tested) #
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
|
|
35
|
+
def percentile(sorted_vals: Sequence[float], pct: float) -> float:
|
|
36
|
+
"""Nearest-rank percentile of an ascending-sorted sequence."""
|
|
37
|
+
if not sorted_vals:
|
|
38
|
+
return 0.0
|
|
39
|
+
if pct >= 100.0:
|
|
40
|
+
return sorted_vals[-1]
|
|
41
|
+
rank = max(1, int(round(pct / 100.0 * len(sorted_vals) + 0.5)))
|
|
42
|
+
return sorted_vals[min(rank, len(sorted_vals)) - 1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def histogram_buckets(
|
|
46
|
+
sorted_vals: Sequence[float], n_buckets: int = HISTOGRAM_BUCKETS
|
|
47
|
+
) -> List[Tuple[float, int]]:
|
|
48
|
+
"""Linear buckets over [min, p99.9] (upper-edge, count) — outliers clamp to
|
|
49
|
+
the last bucket so one straggler cannot flatten the whole picture."""
|
|
50
|
+
if not sorted_vals:
|
|
51
|
+
return []
|
|
52
|
+
lo = sorted_vals[0]
|
|
53
|
+
hi = max(percentile(sorted_vals, 99.9), lo + 1e-9)
|
|
54
|
+
step = (hi - lo) / n_buckets
|
|
55
|
+
buckets = [[lo + step * (i + 1), 0] for i in range(n_buckets)]
|
|
56
|
+
for v in sorted_vals:
|
|
57
|
+
idx = min(int((v - lo) / step) if step > 0 else 0, n_buckets - 1)
|
|
58
|
+
buckets[idx][1] += 1
|
|
59
|
+
return [(edge, count) for edge, count in buckets]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# the burst #
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
|
|
66
|
+
class _Sample(Dict[str, Any]):
|
|
67
|
+
"""One lifecycle sample: phase timings (s) or an error code."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _one_lifecycle(client: GatewayClient, template: str, exec_cmd: str,
|
|
71
|
+
timeout_s: int) -> _Sample:
|
|
72
|
+
s = _Sample()
|
|
73
|
+
t0 = time.perf_counter()
|
|
74
|
+
try:
|
|
75
|
+
created = client.create_sandbox(template)
|
|
76
|
+
s["create"] = time.perf_counter() - t0
|
|
77
|
+
sid = created["id"]
|
|
78
|
+
except GatewayError as e:
|
|
79
|
+
s["error"] = getattr(e, "code", "") or "create_failed"
|
|
80
|
+
s["total"] = time.perf_counter() - t0
|
|
81
|
+
return s
|
|
82
|
+
try:
|
|
83
|
+
t1 = time.perf_counter()
|
|
84
|
+
r = client.exec(sid, command=exec_cmd, timeout_seconds=timeout_s)
|
|
85
|
+
s["exec"] = time.perf_counter() - t1
|
|
86
|
+
if r.get("exit_code", 0) != 0:
|
|
87
|
+
s["error"] = "exec_nonzero"
|
|
88
|
+
except GatewayError as e:
|
|
89
|
+
s["error"] = getattr(e, "code", "") or "exec_failed"
|
|
90
|
+
finally:
|
|
91
|
+
t2 = time.perf_counter()
|
|
92
|
+
try:
|
|
93
|
+
client.delete_sandbox(sid)
|
|
94
|
+
s["delete"] = time.perf_counter() - t2
|
|
95
|
+
except GatewayError:
|
|
96
|
+
s.setdefault("error", "delete_failed")
|
|
97
|
+
s["total"] = time.perf_counter() - t0
|
|
98
|
+
return s
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_burst(client: GatewayClient, template: str, *, n: int, concurrency: int,
|
|
102
|
+
exec_cmd: str = DEFAULT_EXEC, timeout_s: int = 30,
|
|
103
|
+
on_progress=None) -> Dict[str, Any]:
|
|
104
|
+
"""Run the burst; returns {samples, wall_s}. Thread-per-inflight-lifecycle —
|
|
105
|
+
each worker owns its create/exec/delete chain end to end."""
|
|
106
|
+
samples: List[_Sample] = []
|
|
107
|
+
lock = threading.Lock()
|
|
108
|
+
done = 0
|
|
109
|
+
t0 = time.perf_counter()
|
|
110
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
111
|
+
futs = [pool.submit(_one_lifecycle, client, template, exec_cmd, timeout_s)
|
|
112
|
+
for _ in range(n)]
|
|
113
|
+
for fut in as_completed(futs):
|
|
114
|
+
sample = fut.result()
|
|
115
|
+
with lock:
|
|
116
|
+
samples.append(sample)
|
|
117
|
+
done += 1
|
|
118
|
+
if on_progress is not None:
|
|
119
|
+
on_progress(done, n, time.perf_counter() - t0)
|
|
120
|
+
return {"samples": samples, "wall_s": time.perf_counter() - t0}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
# rendering (rich — same optional extra as the rest of the harness) #
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
|
|
127
|
+
def render_report(console, result: Dict[str, Any], *, template: str, n: int,
|
|
128
|
+
concurrency: int, note: str = "") -> None:
|
|
129
|
+
from rich.text import Text
|
|
130
|
+
|
|
131
|
+
samples: List[_Sample] = result["samples"]
|
|
132
|
+
ok = [s for s in samples if "error" not in s]
|
|
133
|
+
totals = sorted(s["total"] for s in ok)
|
|
134
|
+
wall = result["wall_s"]
|
|
135
|
+
|
|
136
|
+
console.print()
|
|
137
|
+
console.print(Text.assemble(
|
|
138
|
+
(" collimate burst", "bold cyan"),
|
|
139
|
+
(f" · start → exec → stop × {n:,} · concurrency {concurrency}", "bold"),
|
|
140
|
+
))
|
|
141
|
+
console.print(Text(f" template {template} — every sample forks a live sandbox "
|
|
142
|
+
f"off the warm pool, runs, and is destroyed", "dim"))
|
|
143
|
+
console.print()
|
|
144
|
+
|
|
145
|
+
if not totals:
|
|
146
|
+
console.print(Text(" no successful lifecycles — see errors below", "bold red"))
|
|
147
|
+
else:
|
|
148
|
+
console.print(Text("Response time histogram (full lifecycle):", "bold"))
|
|
149
|
+
buckets = histogram_buckets(totals)
|
|
150
|
+
top = max(count for _, count in buckets) or 1
|
|
151
|
+
for edge, count in buckets:
|
|
152
|
+
bar = "■" * max(0, round(count / top * BAR_WIDTH))
|
|
153
|
+
console.print(Text.assemble(
|
|
154
|
+
(f" {edge:7.3f}s ", ""), (f"[{count:5d}] ", "dim"), (bar, "cyan"),
|
|
155
|
+
))
|
|
156
|
+
console.print()
|
|
157
|
+
console.print(Text("Latency distribution:", "bold"))
|
|
158
|
+
for pct in PERCENTILES:
|
|
159
|
+
console.print(f" {pct:6.2f}% in {percentile(totals, pct):8.4f}s")
|
|
160
|
+
console.print()
|
|
161
|
+
console.print(Text("Per-phase (p50 / p90):", "bold"))
|
|
162
|
+
for phase in ("create", "exec", "delete"):
|
|
163
|
+
vals = sorted(s[phase] for s in ok if phase in s)
|
|
164
|
+
if vals:
|
|
165
|
+
console.print(
|
|
166
|
+
f" {phase:<7} {percentile(vals, 50)*1000:7.1f} ms / "
|
|
167
|
+
f"{percentile(vals, 90)*1000:7.1f} ms"
|
|
168
|
+
)
|
|
169
|
+
console.print()
|
|
170
|
+
|
|
171
|
+
rate = (len(ok) / wall) if wall > 0 else 0.0
|
|
172
|
+
console.print(Text("Summary:", "bold"))
|
|
173
|
+
console.print(Text.assemble(
|
|
174
|
+
(f" total {wall:.2f}s", "bold"),
|
|
175
|
+
(f" · {rate:,.1f} lifecycles/sec · success ", ""),
|
|
176
|
+
(f"{len(ok):,}/{n:,}", "bold green" if len(ok) == n else "bold red"),
|
|
177
|
+
))
|
|
178
|
+
errors: Dict[str, int] = {}
|
|
179
|
+
for s in samples:
|
|
180
|
+
if "error" in s:
|
|
181
|
+
errors[s["error"]] = errors.get(s["error"], 0) + 1
|
|
182
|
+
for code, count in sorted(errors.items()):
|
|
183
|
+
console.print(Text(f" error {code}: {count}", "red"))
|
|
184
|
+
if note:
|
|
185
|
+
console.print(Text(f" {note}", "dim"))
|
|
186
|
+
console.print()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_burst(args: argparse.Namespace) -> int:
|
|
190
|
+
from rich.console import Console
|
|
191
|
+
from rich.progress import (BarColumn, Progress, TextColumn,
|
|
192
|
+
TimeElapsedColumn)
|
|
193
|
+
|
|
194
|
+
console = Console()
|
|
195
|
+
# retries=6: a lifecycle burst that outruns the advertised warm pool sees
|
|
196
|
+
# placer-level 503 at_capacity (retryable, no side effect); six full-jitter
|
|
197
|
+
# attempts (~10 s worst-case) ride one node-agent report/refill window
|
|
198
|
+
# instead of failing the sample.
|
|
199
|
+
client = GatewayClient(api_key=args.api_key, base_url=args.base_url,
|
|
200
|
+
retries=6)
|
|
201
|
+
progress = Progress(
|
|
202
|
+
TextColumn("[bold cyan]burst[/bold cyan]"),
|
|
203
|
+
BarColumn(bar_width=40, complete_style="cyan"),
|
|
204
|
+
TextColumn("{task.completed:,}/{task.total:,} lifecycles"),
|
|
205
|
+
TextColumn("[dim]{task.fields[rate]:,.0f}/s[/dim]"),
|
|
206
|
+
TimeElapsedColumn(),
|
|
207
|
+
console=console,
|
|
208
|
+
)
|
|
209
|
+
with progress:
|
|
210
|
+
task = progress.add_task("burst", total=args.n, rate=0.0)
|
|
211
|
+
|
|
212
|
+
def on_progress(done: int, total: int, elapsed: float) -> None:
|
|
213
|
+
progress.update(task, completed=done,
|
|
214
|
+
rate=(done / elapsed) if elapsed > 0 else 0.0)
|
|
215
|
+
|
|
216
|
+
result = run_burst(client, args.template, n=args.n,
|
|
217
|
+
concurrency=args.concurrency, exec_cmd=args.exec_cmd,
|
|
218
|
+
timeout_s=args.timeout, on_progress=on_progress)
|
|
219
|
+
render_report(console, result, template=args.template, n=args.n,
|
|
220
|
+
concurrency=args.concurrency, note=args.note)
|
|
221
|
+
ok = sum(1 for s in result["samples"] if "error" not in s)
|
|
222
|
+
return 0 if ok == args.n else 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def add_burst_parser(sub, add_common_args) -> None:
|
|
226
|
+
p = sub.add_parser(
|
|
227
|
+
"burst",
|
|
228
|
+
help="start → exec → stop N sandboxes at concurrency C; "
|
|
229
|
+
"histogram + latency distribution",
|
|
230
|
+
)
|
|
231
|
+
add_common_args(p)
|
|
232
|
+
p.add_argument("template", help="template id to fork from")
|
|
233
|
+
p.add_argument("-n", type=int, default=1000, dest="n",
|
|
234
|
+
help="number of full lifecycles (default 1000)")
|
|
235
|
+
p.add_argument("-c", type=int, default=100, dest="concurrency",
|
|
236
|
+
help="concurrent in-flight lifecycles (default 100)")
|
|
237
|
+
p.add_argument("--exec-cmd", default=DEFAULT_EXEC,
|
|
238
|
+
help="command each sandbox runs (default: trivial python one-liner)")
|
|
239
|
+
p.add_argument("--timeout", type=int, default=30,
|
|
240
|
+
help="per-exec timeout seconds")
|
|
241
|
+
p.add_argument("--note", default="",
|
|
242
|
+
help="footer annotation (e.g. client region / env description)")
|
|
243
|
+
p.set_defaults(func=cmd_burst)
|