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.
- rigorloop/__init__.py +18 -0
- rigorloop/_version.py +24 -0
- rigorloop/core/__init__.py +1 -0
- rigorloop/core/config_calcs.py +302 -0
- rigorloop/core/dataset_calcs.py +193 -0
- rigorloop/core/prompt_calcs.py +329 -0
- rigorloop/core/report_calcs.py +194 -0
- rigorloop/core/scoring_calcs.py +264 -0
- rigorloop/core/strategy_calcs.py +507 -0
- rigorloop/core/types.py +649 -0
- rigorloop/py.typed +0 -0
- rigorloop/shell/__init__.py +1 -0
- rigorloop/shell/agent_calls.py +120 -0
- rigorloop/shell/cli.py +1111 -0
- rigorloop/shell/io_actions.py +497 -0
- rigorloop-0.1.0.dist-info/METADATA +224 -0
- rigorloop-0.1.0.dist-info/RECORD +20 -0
- rigorloop-0.1.0.dist-info/WHEEL +4 -0
- rigorloop-0.1.0.dist-info/entry_points.txt +2 -0
- rigorloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]}")
|