rigorloop 0.1.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,497 @@
1
+ """Shell I/O: the run directory, artifact persistence and reload for resume,
2
+ and sandboxed execution of candidate scripts and custom checks.
3
+
4
+ The JSON (de)serialization helpers here are pure data conversions; the
5
+ functions that touch the filesystem or subprocesses are the effects."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from rigorloop.core.types import (
15
+ NOTHING,
16
+ BudgetExhausted,
17
+ CandidateScore,
18
+ ChampionArtifact,
19
+ CheckOutcome,
20
+ CheckPassRate,
21
+ Directive,
22
+ Errored,
23
+ Example,
24
+ ExecutionFailed,
25
+ ExecutionOk,
26
+ ExecutionResult,
27
+ Failed,
28
+ GuidanceSolution,
29
+ JsonValue,
30
+ LeaderboardEntry,
31
+ Nothing,
32
+ Option,
33
+ Passed,
34
+ RunScriptRequest,
35
+ RunState,
36
+ ScriptSolution,
37
+ SkillSolution,
38
+ SolutionKind,
39
+ Some,
40
+ SplitManifest,
41
+ StopReason,
42
+ StrategyLogEntry,
43
+ StrategyRequestedStop,
44
+ StrategyUnresponsive,
45
+ TargetReached,
46
+ ValCheckpoint,
47
+ ValidatedCandidate,
48
+ ValidationPlateau,
49
+ )
50
+
51
+ SCRIPT_TIMEOUT_S = 60.0
52
+ OUTPUT_CAP_CHARS = 262_144
53
+ _STDERR_PREVIEW = 300
54
+
55
+
56
+ # --------------------------------------------------------------------------
57
+ # Filesystem primitives
58
+ # --------------------------------------------------------------------------
59
+
60
+
61
+ def write_text(path: Path, text: str) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(text, encoding="utf-8")
64
+
65
+
66
+ def write_json(path: Path, data: JsonValue) -> None:
67
+ write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n")
68
+
69
+
70
+ def append_jsonl(path: Path, data: JsonValue) -> None:
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ with path.open("a", encoding="utf-8") as handle:
73
+ handle.write(json.dumps(data, sort_keys=True) + "\n")
74
+
75
+
76
+ def read_json(path: Path) -> JsonValue:
77
+ loaded: JsonValue = json.loads(path.read_text(encoding="utf-8"))
78
+ return loaded
79
+
80
+
81
+ def write_examples_jsonl(path: Path, examples: tuple[Example, ...]) -> None:
82
+ write_text(
83
+ path,
84
+ "\n".join(json.dumps(example_to_json(e), sort_keys=True) for e in examples) + "\n",
85
+ )
86
+
87
+
88
+ # --------------------------------------------------------------------------
89
+ # Run directory layout
90
+ # --------------------------------------------------------------------------
91
+
92
+
93
+ def run_dir(base: Path, run_id: str) -> Path:
94
+ return base / "runs" / run_id
95
+
96
+
97
+ def loop_dir(run: Path, loop_index: int) -> Path:
98
+ return run / "loops" / str(loop_index)
99
+
100
+
101
+ def candidate_dir(run: Path, loop_index: int, candidate_id: str) -> Path:
102
+ return loop_dir(run, loop_index) / "candidates" / candidate_id
103
+
104
+
105
+ def solution_filename(kind: SolutionKind) -> str:
106
+ match kind:
107
+ case ScriptSolution():
108
+ return "solution.py"
109
+ case SkillSolution():
110
+ return "SKILL.md"
111
+ case GuidanceSolution():
112
+ return "GUIDANCE.md"
113
+
114
+
115
+ # --------------------------------------------------------------------------
116
+ # Pure JSON conversions (kind, examples, scores, state)
117
+ # --------------------------------------------------------------------------
118
+
119
+
120
+ def kind_to_name(kind: SolutionKind) -> str:
121
+ match kind:
122
+ case ScriptSolution():
123
+ return "script"
124
+ case SkillSolution():
125
+ return "skill"
126
+ case GuidanceSolution():
127
+ return "guidance"
128
+
129
+
130
+ def kind_from_name(name: str) -> SolutionKind:
131
+ match name:
132
+ case "skill":
133
+ return SkillSolution()
134
+ case "guidance":
135
+ return GuidanceSolution()
136
+ case _:
137
+ return ScriptSolution()
138
+
139
+
140
+ def example_to_json(example: Example) -> dict[str, JsonValue]:
141
+ return {
142
+ "id": example.example_id,
143
+ "input": example.input_text,
144
+ "expected_output": example.expected_output,
145
+ }
146
+
147
+
148
+ def example_from_json(data: dict[str, JsonValue]) -> Example:
149
+ return Example(str(data["id"]), str(data["input"]), str(data["expected_output"]))
150
+
151
+
152
+ def manifest_to_json(manifest: SplitManifest) -> dict[str, JsonValue]:
153
+ def digests(split: str) -> list[JsonValue]:
154
+ entries: tuple[object, ...] = getattr(manifest, split)
155
+ return [
156
+ {"id": d.example_id, "hash": d.content_hash} # type: ignore[attr-defined]
157
+ for d in entries
158
+ ]
159
+
160
+ return {
161
+ "seed": manifest.seed,
162
+ "ratios": [manifest.ratios.dev, manifest.ratios.val, manifest.ratios.test],
163
+ "dev": digests("dev"),
164
+ "val": digests("val"),
165
+ "test": digests("test"),
166
+ "eval_model": manifest.eval_model,
167
+ "cli_version": manifest.cli_version,
168
+ }
169
+
170
+
171
+ def score_to_json(score: CandidateScore) -> dict[str, JsonValue]:
172
+ return {
173
+ "n": score.n,
174
+ "passes": score.passes,
175
+ "pass_rate": score.pass_rate,
176
+ "ci_low": score.ci_low,
177
+ "ci_high": score.ci_high,
178
+ "per_check": [
179
+ {"check": c.check_name, "passes": c.passes, "n": c.n} for c in score.per_check
180
+ ],
181
+ "pass_vector": list(score.pass_vector),
182
+ "eval_aborted": score.eval_aborted,
183
+ }
184
+
185
+
186
+ def score_from_json(data: dict[str, JsonValue]) -> CandidateScore:
187
+ per_check_raw = data["per_check"]
188
+ per_check = (
189
+ tuple(
190
+ CheckPassRate(str(c["check"]), int(str(c["passes"])), int(str(c["n"])))
191
+ for c in per_check_raw
192
+ if isinstance(c, dict)
193
+ )
194
+ if isinstance(per_check_raw, list)
195
+ else ()
196
+ )
197
+ vector_raw = data["pass_vector"]
198
+ vector = tuple(bool(v) for v in vector_raw) if isinstance(vector_raw, list) else ()
199
+ return CandidateScore(
200
+ n=int(str(data["n"])),
201
+ passes=int(str(data["passes"])),
202
+ pass_rate=float(str(data["pass_rate"])),
203
+ ci_low=float(str(data["ci_low"])),
204
+ ci_high=float(str(data["ci_high"])),
205
+ per_check=per_check,
206
+ pass_vector=vector,
207
+ eval_aborted=bool(data["eval_aborted"]),
208
+ )
209
+
210
+
211
+ def entry_to_json(entry: LeaderboardEntry) -> dict[str, JsonValue]:
212
+ return {
213
+ "candidate_id": entry.candidate_id,
214
+ "loop_index": entry.loop_index,
215
+ "kind": kind_to_name(entry.kind),
216
+ "content": entry.content,
217
+ "score": score_to_json(entry.score),
218
+ }
219
+
220
+
221
+ def _entry_from_json(data: dict[str, JsonValue]) -> LeaderboardEntry:
222
+ score = data["score"]
223
+ return LeaderboardEntry(
224
+ candidate_id=str(data["candidate_id"]),
225
+ loop_index=int(str(data["loop_index"])),
226
+ kind=kind_from_name(str(data["kind"])),
227
+ content=str(data["content"]),
228
+ score=score_from_json(score if isinstance(score, dict) else {}),
229
+ )
230
+
231
+
232
+ def _directive_to_json(directive: Directive) -> dict[str, JsonValue]:
233
+ base: JsonValue
234
+ match directive.base:
235
+ case Some(artifact):
236
+ base = {
237
+ "candidate_id": artifact.candidate_id,
238
+ "kind": kind_to_name(artifact.kind),
239
+ "content": artifact.content,
240
+ }
241
+ case Nothing():
242
+ base = None
243
+ return {
244
+ "directive_id": directive.directive_id,
245
+ "approach_summary": directive.approach_summary,
246
+ "instructions": directive.instructions,
247
+ "base": base,
248
+ }
249
+
250
+
251
+ def _directive_from_json(data: dict[str, JsonValue]) -> Directive:
252
+ raw_base = data["base"]
253
+ base: Option[ChampionArtifact] = (
254
+ Some(
255
+ ChampionArtifact(
256
+ str(raw_base["candidate_id"]),
257
+ kind_from_name(str(raw_base["kind"])),
258
+ str(raw_base["content"]),
259
+ )
260
+ )
261
+ if isinstance(raw_base, dict)
262
+ else NOTHING
263
+ )
264
+ return Directive(
265
+ directive_id=str(data["directive_id"]),
266
+ approach_summary=str(data["approach_summary"]),
267
+ instructions=str(data["instructions"]),
268
+ base=base,
269
+ )
270
+
271
+
272
+ def log_entry_to_json(entry: StrategyLogEntry) -> dict[str, JsonValue]:
273
+ val_summary: JsonValue
274
+ match entry.val_summary:
275
+ case Some(summary):
276
+ val_summary = summary
277
+ case Nothing():
278
+ val_summary = None
279
+ return {
280
+ "loop_index": entry.loop_index,
281
+ "observations": entry.observations,
282
+ "hypotheses": entry.hypotheses,
283
+ "directives": [_directive_to_json(d) for d in entry.directives],
284
+ "dev_summary": entry.dev_summary,
285
+ "val_summary": val_summary,
286
+ "fallback": entry.fallback,
287
+ }
288
+
289
+
290
+ def log_entry_from_json(data: dict[str, JsonValue]) -> StrategyLogEntry:
291
+ raw_directives = data["directives"]
292
+ directives = tuple(
293
+ _directive_from_json(d)
294
+ for d in (raw_directives if isinstance(raw_directives, list) else [])
295
+ if isinstance(d, dict)
296
+ )
297
+ raw_val = data["val_summary"]
298
+ return StrategyLogEntry(
299
+ loop_index=int(str(data["loop_index"])),
300
+ observations=str(data["observations"]),
301
+ hypotheses=str(data["hypotheses"]),
302
+ directives=directives,
303
+ dev_summary=str(data["dev_summary"]),
304
+ val_summary=Some(raw_val) if isinstance(raw_val, str) else NOTHING,
305
+ fallback=bool(data["fallback"]),
306
+ )
307
+
308
+
309
+ def validated_to_json(validated: ValidatedCandidate) -> dict[str, JsonValue]:
310
+ return {
311
+ "candidate_id": validated.candidate_id,
312
+ "kind": kind_to_name(validated.kind),
313
+ "content": validated.content,
314
+ "dev_score": score_to_json(validated.dev_score),
315
+ "val_score": score_to_json(validated.val_score),
316
+ }
317
+
318
+
319
+ def validated_from_json(data: dict[str, JsonValue]) -> ValidatedCandidate:
320
+ dev_score = data["dev_score"]
321
+ val_score = data["val_score"]
322
+ return ValidatedCandidate(
323
+ candidate_id=str(data["candidate_id"]),
324
+ kind=kind_from_name(str(data["kind"])),
325
+ content=str(data["content"]),
326
+ dev_score=score_from_json(dev_score if isinstance(dev_score, dict) else {}),
327
+ val_score=score_from_json(val_score if isinstance(val_score, dict) else {}),
328
+ )
329
+
330
+
331
+ def _checkpoint_to_json(checkpoint: ValCheckpoint) -> dict[str, JsonValue]:
332
+ return {
333
+ "loop_index": checkpoint.loop_index,
334
+ "candidate_id": checkpoint.candidate_id,
335
+ "dev_pass_rate": checkpoint.dev_pass_rate,
336
+ "val_pass_rate": checkpoint.val_pass_rate,
337
+ "displaced_champion": checkpoint.displaced_champion,
338
+ }
339
+
340
+
341
+ def _checkpoint_from_json(data: dict[str, JsonValue]) -> ValCheckpoint:
342
+ return ValCheckpoint(
343
+ loop_index=int(str(data["loop_index"])),
344
+ candidate_id=str(data["candidate_id"]),
345
+ dev_pass_rate=float(str(data["dev_pass_rate"])),
346
+ val_pass_rate=float(str(data["val_pass_rate"])),
347
+ displaced_champion=bool(data["displaced_champion"]),
348
+ )
349
+
350
+
351
+ def state_to_json(state: RunState) -> dict[str, JsonValue]:
352
+ val_champion: JsonValue
353
+ match state.val_champion:
354
+ case Some(champion):
355
+ val_champion = validated_to_json(champion)
356
+ case Nothing():
357
+ val_champion = None
358
+ last_peek: JsonValue
359
+ match state.last_peek_loop:
360
+ case Some(loop_index):
361
+ last_peek = loop_index
362
+ case Nothing():
363
+ last_peek = None
364
+ return {
365
+ "loops_completed": state.loops_completed,
366
+ "leaderboard": [entry_to_json(e) for e in state.leaderboard],
367
+ "strategy_log": [log_entry_to_json(e) for e in state.strategy_log],
368
+ "val_champion": val_champion,
369
+ "checkpoints": [_checkpoint_to_json(c) for c in state.checkpoints],
370
+ "peeks_used": state.peeks_used,
371
+ "last_peek_loop": last_peek,
372
+ "consecutive_fallbacks": state.consecutive_fallbacks,
373
+ }
374
+
375
+
376
+ def state_from_json(data: dict[str, JsonValue]) -> RunState:
377
+ raw_leaderboard = data["leaderboard"]
378
+ raw_log = data["strategy_log"]
379
+ raw_checkpoints = data["checkpoints"]
380
+ raw_champion = data["val_champion"]
381
+ raw_peek = data["last_peek_loop"]
382
+ return RunState(
383
+ loops_completed=int(str(data["loops_completed"])),
384
+ leaderboard=tuple(
385
+ _entry_from_json(e)
386
+ for e in (raw_leaderboard if isinstance(raw_leaderboard, list) else [])
387
+ if isinstance(e, dict)
388
+ ),
389
+ strategy_log=tuple(
390
+ log_entry_from_json(e)
391
+ for e in (raw_log if isinstance(raw_log, list) else [])
392
+ if isinstance(e, dict)
393
+ ),
394
+ val_champion=(
395
+ Some(validated_from_json(raw_champion)) if isinstance(raw_champion, dict) else NOTHING
396
+ ),
397
+ checkpoints=tuple(
398
+ _checkpoint_from_json(c)
399
+ for c in (raw_checkpoints if isinstance(raw_checkpoints, list) else [])
400
+ if isinstance(c, dict)
401
+ ),
402
+ peeks_used=int(str(data["peeks_used"])),
403
+ last_peek_loop=Some(int(raw_peek)) if isinstance(raw_peek, int) else NOTHING,
404
+ consecutive_fallbacks=int(str(data["consecutive_fallbacks"])),
405
+ )
406
+
407
+
408
+ def stop_reason_to_json(reason: StopReason) -> dict[str, JsonValue]:
409
+ match reason:
410
+ case BudgetExhausted(max_loops):
411
+ return {"type": "budget_exhausted", "max_loops": max_loops}
412
+ case ValidationPlateau(checkpoints):
413
+ return {"type": "validation_plateau", "checkpoints": checkpoints}
414
+ case TargetReached(pass_rate):
415
+ return {"type": "target_reached", "pass_rate": pass_rate}
416
+ case StrategyRequestedStop(why):
417
+ return {"type": "strategy_requested_stop", "reason": why}
418
+ case StrategyUnresponsive(fallbacks):
419
+ return {"type": "strategy_unresponsive", "fallbacks": fallbacks}
420
+
421
+
422
+ def stop_reason_from_json(data: dict[str, JsonValue]) -> StopReason:
423
+ match data.get("type"):
424
+ case "validation_plateau":
425
+ return ValidationPlateau(int(str(data["checkpoints"])))
426
+ case "target_reached":
427
+ return TargetReached(float(str(data["pass_rate"])))
428
+ case "strategy_requested_stop":
429
+ return StrategyRequestedStop(str(data["reason"]))
430
+ case "strategy_unresponsive":
431
+ return StrategyUnresponsive(int(str(data["fallbacks"])))
432
+ case _:
433
+ return BudgetExhausted(int(str(data.get("max_loops", 0))))
434
+
435
+
436
+ # --------------------------------------------------------------------------
437
+ # Sandboxed execution of untrusted generated code.
438
+ # NOT a security boundary (documented): mitigations are a hard timeout, an
439
+ # output cap, no stdin inheritance, and a scratch working directory.
440
+ # --------------------------------------------------------------------------
441
+
442
+
443
+ def run_script(request: RunScriptRequest, scratch_dir: Path) -> ExecutionResult:
444
+ scratch_dir.mkdir(parents=True, exist_ok=True)
445
+ try:
446
+ proc = subprocess.run(
447
+ [sys.executable, request.script_path],
448
+ input=request.stdin_text,
449
+ capture_output=True,
450
+ text=True,
451
+ timeout=request.timeout_s,
452
+ cwd=scratch_dir,
453
+ )
454
+ except subprocess.TimeoutExpired:
455
+ return ExecutionFailed(f"timed out after {request.timeout_s}s")
456
+ except OSError as exc:
457
+ return ExecutionFailed(f"could not run script: {exc}")
458
+ if proc.returncode != 0:
459
+ return ExecutionFailed(f"exit {proc.returncode}: {proc.stderr.strip()[:_STDERR_PREVIEW]}")
460
+ if len(proc.stdout) > OUTPUT_CAP_CHARS:
461
+ return ExecutionFailed(f"output exceeded {OUTPUT_CAP_CHARS} characters")
462
+ # Scripts conventionally end stdout with one newline; the expected outputs
463
+ # are single-line JSONL strings, so exactly one trailing newline is shed.
464
+ return ExecutionOk(proc.stdout.removesuffix("\n"))
465
+
466
+
467
+ def run_custom_check(
468
+ script_path: str, example: Example, actual_output: str, timeout_s: float = SCRIPT_TIMEOUT_S
469
+ ) -> CheckOutcome:
470
+ """User-supplied checker: JSON on stdin, exit 0 = pass, exit 1 = fail
471
+ (stderr as the reason), anything else = error."""
472
+ payload = json.dumps(
473
+ {
474
+ "input": example.input_text,
475
+ "expected_output": example.expected_output,
476
+ "actual_output": actual_output,
477
+ }
478
+ )
479
+ try:
480
+ proc = subprocess.run(
481
+ [sys.executable, script_path],
482
+ input=payload,
483
+ capture_output=True,
484
+ text=True,
485
+ timeout=timeout_s,
486
+ )
487
+ except subprocess.TimeoutExpired:
488
+ return Errored(f"custom check timed out after {timeout_s}s")
489
+ except OSError as exc:
490
+ return Errored(f"could not run custom check: {exc}")
491
+ match proc.returncode:
492
+ case 0:
493
+ return Passed()
494
+ case 1:
495
+ return Failed(proc.stderr.strip()[:_STDERR_PREVIEW] or "custom check failed")
496
+ case code:
497
+ return Errored(f"custom check exit {code}: {proc.stderr.strip()[:_STDERR_PREVIEW]}")