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
rigorloop/shell/cli.py
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
"""argparse entry point and the orchestration loop.
|
|
2
|
+
|
|
3
|
+
The loop is a dumb driver: at each step it hands current state to a core
|
|
4
|
+
function and performs the decision it gets back. The core sequences nothing."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from rigorloop import __version__
|
|
18
|
+
from rigorloop.core import (
|
|
19
|
+
config_calcs,
|
|
20
|
+
dataset_calcs,
|
|
21
|
+
prompt_calcs,
|
|
22
|
+
report_calcs,
|
|
23
|
+
scoring_calcs,
|
|
24
|
+
strategy_calcs,
|
|
25
|
+
)
|
|
26
|
+
from rigorloop.core.types import (
|
|
27
|
+
NOTHING,
|
|
28
|
+
AgentConfig,
|
|
29
|
+
AgentContextPrompt,
|
|
30
|
+
AgentTextRequest,
|
|
31
|
+
BadRatios,
|
|
32
|
+
Candidate,
|
|
33
|
+
CandidateScore,
|
|
34
|
+
Check,
|
|
35
|
+
CheckOutcome,
|
|
36
|
+
ConfigParseError,
|
|
37
|
+
ContinueDecision,
|
|
38
|
+
CustomPython,
|
|
39
|
+
DuplicateWarning,
|
|
40
|
+
Err,
|
|
41
|
+
EvalPrompt,
|
|
42
|
+
Example,
|
|
43
|
+
ExampleParseError,
|
|
44
|
+
ExampleResult,
|
|
45
|
+
ExecutionFailed,
|
|
46
|
+
ExecutionOk,
|
|
47
|
+
ExecutionResult,
|
|
48
|
+
FailureSample,
|
|
49
|
+
GuidanceSolution,
|
|
50
|
+
InvalidJsonLine,
|
|
51
|
+
InvalidValue,
|
|
52
|
+
JudgeVerdict,
|
|
53
|
+
LeaderboardEntry,
|
|
54
|
+
LlmJudge,
|
|
55
|
+
MissingField,
|
|
56
|
+
MissingKey,
|
|
57
|
+
NamedOutcome,
|
|
58
|
+
NotAnObject,
|
|
59
|
+
Nothing,
|
|
60
|
+
Ok,
|
|
61
|
+
Option,
|
|
62
|
+
Result,
|
|
63
|
+
RunConfig,
|
|
64
|
+
RunScriptRequest,
|
|
65
|
+
RunState,
|
|
66
|
+
ScriptSolution,
|
|
67
|
+
SkillSolution,
|
|
68
|
+
Some,
|
|
69
|
+
Split,
|
|
70
|
+
StopReason,
|
|
71
|
+
StopRequested,
|
|
72
|
+
StrategyDecision,
|
|
73
|
+
StrategyLogEntry,
|
|
74
|
+
StrategyRequestedStop,
|
|
75
|
+
TomlSyntax,
|
|
76
|
+
TooFewExamples,
|
|
77
|
+
ValCheckpoint,
|
|
78
|
+
ValidatedCandidate,
|
|
79
|
+
)
|
|
80
|
+
from rigorloop.shell import agent_calls, io_actions
|
|
81
|
+
|
|
82
|
+
_EXIT_OK = 0
|
|
83
|
+
_EXIT_ERROR = 1
|
|
84
|
+
_EXIT_NO_RESULT = 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class ShellDeps:
|
|
89
|
+
"""Injected effects: tests swap in fakes, main() wires the real ones."""
|
|
90
|
+
|
|
91
|
+
agent_runner: agent_calls.AgentRunner
|
|
92
|
+
calls_made: Callable[[], int]
|
|
93
|
+
script_runner: Callable[[RunScriptRequest, Path], ExecutionResult]
|
|
94
|
+
custom_check_runner: Callable[[str, Example, str], CheckOutcome]
|
|
95
|
+
make_run_id: Callable[[], str]
|
|
96
|
+
cli_version: Callable[[], str]
|
|
97
|
+
echo: Callable[[str], None]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
# Channel conversion: prompts → transport requests
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _request_from_context(prompt: AgentContextPrompt, agents: AgentConfig) -> AgentTextRequest:
|
|
106
|
+
return AgentTextRequest(prompt.text, NOTHING, agents.model, agents.timeout_s)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _request_from_eval(prompt: EvalPrompt, agents: AgentConfig) -> AgentTextRequest:
|
|
110
|
+
return AgentTextRequest(
|
|
111
|
+
prompt.user_prompt, prompt.system_prompt, agents.model, agents.timeout_s
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
# Candidate evaluation (the evaluation prompt channel)
|
|
117
|
+
# --------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _judge_outcome(
|
|
121
|
+
check: LlmJudge, example: Example, actual: str, config: RunConfig, deps: ShellDeps
|
|
122
|
+
) -> CheckOutcome:
|
|
123
|
+
prompt = prompt_calcs.build_judge_prompt(check.rubric, example, actual)
|
|
124
|
+
|
|
125
|
+
def one_verdict(_: int) -> Result[JudgeVerdict, str]:
|
|
126
|
+
reply = deps.agent_runner(_request_from_eval(prompt, config.agents))
|
|
127
|
+
match reply:
|
|
128
|
+
case Err(error):
|
|
129
|
+
return Err(str(error))
|
|
130
|
+
case Ok(text):
|
|
131
|
+
parsed = strategy_calcs.parse_judge_reply(text)
|
|
132
|
+
match parsed:
|
|
133
|
+
case Ok(verdict):
|
|
134
|
+
return Ok(verdict)
|
|
135
|
+
case Err(malformed):
|
|
136
|
+
retry = deps.agent_runner(
|
|
137
|
+
_request_from_eval(
|
|
138
|
+
prompt_calcs.reformat_eval_prompt(prompt, malformed.detail),
|
|
139
|
+
config.agents,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
match retry:
|
|
143
|
+
case Ok(retry_text):
|
|
144
|
+
reparsed = strategy_calcs.parse_judge_reply(retry_text)
|
|
145
|
+
match reparsed:
|
|
146
|
+
case Ok(verdict):
|
|
147
|
+
return Ok(verdict)
|
|
148
|
+
case Err(again):
|
|
149
|
+
return Err(again.detail)
|
|
150
|
+
case Err(error):
|
|
151
|
+
return Err(str(error))
|
|
152
|
+
|
|
153
|
+
votes = tuple(one_verdict(i) for i in range(check.n_samples))
|
|
154
|
+
verdicts = tuple(v.value for v in votes if isinstance(v, Ok))
|
|
155
|
+
errors = sum(1 for v in votes if isinstance(v, Err))
|
|
156
|
+
return scoring_calcs.judge_outcome(check, verdicts, errors)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _produce_output(
|
|
160
|
+
candidate: Candidate,
|
|
161
|
+
example: Example,
|
|
162
|
+
script_path: Option[Path],
|
|
163
|
+
config: RunConfig,
|
|
164
|
+
deps: ShellDeps,
|
|
165
|
+
scratch_dir: Path,
|
|
166
|
+
) -> ExecutionResult:
|
|
167
|
+
match candidate.kind:
|
|
168
|
+
case ScriptSolution():
|
|
169
|
+
match script_path:
|
|
170
|
+
case Some(path):
|
|
171
|
+
return deps.script_runner(
|
|
172
|
+
RunScriptRequest(
|
|
173
|
+
str(path), example.input_text, io_actions.SCRIPT_TIMEOUT_S
|
|
174
|
+
),
|
|
175
|
+
scratch_dir,
|
|
176
|
+
)
|
|
177
|
+
case Nothing():
|
|
178
|
+
return ExecutionFailed("script was never materialized (internal error)")
|
|
179
|
+
case SkillSolution() | GuidanceSolution():
|
|
180
|
+
prompt = prompt_calcs.build_solution_eval_prompt(candidate.content, example)
|
|
181
|
+
reply = deps.agent_runner(_request_from_eval(prompt, config.agents))
|
|
182
|
+
match reply:
|
|
183
|
+
case Ok(text):
|
|
184
|
+
return ExecutionOk(text.strip())
|
|
185
|
+
case Err(error):
|
|
186
|
+
return ExecutionFailed(str(error))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _check_outcomes(
|
|
190
|
+
checks: tuple[tuple[str, Check], ...],
|
|
191
|
+
example: Example,
|
|
192
|
+
actual: str,
|
|
193
|
+
config: RunConfig,
|
|
194
|
+
deps: ShellDeps,
|
|
195
|
+
) -> tuple[NamedOutcome, ...]:
|
|
196
|
+
def outcome(name: str, check: Check) -> NamedOutcome:
|
|
197
|
+
match check:
|
|
198
|
+
case LlmJudge():
|
|
199
|
+
return NamedOutcome(name, _judge_outcome(check, example, actual, config, deps))
|
|
200
|
+
case CustomPython(script_path):
|
|
201
|
+
return NamedOutcome(name, deps.custom_check_runner(script_path, example, actual))
|
|
202
|
+
case _:
|
|
203
|
+
return NamedOutcome(
|
|
204
|
+
name,
|
|
205
|
+
scoring_calcs.evaluate_deterministic_check(
|
|
206
|
+
check, example.expected_output, actual
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return tuple(outcome(name, check) for name, check in checks)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _evaluate_candidate(
|
|
214
|
+
candidate: Candidate,
|
|
215
|
+
examples: tuple[Example, ...],
|
|
216
|
+
config: RunConfig,
|
|
217
|
+
deps: ShellDeps,
|
|
218
|
+
work_dir: Path,
|
|
219
|
+
) -> tuple[tuple[ExampleResult, ...], bool]:
|
|
220
|
+
"""Evaluate one candidate on every example (sorted by id), short-circuiting
|
|
221
|
+
after max_consecutive_eval_failures consecutive execution failures."""
|
|
222
|
+
checks = scoring_calcs.named_checks(config.checks)
|
|
223
|
+
check_names = tuple(name for name, _ in checks)
|
|
224
|
+
ordered = tuple(sorted(examples, key=lambda e: e.example_id))
|
|
225
|
+
|
|
226
|
+
script_path: Option[Path] = NOTHING
|
|
227
|
+
if isinstance(candidate.kind, ScriptSolution):
|
|
228
|
+
path = work_dir / io_actions.solution_filename(candidate.kind)
|
|
229
|
+
io_actions.write_text(path, candidate.content)
|
|
230
|
+
script_path = Some(path)
|
|
231
|
+
scratch_dir = work_dir / "scratch"
|
|
232
|
+
|
|
233
|
+
# Local mutable accumulation: this is the shell's evaluation drive loop.
|
|
234
|
+
results: list[ExampleResult] = []
|
|
235
|
+
consecutive_failures = 0
|
|
236
|
+
aborted = False
|
|
237
|
+
for example in ordered:
|
|
238
|
+
if consecutive_failures >= config.loop.max_consecutive_eval_failures:
|
|
239
|
+
aborted = True
|
|
240
|
+
break
|
|
241
|
+
execution = _produce_output(candidate, example, script_path, config, deps, scratch_dir)
|
|
242
|
+
match execution:
|
|
243
|
+
case ExecutionFailed(detail):
|
|
244
|
+
consecutive_failures += 1
|
|
245
|
+
outcomes = scoring_calcs.execution_failure_outcomes(check_names, detail)
|
|
246
|
+
case ExecutionOk(actual):
|
|
247
|
+
consecutive_failures = 0
|
|
248
|
+
outcomes = _check_outcomes(checks, example, actual, config, deps)
|
|
249
|
+
results.append(ExampleResult(example.example_id, execution, outcomes))
|
|
250
|
+
return tuple(results), aborted
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _score(
|
|
254
|
+
results: tuple[ExampleResult, ...],
|
|
255
|
+
examples: tuple[Example, ...],
|
|
256
|
+
checks: tuple[Check, ...],
|
|
257
|
+
aborted: bool,
|
|
258
|
+
) -> CandidateScore:
|
|
259
|
+
order = tuple(sorted(e.example_id for e in examples))
|
|
260
|
+
names = tuple(name for name, _ in scoring_calcs.named_checks(checks))
|
|
261
|
+
return scoring_calcs.score_candidate(results, order, names, aborted)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------
|
|
265
|
+
# Loading a project (config + task + examples)
|
|
266
|
+
# --------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _format_config_error(error: ConfigParseError) -> str:
|
|
270
|
+
match error:
|
|
271
|
+
case TomlSyntax(detail):
|
|
272
|
+
return f"rigorloop.toml is not valid TOML: {detail}"
|
|
273
|
+
case MissingKey(key):
|
|
274
|
+
return f"rigorloop.toml is missing required key: {key}"
|
|
275
|
+
case InvalidValue(key, detail):
|
|
276
|
+
return f"rigorloop.toml has an invalid value for {key}: {detail}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _format_example_error(error: ExampleParseError) -> str:
|
|
280
|
+
match error:
|
|
281
|
+
case InvalidJsonLine(line, detail):
|
|
282
|
+
return f"examples file line {line} is not valid JSON: {detail}"
|
|
283
|
+
case NotAnObject(line):
|
|
284
|
+
return f"examples file line {line} is not a JSON object"
|
|
285
|
+
case MissingField(line, field):
|
|
286
|
+
return f"examples file line {line} is missing the {field!r} field"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True, slots=True)
|
|
290
|
+
class LoadedProject:
|
|
291
|
+
config: RunConfig
|
|
292
|
+
config_text: str
|
|
293
|
+
task_description: str
|
|
294
|
+
examples: tuple[Example, ...]
|
|
295
|
+
duplicates: tuple[DuplicateWarning, ...]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _load_project(
|
|
299
|
+
project_dir: Path, config_file: str, echo: Callable[[str], None]
|
|
300
|
+
) -> LoadedProject | None:
|
|
301
|
+
config_path = project_dir / config_file
|
|
302
|
+
if not config_path.is_file():
|
|
303
|
+
echo(f"error: {config_path} not found (run `rigorloop init` to scaffold a project)")
|
|
304
|
+
return None
|
|
305
|
+
config_text = config_path.read_text(encoding="utf-8")
|
|
306
|
+
parsed = config_calcs.parse_config(config_text)
|
|
307
|
+
match parsed:
|
|
308
|
+
case Err(config_error):
|
|
309
|
+
echo(f"error: {_format_config_error(config_error)}")
|
|
310
|
+
return None
|
|
311
|
+
case Ok(config):
|
|
312
|
+
pass
|
|
313
|
+
task_path = project_dir / config.task.description_file
|
|
314
|
+
if not task_path.is_file():
|
|
315
|
+
echo(f"error: task description file {task_path} not found")
|
|
316
|
+
return None
|
|
317
|
+
examples_path = project_dir / config.task.examples_file
|
|
318
|
+
if not examples_path.is_file():
|
|
319
|
+
echo(f"error: examples file {examples_path} not found")
|
|
320
|
+
return None
|
|
321
|
+
examples_result = dataset_calcs.parse_examples(examples_path.read_text(encoding="utf-8"))
|
|
322
|
+
match examples_result:
|
|
323
|
+
case Err(example_error):
|
|
324
|
+
echo(f"error: {_format_example_error(example_error)}")
|
|
325
|
+
return None
|
|
326
|
+
case Ok(raw_examples):
|
|
327
|
+
pass
|
|
328
|
+
unique, duplicates = dataset_calcs.dedupe_examples(raw_examples)
|
|
329
|
+
return LoadedProject(
|
|
330
|
+
config=config,
|
|
331
|
+
config_text=config_text,
|
|
332
|
+
task_description=task_path.read_text(encoding="utf-8"),
|
|
333
|
+
examples=unique,
|
|
334
|
+
duplicates=duplicates,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _split_or_report(project: LoadedProject, echo: Callable[[str], None]) -> Split | None:
|
|
339
|
+
result = dataset_calcs.split_examples(
|
|
340
|
+
project.examples, project.config.split.ratios, project.config.split.seed
|
|
341
|
+
)
|
|
342
|
+
match result:
|
|
343
|
+
case Ok(split):
|
|
344
|
+
return split
|
|
345
|
+
case Err(error):
|
|
346
|
+
match error:
|
|
347
|
+
case TooFewExamples(n_total, minimum):
|
|
348
|
+
echo(f"error: {n_total} unique examples is too few (minimum {minimum})")
|
|
349
|
+
case BadRatios(detail):
|
|
350
|
+
echo(f"error: bad split ratios: {detail}")
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --------------------------------------------------------------------------
|
|
355
|
+
# The strategy turn
|
|
356
|
+
# --------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _strategy_decision(
|
|
360
|
+
context_prompt: AgentContextPrompt,
|
|
361
|
+
config: RunConfig,
|
|
362
|
+
deps: ShellDeps,
|
|
363
|
+
) -> tuple[Option[StrategyDecision], str]:
|
|
364
|
+
"""Returns (decision, raw_reply_text); Nothing means both the reply and the
|
|
365
|
+
one sanctioned reformat retry were unusable and the fallback applies."""
|
|
366
|
+
reply = deps.agent_runner(_request_from_context(context_prompt, config.agents))
|
|
367
|
+
match reply:
|
|
368
|
+
case Ok(text):
|
|
369
|
+
parsed = strategy_calcs.parse_strategy_reply(text)
|
|
370
|
+
match parsed:
|
|
371
|
+
case Ok(decision):
|
|
372
|
+
return Some(decision), text
|
|
373
|
+
case Err(malformed):
|
|
374
|
+
retry_prompt = prompt_calcs.reformat_context_prompt(
|
|
375
|
+
context_prompt, malformed.detail
|
|
376
|
+
)
|
|
377
|
+
retry = deps.agent_runner(_request_from_context(retry_prompt, config.agents))
|
|
378
|
+
match retry:
|
|
379
|
+
case Ok(retry_text):
|
|
380
|
+
reparsed = strategy_calcs.parse_strategy_reply(retry_text)
|
|
381
|
+
match reparsed:
|
|
382
|
+
case Ok(decision):
|
|
383
|
+
return Some(decision), retry_text
|
|
384
|
+
case Err(_):
|
|
385
|
+
return NOTHING, retry_text
|
|
386
|
+
case Err(error):
|
|
387
|
+
return NOTHING, f"(transport error: {error})"
|
|
388
|
+
case Err(error):
|
|
389
|
+
return NOTHING, f"(transport error: {error})"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# --------------------------------------------------------------------------
|
|
393
|
+
# The run protocol
|
|
394
|
+
# --------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def execute_run(
|
|
398
|
+
project: LoadedProject,
|
|
399
|
+
project_dir: Path,
|
|
400
|
+
deps: ShellDeps,
|
|
401
|
+
resume_run_id: Option[str],
|
|
402
|
+
) -> int:
|
|
403
|
+
config = project.config
|
|
404
|
+
split = _split_or_report(project, deps.echo)
|
|
405
|
+
if split is None:
|
|
406
|
+
return _EXIT_ERROR
|
|
407
|
+
|
|
408
|
+
for warning in dataset_calcs.power_warnings(split):
|
|
409
|
+
deps.echo(f"power warning: {warning.message}")
|
|
410
|
+
for duplicate in project.duplicates:
|
|
411
|
+
deps.echo(
|
|
412
|
+
f"duplicate inputs collapsed: {duplicate.input_preview!r} "
|
|
413
|
+
f"appears {duplicate.occurrences} times"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
cli_version = deps.cli_version()
|
|
417
|
+
manifest = dataset_calcs.build_manifest(
|
|
418
|
+
split, config.split.ratios, config.split.seed, config.agents.model, cli_version
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
match resume_run_id:
|
|
422
|
+
case Some(existing_id):
|
|
423
|
+
run_id = existing_id
|
|
424
|
+
run_path = io_actions.run_dir(project_dir, run_id)
|
|
425
|
+
state_path = run_path / "state.json"
|
|
426
|
+
manifest_path = run_path / "manifest.json"
|
|
427
|
+
if not state_path.is_file() or not manifest_path.is_file():
|
|
428
|
+
deps.echo(f"error: run {run_id} has no resumable state under {run_path}")
|
|
429
|
+
return _EXIT_ERROR
|
|
430
|
+
if (run_path / "final" / "report.md").is_file():
|
|
431
|
+
deps.echo(f"error: run {run_id} is already finalized")
|
|
432
|
+
return _EXIT_ERROR
|
|
433
|
+
stored_manifest = io_actions.read_json(manifest_path)
|
|
434
|
+
if not isinstance(stored_manifest, dict) or stored_manifest.get(
|
|
435
|
+
"manifest"
|
|
436
|
+
) != io_actions.manifest_to_json(manifest):
|
|
437
|
+
deps.echo(
|
|
438
|
+
"error: the examples file or split config changed since this run "
|
|
439
|
+
"started; resuming would reshuffle examples across splits. Aborting."
|
|
440
|
+
)
|
|
441
|
+
return _EXIT_ERROR
|
|
442
|
+
stored_state = io_actions.read_json(state_path)
|
|
443
|
+
if not isinstance(stored_state, dict):
|
|
444
|
+
deps.echo("error: state.json is corrupt")
|
|
445
|
+
return _EXIT_ERROR
|
|
446
|
+
state = io_actions.state_from_json(stored_state)
|
|
447
|
+
deps.echo(f"resuming run {run_id} at loop {state.loops_completed + 1}")
|
|
448
|
+
case Nothing():
|
|
449
|
+
run_id = deps.make_run_id()
|
|
450
|
+
run_path = io_actions.run_dir(project_dir, run_id)
|
|
451
|
+
io_actions.write_json(
|
|
452
|
+
run_path / "manifest.json",
|
|
453
|
+
{
|
|
454
|
+
"manifest": io_actions.manifest_to_json(manifest),
|
|
455
|
+
"config_snapshot": project.config_text,
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
for name, examples in (
|
|
459
|
+
("dev", tuple(d.example for d in split.dev)),
|
|
460
|
+
("val", tuple(v.example for v in split.val)),
|
|
461
|
+
("test", tuple(t.example for t in split.test)),
|
|
462
|
+
):
|
|
463
|
+
io_actions.write_examples_jsonl(run_path / "splits" / f"{name}.jsonl", examples)
|
|
464
|
+
state = strategy_calcs.initial_state()
|
|
465
|
+
deps.echo(
|
|
466
|
+
f"run {run_id}: dev {len(split.dev)} / val {len(split.val)} / "
|
|
467
|
+
f"test {len(split.test)} examples"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
dev_examples = tuple(d.example for d in split.dev)
|
|
471
|
+
val_examples = tuple(v.example for v in split.val)
|
|
472
|
+
check_pairs = scoring_calcs.named_checks(config.checks)
|
|
473
|
+
check_names = tuple(name for name, _ in check_pairs)
|
|
474
|
+
|
|
475
|
+
# In-memory only: failing examples of the champion candidate from the most
|
|
476
|
+
# recent loop, for the strategy context. Empty after a resume.
|
|
477
|
+
last_samples: tuple[FailureSample, ...] = ()
|
|
478
|
+
|
|
479
|
+
stop_reason: StopReason | None = None
|
|
480
|
+
while stop_reason is None:
|
|
481
|
+
pre_stop = strategy_calcs.stopping_decision(state, config)
|
|
482
|
+
match pre_stop:
|
|
483
|
+
case Some(reason):
|
|
484
|
+
stop_reason = reason
|
|
485
|
+
continue
|
|
486
|
+
case Nothing():
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
loop_index = state.loops_completed + 1
|
|
490
|
+
loop_path = io_actions.loop_dir(run_path, loop_index)
|
|
491
|
+
deps.echo(f"loop {loop_index}: strategy turn")
|
|
492
|
+
|
|
493
|
+
context = strategy_calcs.assemble_strategy_context(
|
|
494
|
+
project.task_description, config, state, last_samples, check_names
|
|
495
|
+
)
|
|
496
|
+
strategy_prompt = prompt_calcs.build_strategy_prompt(context)
|
|
497
|
+
io_actions.write_text(loop_path / "strategy_prompt.md", strategy_prompt.text)
|
|
498
|
+
maybe_decision, raw_reply = _strategy_decision(strategy_prompt, config, deps)
|
|
499
|
+
io_actions.write_text(loop_path / "strategy_reply.md", raw_reply)
|
|
500
|
+
match maybe_decision:
|
|
501
|
+
case Some(parsed_decision):
|
|
502
|
+
decision: StrategyDecision = parsed_decision
|
|
503
|
+
fallback_used = False
|
|
504
|
+
case Nothing():
|
|
505
|
+
best_now = strategy_calcs.dev_best(state.leaderboard)
|
|
506
|
+
decision = strategy_calcs.fallback_decision(isinstance(best_now, Some))
|
|
507
|
+
fallback_used = True
|
|
508
|
+
deps.echo(f"loop {loop_index}: strategy reply malformed; using fallback directive")
|
|
509
|
+
|
|
510
|
+
consecutive_fallbacks = state.consecutive_fallbacks + 1 if fallback_used else 0
|
|
511
|
+
if consecutive_fallbacks >= strategy_calcs.MAX_FALLBACKS:
|
|
512
|
+
state = replace(
|
|
513
|
+
state,
|
|
514
|
+
loops_completed=loop_index,
|
|
515
|
+
consecutive_fallbacks=consecutive_fallbacks,
|
|
516
|
+
)
|
|
517
|
+
io_actions.write_json(run_path / "state.json", io_actions.state_to_json(state))
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
match decision:
|
|
521
|
+
case StopRequested(reason_text):
|
|
522
|
+
stop_reason = StrategyRequestedStop(reason_text)
|
|
523
|
+
stop_entry = StrategyLogEntry(
|
|
524
|
+
loop_index=loop_index,
|
|
525
|
+
observations="",
|
|
526
|
+
hypotheses="",
|
|
527
|
+
directives=(),
|
|
528
|
+
dev_summary="(strategy requested stop before executing)",
|
|
529
|
+
val_summary=NOTHING,
|
|
530
|
+
fallback=False,
|
|
531
|
+
)
|
|
532
|
+
state = replace(
|
|
533
|
+
state,
|
|
534
|
+
strategy_log=(*state.strategy_log, stop_entry),
|
|
535
|
+
consecutive_fallbacks=0,
|
|
536
|
+
)
|
|
537
|
+
io_actions.append_jsonl(
|
|
538
|
+
run_path / "strategy_log.jsonl", io_actions.log_entry_to_json(stop_entry)
|
|
539
|
+
)
|
|
540
|
+
io_actions.write_json(run_path / "state.json", io_actions.state_to_json(state))
|
|
541
|
+
continue
|
|
542
|
+
case ContinueDecision():
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
previous_best = strategy_calcs.dev_best(state.leaderboard)
|
|
546
|
+
champion = (
|
|
547
|
+
Some(strategy_calcs.champion_artifact(previous_best.value))
|
|
548
|
+
if isinstance(previous_best, Some)
|
|
549
|
+
else NOTHING
|
|
550
|
+
)
|
|
551
|
+
directives = strategy_calcs.build_directives(
|
|
552
|
+
decision.directive_specs, champion, loop_index, config.loop.executors_per_loop
|
|
553
|
+
)
|
|
554
|
+
dev_sample = strategy_calcs.sample_dev_subset(
|
|
555
|
+
split.dev, config.loop.dev_examples_in_prompt, config.split.seed, loop_index
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
deps.echo(f"loop {loop_index}: running {len(directives)} executor(s)")
|
|
559
|
+
executor_prompts = tuple(
|
|
560
|
+
prompt_calcs.build_executor_prompt(
|
|
561
|
+
project.task_description, config.task.solution_kind, d, config.checks, dev_sample
|
|
562
|
+
)
|
|
563
|
+
for d in directives
|
|
564
|
+
)
|
|
565
|
+
replies = agent_calls.run_concurrently(
|
|
566
|
+
tuple(_request_from_context(p, config.agents) for p in executor_prompts),
|
|
567
|
+
deps.agent_runner,
|
|
568
|
+
config.loop.executors_per_loop,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
candidates: list[Candidate] = []
|
|
572
|
+
n_malformed = 0
|
|
573
|
+
for directive, ex_prompt, reply in zip(directives, executor_prompts, replies, strict=True):
|
|
574
|
+
candidate_id = f"cand-{directive.directive_id}"
|
|
575
|
+
content: Result[str, object]
|
|
576
|
+
match reply:
|
|
577
|
+
case Ok(text):
|
|
578
|
+
content = strategy_calcs.parse_executor_reply(text)
|
|
579
|
+
match content:
|
|
580
|
+
case Err(malformed_reply):
|
|
581
|
+
retry = deps.agent_runner(
|
|
582
|
+
_request_from_context(
|
|
583
|
+
prompt_calcs.reformat_context_prompt(
|
|
584
|
+
ex_prompt, malformed_reply.detail
|
|
585
|
+
),
|
|
586
|
+
config.agents,
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
match retry:
|
|
590
|
+
case Ok(retry_text):
|
|
591
|
+
content = strategy_calcs.parse_executor_reply(retry_text)
|
|
592
|
+
case Err(error):
|
|
593
|
+
content = Err(error)
|
|
594
|
+
case Ok(_):
|
|
595
|
+
pass
|
|
596
|
+
case Err(error):
|
|
597
|
+
content = Err(error)
|
|
598
|
+
match content:
|
|
599
|
+
case Ok(solution_text):
|
|
600
|
+
candidates.append(
|
|
601
|
+
Candidate(
|
|
602
|
+
candidate_id=candidate_id,
|
|
603
|
+
loop_index=loop_index,
|
|
604
|
+
kind=config.task.solution_kind,
|
|
605
|
+
content=solution_text,
|
|
606
|
+
directive_id=directive.directive_id,
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
case Err(failure):
|
|
610
|
+
n_malformed += 1
|
|
611
|
+
io_actions.write_text(
|
|
612
|
+
io_actions.candidate_dir(run_path, loop_index, candidate_id)
|
|
613
|
+
/ "malformed.txt",
|
|
614
|
+
str(failure),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
new_entries: list[LeaderboardEntry] = []
|
|
618
|
+
loop_results: dict[str, tuple[ExampleResult, ...]] = {}
|
|
619
|
+
for candidate in candidates:
|
|
620
|
+
deps.echo(f"loop {loop_index}: evaluating {candidate.candidate_id} on dev")
|
|
621
|
+
cand_path = io_actions.candidate_dir(run_path, loop_index, candidate.candidate_id)
|
|
622
|
+
results, aborted = _evaluate_candidate(candidate, dev_examples, config, deps, cand_path)
|
|
623
|
+
score = _score(results, dev_examples, config.checks, aborted)
|
|
624
|
+
io_actions.write_text(
|
|
625
|
+
cand_path / io_actions.solution_filename(candidate.kind), candidate.content
|
|
626
|
+
)
|
|
627
|
+
io_actions.write_json(cand_path / "scores.json", io_actions.score_to_json(score))
|
|
628
|
+
for result in results:
|
|
629
|
+
io_actions.append_jsonl(
|
|
630
|
+
cand_path / "outputs.jsonl",
|
|
631
|
+
{
|
|
632
|
+
"example_id": result.example_id,
|
|
633
|
+
"output": (
|
|
634
|
+
result.execution.output_text
|
|
635
|
+
if isinstance(result.execution, ExecutionOk)
|
|
636
|
+
else None
|
|
637
|
+
),
|
|
638
|
+
"error": (
|
|
639
|
+
result.execution.detail
|
|
640
|
+
if isinstance(result.execution, ExecutionFailed)
|
|
641
|
+
else None
|
|
642
|
+
),
|
|
643
|
+
"outcomes": [
|
|
644
|
+
{"check": o.check_name, "outcome": type(o.outcome).__name__}
|
|
645
|
+
for o in result.outcomes
|
|
646
|
+
],
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
loop_results[candidate.candidate_id] = results
|
|
650
|
+
new_entries.append(
|
|
651
|
+
LeaderboardEntry(
|
|
652
|
+
candidate_id=candidate.candidate_id,
|
|
653
|
+
loop_index=loop_index,
|
|
654
|
+
kind=candidate.kind,
|
|
655
|
+
content=candidate.content,
|
|
656
|
+
score=score,
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
new_best_significant = any(
|
|
661
|
+
strategy_calcs.beats_previous_best(previous_best, entry) for entry in new_entries
|
|
662
|
+
)
|
|
663
|
+
leaderboard = strategy_calcs.fold_leaderboard(state.leaderboard, tuple(new_entries))
|
|
664
|
+
state = replace(
|
|
665
|
+
state,
|
|
666
|
+
loops_completed=loop_index,
|
|
667
|
+
leaderboard=leaderboard,
|
|
668
|
+
consecutive_fallbacks=consecutive_fallbacks,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
best_after = strategy_calcs.dev_best(leaderboard)
|
|
672
|
+
match best_after:
|
|
673
|
+
case Some(best_entry) if best_entry.candidate_id in loop_results:
|
|
674
|
+
last_samples = strategy_calcs.failure_samples(
|
|
675
|
+
split.dev, loop_results[best_entry.candidate_id]
|
|
676
|
+
)
|
|
677
|
+
case _:
|
|
678
|
+
last_samples = ()
|
|
679
|
+
|
|
680
|
+
val_summary: Option[str] = NOTHING
|
|
681
|
+
if strategy_calcs.should_validate(
|
|
682
|
+
state, config, decision.request_validation, new_best_significant
|
|
683
|
+
):
|
|
684
|
+
match strategy_calcs.dev_best(state.leaderboard):
|
|
685
|
+
case Some(entry):
|
|
686
|
+
deps.echo(f"loop {loop_index}: validation peek at {entry.candidate_id}")
|
|
687
|
+
val_candidate = Candidate(
|
|
688
|
+
entry.candidate_id, loop_index, entry.kind, entry.content, "validation"
|
|
689
|
+
)
|
|
690
|
+
val_path = loop_path / "validation" / entry.candidate_id
|
|
691
|
+
val_results, val_aborted = _evaluate_candidate(
|
|
692
|
+
val_candidate, val_examples, config, deps, val_path
|
|
693
|
+
)
|
|
694
|
+
val_score = _score(val_results, val_examples, config.checks, val_aborted)
|
|
695
|
+
challenger = ValidatedCandidate(
|
|
696
|
+
candidate_id=entry.candidate_id,
|
|
697
|
+
kind=entry.kind,
|
|
698
|
+
content=entry.content,
|
|
699
|
+
dev_score=entry.score,
|
|
700
|
+
val_score=val_score,
|
|
701
|
+
)
|
|
702
|
+
winner, improved = strategy_calcs.select_val_champion(
|
|
703
|
+
state.val_champion, challenger
|
|
704
|
+
)
|
|
705
|
+
checkpoint = ValCheckpoint(
|
|
706
|
+
loop_index=loop_index,
|
|
707
|
+
candidate_id=entry.candidate_id,
|
|
708
|
+
dev_pass_rate=entry.score.pass_rate,
|
|
709
|
+
val_pass_rate=val_score.pass_rate,
|
|
710
|
+
displaced_champion=improved,
|
|
711
|
+
)
|
|
712
|
+
state = replace(
|
|
713
|
+
state,
|
|
714
|
+
val_champion=Some(winner),
|
|
715
|
+
checkpoints=(*state.checkpoints, checkpoint),
|
|
716
|
+
peeks_used=state.peeks_used + 1,
|
|
717
|
+
last_peek_loop=Some(loop_index),
|
|
718
|
+
)
|
|
719
|
+
val_summary = Some(strategy_calcs.val_summary_line(checkpoint))
|
|
720
|
+
case Nothing():
|
|
721
|
+
pass
|
|
722
|
+
|
|
723
|
+
entry_log = StrategyLogEntry(
|
|
724
|
+
loop_index=loop_index,
|
|
725
|
+
observations=decision.observations,
|
|
726
|
+
hypotheses=decision.hypotheses,
|
|
727
|
+
directives=directives,
|
|
728
|
+
dev_summary=strategy_calcs.dev_summary_line(
|
|
729
|
+
len(new_entries),
|
|
730
|
+
n_malformed,
|
|
731
|
+
strategy_calcs.dev_best(
|
|
732
|
+
tuple(sorted(new_entries, key=lambda e: (-e.score.pass_rate, e.candidate_id)))
|
|
733
|
+
),
|
|
734
|
+
),
|
|
735
|
+
val_summary=val_summary,
|
|
736
|
+
fallback=fallback_used,
|
|
737
|
+
)
|
|
738
|
+
state = replace(state, strategy_log=(*state.strategy_log, entry_log))
|
|
739
|
+
|
|
740
|
+
io_actions.append_jsonl(
|
|
741
|
+
run_path / "strategy_log.jsonl", io_actions.log_entry_to_json(entry_log)
|
|
742
|
+
)
|
|
743
|
+
io_actions.write_json(
|
|
744
|
+
run_path / "leaderboard.json",
|
|
745
|
+
[io_actions.entry_to_json(e) for e in state.leaderboard],
|
|
746
|
+
)
|
|
747
|
+
io_actions.write_json(run_path / "state.json", io_actions.state_to_json(state))
|
|
748
|
+
|
|
749
|
+
return _finalize(project, split, run_path, run_id, state, stop_reason, deps)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _finalize(
|
|
753
|
+
project: LoadedProject,
|
|
754
|
+
split: Split,
|
|
755
|
+
run_path: Path,
|
|
756
|
+
run_id: str,
|
|
757
|
+
state: RunState,
|
|
758
|
+
stop_reason: StopReason,
|
|
759
|
+
deps: ShellDeps,
|
|
760
|
+
) -> int:
|
|
761
|
+
config = project.config
|
|
762
|
+
deps.echo(f"stopping: {report_calcs.stop_reason_label(stop_reason)}")
|
|
763
|
+
if not state.leaderboard:
|
|
764
|
+
deps.echo("no scored candidates were produced; nothing to finalize")
|
|
765
|
+
return _EXIT_NO_RESULT
|
|
766
|
+
|
|
767
|
+
val_examples = tuple(v.example for v in split.val)
|
|
768
|
+
test_examples = tuple(t.example for t in split.test)
|
|
769
|
+
|
|
770
|
+
winner: ValidatedCandidate
|
|
771
|
+
match state.val_champion:
|
|
772
|
+
case Some(champion):
|
|
773
|
+
winner = champion
|
|
774
|
+
case Nothing():
|
|
775
|
+
# No checkpoint ever ran (e.g. a very short run): selection on
|
|
776
|
+
# validation is mandatory, so run one final peek now.
|
|
777
|
+
entry = state.leaderboard[0]
|
|
778
|
+
deps.echo(f"final validation peek at {entry.candidate_id}")
|
|
779
|
+
candidate = Candidate(
|
|
780
|
+
entry.candidate_id,
|
|
781
|
+
state.loops_completed,
|
|
782
|
+
entry.kind,
|
|
783
|
+
entry.content,
|
|
784
|
+
"final-validation",
|
|
785
|
+
)
|
|
786
|
+
results, aborted = _evaluate_candidate(
|
|
787
|
+
candidate, val_examples, config, deps, run_path / "final" / "validation"
|
|
788
|
+
)
|
|
789
|
+
winner = ValidatedCandidate(
|
|
790
|
+
candidate_id=entry.candidate_id,
|
|
791
|
+
kind=entry.kind,
|
|
792
|
+
content=entry.content,
|
|
793
|
+
dev_score=entry.score,
|
|
794
|
+
val_score=_score(results, val_examples, config.checks, aborted),
|
|
795
|
+
)
|
|
796
|
+
state = replace(state, val_champion=Some(winner), peeks_used=state.peeks_used + 1)
|
|
797
|
+
|
|
798
|
+
# The only time test examples are ever read after splitting: a pure
|
|
799
|
+
# harness computation, run exactly once. No agent sees test results.
|
|
800
|
+
deps.echo(f"evaluating winner {winner.candidate_id} on the test set (once)")
|
|
801
|
+
test_candidate = Candidate(
|
|
802
|
+
winner.candidate_id, state.loops_completed, winner.kind, winner.content, "final-test"
|
|
803
|
+
)
|
|
804
|
+
test_results, test_aborted = _evaluate_candidate(
|
|
805
|
+
test_candidate, test_examples, config, deps, run_path / "final" / "test"
|
|
806
|
+
)
|
|
807
|
+
test_score = _score(test_results, test_examples, config.checks, test_aborted)
|
|
808
|
+
|
|
809
|
+
manifest_data = io_actions.read_json(run_path / "manifest.json")
|
|
810
|
+
manifest_inner = manifest_data.get("manifest") if isinstance(manifest_data, dict) else None
|
|
811
|
+
cli_version = (
|
|
812
|
+
str(manifest_inner.get("cli_version", "unknown"))
|
|
813
|
+
if isinstance(manifest_inner, dict)
|
|
814
|
+
else "unknown"
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
report = report_calcs.render_report(
|
|
818
|
+
run_id=run_id,
|
|
819
|
+
kind=winner.kind,
|
|
820
|
+
eval_model=config.agents.model,
|
|
821
|
+
cli_version=cli_version,
|
|
822
|
+
stop_reason=stop_reason,
|
|
823
|
+
winner=winner,
|
|
824
|
+
test_score=test_score,
|
|
825
|
+
state=state,
|
|
826
|
+
agent_calls_made=deps.calls_made(),
|
|
827
|
+
)
|
|
828
|
+
io_actions.write_text(
|
|
829
|
+
run_path / "final" / io_actions.solution_filename(winner.kind), winner.content
|
|
830
|
+
)
|
|
831
|
+
io_actions.write_json(
|
|
832
|
+
run_path / "final" / "test_results.json",
|
|
833
|
+
{
|
|
834
|
+
"run_id": run_id,
|
|
835
|
+
"stop_reason": io_actions.stop_reason_to_json(stop_reason),
|
|
836
|
+
"winner": io_actions.validated_to_json(winner),
|
|
837
|
+
"test_score": io_actions.score_to_json(test_score),
|
|
838
|
+
"agent_calls_made": deps.calls_made(),
|
|
839
|
+
},
|
|
840
|
+
)
|
|
841
|
+
io_actions.write_text(run_path / "final" / "report.md", report)
|
|
842
|
+
io_actions.write_json(run_path / "state.json", io_actions.state_to_json(state))
|
|
843
|
+
|
|
844
|
+
deps.echo(f"final artifact: {run_path / 'final' / io_actions.solution_filename(winner.kind)}")
|
|
845
|
+
deps.echo(f"report: {run_path / 'final' / 'report.md'}")
|
|
846
|
+
deps.echo(
|
|
847
|
+
f"test pass rate: {test_score.pass_rate:.1%} "
|
|
848
|
+
f"[{test_score.ci_low:.1%}, {test_score.ci_high:.1%}]"
|
|
849
|
+
)
|
|
850
|
+
return _EXIT_OK
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
# --------------------------------------------------------------------------
|
|
854
|
+
# Commands
|
|
855
|
+
# --------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _cmd_init(project_dir: Path, echo: Callable[[str], None]) -> int:
|
|
859
|
+
targets = {
|
|
860
|
+
"rigorloop.toml": _SAMPLE_CONFIG,
|
|
861
|
+
"task.md": _SAMPLE_TASK,
|
|
862
|
+
"examples.jsonl": _toy_examples_jsonl(),
|
|
863
|
+
}
|
|
864
|
+
existing = [name for name in targets if (project_dir / name).exists()]
|
|
865
|
+
if existing:
|
|
866
|
+
echo(f"error: refusing to overwrite existing file(s): {', '.join(existing)}")
|
|
867
|
+
return _EXIT_ERROR
|
|
868
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
869
|
+
for name, content in targets.items():
|
|
870
|
+
io_actions.write_text(project_dir / name, content)
|
|
871
|
+
echo(f"wrote {project_dir / name}")
|
|
872
|
+
echo("next: edit task.md and examples.jsonl, then run `rigorloop check`")
|
|
873
|
+
return _EXIT_OK
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _cmd_check(project_dir: Path, config_file: str, echo: Callable[[str], None]) -> int:
|
|
877
|
+
project = _load_project(project_dir, config_file, echo)
|
|
878
|
+
if project is None:
|
|
879
|
+
return _EXIT_ERROR
|
|
880
|
+
split = _split_or_report(project, echo)
|
|
881
|
+
if split is None:
|
|
882
|
+
return _EXIT_ERROR
|
|
883
|
+
budget = report_calcs.estimate_budget(
|
|
884
|
+
project.config, len(split.dev), len(split.val), len(split.test)
|
|
885
|
+
)
|
|
886
|
+
echo(
|
|
887
|
+
report_calcs.render_check_summary(
|
|
888
|
+
n_total=len(project.examples),
|
|
889
|
+
n_dev=len(split.dev),
|
|
890
|
+
n_val=len(split.val),
|
|
891
|
+
n_test=len(split.test),
|
|
892
|
+
duplicates=project.duplicates,
|
|
893
|
+
warnings=dataset_calcs.power_warnings(split),
|
|
894
|
+
budget=budget,
|
|
895
|
+
model=project.config.agents.model,
|
|
896
|
+
)
|
|
897
|
+
)
|
|
898
|
+
return _EXIT_OK
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _cmd_report(project_dir: Path, run_id: str, echo: Callable[[str], None]) -> int:
|
|
902
|
+
run_path = io_actions.run_dir(project_dir, run_id)
|
|
903
|
+
results_path = run_path / "final" / "test_results.json"
|
|
904
|
+
state_path = run_path / "state.json"
|
|
905
|
+
manifest_path = run_path / "manifest.json"
|
|
906
|
+
if not results_path.is_file() or not state_path.is_file():
|
|
907
|
+
echo(f"error: run {run_id} has no finalized results under {run_path}")
|
|
908
|
+
return _EXIT_ERROR
|
|
909
|
+
results = io_actions.read_json(results_path)
|
|
910
|
+
stored_state = io_actions.read_json(state_path)
|
|
911
|
+
manifest = io_actions.read_json(manifest_path) if manifest_path.is_file() else {}
|
|
912
|
+
if not isinstance(results, dict) or not isinstance(stored_state, dict):
|
|
913
|
+
echo("error: persisted artifacts are corrupt")
|
|
914
|
+
return _EXIT_ERROR
|
|
915
|
+
winner_raw = results["winner"]
|
|
916
|
+
stop_raw = results["stop_reason"]
|
|
917
|
+
test_raw = results["test_score"]
|
|
918
|
+
if not (
|
|
919
|
+
isinstance(winner_raw, dict) and isinstance(stop_raw, dict) and isinstance(test_raw, dict)
|
|
920
|
+
):
|
|
921
|
+
echo("error: persisted artifacts are corrupt")
|
|
922
|
+
return _EXIT_ERROR
|
|
923
|
+
winner = io_actions.validated_from_json(winner_raw)
|
|
924
|
+
manifest_inner = manifest.get("manifest") if isinstance(manifest, dict) else None
|
|
925
|
+
eval_model = (
|
|
926
|
+
str(manifest_inner.get("eval_model", "unknown"))
|
|
927
|
+
if isinstance(manifest_inner, dict)
|
|
928
|
+
else "unknown"
|
|
929
|
+
)
|
|
930
|
+
cli_version = (
|
|
931
|
+
str(manifest_inner.get("cli_version", "unknown"))
|
|
932
|
+
if isinstance(manifest_inner, dict)
|
|
933
|
+
else "unknown"
|
|
934
|
+
)
|
|
935
|
+
calls_raw = results.get("agent_calls_made", 0)
|
|
936
|
+
report = report_calcs.render_report(
|
|
937
|
+
run_id=run_id,
|
|
938
|
+
kind=winner.kind,
|
|
939
|
+
eval_model=eval_model,
|
|
940
|
+
cli_version=cli_version,
|
|
941
|
+
stop_reason=io_actions.stop_reason_from_json(stop_raw),
|
|
942
|
+
winner=winner,
|
|
943
|
+
test_score=io_actions.score_from_json(test_raw),
|
|
944
|
+
state=io_actions.state_from_json(stored_state),
|
|
945
|
+
agent_calls_made=int(calls_raw) if isinstance(calls_raw, int) else 0,
|
|
946
|
+
)
|
|
947
|
+
io_actions.write_text(run_path / "final" / "report.md", report)
|
|
948
|
+
echo(report)
|
|
949
|
+
return _EXIT_OK
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _real_cli_version(claude_cmd: str) -> str:
|
|
953
|
+
try:
|
|
954
|
+
proc = subprocess.run([claude_cmd, "--version"], capture_output=True, text=True, timeout=30)
|
|
955
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
956
|
+
return "unknown"
|
|
957
|
+
return proc.stdout.strip() or "unknown"
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _real_deps(claude_cmd: str) -> ShellDeps:
|
|
961
|
+
runner, calls_made = agent_calls.make_runner(claude_cmd)
|
|
962
|
+
return ShellDeps(
|
|
963
|
+
agent_runner=runner,
|
|
964
|
+
calls_made=calls_made,
|
|
965
|
+
script_runner=io_actions.run_script,
|
|
966
|
+
custom_check_runner=io_actions.run_custom_check,
|
|
967
|
+
make_run_id=lambda: time.strftime("%Y%m%d-%H%M%S"),
|
|
968
|
+
cli_version=lambda: _real_cli_version(claude_cmd),
|
|
969
|
+
echo=print,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def main(argv: list[str] | None = None) -> int:
|
|
974
|
+
parser = argparse.ArgumentParser(
|
|
975
|
+
prog="rigorloop",
|
|
976
|
+
description=(
|
|
977
|
+
"Statistically-sound agentic build loops: dev/val/test splits, a strategy "
|
|
978
|
+
"agent, executor agents, and a one-shot final test evaluation."
|
|
979
|
+
),
|
|
980
|
+
)
|
|
981
|
+
parser.add_argument("--version", action="version", version=f"rigorloop {__version__}")
|
|
982
|
+
parser.add_argument("--dir", default=".", help="project directory (default: current directory)")
|
|
983
|
+
parser.add_argument(
|
|
984
|
+
"--config", default="rigorloop.toml", help="config file name inside the project directory"
|
|
985
|
+
)
|
|
986
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
987
|
+
subparsers.add_parser("init", help="scaffold rigorloop.toml, task.md and examples.jsonl")
|
|
988
|
+
subparsers.add_parser("check", help="parse everything, print split sizes and budget; no tokens")
|
|
989
|
+
run_parser = subparsers.add_parser("run", help="execute the loop protocol")
|
|
990
|
+
run_parser.add_argument("--resume", metavar="RUN_ID", default=None)
|
|
991
|
+
report_parser = subparsers.add_parser("report", help="re-render a run's report.md")
|
|
992
|
+
report_parser.add_argument("run_id")
|
|
993
|
+
|
|
994
|
+
args = parser.parse_args(argv)
|
|
995
|
+
project_dir = Path(args.dir).resolve()
|
|
996
|
+
claude_cmd = os.environ.get("RIGORLOOP_CLAUDE_CMD", "claude")
|
|
997
|
+
|
|
998
|
+
match args.command:
|
|
999
|
+
case "init":
|
|
1000
|
+
return _cmd_init(project_dir, print)
|
|
1001
|
+
case "check":
|
|
1002
|
+
return _cmd_check(project_dir, args.config, print)
|
|
1003
|
+
case "run":
|
|
1004
|
+
project = _load_project(project_dir, args.config, print)
|
|
1005
|
+
if project is None:
|
|
1006
|
+
return _EXIT_ERROR
|
|
1007
|
+
resume: Option[str] = Some(args.resume) if args.resume else NOTHING
|
|
1008
|
+
return execute_run(project, project_dir, _real_deps(claude_cmd), resume)
|
|
1009
|
+
case "report":
|
|
1010
|
+
return _cmd_report(project_dir, args.run_id, print)
|
|
1011
|
+
case _:
|
|
1012
|
+
# argparse enforces the subcommand set; this is unreachable.
|
|
1013
|
+
parser.error(f"unknown command {args.command!r}")
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
# --------------------------------------------------------------------------
|
|
1017
|
+
# `init` scaffolding content
|
|
1018
|
+
# --------------------------------------------------------------------------
|
|
1019
|
+
|
|
1020
|
+
_SAMPLE_CONFIG = """\
|
|
1021
|
+
# RigorLoop project configuration. Run `rigorloop check` to validate.
|
|
1022
|
+
|
|
1023
|
+
[task]
|
|
1024
|
+
description_file = "task.md"
|
|
1025
|
+
solution_kind = "script" # script | skill | guidance
|
|
1026
|
+
examples_file = "examples.jsonl"
|
|
1027
|
+
|
|
1028
|
+
[split]
|
|
1029
|
+
ratios = [0.6, 0.2, 0.2] # dev / validation / test
|
|
1030
|
+
seed = 17
|
|
1031
|
+
|
|
1032
|
+
[loop]
|
|
1033
|
+
max_loops = 12
|
|
1034
|
+
executors_per_loop = 4
|
|
1035
|
+
dev_examples_in_prompt = 30
|
|
1036
|
+
|
|
1037
|
+
[validation]
|
|
1038
|
+
val_every = 3
|
|
1039
|
+
max_peeks = 10
|
|
1040
|
+
patience = 2
|
|
1041
|
+
target_pass_rate = 0.95
|
|
1042
|
+
|
|
1043
|
+
[agents]
|
|
1044
|
+
model = "claude-sonnet-5"
|
|
1045
|
+
timeout_s = 300
|
|
1046
|
+
|
|
1047
|
+
[[checks]]
|
|
1048
|
+
type = "json_equality"
|
|
1049
|
+
"""
|
|
1050
|
+
|
|
1051
|
+
_SAMPLE_TASK = """\
|
|
1052
|
+
# Task
|
|
1053
|
+
|
|
1054
|
+
Convert a short plain-text contact card into a JSON object.
|
|
1055
|
+
|
|
1056
|
+
Each input is a few lines of text describing one person. Produce a single-line
|
|
1057
|
+
JSON object with exactly these keys:
|
|
1058
|
+
|
|
1059
|
+
- "name": the person's full name
|
|
1060
|
+
- "email": their email address
|
|
1061
|
+
- "city": the city they live in
|
|
1062
|
+
|
|
1063
|
+
Output the JSON object only — no code fences, no commentary.
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _toy_examples_jsonl() -> str:
|
|
1068
|
+
people = (
|
|
1069
|
+
("Ada Lovelace", "ada@calc.org", "London"),
|
|
1070
|
+
("Grace Hopper", "grace@navy.mil", "Arlington"),
|
|
1071
|
+
("Alan Turing", "alan@bletchley.uk", "Wilmslow"),
|
|
1072
|
+
("Katherine Johnson", "kj@nasa.gov", "Hampton"),
|
|
1073
|
+
("Edsger Dijkstra", "ewd@utexas.edu", "Austin"),
|
|
1074
|
+
("Barbara Liskov", "liskov@mit.edu", "Cambridge"),
|
|
1075
|
+
("Donald Knuth", "don@stanford.edu", "Stanford"),
|
|
1076
|
+
("Margaret Hamilton", "mh@draper.com", "Boston"),
|
|
1077
|
+
("John McCarthy", "jmc@stanford.edu", "Palo Alto"),
|
|
1078
|
+
("Frances Allen", "fran@ibm.com", "Yorktown"),
|
|
1079
|
+
("Tony Hoare", "car@oxford.uk", "Oxford"),
|
|
1080
|
+
("Radia Perlman", "radia@dec.com", "Boston"),
|
|
1081
|
+
("Dennis Ritchie", "dmr@bell-labs.com", "Murray Hill"),
|
|
1082
|
+
("Adele Goldberg", "adele@parc.com", "Palo Alto"),
|
|
1083
|
+
("Ken Thompson", "ken@bell-labs.com", "Murray Hill"),
|
|
1084
|
+
("Jean Bartik", "jean@eniac.org", "Philadelphia"),
|
|
1085
|
+
("Niklaus Wirth", "wirth@ethz.ch", "Zurich"),
|
|
1086
|
+
("Mary Shaw", "shaw@cmu.edu", "Pittsburgh"),
|
|
1087
|
+
("Leslie Lamport", "ll@microsoft.com", "New York"),
|
|
1088
|
+
("Shafi Goldwasser", "shafi@mit.edu", "Cambridge"),
|
|
1089
|
+
("Vint Cerf", "vint@google.com", "McLean"),
|
|
1090
|
+
("Lynn Conway", "lynn@umich.edu", "Ann Arbor"),
|
|
1091
|
+
("Tim Berners-Lee", "timbl@w3.org", "Boston"),
|
|
1092
|
+
("Anita Borg", "anita@borg.org", "Palo Alto"),
|
|
1093
|
+
)
|
|
1094
|
+
templates = (
|
|
1095
|
+
"Name: {name}\nEmail: {email}\nCity: {city}",
|
|
1096
|
+
"{name} lives in {city}. Reach them at {email}.",
|
|
1097
|
+
"Contact card — {name} <{email}> based in {city}",
|
|
1098
|
+
)
|
|
1099
|
+
lines = [
|
|
1100
|
+
json.dumps(
|
|
1101
|
+
{
|
|
1102
|
+
"input": templates[i % len(templates)].format(name=name, email=email, city=city),
|
|
1103
|
+
"expected_output": json.dumps(
|
|
1104
|
+
{"name": name, "email": email, "city": city}, sort_keys=True
|
|
1105
|
+
),
|
|
1106
|
+
},
|
|
1107
|
+
sort_keys=True,
|
|
1108
|
+
)
|
|
1109
|
+
for i, (name, email, city) in enumerate(people)
|
|
1110
|
+
]
|
|
1111
|
+
return "\n".join(lines) + "\n"
|