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/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"