rigorloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,507 @@
1
+ """Pure strategy logic: context assembly, agent-reply parsing, validation
2
+ cadence, stopping rules, champion selection, and the dev leaderboard."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import random
8
+ import re
9
+
10
+ from rigorloop.core.scoring_calcs import example_passed, significantly_better
11
+ from rigorloop.core.types import (
12
+ NOTHING,
13
+ BudgetExhausted,
14
+ ChampionArtifact,
15
+ ContinueDecision,
16
+ DevExample,
17
+ Directive,
18
+ DirectiveSpec,
19
+ Err,
20
+ ExampleResult,
21
+ ExecutionFailed,
22
+ ExecutionOk,
23
+ FailureSample,
24
+ JudgeVerdict,
25
+ LeaderboardEntry,
26
+ MalformedReply,
27
+ Nothing,
28
+ Ok,
29
+ Option,
30
+ Passed,
31
+ Result,
32
+ RunConfig,
33
+ RunState,
34
+ Some,
35
+ StopReason,
36
+ StopRequested,
37
+ StrategyContext,
38
+ StrategyDecision,
39
+ StrategyLogEntry,
40
+ StrategyUnresponsive,
41
+ TargetReached,
42
+ ValCheckpoint,
43
+ ValidatedCandidate,
44
+ ValidationPlateau,
45
+ )
46
+
47
+ ALPHA = 0.05
48
+ MAX_FALLBACKS = 2
49
+ _LEADERBOARD_TOP = 8
50
+ _FAILURE_SAMPLE_LIMIT = 3
51
+ _GAP_WARN = 0.15
52
+
53
+
54
+ def initial_state() -> RunState:
55
+ return RunState(
56
+ loops_completed=0,
57
+ leaderboard=(),
58
+ strategy_log=(),
59
+ val_champion=NOTHING,
60
+ checkpoints=(),
61
+ peeks_used=0,
62
+ last_peek_loop=NOTHING,
63
+ consecutive_fallbacks=0,
64
+ )
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # Deterministic dev-subset sampling
69
+ # --------------------------------------------------------------------------
70
+
71
+
72
+ def sample_dev_subset(
73
+ dev: tuple[DevExample, ...], k: int, seed: int, loop_index: int
74
+ ) -> tuple[DevExample, ...]:
75
+ """Resampled every loop, deterministically from the injected seed and the
76
+ loop index, so score movement isn't over-attributed to directives."""
77
+ if k >= len(dev):
78
+ return dev
79
+ rng = random.Random(seed * 1_000_003 + loop_index) # pure function of its inputs
80
+ return tuple(rng.sample(dev, k=k))
81
+
82
+
83
+ # --------------------------------------------------------------------------
84
+ # Agent-reply parsing (strategy JSON, executor fenced block, judge JSON)
85
+ # --------------------------------------------------------------------------
86
+
87
+
88
+ def _json_candidates(text: str) -> tuple[str, ...]:
89
+ fenced = tuple(re.findall(r"```(?:json)?\s*\n(.*?)```", text, re.DOTALL))
90
+ start, end = text.find("{"), text.rfind("}")
91
+ braced = (text[start : end + 1],) if 0 <= start < end else ()
92
+ return (text.strip(), *fenced, *braced)
93
+
94
+
95
+ def _extract_json_object(text: str) -> Result[dict[str, object], MalformedReply]:
96
+ def try_load(chunk: str) -> dict[str, object] | None:
97
+ try:
98
+ obj = json.loads(chunk)
99
+ except json.JSONDecodeError:
100
+ return None
101
+ return obj if isinstance(obj, dict) else None
102
+
103
+ loaded = next((o for o in map(try_load, _json_candidates(text)) if o is not None), None)
104
+ return Ok(loaded) if loaded is not None else Err(MalformedReply("no JSON object found"))
105
+
106
+
107
+ def _parse_directive_spec(index: int, raw: object) -> Result[DirectiveSpec, MalformedReply]:
108
+ if not isinstance(raw, dict):
109
+ return Err(MalformedReply(f"directives[{index}] is not an object"))
110
+ summary = raw.get("approach_summary")
111
+ instructions = raw.get("instructions")
112
+ base = raw.get("base_on_champion", False)
113
+ if not isinstance(summary, str) or not summary.strip():
114
+ return Err(MalformedReply(f"directives[{index}].approach_summary missing or empty"))
115
+ if not isinstance(instructions, str) or not instructions.strip():
116
+ return Err(MalformedReply(f"directives[{index}].instructions missing or empty"))
117
+ if not isinstance(base, bool):
118
+ return Err(MalformedReply(f"directives[{index}].base_on_champion must be a boolean"))
119
+ return Ok(DirectiveSpec(summary.strip(), instructions.strip(), base))
120
+
121
+
122
+ def parse_strategy_reply(text: str) -> Result[StrategyDecision, MalformedReply]:
123
+ obj = _extract_json_object(text)
124
+ if isinstance(obj, Err):
125
+ return obj
126
+ data = obj.value
127
+ action = data.get("action")
128
+ if action == "stop":
129
+ reason = data.get("reason", data.get("stop_reason", ""))
130
+ return Ok(StopRequested(reason if isinstance(reason, str) else ""))
131
+ if action != "continue":
132
+ return Err(MalformedReply(f"action must be 'continue' or 'stop', got {action!r}"))
133
+ raw_directives = data.get("directives")
134
+ if not isinstance(raw_directives, list) or not raw_directives:
135
+ return Err(MalformedReply("continue decision needs a non-empty 'directives' array"))
136
+ parsed = [_parse_directive_spec(i, d) for i, d in enumerate(raw_directives)]
137
+ failures = [p for p in parsed if isinstance(p, Err)]
138
+ if failures:
139
+ return failures[0]
140
+ observations = data.get("observations", "")
141
+ hypotheses = data.get("hypotheses", "")
142
+ request_validation = data.get("request_validation", False)
143
+ if not isinstance(request_validation, bool):
144
+ return Err(MalformedReply("'request_validation' must be a boolean"))
145
+ return Ok(
146
+ ContinueDecision(
147
+ observations=observations if isinstance(observations, str) else "",
148
+ hypotheses=hypotheses if isinstance(hypotheses, str) else "",
149
+ directive_specs=tuple(p.value for p in parsed if isinstance(p, Ok)),
150
+ request_validation=request_validation,
151
+ )
152
+ )
153
+
154
+
155
+ def parse_executor_reply(text: str) -> Result[str, MalformedReply]:
156
+ """The output contract: exactly one fenced block tagged `solution`."""
157
+ markers = text.count("```solution")
158
+ if markers == 0:
159
+ return Err(MalformedReply("no ```solution fenced block found"))
160
+ if markers > 1:
161
+ return Err(MalformedReply(f"expected exactly one ```solution block, found {markers}"))
162
+ # The block must close with the LAST fence in the reply, so solution
163
+ # content may itself contain fenced code blocks.
164
+ match = re.search(r"```solution[^\n]*\n(.*)\n```\s*\Z", text, re.DOTALL)
165
+ if match is None:
166
+ return Err(MalformedReply("```solution block is not terminated at the end of the reply"))
167
+ content = match.group(1)
168
+ if not content.strip():
169
+ return Err(MalformedReply("```solution block is empty"))
170
+ return Ok(content)
171
+
172
+
173
+ def parse_judge_reply(text: str) -> Result[JudgeVerdict, MalformedReply]:
174
+ obj = _extract_json_object(text)
175
+ if isinstance(obj, Err):
176
+ return obj
177
+ verdict = obj.value.get("pass", obj.value.get("passed"))
178
+ if not isinstance(verdict, bool):
179
+ return Err(MalformedReply("judge reply needs a boolean 'pass' field"))
180
+ reason = obj.value.get("reason", "")
181
+ return Ok(JudgeVerdict(verdict, reason if isinstance(reason, str) else ""))
182
+
183
+
184
+ # --------------------------------------------------------------------------
185
+ # Directives and the fallback path
186
+ # --------------------------------------------------------------------------
187
+
188
+
189
+ def build_directives(
190
+ specs: tuple[DirectiveSpec, ...],
191
+ champion: Option[ChampionArtifact],
192
+ loop_index: int,
193
+ max_directives: int,
194
+ ) -> tuple[Directive, ...]:
195
+ """Attach the champion artifact where requested. The artifact is the one
196
+ sanctioned carry-forward channel: solution content only."""
197
+
198
+ def build(index: int, spec: DirectiveSpec) -> Directive:
199
+ base: Option[ChampionArtifact] = champion if spec.base_on_champion else NOTHING
200
+ return Directive(
201
+ directive_id=f"L{loop_index}-d{index}",
202
+ approach_summary=spec.approach_summary,
203
+ instructions=spec.instructions,
204
+ base=base,
205
+ )
206
+
207
+ return tuple(build(i, spec) for i, spec in enumerate(specs[:max_directives], start=1))
208
+
209
+
210
+ def fallback_decision(has_champion: bool) -> ContinueDecision:
211
+ """Substituted when the strategy agent's reply cannot be parsed twice."""
212
+ spec = (
213
+ DirectiveSpec(
214
+ approach_summary="Refine the current champion solution",
215
+ instructions=(
216
+ "Carefully review the provided current best solution and produce an "
217
+ "improved version: fix edge cases, tighten output formatting, and "
218
+ "keep whatever already works."
219
+ ),
220
+ base_on_champion=True,
221
+ )
222
+ if has_champion
223
+ else DirectiveSpec(
224
+ approach_summary="Produce a solid first solution",
225
+ instructions=(
226
+ "Read the task description and the example inputs and outputs "
227
+ "closely, then produce a careful, straightforward solution."
228
+ ),
229
+ base_on_champion=False,
230
+ )
231
+ )
232
+ return ContinueDecision(
233
+ observations="(fallback: strategy reply was malformed)",
234
+ hypotheses="",
235
+ directive_specs=(spec,),
236
+ request_validation=False,
237
+ )
238
+
239
+
240
+ # --------------------------------------------------------------------------
241
+ # Leaderboard
242
+ # --------------------------------------------------------------------------
243
+
244
+
245
+ def fold_leaderboard(
246
+ leaderboard: tuple[LeaderboardEntry, ...], new_entries: tuple[LeaderboardEntry, ...]
247
+ ) -> tuple[LeaderboardEntry, ...]:
248
+ """Ranked by pass rate (stable: earlier candidates win ties)."""
249
+ merged = (*leaderboard, *new_entries)
250
+ return tuple(sorted(merged, key=lambda e: (-e.score.pass_rate, e.loop_index, e.candidate_id)))
251
+
252
+
253
+ def dev_best(leaderboard: tuple[LeaderboardEntry, ...]) -> Option[LeaderboardEntry]:
254
+ return Some(leaderboard[0]) if leaderboard else NOTHING
255
+
256
+
257
+ def champion_artifact(entry: LeaderboardEntry) -> ChampionArtifact:
258
+ return ChampionArtifact(entry.candidate_id, entry.kind, entry.content)
259
+
260
+
261
+ def beats_previous_best(
262
+ previous_best: Option[LeaderboardEntry], challenger: LeaderboardEntry
263
+ ) -> bool:
264
+ """CI-band-gated: a raw uptick is not an improvement."""
265
+ match previous_best:
266
+ case Nothing():
267
+ return True
268
+ case Some(best):
269
+ return significantly_better(challenger.score.pass_vector, best.score.pass_vector, ALPHA)
270
+
271
+
272
+ def render_leaderboard_lines(
273
+ leaderboard: tuple[LeaderboardEntry, ...], top: int = _LEADERBOARD_TOP
274
+ ) -> tuple[str, ...]:
275
+ """Aggregate scores only; differences within noise of the best are marked
276
+ so the strategy agent doesn't chase them."""
277
+ if not leaderboard:
278
+ return ()
279
+ best = leaderboard[0]
280
+
281
+ def line(rank: int, e: LeaderboardEntry) -> str:
282
+ score = e.score
283
+ within_noise = e is not best and not significantly_better(
284
+ best.score.pass_vector, score.pass_vector, ALPHA
285
+ )
286
+ flags = (" — not statistically distinguishable from best" if within_noise else "") + (
287
+ " — evaluation aborted early; missing examples count as failures"
288
+ if score.eval_aborted
289
+ else ""
290
+ )
291
+ return (
292
+ f"{rank}. {e.candidate_id} (loop {e.loop_index}): "
293
+ f"{score.pass_rate:.1%} [{score.ci_low:.1%}, {score.ci_high:.1%}] "
294
+ f"on n={score.n}{flags}"
295
+ )
296
+
297
+ return tuple(line(i, e) for i, e in enumerate(leaderboard[:top], start=1))
298
+
299
+
300
+ # --------------------------------------------------------------------------
301
+ # Failure patterns (dev-only, for the strategy context)
302
+ # --------------------------------------------------------------------------
303
+
304
+
305
+ def failure_samples(
306
+ dev: tuple[DevExample, ...],
307
+ results: tuple[ExampleResult, ...],
308
+ limit: int = _FAILURE_SAMPLE_LIMIT,
309
+ ) -> tuple[FailureSample, ...]:
310
+ by_id = {d.example.example_id: d for d in dev}
311
+
312
+ def sample(result: ExampleResult) -> FailureSample:
313
+ match result.execution:
314
+ case ExecutionOk(output_text):
315
+ actual = output_text
316
+ case ExecutionFailed(detail):
317
+ actual = f"(no output: {detail})"
318
+ return FailureSample(
319
+ dev_example=by_id[result.example_id],
320
+ actual_output=actual,
321
+ failed_checks=tuple(
322
+ o.check_name for o in result.outcomes if not isinstance(o.outcome, Passed)
323
+ ),
324
+ )
325
+
326
+ failing = tuple(r for r in results if not example_passed(r) and r.example_id in by_id)
327
+ return tuple(sample(r) for r in failing[:limit])
328
+
329
+
330
+ # --------------------------------------------------------------------------
331
+ # Validation cadence and champion selection
332
+ # --------------------------------------------------------------------------
333
+
334
+
335
+ def should_validate(
336
+ state: RunState,
337
+ config: RunConfig,
338
+ strategy_requested: bool,
339
+ new_best_significant: bool,
340
+ ) -> bool:
341
+ """Peeks are budgeted; triggered peeks respect a minimum gap so early easy
342
+ wins can't front-load the budget."""
343
+ best = dev_best(state.leaderboard)
344
+ if isinstance(best, Nothing) or state.peeks_used >= config.validation.max_peeks:
345
+ return False
346
+ already_validated = {c.candidate_id for c in state.checkpoints}
347
+ if best.value.candidate_id in already_validated:
348
+ return False
349
+ scheduled = state.loops_completed % config.validation.val_every == 0
350
+ match state.last_peek_loop:
351
+ case Nothing():
352
+ gap_ok = True
353
+ case Some(last):
354
+ gap_ok = state.loops_completed - last >= config.validation.min_loops_between_peeks
355
+ triggered = (strategy_requested or new_best_significant) and gap_ok
356
+ return scheduled or triggered
357
+
358
+
359
+ def select_val_champion(
360
+ incumbent: Option[ValidatedCandidate], challenger: ValidatedCandidate
361
+ ) -> tuple[ValidatedCandidate, bool]:
362
+ """Noise-aware selection: (winner, improvement).
363
+
364
+ A challenger displaces the incumbent only beyond the paired-test noise
365
+ band; within the band the dev score breaks the tie (without counting as
366
+ improvement for the plateau rule)."""
367
+ match incumbent:
368
+ case Nothing():
369
+ return challenger, True
370
+ case Some(current):
371
+ if significantly_better(
372
+ challenger.val_score.pass_vector, current.val_score.pass_vector, ALPHA
373
+ ):
374
+ return challenger, True
375
+ if significantly_better(
376
+ current.val_score.pass_vector, challenger.val_score.pass_vector, ALPHA
377
+ ):
378
+ return current, False
379
+ tie_break = challenger.dev_score.pass_rate > current.dev_score.pass_rate
380
+ return (challenger, False) if tie_break else (current, False)
381
+
382
+
383
+ # --------------------------------------------------------------------------
384
+ # Stopping rules
385
+ # --------------------------------------------------------------------------
386
+
387
+
388
+ def stopping_decision(state: RunState, config: RunConfig) -> Option[StopReason]:
389
+ """Checked after each loop's bookkeeping. Strategy-requested stops and
390
+ unresponsiveness are handled where they arise; this covers the rest."""
391
+ if state.consecutive_fallbacks >= MAX_FALLBACKS:
392
+ return Some(StrategyUnresponsive(state.consecutive_fallbacks))
393
+ match config.validation.target_pass_rate, state.val_champion:
394
+ case Some(target), Some(champion):
395
+ if champion.val_score.pass_rate >= target:
396
+ return Some(TargetReached(champion.val_score.pass_rate))
397
+ case _, _:
398
+ pass
399
+ patience = config.validation.patience
400
+ recent = state.checkpoints[-patience:]
401
+ if len(recent) == patience and all(not c.displaced_champion for c in recent):
402
+ return Some(ValidationPlateau(patience))
403
+ if state.loops_completed >= config.loop.max_loops:
404
+ return Some(BudgetExhausted(config.loop.max_loops))
405
+ return NOTHING
406
+
407
+
408
+ # --------------------------------------------------------------------------
409
+ # Strategy log and context assembly
410
+ # --------------------------------------------------------------------------
411
+
412
+
413
+ def dev_summary_line(n_scored: int, n_malformed: int, best: Option[LeaderboardEntry]) -> str:
414
+ best_text = ""
415
+ match best:
416
+ case Some(entry):
417
+ best_text = (
418
+ f"; loop best {entry.candidate_id} at {entry.score.pass_rate:.1%} "
419
+ f"[{entry.score.ci_low:.1%}, {entry.score.ci_high:.1%}]"
420
+ )
421
+ case Nothing():
422
+ best_text = ""
423
+ malformed_text = f"; {n_malformed} malformed candidate(s)" if n_malformed else ""
424
+ return f"{n_scored} candidate(s) scored{malformed_text}{best_text}"
425
+
426
+
427
+ def val_summary_line(checkpoint: ValCheckpoint) -> str:
428
+ gap = checkpoint.dev_pass_rate - checkpoint.val_pass_rate
429
+ displaced = "new champion" if checkpoint.displaced_champion else "champion unchanged"
430
+ return (
431
+ f"validated {checkpoint.candidate_id}: {checkpoint.val_pass_rate:.1%} on validation "
432
+ f"(dev {checkpoint.dev_pass_rate:.1%}, gap {gap:+.1%}); {displaced}"
433
+ )
434
+
435
+
436
+ def compact_log_line(entry: StrategyLogEntry) -> str:
437
+ approaches = "; ".join(d.approach_summary for d in entry.directives)
438
+ val_text = ""
439
+ match entry.val_summary:
440
+ case Some(summary):
441
+ val_text = f" | {summary}"
442
+ case Nothing():
443
+ val_text = ""
444
+ return f"loop {entry.loop_index}: [{approaches}] | {entry.dev_summary}{val_text}"
445
+
446
+
447
+ def assemble_strategy_context(
448
+ task_description: str,
449
+ config: RunConfig,
450
+ state: RunState,
451
+ samples: tuple[FailureSample, ...],
452
+ check_names: tuple[str, ...],
453
+ ) -> StrategyContext:
454
+ """Full detail for the most recent loops, compact lines beyond that, the
455
+ dev leaderboard with CIs, dev failure patterns, the champion's content,
456
+ and aggregate validation scores. Nothing else."""
457
+ detail = config.loop.strategy_full_detail_loops
458
+ recent = state.strategy_log[-detail:]
459
+ compacted = tuple(compact_log_line(e) for e in state.strategy_log[:-detail])
460
+
461
+ best = dev_best(state.leaderboard)
462
+ champion: Option[ChampionArtifact] = NOTHING
463
+ champion_dev_line: Option[str] = NOTHING
464
+ match best:
465
+ case Some(entry):
466
+ champion = Some(champion_artifact(entry))
467
+ champion_dev_line = Some(
468
+ f"{entry.candidate_id}: dev {entry.score.pass_rate:.1%} "
469
+ f"[{entry.score.ci_low:.1%}, {entry.score.ci_high:.1%}] on n={entry.score.n}"
470
+ )
471
+ case Nothing():
472
+ pass
473
+
474
+ val_lines = tuple(val_summary_line(c) for c in state.checkpoints)
475
+ gap_warning: Option[str] = NOTHING
476
+ if state.checkpoints:
477
+ last = state.checkpoints[-1]
478
+ gap = last.dev_pass_rate - last.val_pass_rate
479
+ if gap > _GAP_WARN:
480
+ gap_warning = Some(
481
+ f"WARNING: dev-val gap is {gap:+.1%} — the loop may be overfitting "
482
+ "to the dev set. Prefer simpler, more general approaches."
483
+ )
484
+
485
+ return StrategyContext(
486
+ task_description=task_description,
487
+ solution_kind=config.task.solution_kind,
488
+ loops_completed=state.loops_completed,
489
+ max_loops=config.loop.max_loops,
490
+ executors_per_loop=config.loop.executors_per_loop,
491
+ check_names=check_names,
492
+ recent_log=recent,
493
+ compacted_log=compacted,
494
+ leaderboard_lines=render_leaderboard_lines(state.leaderboard),
495
+ failure_samples=samples,
496
+ champion=champion,
497
+ champion_dev_line=champion_dev_line,
498
+ val_lines=val_lines,
499
+ dev_val_gap_warning=gap_warning,
500
+ peeks_used=state.peeks_used,
501
+ max_peeks=config.validation.max_peeks,
502
+ dev_subset_note=(
503
+ "Executor agents see a dev-example subset that is resampled every loop; "
504
+ "some loop-to-loop score movement is sample luck, and differences marked "
505
+ "as within noise should not be chased."
506
+ ),
507
+ )