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.
@@ -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)