self-evolve-framework 1.4.0 → 1.6.0

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.
Files changed (171) hide show
  1. package/package.json +1 -1
  2. package/template/rules/ponytail.mdc +98 -23
  3. package/template/skills/skillopt-sleep/SKILL.md +42 -0
  4. package/template/skills/skillopt-sleep/configs/_base_/default.yaml +103 -0
  5. package/template/skills/skillopt-sleep/configs/alfworld/default.yaml +29 -0
  6. package/template/skills/skillopt-sleep/configs/docvqa/default.yaml +28 -0
  7. package/template/skills/skillopt-sleep/configs/features/soft_gate.yaml +47 -0
  8. package/template/skills/skillopt-sleep/configs/livemathematicianbench/default.yaml +22 -0
  9. package/template/skills/skillopt-sleep/configs/officeqa/default.yaml +34 -0
  10. package/template/skills/skillopt-sleep/configs/searchqa/default.yaml +32 -0
  11. package/template/skills/skillopt-sleep/configs/spreadsheetbench/default.yaml +34 -0
  12. package/template/skills/skillopt-sleep/scripts/framework/skillopt/__init__.py +28 -0
  13. package/template/skills/skillopt-sleep/scripts/framework/skillopt/config.py +282 -0
  14. package/template/skills/skillopt-sleep/scripts/framework/skillopt/datasets/__init__.py +7 -0
  15. package/template/skills/skillopt-sleep/scripts/framework/skillopt/datasets/base.py +512 -0
  16. package/template/skills/skillopt-sleep/scripts/framework/skillopt/engine/__init__.py +9 -0
  17. package/template/skills/skillopt-sleep/scripts/framework/skillopt/engine/trainer.py +2379 -0
  18. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/__init__.py +1 -0
  19. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/_template/README.md +43 -0
  20. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/_template/config_template.yaml +55 -0
  21. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/_template/env_template.py +151 -0
  22. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/_template/loader_template.py +87 -0
  23. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/__init__.py +5 -0
  24. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/adapter.py +428 -0
  25. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/dataloader.py +123 -0
  26. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/prompts/analyst_error.md +55 -0
  27. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/prompts/analyst_success.md +33 -0
  28. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/prompts/rollout_no_history.md +8 -0
  29. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/prompts/rollout_with_history.md +9 -0
  30. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/prompts/rollout_with_memory.md +16 -0
  31. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/reflect.py +4 -0
  32. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/rollout.py +366 -0
  33. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/skills/initial.md +45 -0
  34. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/__init__.py +9 -0
  35. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/alfworld_envs.py +221 -0
  36. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/alfworld_projection.py +60 -0
  37. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/alfworld_prompts.py +8 -0
  38. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/config_tw.yaml +145 -0
  39. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/env_base.py +84 -0
  40. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/env_manager.py +139 -0
  41. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/alfworld/vendor/memory.py +87 -0
  42. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/base.py +329 -0
  43. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/__init__.py +1 -0
  44. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/adapter.py +90 -0
  45. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/dataloader.py +61 -0
  46. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/evaluator.py +113 -0
  47. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/prompts/analyst_error.md +35 -0
  48. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/prompts/analyst_success.md +24 -0
  49. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/prompts/rollout_system.md +12 -0
  50. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/rollout.py +391 -0
  51. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/docvqa/skills/initial.md +11 -0
  52. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/__init__.py +1 -0
  53. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/adapter.py +129 -0
  54. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/dataloader.py +308 -0
  55. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/evaluator.py +62 -0
  56. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/prompts/analyst_error.md +37 -0
  57. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/prompts/analyst_success.md +25 -0
  58. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/prompts/rollout_system.md +12 -0
  59. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/reflect.py +4 -0
  60. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/rollout.py +434 -0
  61. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/livemathematicianbench/skills/initial.md +16 -0
  62. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/__init__.py +1 -0
  63. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/adapter.py +112 -0
  64. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/dataloader.py +71 -0
  65. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/evaluator.py +46 -0
  66. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/prompts/analyst_error.md +37 -0
  67. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/prompts/analyst_success.md +25 -0
  68. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/prompts/rollout_system.md +15 -0
  69. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/rollout.py +799 -0
  70. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/skills/initial.md +15 -0
  71. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/officeqa/tool_runtime.py +552 -0
  72. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/__init__.py +1 -0
  73. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/adapter.py +96 -0
  74. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/dataloader.py +42 -0
  75. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/evaluator.py +100 -0
  76. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/prompts/analyst_error.md +46 -0
  77. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/prompts/analyst_success.md +32 -0
  78. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/prompts/rollout_system.md +13 -0
  79. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/reflect.py +4 -0
  80. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/rollout.py +494 -0
  81. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/searchqa/skills/initial.md +3 -0
  82. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/__init__.py +5 -0
  83. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/adapter.py +159 -0
  84. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/codegen_agent.py +731 -0
  85. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/dataloader.py +37 -0
  86. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/evaluator.py +158 -0
  87. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/executor.py +67 -0
  88. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/prompts/analyst_error.md +46 -0
  89. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/prompts/analyst_success.md +32 -0
  90. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/prompts/codegen_system.md +1 -0
  91. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/prompts/critical_rules.md +9 -0
  92. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/prompts/react_system.md +21 -0
  93. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/react_agent.py +395 -0
  94. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/reflect.py +4 -0
  95. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/rollout.py +979 -0
  96. package/template/skills/skillopt-sleep/scripts/framework/skillopt/envs/spreadsheetbench/skills/initial.md +56 -0
  97. package/template/skills/skillopt-sleep/scripts/framework/skillopt/evaluation/__init__.py +13 -0
  98. package/template/skills/skillopt-sleep/scripts/framework/skillopt/evaluation/gate.py +148 -0
  99. package/template/skills/skillopt-sleep/scripts/framework/skillopt/gradient/__init__.py +15 -0
  100. package/template/skills/skillopt-sleep/scripts/framework/skillopt/gradient/aggregate.py +253 -0
  101. package/template/skills/skillopt-sleep/scripts/framework/skillopt/gradient/reflect.py +635 -0
  102. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/__init__.py +514 -0
  103. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/azure_openai.py +915 -0
  104. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/backend_config.py +185 -0
  105. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/claude_backend.py +371 -0
  106. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/codex_backend.py +666 -0
  107. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/codex_harness.py +1057 -0
  108. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/common.py +229 -0
  109. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/minimax_backend.py +277 -0
  110. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/qwen_backend.py +456 -0
  111. package/template/skills/skillopt-sleep/scripts/framework/skillopt/model/router.py +236 -0
  112. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/__init__.py +15 -0
  113. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/appendix.py +156 -0
  114. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/clip.py +109 -0
  115. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/lr_autonomous.py +108 -0
  116. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/meta_skill.py +79 -0
  117. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/rewrite.py +59 -0
  118. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/scheduler.py +127 -0
  119. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/select.py +4 -0
  120. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/skill.py +201 -0
  121. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/skill_aware.py +206 -0
  122. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/slow_update.py +396 -0
  123. package/template/skills/skillopt-sleep/scripts/framework/skillopt/optimizer/update_modes.py +135 -0
  124. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/__init__.py +63 -0
  125. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/analyst_error.md +41 -0
  126. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/analyst_error_full_rewrite.md +32 -0
  127. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/analyst_error_rewrite.md +44 -0
  128. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/analyst_success.md +36 -0
  129. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/analyst_success_full_rewrite.md +30 -0
  130. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/analyst_success_rewrite.md +33 -0
  131. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/lr_autonomous.md +20 -0
  132. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_failure.md +30 -0
  133. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_failure_full_rewrite.md +28 -0
  134. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_failure_rewrite.md +26 -0
  135. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_final.md +33 -0
  136. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_final_full_rewrite.md +28 -0
  137. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_final_rewrite.md +25 -0
  138. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_success.md +28 -0
  139. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_success_full_rewrite.md +28 -0
  140. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/merge_success_rewrite.md +25 -0
  141. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/meta_skill.md +40 -0
  142. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/ranking.md +20 -0
  143. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/ranking_rewrite.md +15 -0
  144. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/rewrite_skill.md +25 -0
  145. package/template/skills/skillopt-sleep/scripts/framework/skillopt/prompts/slow_update.md +60 -0
  146. package/template/skills/skillopt-sleep/scripts/framework/skillopt/scheduler/__init__.py +8 -0
  147. package/template/skills/skillopt-sleep/scripts/framework/skillopt/types.py +306 -0
  148. package/template/skills/skillopt-sleep/scripts/framework/skillopt/utils/__init__.py +4 -0
  149. package/template/skills/skillopt-sleep/scripts/framework/skillopt/utils/json_utils.py +172 -0
  150. package/template/skills/skillopt-sleep/scripts/framework/skillopt/utils/scoring.py +28 -0
  151. package/template/skills/ponytail/SKILL.md +0 -133
  152. package/template/skills/ponytail/scripts/hooks/claude-codex-hooks.json +0 -44
  153. package/template/skills/ponytail/scripts/hooks/copilot-hooks.json +0 -21
  154. package/template/skills/ponytail/scripts/hooks/ponytail-activate.js +0 -91
  155. package/template/skills/ponytail/scripts/hooks/ponytail-config.js +0 -122
  156. package/template/skills/ponytail/scripts/hooks/ponytail-instructions.js +0 -94
  157. package/template/skills/ponytail/scripts/hooks/ponytail-mode-tracker.js +0 -55
  158. package/template/skills/ponytail/scripts/hooks/ponytail-runtime.js +0 -68
  159. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.ps1 +0 -21
  160. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.sh +0 -12
  161. package/template/skills/ponytail/scripts/hooks/ponytail-subagent.js +0 -22
  162. package/template/skills/ponytail/scripts/mcp/README.md +0 -46
  163. package/template/skills/ponytail/scripts/mcp/index.js +0 -48
  164. package/template/skills/ponytail/scripts/mcp/instructions.js +0 -26
  165. package/template/skills/ponytail/scripts/mcp/package.json +0 -13
  166. package/template/skills/ponytail/scripts/mcp/test/instructions.test.js +0 -22
  167. package/template/skills/ponytail-audit/SKILL.md +0 -41
  168. package/template/skills/ponytail-debt/SKILL.md +0 -44
  169. package/template/skills/ponytail-gain/SKILL.md +0 -50
  170. package/template/skills/ponytail-help/SKILL.md +0 -69
  171. package/template/skills/ponytail-review/SKILL.md +0 -57
@@ -0,0 +1,2379 @@
1
+ """ReflACT Trainer — the main training loop.
2
+
3
+ Orchestrates the 6-stage ReflACT pipeline:
4
+ 1. Rollout — execute episodes with current skill
5
+ 2. Reflect — analyze trajectories, generate patches
6
+ 3. Aggregate — hierarchical merge of patches
7
+ 4. Select — rank and select top edits
8
+ 5. Update — apply edits to skill document
9
+ 6. Evaluate — validate candidate skill, accept/reject
10
+
11
+ The trainer is environment-agnostic; all environment-specific logic is
12
+ delegated to an :class:`~skillopt.envs.base.EnvAdapter` instance.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import glob
17
+ import json
18
+ import math
19
+ import os
20
+ import random
21
+ import re
22
+ import time
23
+ from collections import defaultdict
24
+
25
+ from skillopt.datasets.base import BatchSpec
26
+ from skillopt.envs.base import EnvAdapter
27
+ from skillopt.evaluation.gate import GateResult, evaluate_gate, select_gate_score
28
+ from skillopt.gradient.aggregate import merge_patches
29
+ from skillopt.optimizer.meta_skill import run_meta_skill
30
+ from skillopt.optimizer.clip import rank_and_select
31
+ from skillopt.optimizer.lr_autonomous import decide_autonomous_learning_rate
32
+ from skillopt.optimizer.rewrite import rewrite_skill_from_suggestions
33
+ from skillopt.optimizer.scheduler import build_scheduler
34
+ from skillopt.optimizer.skill import apply_patch_with_report
35
+ from skillopt.optimizer.appendix import (
36
+ append_to_appendix_field,
37
+ extract_appendix_notes as extract_appendix_notes_from_skill,
38
+ inject_empty_appendix_field,
39
+ _strip_all_appendix_fields,
40
+ )
41
+ from skillopt.optimizer.skill_aware import (
42
+ configure_skill_aware_reflection,
43
+ consolidate_appendix_notes,
44
+ extract_appendix_notes as extract_appendix_notes_from_result,
45
+ )
46
+ from skillopt.optimizer.slow_update import (
47
+ build_comparison_pairs,
48
+ extract_slow_update_field,
49
+ inject_empty_slow_update_field,
50
+ replace_slow_update_field,
51
+ run_slow_update,
52
+ save_comparison_pairs,
53
+ )
54
+ from skillopt.optimizer.update_modes import (
55
+ get_payload_items,
56
+ is_full_rewrite_minibatch_mode,
57
+ normalize_update_mode,
58
+ payload_label,
59
+ short_item_summary,
60
+ )
61
+ from skillopt.model import (
62
+ chat_optimizer,
63
+ configure_azure_openai,
64
+ configure_claude_code_exec,
65
+ configure_codex_exec,
66
+ configure_minimax_chat,
67
+ configure_qwen_chat,
68
+ get_token_summary,
69
+ reset_token_tracker,
70
+ set_reasoning_effort,
71
+ set_target_backend,
72
+ set_target_deployment,
73
+ set_optimizer_backend,
74
+ set_optimizer_deployment,
75
+ )
76
+ from skillopt.utils import compute_score, skill_hash
77
+
78
+
79
+ # ── Skill-aware reflection: appendix flush ───────────────────────────────────
80
+
81
+ def _flush_skill_aware_appendix(
82
+ current_skill: str,
83
+ all_raw_patches: list,
84
+ step_rec: dict,
85
+ step_dir: str,
86
+ cfg: dict,
87
+ ) -> str:
88
+ """Append this step's EXECUTION_LAPSE notes into the protected appendix.
89
+
90
+ Returns the (possibly) updated skill. Must be called on BOTH the normal
91
+ update path and the skip branches: a lapse-only step yields no body
92
+ patches by design (analysts return ``edits: []`` carriers), so the skip
93
+ paths would otherwise silently drop every note of the step.
94
+ """
95
+ step_appendix_notes: list[str] = []
96
+ for rp in all_raw_patches:
97
+ if isinstance(rp, dict):
98
+ step_appendix_notes.extend(extract_appendix_notes_from_result(rp))
99
+ if not step_appendix_notes:
100
+ return current_skill
101
+
102
+ before_notes = extract_appendix_notes_from_skill(current_skill)
103
+ current_skill = append_to_appendix_field(
104
+ current_skill, step_appendix_notes,
105
+ )
106
+ after_notes = extract_appendix_notes_from_skill(current_skill)
107
+ n_added = len(after_notes) - len(before_notes)
108
+ step_rec["n_execution_lapse_notes"] = len(step_appendix_notes)
109
+ step_rec["n_appendix_notes_added"] = n_added
110
+ step_rec["n_appendix_notes_total"] = len(after_notes)
111
+ with open(os.path.join(step_dir, "appendix_notes.json"), "w") as f:
112
+ json.dump(
113
+ {
114
+ "step_notes": step_appendix_notes,
115
+ "appendix_after": after_notes,
116
+ },
117
+ f, indent=2, ensure_ascii=False,
118
+ )
119
+ print(
120
+ f" [skill-aware] +{n_added} appendix note(s) "
121
+ f"(total {len(after_notes)}) from {len(step_appendix_notes)} lapse signal(s)"
122
+ )
123
+ # Threshold-gated LLM consolidation (paper Eq.11): when the
124
+ # appendix grows past N notes, compact it with one optimizer
125
+ # call (dedupe / merge / shorten). 0 disables it. Any failure
126
+ # leaves the appendix unchanged.
127
+ consolidate_threshold = int(
128
+ cfg.get("skill_aware_consolidate_threshold", 0) or 0
129
+ )
130
+ if consolidate_threshold > 0 and len(after_notes) > consolidate_threshold:
131
+ compacted = consolidate_appendix_notes(
132
+ after_notes, chat_fn=chat_optimizer,
133
+ )
134
+ if compacted and len(compacted) < len(after_notes):
135
+ current_skill = append_to_appendix_field(
136
+ _strip_all_appendix_fields(current_skill), compacted,
137
+ )
138
+ step_rec["n_appendix_notes_consolidated"] = len(compacted)
139
+ step_rec["n_appendix_notes_total"] = len(compacted)
140
+ print(
141
+ f" [skill-aware] consolidated appendix "
142
+ f"{len(after_notes)} -> {len(compacted)} notes"
143
+ )
144
+ return current_skill
145
+
146
+
147
+ # ── Patch normalization ───────────────────────────────────────────────────────
148
+
149
+ def _normalise_patches(
150
+ raw_patches: list[dict | None],
151
+ update_mode: str = "patch",
152
+ ) -> tuple[list[dict], list[dict]]:
153
+ """Extract inner 'patch' sub-dict, split into failure/success lists.
154
+
155
+ Each element is expected to conform to :class:`~skillopt.types.RawPatch`.
156
+ """
157
+ mode = normalize_update_mode(update_mode)
158
+ failure: list[dict] = []
159
+ success: list[dict] = []
160
+ for p in raw_patches:
161
+ if not isinstance(p, dict):
162
+ continue
163
+ inner = p.get("patch", p)
164
+ if not isinstance(inner, dict):
165
+ continue
166
+ items = get_payload_items(inner, mode)
167
+ if not items:
168
+ continue
169
+ support = max(int(p.get("batch_size", 0) or 0), 1)
170
+ for item in items:
171
+ if isinstance(item, dict):
172
+ item.setdefault("source_type", p.get("source_type", "failure"))
173
+ item.setdefault("support_count", support)
174
+ if p.get("source_type", "failure") == "success":
175
+ success.append(inner)
176
+ else:
177
+ failure.append(inner)
178
+ return failure, success
179
+
180
+
181
+ def _normalise_longitudinal_pair_policy(policy: str | None) -> str:
182
+ raw = str(policy or "mixed").strip().lower()
183
+ aliases = {
184
+ "mixed": "mixed",
185
+ "default": "mixed",
186
+ "random": "mixed",
187
+ "all": "mixed",
188
+ "changed": "changed",
189
+ "change": "changed",
190
+ "delta": "changed",
191
+ "10_01": "changed",
192
+ "01_10": "changed",
193
+ "unchanged": "unchanged",
194
+ "stable": "unchanged",
195
+ "same": "unchanged",
196
+ "00_11": "unchanged",
197
+ }
198
+ if raw not in aliases:
199
+ raise ValueError(
200
+ "optimizer.longitudinal_pair_policy must be one of "
201
+ "mixed, changed, unchanged"
202
+ )
203
+ return aliases[raw]
204
+
205
+
206
+ def _normalise_lr_control_mode(mode: str | None) -> str:
207
+ raw = str(mode or "fixed").strip().lower()
208
+ aliases = {
209
+ "fixed": "fixed",
210
+ "manual": "fixed",
211
+ "scheduler": "fixed",
212
+ "scheduled": "fixed",
213
+ "autonomous": "autonomous",
214
+ "auto": "autonomous",
215
+ "optimizer": "autonomous",
216
+ "none": "none",
217
+ "off": "none",
218
+ "no_lr": "none",
219
+ }
220
+ if raw not in aliases:
221
+ raise ValueError("optimizer.lr_control_mode must be one of fixed, autonomous, none")
222
+ return aliases[raw]
223
+
224
+
225
+ def _filter_longitudinal_pairs(pairs: list[dict], policy: str) -> list[dict]:
226
+ if policy == "mixed":
227
+ return pairs
228
+ if policy == "changed":
229
+ keep = {"improved", "regressed"}
230
+ elif policy == "unchanged":
231
+ keep = {"persistent_fail", "stable_success"}
232
+ else:
233
+ raise ValueError(f"Unknown longitudinal pair policy: {policy}")
234
+ return [p for p in pairs if p.get("category") in keep]
235
+
236
+
237
+ def _pair_category_counts(pairs: list[dict]) -> dict[str, int]:
238
+ counts = {
239
+ "improved": 0,
240
+ "regressed": 0,
241
+ "persistent_fail": 0,
242
+ "stable_success": 0,
243
+ }
244
+ for pair in pairs:
245
+ cat = str(pair.get("category", ""))
246
+ counts[cat] = counts.get(cat, 0) + 1
247
+ return counts
248
+
249
+
250
+ def _safe_pair_id(value: str) -> str:
251
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value)).strip("_")
252
+ return safe[:80] or "item"
253
+
254
+
255
+ def _build_longitudinal_pairs(
256
+ *,
257
+ adapter: EnvAdapter,
258
+ dataloader,
259
+ prev_skill: str,
260
+ curr_skill: str,
261
+ initial_items: list[dict],
262
+ initial_prev_results: list[dict],
263
+ initial_curr_results: list[dict],
264
+ prev_rollout_dir: str,
265
+ curr_rollout_dir: str,
266
+ policy: str,
267
+ target_n: int,
268
+ seed: int,
269
+ out_root: str,
270
+ ) -> tuple[list[dict], list[dict]]:
271
+ """Build longitudinal pairs, optionally filtering by change category.
272
+
273
+ ``mixed`` preserves the legacy behavior exactly. ``changed`` keeps only
274
+ 10/01 pairs and attempts to top up to ``target_n`` by scanning the train
275
+ split once. ``unchanged`` keeps only 00/11 pairs and does not top up.
276
+ """
277
+ all_pairs = build_comparison_pairs(
278
+ initial_prev_results,
279
+ initial_curr_results,
280
+ initial_items,
281
+ prev_rollout_dir=prev_rollout_dir,
282
+ curr_rollout_dir=curr_rollout_dir,
283
+ )
284
+ selected_pairs = _filter_longitudinal_pairs(all_pairs, policy)
285
+ if policy != "changed" or len(selected_pairs) >= target_n or dataloader is None:
286
+ return selected_pairs, all_pairs
287
+
288
+ train_items = list(getattr(dataloader, "train_items", []) or [])
289
+ if not train_items:
290
+ return selected_pairs, all_pairs
291
+
292
+ seen_ids = {str(p.get("id", "")) for p in all_pairs}
293
+ rng = random.Random(seed)
294
+ candidates = list(train_items)
295
+ rng.shuffle(candidates)
296
+ candidates = [item for item in candidates if str(item.get("id", "")) not in seen_ids]
297
+
298
+ for idx, item in enumerate(candidates):
299
+ if len(selected_pairs) >= target_n:
300
+ break
301
+ item_id = _safe_pair_id(str(item.get("id", f"item_{idx}")))
302
+ batch = BatchSpec(
303
+ phase="train",
304
+ split="train",
305
+ seed=seed + idx + 1,
306
+ batch_size=1,
307
+ payload=[item],
308
+ )
309
+ env = adapter.build_env_from_batch(batch, out_root=out_root)
310
+ prev_dir = os.path.join(prev_rollout_dir, "topup", item_id)
311
+ curr_dir = os.path.join(curr_rollout_dir, "topup", item_id)
312
+ prev_results = adapter.rollout(env, prev_skill, prev_dir)
313
+ curr_results = adapter.rollout(env, curr_skill, curr_dir)
314
+ pair = build_comparison_pairs(
315
+ prev_results,
316
+ curr_results,
317
+ [item],
318
+ prev_rollout_dir=prev_dir,
319
+ curr_rollout_dir=curr_dir,
320
+ )
321
+ all_pairs.extend(pair)
322
+ selected_pairs.extend(_filter_longitudinal_pairs(pair, policy))
323
+
324
+ return selected_pairs[:target_n], all_pairs
325
+
326
+
327
+ # ── History / persistence helpers ─────────────────────────────────────────────
328
+
329
+ _SECRET_KEYS = {
330
+ "azure_api_key",
331
+ "api_key",
332
+ "openai_api_key",
333
+ }
334
+
335
+
336
+ def _redact_value(val: str) -> str:
337
+ if len(val) <= 8:
338
+ return "*" * len(val)
339
+ return f"{val[:4]}...{val[-4:]}"
340
+
341
+
342
+ def _redact_cfg(cfg: dict) -> dict:
343
+ redacted = dict(cfg)
344
+ for key in list(redacted):
345
+ if key.lower() in _SECRET_KEYS and redacted.get(key):
346
+ redacted[key] = _redact_value(str(redacted[key]))
347
+ return redacted
348
+
349
+ def _load_history(out_root: str) -> list[dict]:
350
+ path = os.path.join(out_root, "history.json")
351
+ if os.path.exists(path):
352
+ with open(path) as f:
353
+ return json.load(f)
354
+ return []
355
+
356
+
357
+ def _save_history(out_root: str, history: list[dict]) -> None:
358
+ path = os.path.join(out_root, "history.json")
359
+ with open(path, "w") as f:
360
+ json.dump(history, f, ensure_ascii=False, indent=2)
361
+
362
+
363
+ def _save_skill(out_root: str, step: int, content: str) -> None:
364
+ skills_dir = os.path.join(out_root, "skills")
365
+ os.makedirs(skills_dir, exist_ok=True)
366
+ with open(os.path.join(skills_dir, f"skill_v{step:04d}.md"), "w") as f:
367
+ f.write(content)
368
+
369
+
370
+ def _load_skill(out_root: str, step: int) -> str:
371
+ path = os.path.join(out_root, "skills", f"skill_v{step:04d}.md")
372
+ with open(path) as f:
373
+ return f.read()
374
+
375
+
376
+ def _load_meta_skill_content(out_root: str, epoch: int) -> str:
377
+ if epoch <= 0:
378
+ return ""
379
+ path = os.path.join(
380
+ out_root, "meta_skill", f"epoch_{epoch:02d}", "meta_skill_result.json",
381
+ )
382
+ if not os.path.exists(path):
383
+ return ""
384
+ try:
385
+ with open(path) as f:
386
+ result = json.load(f)
387
+ return str(result.get("meta_skill_content", "")).strip()
388
+ except Exception:
389
+ return ""
390
+
391
+
392
+ def _load_runtime_state(out_root: str) -> dict | None:
393
+ path = os.path.join(out_root, "runtime_state.json")
394
+ if not os.path.exists(path):
395
+ return None
396
+ try:
397
+ with open(path) as f:
398
+ state = json.load(f)
399
+ return state if isinstance(state, dict) else None
400
+ except Exception:
401
+ return None
402
+
403
+
404
+ def _save_runtime_state(out_root: str, state: dict) -> None:
405
+ path = os.path.join(out_root, "runtime_state.json")
406
+ with open(path, "w") as f:
407
+ json.dump(state, f, ensure_ascii=False, indent=2)
408
+
409
+
410
+ def _resolve_train_size(cfg: dict, dataloader) -> int:
411
+ configured = int(cfg.get("train_size", 0) or 0)
412
+ inferred: int | None = None
413
+
414
+ if dataloader is not None:
415
+ getter = getattr(dataloader, "get_train_size", None)
416
+ if callable(getter):
417
+ try:
418
+ value = getter()
419
+ except Exception:
420
+ value = None
421
+ if value is not None:
422
+ inferred = int(value)
423
+ elif hasattr(dataloader, "train_items"):
424
+ try:
425
+ inferred = len(getattr(dataloader, "train_items"))
426
+ except Exception:
427
+ inferred = None
428
+
429
+ if inferred is not None and inferred <= 0:
430
+ inferred = None
431
+
432
+ if configured > 0 and inferred is not None and configured != inferred:
433
+ raise ValueError(
434
+ f"Configured train_size={configured} does not match loaded train split "
435
+ f"size={inferred}. Fix the config or the dataset split."
436
+ )
437
+
438
+ train_size = configured if configured > 0 else inferred
439
+ if train_size is None or train_size <= 0:
440
+ raise ValueError(
441
+ "Unable to determine train_size automatically. "
442
+ "Provide train.train_size in the config for this environment."
443
+ )
444
+ return int(train_size)
445
+
446
+
447
+ def _compute_task_type_buckets(results: list[dict], task_types: list[str]) -> dict[str, dict]:
448
+ """Compute per-task-type success rates."""
449
+ buckets: dict[str, dict] = {}
450
+ for task in task_types + ["overall"]:
451
+ buckets[task] = {"total": 0, "hard": 0, "soft": 0.0}
452
+ for r in results:
453
+ tt = r.get("task_type", "other")
454
+ for key in [tt, "overall"]:
455
+ if key not in buckets:
456
+ buckets[key] = {"total": 0, "hard": 0, "soft": 0.0}
457
+ buckets[key]["total"] += 1
458
+ buckets[key]["hard"] += float(r.get("hard", 0))
459
+ buckets[key]["soft"] += float(r.get("soft", 0.0))
460
+ return buckets
461
+
462
+
463
+ def _format_rejection_buffer(buffer: list[dict]) -> str:
464
+ """**DEPRECATED** — kept for backward compat; use _format_step_buffer."""
465
+ return _format_step_buffer(buffer)
466
+
467
+
468
+ def _extract_failure_patterns(
469
+ rollout_results: list[dict],
470
+ step_dir: str,
471
+ ) -> list[dict]:
472
+ """Extract compact failure patterns from rollout results.
473
+
474
+ Uses analyst ``failure_summary`` from minibatch patches when available,
475
+ otherwise falls back to ``fail_reason`` prefix grouping.
476
+ """
477
+ failures = [r for r in rollout_results if not r.get("hard") or float(r.get("hard", 0)) < 1e-9]
478
+ if not failures:
479
+ return []
480
+
481
+ # Group by fail_reason prefix
482
+ groups: dict[str, list[dict]] = defaultdict(list)
483
+ for r in failures:
484
+ reason = r.get("fail_reason", "unknown")
485
+ prefix = reason.split(":")[0].strip() if ":" in reason else reason
486
+ groups[prefix].append(r)
487
+
488
+ # Try richer descriptions from analyst patches
489
+ analyst_descs: list[str] = []
490
+ patch_globs = [
491
+ os.path.join(step_dir, "patches", "minibatch_fail_*.json"),
492
+ os.path.join(step_dir, "batch_*", "patches", "minibatch_fail_*.json"),
493
+ ]
494
+ seen_patch_files: set[str] = set()
495
+ for pattern in patch_globs:
496
+ for fname in sorted(glob.glob(pattern)):
497
+ if fname in seen_patch_files:
498
+ continue
499
+ seen_patch_files.add(fname)
500
+ try:
501
+ with open(fname) as f:
502
+ patch = json.load(f)
503
+ for fs in patch.get("failure_summary", []):
504
+ ft = fs.get("failure_type", "")
505
+ sd = fs.get("description", "")
506
+ analyst_descs.append(f"{ft}: {sd}" if sd else ft)
507
+ except Exception:
508
+ pass
509
+
510
+ patterns = []
511
+ desc_iter = iter(analyst_descs)
512
+ for prefix, items in groups.items():
513
+ desc = next(desc_iter, None) or prefix
514
+ patterns.append({
515
+ "pattern": desc,
516
+ "count": len(items),
517
+ "task_ids": [str(r.get("id", "?")) for r in items],
518
+ })
519
+ return patterns
520
+
521
+
522
+ def _format_step_buffer(buffer: list[dict]) -> str:
523
+ """Format the unified step buffer into a single context block.
524
+
525
+ Each entry captures what happened at a previous step: failure patterns
526
+ observed during rollout, and — when the step was rejected — the specific
527
+ edits that were tried and the resulting score drop.
528
+
529
+ Returns empty string when *buffer* is empty.
530
+ """
531
+ if not buffer:
532
+ return ""
533
+
534
+ parts = [
535
+ "Below is a summary of previous steps in this epoch. "
536
+ "Use it to avoid repeating ineffective edits and to prioritise "
537
+ "failure patterns that remain unsolved.\n"
538
+ ]
539
+
540
+ for entry in buffer:
541
+ step = entry["step"]
542
+ action = entry["action"]
543
+ n_fail = entry.get("n_fail", 0)
544
+ n_total = entry.get("n_total", "?")
545
+
546
+ parts.append(f"### Step {step} — {action.upper()} ({n_fail}/{n_total} failed)")
547
+
548
+ # Failure patterns
549
+ for p in entry.get("failure_patterns", []):
550
+ ids = ", ".join(p["task_ids"])
551
+ parts.append(f' - "{p["pattern"]}" (×{p["count"]}, tasks: {ids})')
552
+
553
+ # Rejected edits (only present on reject)
554
+ rejected = entry.get("rejected_edits", [])
555
+ if rejected:
556
+ score_before = entry.get("score_before", "?")
557
+ score_after = entry.get("score_after", "?")
558
+ parts.append(
559
+ f" Rejected edits (score {score_before} → {score_after}):"
560
+ )
561
+ for i, e in enumerate(rejected, 1):
562
+ if e.get("op") is not None:
563
+ op = e.get("op", "?")
564
+ content = e.get("content", "")
565
+ target = e.get("target", "")
566
+ if target:
567
+ parts.append(f' {i}. [{op}] target="{target}" → "{content}"')
568
+ else:
569
+ parts.append(f' {i}. [{op}] "{content}"')
570
+ else:
571
+ kind = e.get("type", "?")
572
+ title = e.get("title", "")
573
+ instruction = e.get("instruction", "")
574
+ parts.append(f' {i}. [{kind}] "{title}" → "{instruction}"')
575
+
576
+ return "\n".join(parts)
577
+
578
+
579
+ # ── Trainer ──────────────────────────────────────────────────────────────────
580
+
581
+ class ReflACTTrainer:
582
+ """Main ReflACT training loop.
583
+
584
+ Parameters
585
+ ----------
586
+ cfg : dict
587
+ Configuration dictionary. See ``configs/alfworld_default.yaml``
588
+ for the full list of keys.
589
+ adapter : EnvAdapter
590
+ Environment adapter instance.
591
+ """
592
+
593
+ def __init__(self, cfg: dict, adapter: EnvAdapter) -> None:
594
+ self.cfg = cfg
595
+ self.adapter = adapter
596
+
597
+ def train(self) -> dict:
598
+ """Execute the full ReflACT training loop. Returns summary dict."""
599
+ cfg = self.cfg
600
+ adapter = self.adapter
601
+ out_root = cfg["out_root"]
602
+ os.makedirs(out_root, exist_ok=True)
603
+
604
+ # ── Adapter setup (one-time init) ────────────────────────────
605
+ adapter.setup(cfg)
606
+ dataloader = adapter.get_dataloader()
607
+
608
+ def _build_train_env(batch: BatchSpec):
609
+ env_manager = adapter.build_env_from_batch(batch, out_root=out_root)
610
+ return env_manager, batch.batch_size, batch.seed
611
+
612
+ def _build_eval_env(split: str, env_num: int, seed: int):
613
+ if dataloader is None:
614
+ env_manager = adapter.build_eval_env(
615
+ env_num=env_num,
616
+ split=split,
617
+ seed=seed,
618
+ out_root=out_root,
619
+ )
620
+ actual_n = len(env_manager) if hasattr(env_manager, "__len__") else env_num
621
+ return env_manager, actual_n
622
+
623
+ batch = dataloader.build_eval_batch(
624
+ env_num=env_num,
625
+ split=split,
626
+ seed=seed,
627
+ out_root=out_root,
628
+ )
629
+ env_manager = adapter.build_env_from_batch(batch, out_root=out_root)
630
+ return env_manager, batch.batch_size
631
+
632
+ # ── Configure models ─────────────────────────────────────────────
633
+ backend = cfg.get("model_backend", "azure_openai")
634
+ configure_azure_openai(
635
+ endpoint=(
636
+ cfg.get("azure_openai_endpoint")
637
+ or cfg.get("azure_endpoint")
638
+ or None
639
+ ),
640
+ api_version=(
641
+ cfg.get("azure_openai_api_version")
642
+ or cfg.get("azure_api_version")
643
+ or None
644
+ ),
645
+ api_key=(
646
+ cfg.get("azure_openai_api_key")
647
+ or cfg.get("azure_api_key")
648
+ or None
649
+ ),
650
+ auth_mode=cfg.get("azure_openai_auth_mode") or None,
651
+ ad_scope=cfg.get("azure_openai_ad_scope") or None,
652
+ managed_identity_client_id=cfg.get("azure_openai_managed_identity_client_id") or None,
653
+ optimizer_endpoint=cfg.get("optimizer_azure_openai_endpoint") or None,
654
+ optimizer_api_version=cfg.get("optimizer_azure_openai_api_version") or None,
655
+ optimizer_api_key=cfg.get("optimizer_azure_openai_api_key") or None,
656
+ optimizer_auth_mode=cfg.get("optimizer_azure_openai_auth_mode") or None,
657
+ optimizer_ad_scope=cfg.get("optimizer_azure_openai_ad_scope") or None,
658
+ optimizer_managed_identity_client_id=(
659
+ cfg.get("optimizer_azure_openai_managed_identity_client_id") or None
660
+ ),
661
+ target_endpoint=cfg.get("target_azure_openai_endpoint") or None,
662
+ target_api_version=cfg.get("target_azure_openai_api_version") or None,
663
+ target_api_key=cfg.get("target_azure_openai_api_key") or None,
664
+ target_auth_mode=cfg.get("target_azure_openai_auth_mode") or None,
665
+ target_ad_scope=cfg.get("target_azure_openai_ad_scope") or None,
666
+ target_managed_identity_client_id=(
667
+ cfg.get("target_azure_openai_managed_identity_client_id") or None
668
+ ),
669
+ )
670
+ optimizer_backend = cfg.get("optimizer_backend")
671
+ target_backend = cfg.get("target_backend")
672
+ if not optimizer_backend or not target_backend:
673
+ if backend in {"claude", "claude_chat"}:
674
+ optimizer_backend = optimizer_backend or "claude_chat"
675
+ target_backend = target_backend or "claude_chat"
676
+ elif backend in {"codex", "codex_exec"}:
677
+ optimizer_backend = optimizer_backend or "openai_chat"
678
+ target_backend = target_backend or "codex_exec"
679
+ elif backend == "claude_code_exec":
680
+ optimizer_backend = optimizer_backend or "openai_chat"
681
+ target_backend = target_backend or "claude_code_exec"
682
+ elif backend in {"qwen", "qwen_chat"}:
683
+ optimizer_backend = optimizer_backend or "openai_chat"
684
+ target_backend = target_backend or "qwen_chat"
685
+ else:
686
+ optimizer_backend = optimizer_backend or "openai_chat"
687
+ target_backend = target_backend or "openai_chat"
688
+ cfg["optimizer_backend"] = optimizer_backend
689
+ cfg["target_backend"] = target_backend
690
+ set_optimizer_backend(optimizer_backend)
691
+ set_target_backend(target_backend)
692
+ set_optimizer_deployment(cfg["optimizer_model"])
693
+ set_target_deployment(cfg["target_model"])
694
+ configure_codex_exec(
695
+ path=cfg.get("codex_exec_path", "codex"),
696
+ sandbox=cfg.get("codex_exec_sandbox", "workspace-write"),
697
+ profile=cfg.get("codex_exec_profile", ""),
698
+ full_auto=cfg.get("codex_exec_full_auto", False),
699
+ reasoning_effort=cfg.get("codex_exec_reasoning_effort", "none"),
700
+ use_sdk=cfg.get("codex_exec_use_sdk", None),
701
+ network_access=cfg.get("codex_exec_network_access", False),
702
+ web_search=cfg.get("codex_exec_web_search", False),
703
+ approval_policy=cfg.get("codex_exec_approval_policy", "never"),
704
+ )
705
+ configure_claude_code_exec(
706
+ path=cfg.get("claude_code_exec_path", "claude"),
707
+ profile=cfg.get("claude_code_exec_profile", ""),
708
+ use_sdk=cfg.get("claude_code_exec_use_sdk", None),
709
+ effort=cfg.get("claude_code_exec_effort", cfg.get("reasoning_effort", "medium")),
710
+ max_thinking_tokens=cfg.get("claude_code_exec_max_thinking_tokens", 16384),
711
+ )
712
+ configure_qwen_chat(
713
+ base_url=cfg.get("qwen_chat_base_url") or None,
714
+ api_key=cfg.get("qwen_chat_api_key") or None,
715
+ temperature=cfg.get("qwen_chat_temperature"),
716
+ timeout_seconds=cfg.get("qwen_chat_timeout_seconds"),
717
+ max_tokens=cfg.get("qwen_chat_max_tokens"),
718
+ enable_thinking=cfg.get("qwen_chat_enable_thinking"),
719
+ optimizer_base_url=cfg.get("optimizer_qwen_chat_base_url") or None,
720
+ optimizer_api_key=cfg.get("optimizer_qwen_chat_api_key") or None,
721
+ optimizer_temperature=cfg.get("optimizer_qwen_chat_temperature"),
722
+ optimizer_timeout_seconds=cfg.get("optimizer_qwen_chat_timeout_seconds"),
723
+ optimizer_max_tokens=cfg.get("optimizer_qwen_chat_max_tokens"),
724
+ optimizer_enable_thinking=cfg.get("optimizer_qwen_chat_enable_thinking"),
725
+ target_base_url=cfg.get("target_qwen_chat_base_url") or None,
726
+ target_api_key=cfg.get("target_qwen_chat_api_key") or None,
727
+ target_temperature=cfg.get("target_qwen_chat_temperature"),
728
+ target_timeout_seconds=cfg.get("target_qwen_chat_timeout_seconds"),
729
+ target_max_tokens=cfg.get("target_qwen_chat_max_tokens"),
730
+ target_enable_thinking=cfg.get("target_qwen_chat_enable_thinking"),
731
+ )
732
+ configure_minimax_chat(
733
+ base_url=cfg.get("minimax_base_url") or None,
734
+ api_key=cfg.get("minimax_api_key") or None,
735
+ temperature=cfg.get("minimax_temperature"),
736
+ max_tokens=cfg.get("minimax_max_tokens"),
737
+ enable_thinking=cfg.get("minimax_enable_thinking"),
738
+ )
739
+ minimax_model_cfg = cfg.get("minimax_model")
740
+ if minimax_model_cfg and cfg.get("target_backend") == "minimax_chat":
741
+ set_target_deployment(str(minimax_model_cfg))
742
+ os.environ["REFLACT_CODEX_TRACE_TO_OPTIMIZER"] = (
743
+ "1"
744
+ if target_backend == "codex_exec" and cfg.get("codex_trace_to_optimizer", False)
745
+ else "0"
746
+ )
747
+ reasoning = cfg.get("reasoning_effort", "") or None
748
+ set_reasoning_effort(reasoning)
749
+ print(
750
+ f" [model config] backend={backend} "
751
+ f"optimizer={cfg['optimizer_model']} ({optimizer_backend}) "
752
+ f"target={cfg['target_model']} ({target_backend}) "
753
+ f"reasoning={reasoning or 'off'}"
754
+ )
755
+
756
+ # ── Initialize Ray ───────────────────────────────────────────────
757
+ if adapter.requires_ray():
758
+ try:
759
+ import ray
760
+ except ImportError as e:
761
+ raise ImportError(
762
+ "This environment requires ray, but ray is not installed."
763
+ ) from e
764
+
765
+ if not ray.is_initialized():
766
+ ray.init(num_gpus=0)
767
+
768
+ # ── Load initial skill ───────────────────────────────────────────
769
+ skill_init_path = os.path.abspath(cfg["skill_init"])
770
+ if os.path.exists(skill_init_path):
771
+ with open(skill_init_path) as f:
772
+ skill_init = f.read()
773
+ print(f" [initial skill] {skill_init_path} ({len(skill_init)} chars)")
774
+ else:
775
+ skill_init = ""
776
+ print(" [initial skill] no initial skill file — starting from blank")
777
+
778
+ # ── Training parameters ──────────────────────────────────────────
779
+ batch_size = cfg["batch_size"]
780
+ num_epochs = cfg["num_epochs"]
781
+ accumulation = cfg["accumulation"]
782
+ seed = cfg["seed"]
783
+ merge_bs = cfg["merge_batch_size"]
784
+ max_analyst_rounds = int(cfg.get("max_analyst_rounds", 3) or 3)
785
+ update_mode = normalize_update_mode(cfg.get("skill_update_mode", "patch"))
786
+ lr_control_mode = _normalise_lr_control_mode(cfg.get("lr_control_mode", "fixed"))
787
+ if is_full_rewrite_minibatch_mode(update_mode):
788
+ lr_control_mode = "none"
789
+ longitudinal_pair_policy = _normalise_longitudinal_pair_policy(
790
+ cfg.get("longitudinal_pair_policy", "mixed")
791
+ )
792
+ rewrite_reasoning_effort = cfg.get("rewrite_reasoning_effort", "high")
793
+ if rewrite_reasoning_effort == "":
794
+ rewrite_reasoning_effort = None
795
+ rewrite_max_completion_tokens = int(cfg.get("rewrite_max_completion_tokens", 64000))
796
+ if batch_size <= 0:
797
+ raise ValueError(f"batch_size must be positive, got {batch_size}")
798
+ if accumulation <= 0:
799
+ raise ValueError(f"accumulation must be positive, got {accumulation}")
800
+
801
+ train_size = _resolve_train_size(cfg, dataloader)
802
+ steps_per_epoch = math.ceil(train_size / (batch_size * accumulation))
803
+ batches_per_epoch = steps_per_epoch * accumulation
804
+ total_steps = num_epochs * steps_per_epoch
805
+
806
+ # Persist resolved derived fields so config.json / summary.json match
807
+ # the actual runtime recipe.
808
+ cfg["train_size"] = train_size
809
+ cfg["steps_per_epoch"] = steps_per_epoch
810
+ cfg["batches_per_epoch"] = batches_per_epoch
811
+ cfg["samples_per_epoch"] = train_size
812
+ cfg["skill_update_mode"] = update_mode
813
+ cfg["lr_control_mode"] = lr_control_mode
814
+
815
+ # Save config after deriving runtime values.
816
+ with open(os.path.join(out_root, "config.json"), "w") as f:
817
+ json.dump(_redact_cfg(cfg), f, indent=2, ensure_ascii=False)
818
+
819
+ train_pool_size = train_size
820
+
821
+ scheduler = build_scheduler(
822
+ mode=cfg.get("lr_scheduler", "constant"),
823
+ max_lr=cfg["edit_budget"],
824
+ min_lr=cfg.get("min_edit_budget", 2),
825
+ total_steps=total_steps,
826
+ )
827
+
828
+ # Fixed training pool: base seeds (each seed = one deterministic batch)
829
+ if dataloader is not None:
830
+ base_seeds = dataloader.make_base_seeds(
831
+ steps_per_epoch=steps_per_epoch,
832
+ accumulation=accumulation,
833
+ seed=seed,
834
+ )
835
+ else:
836
+ base_seeds = [seed + i + 1 for i in range(batches_per_epoch)]
837
+
838
+ print(f"\n [config] epochs={num_epochs} steps/epoch={steps_per_epoch} "
839
+ f"(auto) accum={accumulation} batch_size={batch_size}")
840
+ print(f" [config] train_size={train_size}")
841
+ print(f" [config] batches/epoch={batches_per_epoch} "
842
+ f"total_steps={total_steps} "
843
+ f"games/epoch={train_pool_size}")
844
+ print(f" [config] lr_scheduler={cfg.get('lr_scheduler', 'constant')} "
845
+ f"edit_budget={cfg['edit_budget']} "
846
+ f"min_edit_budget={cfg.get('min_edit_budget', 2)}")
847
+ print(f" [config] skill_update_mode={update_mode} "
848
+ f"lr_control_mode={lr_control_mode} "
849
+ f"rewrite_reasoning_effort={rewrite_reasoning_effort or 'off'} "
850
+ f"rewrite_max_completion_tokens={rewrite_max_completion_tokens} "
851
+ f"max_analyst_rounds={max_analyst_rounds}")
852
+ print(f" [config] longitudinal_pair_policy={longitudinal_pair_policy}")
853
+ print(f" [config] base_seeds={base_seeds}")
854
+
855
+ # ── Resume check ─────────────────────────────────────────────────
856
+ history = _load_history(out_root)
857
+ runtime_state = _load_runtime_state(out_root)
858
+ if runtime_state:
859
+ last_step = int(runtime_state.get("last_completed_step", 0) or 0)
860
+ current_skill_path = runtime_state.get("current_skill_path") or os.path.join(
861
+ out_root, "skills", f"skill_v{last_step:04d}.md",
862
+ )
863
+ with open(current_skill_path) as f:
864
+ current_skill = f.read()
865
+ best_skill_path = runtime_state.get("best_skill_path") or os.path.join(
866
+ out_root, "best_skill.md",
867
+ )
868
+ if os.path.exists(best_skill_path):
869
+ with open(best_skill_path) as f:
870
+ best_skill = f.read()
871
+ else:
872
+ best_skill = current_skill
873
+ current_score = float(runtime_state.get("current_score", -1.0) or -1.0)
874
+ best_score = float(runtime_state.get("best_score", current_score) or current_score)
875
+ best_step = runtime_state.get("best_step", last_step)
876
+ current_origin = str(
877
+ runtime_state.get("current_origin")
878
+ or (f"step_{last_step:04d}" if last_step > 0 else "initial_skill")
879
+ )
880
+ best_origin = str(runtime_state.get("best_origin") or current_origin)
881
+ resume_from = last_step + 1
882
+ scheduler.load_state_dict({"current_step": last_step})
883
+ print(
884
+ f" [resume] from step {resume_from} "
885
+ f"current={current_score:.4f} best={best_score:.4f} "
886
+ f"(origin={current_origin})"
887
+ )
888
+ elif history:
889
+ last_step = history[-1]["step"]
890
+ current_skill = _load_skill(out_root, last_step)
891
+ best_rec = max(history, key=lambda h: h.get("best_score", 0.0))
892
+ best_score = best_rec["best_score"]
893
+ best_step = best_rec["best_step"]
894
+ best_skill_path = os.path.join(out_root, "best_skill.md")
895
+ if os.path.exists(best_skill_path):
896
+ with open(best_skill_path) as f:
897
+ best_skill = f.read()
898
+ else:
899
+ best_skill = _load_skill(out_root, best_step)
900
+ current_score = history[-1].get("current_score", best_score)
901
+ current_origin = f"step_{last_step:04d}"
902
+ best_origin = f"step_{int(best_step):04d}" if isinstance(best_step, int) else str(best_step)
903
+ resume_from = last_step + 1
904
+ scheduler.load_state_dict({"current_step": last_step})
905
+ print(
906
+ f" [resume] from step {resume_from} "
907
+ f"current={current_score:.4f} best={best_score:.4f}"
908
+ )
909
+ else:
910
+ current_skill = skill_init
911
+ best_skill = skill_init
912
+ best_score = -1.0
913
+ current_score = -1.0
914
+ best_step = 0
915
+ current_origin = "initial_skill"
916
+ best_origin = "initial_skill"
917
+ resume_from = 1
918
+
919
+ _save_skill(out_root, 0, skill_init)
920
+
921
+ use_skill_aware = cfg.get("use_skill_aware_reflection", False)
922
+ # Publish the toggle process-wide so run_minibatch_reflect resolves it
923
+ # from config for EVERY env adapter — no per-benchmark wiring needed.
924
+ configure_skill_aware_reflection(
925
+ use_skill_aware,
926
+ cfg.get("skill_aware_appendix_source", "both"),
927
+ )
928
+ if use_skill_aware:
929
+ current_skill = inject_empty_appendix_field(current_skill)
930
+
931
+ def _persist_runtime_state(last_completed_step: int) -> None:
932
+ _save_runtime_state(
933
+ out_root,
934
+ {
935
+ "last_completed_step": last_completed_step,
936
+ "current_skill_path": os.path.join(
937
+ out_root, "skills", f"skill_v{last_completed_step:04d}.md",
938
+ ),
939
+ "current_score": current_score,
940
+ "current_origin": current_origin,
941
+ "best_skill_path": os.path.join(out_root, "best_skill.md"),
942
+ "best_score": best_score,
943
+ "best_step": best_step,
944
+ "best_origin": best_origin,
945
+ },
946
+ )
947
+
948
+ # ── Selection cache ──────────────────────────────────────────────
949
+ sel_cache: dict[str, tuple[float, float]] = {}
950
+ for rec in history:
951
+ sh = rec.get("candidate_hash", "")
952
+ if sh and rec.get("selection_hard") is not None:
953
+ sel_cache[sh] = (rec["selection_hard"], rec["selection_soft"])
954
+
955
+ # ── Baseline evaluation on selection set ─────────────────────────
956
+ # `use_gate=False` keeps validation running (selection rollout +
957
+ # scoring are unconditional below) but force-accepts every candidate
958
+ # instead of gating it; final skill is chosen manually afterwards.
959
+ use_gate = cfg.get("use_gate", True) is not False
960
+ gate_metric = str(cfg.get("gate_metric", "hard")).strip().lower()
961
+ if gate_metric not in {"hard", "soft", "mixed"}:
962
+ raise ValueError(
963
+ f"evaluation.gate_metric must be 'hard' | 'soft' | 'mixed', "
964
+ f"got {gate_metric!r}"
965
+ )
966
+ gate_mixed_weight = float(cfg.get("gate_mixed_weight", 0.5))
967
+ if not 0.0 <= gate_mixed_weight <= 1.0:
968
+ raise ValueError(
969
+ f"evaluation.gate_mixed_weight must be in [0, 1], "
970
+ f"got {gate_mixed_weight}"
971
+ )
972
+ print(
973
+ f" [gate] metric={gate_metric}"
974
+ + (
975
+ f" mixed_weight={gate_mixed_weight}"
976
+ if gate_metric == "mixed"
977
+ else ""
978
+ )
979
+ + ("" if use_gate
980
+ else " (DISABLED → validation runs, candidates force-accepted)")
981
+ )
982
+ slow_gate_with_selection = bool(
983
+ cfg.get("slow_update_gate_with_selection", False)
984
+ )
985
+ print(
986
+ " [slow update] acceptance="
987
+ + ("gated (selection-set validation)"
988
+ if slow_gate_with_selection
989
+ else "force-accept (unconditional)")
990
+ )
991
+ if current_score < 0:
992
+ print(f"\n{'='*60}")
993
+ print(" BASELINE — evaluate initial skill on Selection set (valid_seen)")
994
+ print(f"{'='*60}")
995
+ sel_env, sel_n = _build_eval_env(
996
+ split="valid_seen",
997
+ env_num=cfg["sel_env_num"],
998
+ seed=seed,
999
+ )
1000
+ print(f" Selection items: {sel_n}")
1001
+ baseline_dir = os.path.join(out_root, "selection_eval_baseline")
1002
+ baseline_results = adapter.rollout(sel_env, skill_init, baseline_dir)
1003
+ baseline_hard, baseline_soft = compute_score(baseline_results)
1004
+ current_score = select_gate_score(
1005
+ baseline_hard, baseline_soft, gate_metric, gate_mixed_weight,
1006
+ )
1007
+ best_score = current_score
1008
+ sh = skill_hash(skill_init)
1009
+ sel_cache[sh] = (baseline_hard, baseline_soft)
1010
+ current_origin = "initial_skill"
1011
+ best_origin = "initial_skill"
1012
+ _persist_runtime_state(0)
1013
+ print(
1014
+ f" [baseline result] selection hard={baseline_hard:.4f} "
1015
+ f"soft={baseline_soft:.4f} "
1016
+ f"gate[{gate_metric}]={current_score:.4f}"
1017
+ )
1018
+
1019
+ # ── Training loop ────────────────────────────────────────────────
1020
+ t_loop_start = time.time()
1021
+
1022
+ if resume_from > total_steps:
1023
+ print(f"\n [skip] all {total_steps} steps complete — jumping to evaluation")
1024
+
1025
+ global_step = 0
1026
+ for epoch in range(1, num_epochs + 1):
1027
+ if dataloader is not None:
1028
+ epoch_batches = dataloader.plan_train_epoch(
1029
+ epoch=epoch,
1030
+ steps_per_epoch=steps_per_epoch,
1031
+ accumulation=accumulation,
1032
+ batch_size=batch_size,
1033
+ seed=seed,
1034
+ out_root=out_root,
1035
+ )
1036
+ shuffled_seeds = [batch.seed for batch in epoch_batches]
1037
+ else:
1038
+ epoch_batches = []
1039
+ epoch_rng = random.Random(seed + epoch * 1000)
1040
+ shuffled_seeds = base_seeds.copy()
1041
+ epoch_rng.shuffle(shuffled_seeds)
1042
+
1043
+ # Step buffer: accumulates per-step context (failure patterns +
1044
+ # rejected edits) within this epoch so optimizers see full history.
1045
+ step_buffer: list[dict] = []
1046
+ active_meta_skill = (
1047
+ _load_meta_skill_content(out_root, epoch - 1)
1048
+ if cfg.get("use_meta_skill", False)
1049
+ else ""
1050
+ )
1051
+
1052
+ print(
1053
+ f"\n [EPOCH {epoch}/{num_epochs}] "
1054
+ f"shuffled_seeds={shuffled_seeds}"
1055
+ )
1056
+ if active_meta_skill:
1057
+ print(
1058
+ f" [meta skill] loaded from epoch {epoch - 1} "
1059
+ f"({len(active_meta_skill)} chars)"
1060
+ )
1061
+
1062
+ for step_in_epoch in range(steps_per_epoch):
1063
+ global_step += 1
1064
+ if global_step < resume_from:
1065
+ continue
1066
+
1067
+ step_t0 = time.time()
1068
+ step_dir = os.path.join(out_root, "steps", f"step_{global_step:04d}")
1069
+ os.makedirs(step_dir, exist_ok=True)
1070
+
1071
+ tokens_before = get_token_summary()
1072
+
1073
+ print(
1074
+ f"\n [STEP {global_step}/{total_steps}] "
1075
+ f"epoch={epoch} step_in_epoch={step_in_epoch} "
1076
+ f"{'='*30}"
1077
+ )
1078
+
1079
+ step_rec: dict = {
1080
+ "step": global_step,
1081
+ "epoch": epoch,
1082
+ "step_in_epoch": step_in_epoch,
1083
+ "timing": {},
1084
+ "tokens": {},
1085
+ }
1086
+
1087
+ # ── Accumulation: Rollout + Reflect ──────────────────────
1088
+ all_failure_patches: list[dict] = []
1089
+ all_success_patches: list[dict] = []
1090
+ all_raw_patches: list[dict | None] = []
1091
+ all_rollout_results: list[dict] = []
1092
+ accum_rollout_stats: list[dict] = []
1093
+ total_rollout_time = 0.0
1094
+ total_reflect_time = 0.0
1095
+
1096
+ for a in range(accumulation):
1097
+ batch_idx = step_in_epoch * accumulation + a
1098
+ if dataloader is not None:
1099
+ batch_spec = epoch_batches[batch_idx]
1100
+ train_env, train_n, batch_seed = _build_train_env(batch_spec)
1101
+ else:
1102
+ batch_seed = shuffled_seeds[batch_idx]
1103
+ train_env = adapter.build_train_env(
1104
+ batch_size=batch_size,
1105
+ seed=batch_seed,
1106
+ out_root=out_root,
1107
+ )
1108
+ train_n = len(train_env) if hasattr(train_env, "__len__") else batch_size
1109
+
1110
+ # Directory routing
1111
+ if accumulation > 1:
1112
+ batch_dir = os.path.join(step_dir, f"batch_{a}")
1113
+ else:
1114
+ batch_dir = step_dir
1115
+
1116
+ rollout_dir = os.path.join(batch_dir, "rollout")
1117
+ patches_dir = os.path.join(batch_dir, "patches")
1118
+
1119
+ # ① ROLLOUT ────────────────────────────────────────────
1120
+ t_phase = time.time()
1121
+ print(f" [1/6 ROLLOUT] train items={train_n} (from pool, batch_seed={batch_seed})")
1122
+ rollout_results = adapter.rollout(
1123
+ train_env, current_skill, rollout_dir,
1124
+ use_eval_feedback=True,
1125
+ )
1126
+ r_hard, r_soft = compute_score(rollout_results)
1127
+ total_rollout_time += time.time() - t_phase
1128
+ all_rollout_results.extend(rollout_results)
1129
+ print(f" [1/6 done] hard={r_hard:.4f} soft={r_soft:.4f}")
1130
+
1131
+ # ② REFLECT ────────────────────────────────────────────
1132
+ t_phase = time.time()
1133
+ pred_dir = os.path.join(rollout_dir, "predictions")
1134
+
1135
+ # Build step context from buffer
1136
+ step_buffer_context = _format_step_buffer(step_buffer)
1137
+
1138
+ raw_patches = adapter.reflect(
1139
+ rollout_results, current_skill, batch_dir,
1140
+ prediction_dir=pred_dir, patches_dir=patches_dir,
1141
+ random_seed=batch_seed,
1142
+ step_buffer_context=step_buffer_context,
1143
+ meta_skill_context=active_meta_skill,
1144
+ )
1145
+ failure_patches, success_patches = _normalise_patches(
1146
+ raw_patches,
1147
+ update_mode=update_mode,
1148
+ )
1149
+ all_failure_patches.extend(failure_patches)
1150
+ all_success_patches.extend(success_patches)
1151
+ all_raw_patches.extend(raw_patches)
1152
+ total_reflect_time += time.time() - t_phase
1153
+
1154
+ print(
1155
+ f" [2/6 done] failure_patches={len(failure_patches)} "
1156
+ f"success_patches={len(success_patches)}"
1157
+ )
1158
+
1159
+ # Track per-batch stats
1160
+ accum_rollout_stats.append({
1161
+ "batch_idx": a,
1162
+ "batch_seed": batch_seed,
1163
+ "n_envs": len(rollout_results),
1164
+ "hard": r_hard,
1165
+ "soft": r_soft,
1166
+ "n_failure_patches": len(failure_patches),
1167
+ "n_success_patches": len(success_patches),
1168
+ })
1169
+
1170
+ # ── End of accumulation loop ─────────────────────────────
1171
+
1172
+ # Aggregate rollout stats across batches
1173
+ total_n = sum(b["n_envs"] for b in accum_rollout_stats)
1174
+ agg_hard = sum(b["hard"] * b["n_envs"] for b in accum_rollout_stats) / max(total_n, 1)
1175
+ agg_soft = sum(b["soft"] * b["n_envs"] for b in accum_rollout_stats) / max(total_n, 1)
1176
+
1177
+ step_rec["rollout_hard"] = round(agg_hard, 6)
1178
+ step_rec["rollout_soft"] = round(agg_soft, 6)
1179
+ step_rec["rollout_n"] = total_n
1180
+ step_rec["accumulation_batches"] = accum_rollout_stats
1181
+ step_rec["timing"]["rollout_s"] = round(total_rollout_time, 1)
1182
+ step_rec["timing"]["reflect_s"] = round(total_reflect_time, 1)
1183
+
1184
+ n_total_patches = len(all_failure_patches) + len(all_success_patches)
1185
+ step_rec["n_patches"] = n_total_patches
1186
+ step_rec["n_failure_patches"] = len(all_failure_patches)
1187
+ step_rec["n_success_patches"] = len(all_success_patches)
1188
+
1189
+ if accumulation > 1:
1190
+ print(
1191
+ f" [accum done] total: failure={len(all_failure_patches)} "
1192
+ f"success={len(all_success_patches)} "
1193
+ f"from {accumulation} batches"
1194
+ )
1195
+
1196
+ # ── No patches? Skip ─────────────────────────────────────
1197
+ if not all_failure_patches and not all_success_patches:
1198
+ # Skill-aware: a lapse-only step has no body patches but
1199
+ # may still carry appendix notes — flush them BEFORE
1200
+ # skipping, or they would be silently dropped.
1201
+ if use_skill_aware:
1202
+ current_skill = _flush_skill_aware_appendix(
1203
+ current_skill, all_raw_patches, step_rec, step_dir, cfg,
1204
+ )
1205
+ step_rec["action"] = "skip_no_patches"
1206
+ step_rec["current_score"] = current_score
1207
+ step_rec["best_score"] = best_score
1208
+ step_rec["best_step"] = best_step
1209
+ step_rec["skill_len"] = len(current_skill)
1210
+ step_rec["wall_time_s"] = round(time.time() - step_t0, 1)
1211
+ history.append(step_rec)
1212
+ _save_history(out_root, history)
1213
+ _save_skill(out_root, global_step, current_skill)
1214
+ _persist_runtime_state(global_step)
1215
+ with open(os.path.join(step_dir, "step_record.json"), "w") as f:
1216
+ json.dump(step_rec, f, indent=2, ensure_ascii=False)
1217
+ print(" [skip] no usable patches — skill unchanged")
1218
+ continue
1219
+
1220
+ # ③ AGGREGATE ──────────────────────────────────────────────
1221
+ t_phase = time.time()
1222
+ merged_patch = merge_patches(
1223
+ current_skill, all_failure_patches, all_success_patches,
1224
+ batch_size=merge_bs, verbose=True,
1225
+ workers=cfg["analyst_workers"],
1226
+ update_mode=update_mode,
1227
+ meta_skill_context=active_meta_skill,
1228
+ )
1229
+ with open(os.path.join(step_dir, "merged_patch.json"), "w") as f:
1230
+ json.dump(merged_patch, f, ensure_ascii=False, indent=2)
1231
+
1232
+ merged_items = get_payload_items(merged_patch, update_mode)
1233
+ n_edits_merged = len(merged_items)
1234
+ step_rec["n_edits_merged"] = n_edits_merged
1235
+ step_rec["timing"]["aggregate_s"] = round(time.time() - t_phase, 1)
1236
+ print(f" [3/6 done] merged {n_edits_merged} {payload_label(update_mode)}")
1237
+
1238
+ # ④ SELECT ─────────────────────────────────────────────────
1239
+ t_phase = time.time()
1240
+ lr_decision = None
1241
+ if is_full_rewrite_minibatch_mode(update_mode):
1242
+ edit_budget = None
1243
+ ranked_patch = merged_patch
1244
+ ranked_items = merged_items
1245
+ n_edits_ranked = len(ranked_items)
1246
+ step_rec["n_edits_ranked"] = n_edits_ranked
1247
+ step_rec["edit_budget"] = None
1248
+ step_rec["lr_control_mode"] = "none"
1249
+ with open(os.path.join(step_dir, "ranked_edits.json"), "w") as f:
1250
+ json.dump(ranked_patch, f, ensure_ascii=False, indent=2)
1251
+ else:
1252
+ if lr_control_mode == "autonomous":
1253
+ lr_decision = decide_autonomous_learning_rate(
1254
+ skill_content=current_skill,
1255
+ merged_patch=merged_patch,
1256
+ update_mode=update_mode,
1257
+ rollout_hard=agg_hard,
1258
+ rollout_soft=agg_soft,
1259
+ rollout_n=total_n,
1260
+ step_buffer_context=step_buffer_context,
1261
+ meta_skill_context=active_meta_skill,
1262
+ )
1263
+ edit_budget = int(lr_decision["learning_rate"])
1264
+ with open(os.path.join(step_dir, "lr_decision.json"), "w") as f:
1265
+ json.dump(lr_decision, f, ensure_ascii=False, indent=2)
1266
+ with open(os.path.join(out_root, "lr_history.jsonl"), "a") as f:
1267
+ f.write(json.dumps({
1268
+ "step": global_step,
1269
+ "epoch": epoch,
1270
+ **lr_decision,
1271
+ }, ensure_ascii=False) + "\n")
1272
+ else:
1273
+ edit_budget = scheduler.step()
1274
+ ranked_patch = rank_and_select(
1275
+ current_skill, merged_patch,
1276
+ max_edits=edit_budget,
1277
+ update_mode=update_mode,
1278
+ meta_skill_context=active_meta_skill,
1279
+ )
1280
+ with open(os.path.join(step_dir, "ranked_edits.json"), "w") as f:
1281
+ json.dump(ranked_patch, f, ensure_ascii=False, indent=2)
1282
+
1283
+ ranked_items = get_payload_items(ranked_patch, update_mode)
1284
+ n_edits_ranked = len(ranked_items)
1285
+ step_rec["n_edits_ranked"] = n_edits_ranked
1286
+ step_rec["edit_budget"] = edit_budget
1287
+ step_rec["lr_control_mode"] = lr_control_mode
1288
+ if lr_decision is not None:
1289
+ step_rec["lr_decision"] = lr_decision
1290
+ step_rec["timing"]["select_s"] = round(time.time() - t_phase, 1)
1291
+
1292
+ support_counts = [
1293
+ item.get("support_count", 0) for item in ranked_items if isinstance(item, dict)
1294
+ ]
1295
+ step_rec["support_counts"] = support_counts
1296
+ if is_full_rewrite_minibatch_mode(update_mode):
1297
+ print(
1298
+ f" [4/6 SELECT] skipped LR/select; "
1299
+ f"using {n_edits_ranked} merged {payload_label(update_mode)}"
1300
+ )
1301
+ else:
1302
+ print(
1303
+ f" [4/6 SELECT] "
1304
+ f"{n_edits_merged} -> {n_edits_ranked} {payload_label(update_mode)} "
1305
+ f"(budget={edit_budget}, lr_control={lr_control_mode})"
1306
+ )
1307
+
1308
+ # ⑤ UPDATE ─────────────────────────────────────────────────
1309
+ t_phase = time.time()
1310
+ rewrite_result = None
1311
+ if update_mode == "rewrite_from_suggestions":
1312
+ rewrite_result = rewrite_skill_from_suggestions(
1313
+ current_skill,
1314
+ ranked_patch,
1315
+ step_buffer_context=step_buffer_context,
1316
+ env=cfg.get("env"),
1317
+ reasoning_effort=rewrite_reasoning_effort,
1318
+ max_completion_tokens=rewrite_max_completion_tokens,
1319
+ )
1320
+ if rewrite_result and rewrite_result.get("new_skill"):
1321
+ candidate_skill = rewrite_result["new_skill"]
1322
+ apply_report = []
1323
+ with open(os.path.join(step_dir, "rewrite_result.json"), "w") as f:
1324
+ json.dump(rewrite_result, f, ensure_ascii=False, indent=2)
1325
+ else:
1326
+ candidate_skill = current_skill
1327
+ apply_report = []
1328
+ elif is_full_rewrite_minibatch_mode(update_mode):
1329
+ skill_candidates = get_payload_items(ranked_patch, update_mode)
1330
+ selected_candidate = next(
1331
+ (
1332
+ item for item in skill_candidates
1333
+ if isinstance(item, dict) and str(item.get("new_skill", "")).strip()
1334
+ ),
1335
+ None,
1336
+ )
1337
+ if selected_candidate:
1338
+ candidate_skill = str(selected_candidate["new_skill"]).rstrip() + "\n"
1339
+ apply_report = []
1340
+ rewrite_result = {
1341
+ "reasoning": ranked_patch.get("reasoning", ""),
1342
+ "change_summary": selected_candidate.get("change_summary", []),
1343
+ "title": selected_candidate.get("title", ""),
1344
+ "source_type": selected_candidate.get("source_type", ""),
1345
+ }
1346
+ with open(os.path.join(step_dir, "full_rewrite_result.json"), "w") as f:
1347
+ json.dump(
1348
+ {
1349
+ "selected_candidate": selected_candidate,
1350
+ "merged_patch": ranked_patch,
1351
+ },
1352
+ f,
1353
+ ensure_ascii=False,
1354
+ indent=2,
1355
+ )
1356
+ else:
1357
+ candidate_skill = current_skill
1358
+ apply_report = []
1359
+ else:
1360
+ candidate_skill, apply_report = apply_patch_with_report(current_skill, ranked_patch)
1361
+ with open(os.path.join(step_dir, "candidate_skill.md"), "w") as f:
1362
+ f.write(candidate_skill)
1363
+ if apply_report:
1364
+ with open(os.path.join(step_dir, "edit_apply_report.json"), "w") as f:
1365
+ json.dump(apply_report, f, indent=2, ensure_ascii=False)
1366
+
1367
+ cand_hash = skill_hash(candidate_skill)
1368
+ step_rec["candidate_hash"] = cand_hash
1369
+ step_rec["candidate_skill_len"] = len(candidate_skill)
1370
+ if rewrite_result:
1371
+ step_rec["rewrite_change_summary"] = rewrite_result.get("change_summary", [])
1372
+ if apply_report:
1373
+ step_rec["edit_apply_summary"] = {
1374
+ "total": len(apply_report),
1375
+ "applied": sum(
1376
+ 1 for row in apply_report if str(row.get("status", "")).startswith("applied")
1377
+ ),
1378
+ "skipped": sum(
1379
+ 1 for row in apply_report if str(row.get("status", "")).startswith("skipped")
1380
+ ),
1381
+ "errors": sum(
1382
+ 1 for row in apply_report if row.get("status") == "error"
1383
+ ),
1384
+ }
1385
+ step_rec["timing"]["update_s"] = round(time.time() - t_phase, 1)
1386
+ if (
1387
+ update_mode == "rewrite_from_suggestions"
1388
+ and rewrite_result is None
1389
+ ) or (
1390
+ is_full_rewrite_minibatch_mode(update_mode)
1391
+ and rewrite_result is None
1392
+ ):
1393
+ # Skill-aware: flush appendix notes before skipping (see
1394
+ # the skip_no_patches branch above).
1395
+ if use_skill_aware:
1396
+ current_skill = _flush_skill_aware_appendix(
1397
+ current_skill, all_raw_patches, step_rec, step_dir, cfg,
1398
+ )
1399
+ step_rec["action"] = "skip_no_rewrite"
1400
+ step_rec["current_score"] = current_score
1401
+ step_rec["best_score"] = best_score
1402
+ step_rec["best_step"] = best_step
1403
+ step_rec["skill_len"] = len(current_skill)
1404
+ step_rec["wall_time_s"] = round(time.time() - step_t0, 1)
1405
+ history.append(step_rec)
1406
+ _save_history(out_root, history)
1407
+ _save_skill(out_root, global_step, current_skill)
1408
+ _persist_runtime_state(global_step)
1409
+ with open(os.path.join(step_dir, "step_record.json"), "w") as f:
1410
+ json.dump(step_rec, f, indent=2, ensure_ascii=False)
1411
+ print(" [skip] no usable rewrite generated — skill unchanged")
1412
+ continue
1413
+ print(
1414
+ f" [5/6 UPDATE] "
1415
+ f"skill_len {len(current_skill)} -> {len(candidate_skill)}"
1416
+ )
1417
+
1418
+ # ⑥ EVALUATE ───────────────────────────────────────────────
1419
+ t_phase = time.time()
1420
+ if cand_hash in sel_cache:
1421
+ cand_hard, cand_soft = sel_cache[cand_hash]
1422
+ print(
1423
+ f" [6/6 EVALUATE] "
1424
+ f"cache hit {cand_hash}: hard={cand_hard:.4f}"
1425
+ )
1426
+ else:
1427
+ sel_env, sel_n = _build_eval_env(
1428
+ split="valid_seen",
1429
+ env_num=cfg["sel_env_num"],
1430
+ seed=seed,
1431
+ )
1432
+ print(f" [6/6 EVALUATE] selection items={sel_n}")
1433
+ sel_eval_dir = os.path.join(step_dir, "selection_eval")
1434
+ sel_results = adapter.rollout(sel_env, candidate_skill, sel_eval_dir)
1435
+ cand_hard, cand_soft = compute_score(sel_results)
1436
+ sel_cache[cand_hash] = (cand_hard, cand_soft)
1437
+
1438
+ step_rec["selection_hard"] = cand_hard
1439
+ step_rec["selection_soft"] = cand_soft
1440
+
1441
+ gate = evaluate_gate(
1442
+ candidate_skill=candidate_skill,
1443
+ cand_hard=cand_hard,
1444
+ current_skill=current_skill,
1445
+ current_score=current_score,
1446
+ best_skill=best_skill,
1447
+ best_score=best_score,
1448
+ best_step=best_step,
1449
+ global_step=global_step,
1450
+ cand_soft=cand_soft,
1451
+ metric=gate_metric,
1452
+ mixed_weight=gate_mixed_weight,
1453
+ ) if use_gate else None
1454
+ cand_gate_score = select_gate_score(
1455
+ cand_hard, cand_soft, gate_metric, gate_mixed_weight,
1456
+ )
1457
+ if not use_gate:
1458
+ # Validation ran (scores recorded above) but the gate is
1459
+ # disabled: force-accept the candidate as the new current
1460
+ # skill. Best-so-far is still tracked for convenience; the
1461
+ # final skill is selected manually from the trajectory.
1462
+ if cand_gate_score > best_score:
1463
+ fa_best_skill = candidate_skill
1464
+ fa_best_score = cand_gate_score
1465
+ fa_best_step = global_step
1466
+ else:
1467
+ fa_best_skill = best_skill
1468
+ fa_best_score = best_score
1469
+ fa_best_step = best_step
1470
+ gate = GateResult(
1471
+ action="force_accept",
1472
+ current_skill=candidate_skill,
1473
+ current_score=cand_gate_score,
1474
+ best_skill=fa_best_skill,
1475
+ best_score=fa_best_score,
1476
+ best_step=fa_best_step,
1477
+ )
1478
+ step_rec["gate_metric"] = gate_metric
1479
+ step_rec["candidate_gate_score"] = cand_gate_score
1480
+ step_rec["action"] = gate.action
1481
+ prev_current = current_score
1482
+ prev_best = best_score
1483
+ current_skill = gate.current_skill
1484
+ current_score = gate.current_score
1485
+ best_skill = gate.best_skill
1486
+ best_score = gate.best_score
1487
+ best_step = gate.best_step
1488
+ if gate.action in {"accept", "accept_new_best", "force_accept"}:
1489
+ current_origin = f"step_{global_step:04d}"
1490
+ if gate.action == "accept_new_best" or (
1491
+ gate.action == "force_accept" and best_step == global_step
1492
+ ):
1493
+ best_origin = current_origin
1494
+
1495
+ if use_skill_aware:
1496
+ current_skill = _flush_skill_aware_appendix(
1497
+ current_skill, all_raw_patches, step_rec, step_dir, cfg,
1498
+ )
1499
+
1500
+ if gate_metric == "hard":
1501
+ score_label = f"hard={cand_hard:.4f}"
1502
+ elif gate_metric == "soft":
1503
+ score_label = f"soft={cand_soft:.4f}"
1504
+ else:
1505
+ score_label = (
1506
+ f"mixed[w={gate_mixed_weight}]={cand_gate_score:.4f} "
1507
+ f"(hard={cand_hard:.4f} soft={cand_soft:.4f})"
1508
+ )
1509
+ if gate.action == "accept_new_best":
1510
+ print(
1511
+ f" [6/6 EVALUATE] ACCEPT (new best) "
1512
+ f"{score_label} > prev best {prev_best:.4f}"
1513
+ )
1514
+ elif gate.action == "accept":
1515
+ print(
1516
+ f" [6/6 EVALUATE] ACCEPT "
1517
+ f"{score_label} > current={prev_current:.4f}"
1518
+ )
1519
+ elif gate.action == "force_accept":
1520
+ print(
1521
+ f" [6/6 EVALUATE] FORCE-ACCEPT (gate disabled) "
1522
+ f"{score_label}"
1523
+ )
1524
+ else:
1525
+ print(
1526
+ f" [6/6 EVALUATE] REJECT "
1527
+ f"{score_label} <= current={current_score:.4f}"
1528
+ )
1529
+
1530
+ step_rec["timing"]["evaluate_s"] = round(time.time() - t_phase, 1)
1531
+
1532
+ # ── Step buffer: unified failure patterns + rejected edits ─
1533
+ action = step_rec.get("action", "unknown")
1534
+ n_total = len(all_rollout_results) or 1
1535
+ n_fail = sum(1 for r in all_rollout_results if not r.get("hard") or float(r.get("hard", 0)) < 1e-9)
1536
+ failure_patterns = _extract_failure_patterns(
1537
+ all_rollout_results, step_dir,
1538
+ )
1539
+
1540
+ buf_entry: dict = {
1541
+ "step": global_step,
1542
+ "action": action,
1543
+ "n_total": n_total,
1544
+ "n_fail": n_fail,
1545
+ "failure_patterns": failure_patterns,
1546
+ }
1547
+
1548
+ # Attach rejected edits when the step was rejected
1549
+ if "reject" in action and ranked_patch:
1550
+ rejected_edits = [
1551
+ short_item_summary(item, update_mode)
1552
+ for item in ranked_items
1553
+ if isinstance(item, dict)
1554
+ ]
1555
+ buf_entry["score_before"] = current_score
1556
+ buf_entry["score_after"] = cand_gate_score
1557
+ buf_entry["rejected_edits"] = rejected_edits
1558
+
1559
+ step_buffer.append(buf_entry)
1560
+
1561
+ # Persist step digest for step buffer context
1562
+ digest_path = os.path.join(step_dir, "trajectory_digest.json")
1563
+ with open(digest_path, "w") as f:
1564
+ json.dump(buf_entry, f, indent=2, ensure_ascii=False)
1565
+
1566
+ # ── Token snapshot ───────────────────────────────────────
1567
+ tokens_after = get_token_summary()
1568
+ step_tokens: dict = {}
1569
+ for stage in tokens_after:
1570
+ if stage == "_total":
1571
+ continue
1572
+ after = tokens_after[stage]
1573
+ before = tokens_before.get(stage, {})
1574
+ step_tokens[stage] = {
1575
+ "calls": after.get("calls", 0) - before.get("calls", 0),
1576
+ "prompt_tokens": after.get("prompt_tokens", 0)
1577
+ - before.get("prompt_tokens", 0),
1578
+ "completion_tokens": after.get("completion_tokens", 0)
1579
+ - before.get("completion_tokens", 0),
1580
+ }
1581
+ step_rec["tokens"] = step_tokens
1582
+
1583
+ # ── Save state ───────────────────────────────────────────
1584
+ step_rec["current_score"] = current_score
1585
+ step_rec["best_score"] = best_score
1586
+ step_rec["best_step"] = best_step
1587
+ step_rec["current_origin"] = current_origin
1588
+ step_rec["best_origin"] = best_origin
1589
+ step_rec["skill_len"] = len(current_skill)
1590
+ step_rec["wall_time_s"] = round(time.time() - step_t0, 1)
1591
+
1592
+ _save_skill(out_root, global_step, current_skill)
1593
+ with open(os.path.join(out_root, "best_skill.md"), "w") as f:
1594
+ f.write(best_skill)
1595
+ history.append(step_rec)
1596
+ _save_history(out_root, history)
1597
+ _persist_runtime_state(global_step)
1598
+ with open(os.path.join(step_dir, "step_record.json"), "w") as f:
1599
+ json.dump(step_rec, f, indent=2, ensure_ascii=False)
1600
+
1601
+ timing = step_rec["timing"]
1602
+ print(
1603
+ f"\n [STEP {global_step} done] "
1604
+ f"epoch={epoch} action={step_rec['action']} "
1605
+ f"current={current_score:.4f} best={best_score:.4f} "
1606
+ f"dt={step_rec['wall_time_s']}s\n"
1607
+ f" timing: rollout={timing.get('rollout_s',0)}s "
1608
+ f"reflect={timing.get('reflect_s',0)}s "
1609
+ f"aggregate={timing.get('aggregate_s',0)}s "
1610
+ f"select={timing.get('select_s',0)}s "
1611
+ f"evaluate={timing.get('evaluate_s',0)}s"
1612
+ )
1613
+
1614
+ epoch_last_step_skill = current_skill
1615
+ epoch_comparison_pairs: list[dict] | None = None
1616
+
1617
+ # ── SLOW UPDATE (end of epoch) ──────────────────────────────
1618
+ use_slow = cfg.get("use_slow_update", False)
1619
+ if use_slow:
1620
+ slow_dir = os.path.join(out_root, "slow_update", f"epoch_{epoch:02d}")
1621
+ slow_done_path = os.path.join(slow_dir, "slow_result.json")
1622
+
1623
+ if os.path.exists(slow_done_path):
1624
+ # Resume support
1625
+ print(
1626
+ f"\n [SLOW UPDATE epoch {epoch}] "
1627
+ f"resumed — already done"
1628
+ )
1629
+ with open(slow_done_path) as f:
1630
+ slow_saved = json.load(f)
1631
+ comparison_path = os.path.join(slow_dir, "comparison_pairs.json")
1632
+ if os.path.exists(comparison_path):
1633
+ try:
1634
+ with open(comparison_path) as f:
1635
+ epoch_comparison_pairs = json.load(f)
1636
+ except Exception:
1637
+ epoch_comparison_pairs = None
1638
+ if (
1639
+ slow_saved.get("slow_update_content")
1640
+ and epoch >= 2
1641
+ ):
1642
+ action = slow_saved.get("action")
1643
+ if slow_gate_with_selection:
1644
+ # Gated mode (follow SkillReflection): re-apply the
1645
+ # guidance to current_skill only when it was accepted.
1646
+ if action in {"accept", "accept_new_best"}:
1647
+ current_skill = replace_slow_update_field(
1648
+ current_skill,
1649
+ slow_saved["slow_update_content"],
1650
+ )
1651
+ elif action in {
1652
+ "accept", "accept_new_best", "force_accept",
1653
+ }:
1654
+ # Force-accept mode: re-apply guidance to
1655
+ # current_skill only. best_skill must remain a
1656
+ # faithful snapshot of the val-best step and must
1657
+ # NOT receive force-injected slow-update content.
1658
+ current_skill = replace_slow_update_field(
1659
+ current_skill, slow_saved["slow_update_content"],
1660
+ )
1661
+ elif epoch == 1:
1662
+ # Epoch 1: inject empty placeholder
1663
+ os.makedirs(slow_dir, exist_ok=True)
1664
+ current_skill = inject_empty_slow_update_field(current_skill)
1665
+ current_origin = f"slow_update_placeholder_epoch_{epoch:02d}"
1666
+ _save_skill(out_root, global_step, current_skill)
1667
+ with open(os.path.join(out_root, "best_skill.md"), "w") as f:
1668
+ f.write(best_skill)
1669
+ with open(slow_done_path, "w") as f:
1670
+ json.dump({"action": "inject_placeholder", "epoch": epoch}, f, indent=2)
1671
+ _persist_runtime_state(global_step)
1672
+ print(
1673
+ f"\n [SLOW UPDATE epoch {epoch}] "
1674
+ f"injected empty placeholder"
1675
+ )
1676
+ else:
1677
+ # Epoch 2+: longitudinal comparison
1678
+ os.makedirs(slow_dir, exist_ok=True)
1679
+ print(
1680
+ f"\n {'='*60}\n"
1681
+ f" SLOW UPDATE — Epoch {epoch} "
1682
+ f"(comparing epoch {epoch-1} vs {epoch})\n"
1683
+ f" {'='*60}"
1684
+ )
1685
+
1686
+ # 1. Get skill from last step of previous epoch
1687
+ prev_epoch_records = [
1688
+ h for h in history if h.get("epoch") == epoch - 1
1689
+ ]
1690
+ prev_epoch_last_step = prev_epoch_records[-1]["step"]
1691
+ prev_skill = _load_skill(out_root, prev_epoch_last_step)
1692
+
1693
+ # 2. Sample items from train set
1694
+ slow_n = cfg.get("slow_update_samples", 20)
1695
+ slow_seed = seed + epoch * 2000
1696
+ if dataloader is not None:
1697
+ slow_batch = dataloader.build_train_batch(
1698
+ batch_size=slow_n,
1699
+ seed=slow_seed,
1700
+ out_root=out_root,
1701
+ )
1702
+ slow_env = adapter.build_env_from_batch(
1703
+ slow_batch, out_root=out_root,
1704
+ )
1705
+ else:
1706
+ slow_env = adapter.build_train_env(
1707
+ batch_size=slow_n,
1708
+ seed=slow_seed,
1709
+ out_root=out_root,
1710
+ )
1711
+ slow_items = list(slow_env) if hasattr(slow_env, "__iter__") else slow_env
1712
+ print(f" [slow update] sampled {len(slow_items)} train items (seed={slow_seed})")
1713
+
1714
+ # 3. Rollout with both skills
1715
+ t_slow = time.time()
1716
+ prev_rollout_dir = os.path.join(slow_dir, "rollout_prev")
1717
+ curr_rollout_dir = os.path.join(slow_dir, "rollout_curr")
1718
+ results_prev = adapter.rollout(slow_env, prev_skill, prev_rollout_dir)
1719
+ results_curr = adapter.rollout(slow_env, current_skill, curr_rollout_dir)
1720
+
1721
+ prev_hard, _ = compute_score(results_prev)
1722
+ curr_hard, _ = compute_score(results_curr)
1723
+ print(
1724
+ f" [slow update] prev epoch hard={prev_hard:.4f} "
1725
+ f"curr epoch hard={curr_hard:.4f}"
1726
+ )
1727
+
1728
+ # 4. Build and save structured comparison pairs
1729
+ comparison_pairs, all_comparison_pairs = _build_longitudinal_pairs(
1730
+ adapter=adapter,
1731
+ dataloader=dataloader,
1732
+ prev_skill=prev_skill,
1733
+ curr_skill=current_skill,
1734
+ initial_items=slow_items,
1735
+ initial_prev_results=results_prev,
1736
+ initial_curr_results=results_curr,
1737
+ prev_rollout_dir=prev_rollout_dir,
1738
+ curr_rollout_dir=curr_rollout_dir,
1739
+ policy=longitudinal_pair_policy,
1740
+ target_n=slow_n,
1741
+ seed=slow_seed,
1742
+ out_root=out_root,
1743
+ )
1744
+ epoch_comparison_pairs = comparison_pairs
1745
+ if all_comparison_pairs is not comparison_pairs:
1746
+ save_comparison_pairs(
1747
+ all_comparison_pairs,
1748
+ os.path.join(slow_dir, "comparison_pairs_all.json"),
1749
+ )
1750
+ save_comparison_pairs(
1751
+ comparison_pairs,
1752
+ os.path.join(slow_dir, "comparison_pairs.json"),
1753
+ )
1754
+ n_regressed = sum(1 for p in comparison_pairs if p["category"] == "regressed")
1755
+ n_improved = sum(1 for p in comparison_pairs if p["category"] == "improved")
1756
+ n_persist = sum(1 for p in comparison_pairs if p["category"] == "persistent_fail")
1757
+ n_stable = sum(1 for p in comparison_pairs if p["category"] == "stable_success")
1758
+ print(
1759
+ f" [slow update] comparison: "
1760
+ f"regressed={n_regressed} improved={n_improved} "
1761
+ f"persistent_fail={n_persist} stable_success={n_stable} "
1762
+ f"policy={longitudinal_pair_policy} "
1763
+ f"kept={len(comparison_pairs)}/{len(all_comparison_pairs)}"
1764
+ )
1765
+
1766
+ # 5. Extract previous slow update guidance for reflection
1767
+ existing_guidance = extract_slow_update_field(current_skill)
1768
+
1769
+ # 6. Optimizer analysis (with reflection on previous guidance)
1770
+ slow_result = run_slow_update(
1771
+ current_skill,
1772
+ results_prev,
1773
+ results_curr,
1774
+ slow_items,
1775
+ prev_skill=prev_skill,
1776
+ prev_slow_update_content=existing_guidance,
1777
+ prev_rollout_dir=prev_rollout_dir,
1778
+ curr_rollout_dir=curr_rollout_dir,
1779
+ comparison_pairs=comparison_pairs,
1780
+ )
1781
+ slow_time = round(time.time() - t_slow, 1)
1782
+
1783
+ if slow_result and slow_result.get("slow_update_content"):
1784
+ slow_candidate = replace_slow_update_field(
1785
+ current_skill, slow_result["slow_update_content"],
1786
+ )
1787
+ slow_candidate_hash = skill_hash(slow_candidate)
1788
+ with open(os.path.join(slow_dir, "candidate_skill.md"), "w") as f:
1789
+ f.write(slow_candidate)
1790
+ slow_result["time_s"] = slow_time
1791
+ slow_result["prev_hard"] = prev_hard
1792
+ slow_result["curr_hard"] = curr_hard
1793
+ slow_result["candidate_hash"] = slow_candidate_hash
1794
+ slow_result["update_origin"] = "slow_update_momentum"
1795
+ slow_result["update_target"] = (
1796
+ "Address longitudinal regressions and persistent failures "
1797
+ "observed across adjacent epochs."
1798
+ )
1799
+
1800
+ # Slow update acceptance — two modes selected via
1801
+ # `optimizer.slow_update_gate_with_selection`.
1802
+ if slow_gate_with_selection:
1803
+ # ── Gated mode (follow SkillReflection) ──────────
1804
+ # Evaluate the slow-update candidate on the
1805
+ # selection set and accept/reject via the same
1806
+ # validation gate used for step-level updates.
1807
+ if slow_candidate_hash in sel_cache:
1808
+ slow_sel_hard, slow_sel_soft = sel_cache[
1809
+ slow_candidate_hash
1810
+ ]
1811
+ print(
1812
+ f" [slow gate] cache hit: "
1813
+ f"hard={slow_sel_hard:.4f}"
1814
+ )
1815
+ else:
1816
+ sel_env, sel_n = _build_eval_env(
1817
+ split="valid_seen",
1818
+ env_num=cfg["sel_env_num"],
1819
+ seed=seed,
1820
+ )
1821
+ print(f" [slow gate] selection items={sel_n}")
1822
+ slow_eval_dir = os.path.join(
1823
+ slow_dir, "selection_eval",
1824
+ )
1825
+ slow_eval_results = adapter.rollout(
1826
+ sel_env, slow_candidate, slow_eval_dir,
1827
+ )
1828
+ slow_sel_hard, slow_sel_soft = compute_score(
1829
+ slow_eval_results
1830
+ )
1831
+ sel_cache[slow_candidate_hash] = (
1832
+ slow_sel_hard, slow_sel_soft,
1833
+ )
1834
+
1835
+ slow_gate = evaluate_gate(
1836
+ candidate_skill=slow_candidate,
1837
+ cand_hard=slow_sel_hard,
1838
+ current_skill=current_skill,
1839
+ current_score=current_score,
1840
+ best_skill=best_skill,
1841
+ best_score=best_score,
1842
+ best_step=best_step,
1843
+ global_step=global_step,
1844
+ cand_soft=slow_sel_soft,
1845
+ metric=gate_metric,
1846
+ mixed_weight=gate_mixed_weight,
1847
+ )
1848
+ slow_result["selection_hard"] = slow_sel_hard
1849
+ slow_result["selection_soft"] = slow_sel_soft
1850
+ slow_result["action"] = slow_gate.action
1851
+ prev_current = current_score
1852
+ prev_best = best_score
1853
+ current_skill = slow_gate.current_skill
1854
+ current_score = slow_gate.current_score
1855
+ best_skill = slow_gate.best_skill
1856
+ best_score = slow_gate.best_score
1857
+ best_step = slow_gate.best_step
1858
+ if slow_gate.action in {"accept", "accept_new_best"}:
1859
+ current_origin = (
1860
+ f"slow_update_epoch_{epoch:02d}"
1861
+ )
1862
+ if slow_gate.action == "accept_new_best":
1863
+ best_origin = current_origin
1864
+ print(
1865
+ f" [slow gate] ACCEPT (new best) "
1866
+ f"hard={slow_sel_hard:.4f} > "
1867
+ f"prev best {prev_best:.4f}"
1868
+ )
1869
+ elif slow_gate.action == "accept":
1870
+ print(
1871
+ f" [slow gate] ACCEPT "
1872
+ f"hard={slow_sel_hard:.4f} > "
1873
+ f"current={prev_current:.4f}"
1874
+ )
1875
+ else:
1876
+ print(
1877
+ f" [slow gate] REJECT "
1878
+ f"hard={slow_sel_hard:.4f} <= "
1879
+ f"current={current_score:.4f}"
1880
+ )
1881
+ print(
1882
+ f" [slow update] guidance written "
1883
+ f"({len(slow_result['slow_update_content'])} "
1884
+ f"chars), {slow_time}s"
1885
+ )
1886
+ else:
1887
+ # ── Force-accept mode (default) ──────────────────
1888
+ # The epoch-level longitudinal guidance is injected
1889
+ # into current_skill ONLY, so training continues
1890
+ # with the accumulated slow memory. best_skill is
1891
+ # left untouched: it must remain a faithful snapshot
1892
+ # of the val-best step (which may be a pre-slow step
1893
+ # such as S_0 carrying no slow_update field at all).
1894
+ slow_content = slow_result["slow_update_content"]
1895
+ current_skill = replace_slow_update_field(
1896
+ current_skill, slow_content,
1897
+ )
1898
+ # Update caches so downstream steps use the
1899
+ # slow-update-injected skill for hashing.
1900
+ slow_candidate_hash = skill_hash(current_skill)
1901
+ sel_cache[slow_candidate_hash] = (current_score, 0.0)
1902
+
1903
+ slow_result["action"] = "force_accept"
1904
+ current_origin = f"slow_update_epoch_{epoch:02d}"
1905
+
1906
+ print(
1907
+ f" [slow update] force-injected into "
1908
+ f"current only "
1909
+ f"({len(slow_content)} chars), "
1910
+ f"{slow_time}s"
1911
+ )
1912
+ else:
1913
+ slow_result = slow_result or {}
1914
+ slow_result["action"] = "no_content"
1915
+ slow_result["time_s"] = slow_time
1916
+ print(
1917
+ f" [slow update] no guidance produced, "
1918
+ f"{slow_time}s"
1919
+ )
1920
+
1921
+ # 5. Save
1922
+ with open(slow_done_path, "w") as f:
1923
+ json.dump(slow_result, f, indent=2, ensure_ascii=False)
1924
+ _save_skill(out_root, global_step, current_skill)
1925
+ with open(os.path.join(out_root, "best_skill.md"), "w") as f:
1926
+ f.write(best_skill)
1927
+ _persist_runtime_state(global_step)
1928
+
1929
+ print(
1930
+ f"\n [SLOW UPDATE epoch {epoch} done] "
1931
+ f"current={current_score:.4f} best={best_score:.4f}"
1932
+ )
1933
+
1934
+ # ── META SKILL (end of epoch, optimizer-side memory) ─────────
1935
+ use_meta_skill = cfg.get("use_meta_skill", False)
1936
+ if use_meta_skill:
1937
+ meta_skill_dir = os.path.join(out_root, "meta_skill", f"epoch_{epoch:02d}")
1938
+ meta_skill_done_path = os.path.join(meta_skill_dir, "meta_skill_result.json")
1939
+ os.makedirs(meta_skill_dir, exist_ok=True)
1940
+
1941
+ if os.path.exists(meta_skill_done_path):
1942
+ print(f"\n [META SKILL epoch {epoch}] resumed — already done")
1943
+ elif epoch == 1:
1944
+ with open(meta_skill_done_path, "w") as f:
1945
+ json.dump(
1946
+ {"action": "skip_first_epoch", "epoch": epoch},
1947
+ f, indent=2, ensure_ascii=False,
1948
+ )
1949
+ print(f"\n [META SKILL epoch {epoch}] skipped — first epoch")
1950
+ else:
1951
+ print(
1952
+ f"\n {'='*60}\n"
1953
+ f" META SKILL — Epoch {epoch} "
1954
+ f"(optimizer memory from epoch {epoch-1} vs {epoch})\n"
1955
+ f" {'='*60}"
1956
+ )
1957
+
1958
+ prev_epoch_records = [h for h in history if h.get("epoch") == epoch - 1]
1959
+ prev_epoch_last_step = prev_epoch_records[-1]["step"]
1960
+ prev_skill = _load_skill(out_root, prev_epoch_last_step)
1961
+ prev_meta_skill = _load_meta_skill_content(out_root, epoch - 1)
1962
+
1963
+ if epoch_comparison_pairs is None:
1964
+ meta_n = cfg.get("slow_update_samples", 20)
1965
+ meta_seed = seed + epoch * 2000
1966
+ if dataloader is not None:
1967
+ meta_batch = dataloader.build_train_batch(
1968
+ batch_size=meta_n,
1969
+ seed=meta_seed,
1970
+ out_root=out_root,
1971
+ )
1972
+ meta_env = adapter.build_env_from_batch(
1973
+ meta_batch, out_root=out_root,
1974
+ )
1975
+ else:
1976
+ meta_env = adapter.build_train_env(
1977
+ batch_size=meta_n,
1978
+ seed=meta_seed,
1979
+ out_root=out_root,
1980
+ )
1981
+ meta_items = list(meta_env) if hasattr(meta_env, "__iter__") else meta_env
1982
+ prev_rollout_dir = os.path.join(meta_skill_dir, "rollout_prev")
1983
+ curr_rollout_dir = os.path.join(meta_skill_dir, "rollout_curr")
1984
+ results_prev = adapter.rollout(meta_env, prev_skill, prev_rollout_dir)
1985
+ results_curr = adapter.rollout(meta_env, epoch_last_step_skill, curr_rollout_dir)
1986
+ epoch_comparison_pairs, all_meta_comparison_pairs = _build_longitudinal_pairs(
1987
+ adapter=adapter,
1988
+ dataloader=dataloader,
1989
+ prev_skill=prev_skill,
1990
+ curr_skill=epoch_last_step_skill,
1991
+ initial_items=meta_items,
1992
+ initial_prev_results=results_prev,
1993
+ initial_curr_results=results_curr,
1994
+ prev_rollout_dir=prev_rollout_dir,
1995
+ curr_rollout_dir=curr_rollout_dir,
1996
+ policy=longitudinal_pair_policy,
1997
+ target_n=meta_n,
1998
+ seed=meta_seed,
1999
+ out_root=out_root,
2000
+ )
2001
+ if all_meta_comparison_pairs is not epoch_comparison_pairs:
2002
+ save_comparison_pairs(
2003
+ all_meta_comparison_pairs,
2004
+ os.path.join(meta_skill_dir, "comparison_pairs_all.json"),
2005
+ )
2006
+ save_comparison_pairs(
2007
+ epoch_comparison_pairs,
2008
+ os.path.join(meta_skill_dir, "comparison_pairs.json"),
2009
+ )
2010
+ meta_counts = _pair_category_counts(epoch_comparison_pairs)
2011
+ print(
2012
+ f" [meta skill] comparison: "
2013
+ f"regressed={meta_counts.get('regressed', 0)} "
2014
+ f"improved={meta_counts.get('improved', 0)} "
2015
+ f"persistent_fail={meta_counts.get('persistent_fail', 0)} "
2016
+ f"stable_success={meta_counts.get('stable_success', 0)} "
2017
+ f"policy={longitudinal_pair_policy} "
2018
+ f"kept={len(epoch_comparison_pairs)}/{len(all_meta_comparison_pairs)}"
2019
+ )
2020
+
2021
+ t_meta_skill = time.time()
2022
+ meta_skill_result = run_meta_skill(
2023
+ prev_skill=prev_skill,
2024
+ curr_skill=epoch_last_step_skill,
2025
+ comparison_pairs=epoch_comparison_pairs or [],
2026
+ prev_meta_skill_content=prev_meta_skill,
2027
+ )
2028
+ meta_skill_time = round(time.time() - t_meta_skill, 1)
2029
+
2030
+ if meta_skill_result and meta_skill_result.get("meta_skill_content"):
2031
+ meta_skill_result["time_s"] = meta_skill_time
2032
+ meta_skill_result["action"] = "write_meta_skill"
2033
+ print(
2034
+ f" [meta skill] memory written "
2035
+ f"({len(meta_skill_result['meta_skill_content'])} chars), "
2036
+ f"{meta_skill_time}s"
2037
+ )
2038
+ else:
2039
+ meta_skill_result = meta_skill_result or {}
2040
+ meta_skill_result["time_s"] = meta_skill_time
2041
+ meta_skill_result["action"] = "no_content"
2042
+ print(f" [meta skill] no memory produced, {meta_skill_time}s")
2043
+
2044
+ with open(meta_skill_done_path, "w") as f:
2045
+ json.dump(meta_skill_result, f, indent=2, ensure_ascii=False)
2046
+
2047
+ # ── Save best skill ──────────────────────────────────────────────
2048
+ with open(os.path.join(out_root, "best_skill.md"), "w") as f:
2049
+ f.write(best_skill)
2050
+ _persist_runtime_state(global_step)
2051
+ print(
2052
+ f"\n [done] best skill from step {best_step}, "
2053
+ f"score={best_score:.4f}"
2054
+ )
2055
+
2056
+ # ── Final test evaluation (valid_unseen) ─────────────────────────
2057
+ baseline_test_hard = None
2058
+ baseline_test_soft = None
2059
+ test_hard = None
2060
+ test_soft = None
2061
+ final_test_hard = None
2062
+ final_test_soft = None
2063
+ final_selection_hard = None
2064
+ final_selection_soft = None
2065
+
2066
+ if cfg["eval_test"]:
2067
+ task_types = adapter.get_task_types()
2068
+
2069
+ # ── Final skill validation (valid_seen) + best promotion ─────
2070
+ # The final (last) skill may carry an epoch-end slow_update that
2071
+ # was force-injected WITHOUT a val pass (use_gate=false or
2072
+ # slow_update_gate_with_selection=false), so it never competed for
2073
+ # best. Run one real val on the final skill; if its gate score
2074
+ # beats the incumbent best, PROMOTE it to best so that best is the
2075
+ # true val-argmax over all skills (including the final slow_update).
2076
+ # When final == best, reuse the existing val score (no rollout).
2077
+ try:
2078
+ if skill_hash(current_skill) == skill_hash(best_skill):
2079
+ final_selection_hard, final_selection_soft = best_score, None
2080
+ print(
2081
+ "\n [final skill == best skill] "
2082
+ f"final_selection_hard={best_score:.4f} (reused)"
2083
+ )
2084
+ else:
2085
+ fval_env, fval_n = _build_eval_env(
2086
+ split="valid_seen",
2087
+ env_num=cfg["sel_env_num"],
2088
+ seed=seed,
2089
+ )
2090
+ fval_dir = os.path.join(out_root, "final_selection_eval")
2091
+ fval_results = adapter.rollout(fval_env, current_skill, fval_dir)
2092
+ final_selection_hard, final_selection_soft = compute_score(fval_results)
2093
+ final_gate_score = select_gate_score(
2094
+ final_selection_hard, final_selection_soft,
2095
+ gate_metric, gate_mixed_weight,
2096
+ )
2097
+ print(
2098
+ f"\n [final skill val] items={fval_n} "
2099
+ f"final_selection_hard={final_selection_hard:.4f} "
2100
+ f"gate={final_gate_score:.4f} "
2101
+ f"(best={best_score:.4f})"
2102
+ )
2103
+ if final_gate_score > best_score:
2104
+ # Promote: the final (slow-updated) skill is val-better
2105
+ # than the incumbent best. Make it the new best so the
2106
+ # subsequent BEST-skill test rollout evaluates it and
2107
+ # best/final test scores coincide.
2108
+ print(
2109
+ f" [promote] final {final_gate_score:.4f} > "
2110
+ f"best {best_score:.4f} → final becomes new best "
2111
+ f"(step {global_step}, origin {current_origin})"
2112
+ )
2113
+ best_skill = current_skill
2114
+ best_score = final_gate_score
2115
+ best_step = global_step
2116
+ best_origin = current_origin
2117
+ with open(os.path.join(out_root, "best_skill.md"), "w") as f:
2118
+ f.write(best_skill)
2119
+ _persist_runtime_state(global_step)
2120
+ except Exception as _e: # noqa: BLE001
2121
+ final_selection_hard = None
2122
+ final_selection_soft = None
2123
+ print(f"\n [final skill val FAILED: {_e!r}]")
2124
+
2125
+ # Baseline: S_0 on test set (valid_unseen)
2126
+ print(f"\n{'='*60}")
2127
+ print(" BASELINE TEST — evaluate initial skill on Test set (valid_unseen)")
2128
+ print(f"{'='*60}")
2129
+ test_env, test_n = _build_eval_env(
2130
+ split="valid_unseen",
2131
+ env_num=cfg["test_env_num"],
2132
+ seed=seed,
2133
+ )
2134
+ print(f" Test items: {test_n}")
2135
+ baseline_test_dir = os.path.join(out_root, "test_eval_baseline")
2136
+ os.makedirs(baseline_test_dir, exist_ok=True)
2137
+ baseline_test_results = adapter.rollout(test_env, skill_init, baseline_test_dir)
2138
+ baseline_test_hard, baseline_test_soft = compute_score(baseline_test_results)
2139
+ baseline_buckets = _compute_task_type_buckets(baseline_test_results, task_types)
2140
+ print("\n === Baseline Test Results (S_0) ===")
2141
+ for task_type in task_types + ["overall"]:
2142
+ b = baseline_buckets.get(task_type, {"total": 0, "hard": 0})
2143
+ t = max(b["total"], 1)
2144
+ print(
2145
+ f" {task_type:<40s}: "
2146
+ f"hard={b['hard']}/{b['total']}={b['hard']/t:.4f}"
2147
+ )
2148
+ with open(os.path.join(baseline_test_dir, "summary.json"), "w") as f:
2149
+ json.dump(
2150
+ {
2151
+ k: {
2152
+ "total": b["total"],
2153
+ "hard_acc": b["hard"] / max(b["total"], 1),
2154
+ }
2155
+ for k, b in baseline_buckets.items()
2156
+ },
2157
+ f, indent=2, ensure_ascii=False,
2158
+ )
2159
+
2160
+ # Best skill on test set
2161
+ print(f"\n{'='*60}")
2162
+ print(" BEST SKILL TEST — evaluate best skill on Test set (valid_unseen)")
2163
+ print(f"{'='*60}")
2164
+ test_env2, test_n2 = _build_eval_env(
2165
+ split="valid_unseen",
2166
+ env_num=cfg["test_env_num"],
2167
+ seed=seed,
2168
+ )
2169
+ print(f" Test items: {test_n2}")
2170
+ test_dir = os.path.join(out_root, "test_eval")
2171
+ os.makedirs(test_dir, exist_ok=True)
2172
+ test_results = adapter.rollout(test_env2, best_skill, test_dir)
2173
+ test_hard, test_soft = compute_score(test_results)
2174
+ best_buckets = _compute_task_type_buckets(test_results, task_types)
2175
+ print("\n === Best Skill Test Results ===")
2176
+ for task_type in task_types + ["overall"]:
2177
+ b = best_buckets.get(task_type, {"total": 0, "hard": 0})
2178
+ t = max(b["total"], 1)
2179
+ print(
2180
+ f" {task_type:<40s}: "
2181
+ f"hard={b['hard']}/{b['total']}={b['hard']/t:.4f}"
2182
+ )
2183
+ with open(os.path.join(test_dir, "summary.json"), "w") as f:
2184
+ json.dump(
2185
+ {
2186
+ k: {
2187
+ "total": b["total"],
2188
+ "hard_acc": b["hard"] / max(b["total"], 1),
2189
+ }
2190
+ for k, b in best_buckets.items()
2191
+ },
2192
+ f, indent=2, ensure_ascii=False,
2193
+ )
2194
+
2195
+ # Final skill (last skill in trajectory) on test set.
2196
+ # Distinct from best_skill: with use_gate=False every candidate is
2197
+ # force-accepted so the final skill is whatever the last step
2198
+ # produced; with use_gate=True it is the last accepted skill, which
2199
+ # may differ from the best-on-val skill. We always evaluate it so
2200
+ # every run reports baseline / best-on-val / final on test.
2201
+ # Guarded so a failure here never prevents summary.json from being
2202
+ # written (the orchestrator's post-hoc safety net fills it in).
2203
+ try:
2204
+ if skill_hash(current_skill) == skill_hash(best_skill):
2205
+ # Final == best: reuse results, skip a redundant rollout.
2206
+ final_test_hard, final_test_soft = test_hard, test_soft
2207
+ final_test_dir = os.path.join(out_root, "test_eval_final")
2208
+ os.makedirs(final_test_dir, exist_ok=True)
2209
+ with open(os.path.join(final_test_dir, "summary.json"), "w") as f:
2210
+ json.dump(
2211
+ {
2212
+ k: {
2213
+ "total": b["total"],
2214
+ "hard_acc": b["hard"] / max(b["total"], 1),
2215
+ }
2216
+ for k, b in best_buckets.items()
2217
+ },
2218
+ f, indent=2, ensure_ascii=False,
2219
+ )
2220
+ print(
2221
+ "\n [final skill == best skill] "
2222
+ f"final_test_hard={final_test_hard:.4f} (reused)"
2223
+ )
2224
+ else:
2225
+ print(f"\n{'='*60}")
2226
+ print(" FINAL SKILL TEST — evaluate last skill on Test set (valid_unseen)")
2227
+ print(f"{'='*60}")
2228
+ test_env3, test_n3 = _build_eval_env(
2229
+ split="valid_unseen",
2230
+ env_num=cfg["test_env_num"],
2231
+ seed=seed,
2232
+ )
2233
+ print(f" Test items: {test_n3}")
2234
+ final_test_dir = os.path.join(out_root, "test_eval_final")
2235
+ os.makedirs(final_test_dir, exist_ok=True)
2236
+ final_test_results = adapter.rollout(test_env3, current_skill, final_test_dir)
2237
+ final_test_hard, final_test_soft = compute_score(final_test_results)
2238
+ final_buckets = _compute_task_type_buckets(final_test_results, task_types)
2239
+ print("\n === Final Skill Test Results ===")
2240
+ for task_type in task_types + ["overall"]:
2241
+ b = final_buckets.get(task_type, {"total": 0, "hard": 0})
2242
+ t = max(b["total"], 1)
2243
+ print(
2244
+ f" {task_type:<40s}: "
2245
+ f"hard={b['hard']}/{b['total']}={b['hard']/t:.4f}"
2246
+ )
2247
+ with open(os.path.join(final_test_dir, "summary.json"), "w") as f:
2248
+ json.dump(
2249
+ {
2250
+ k: {
2251
+ "total": b["total"],
2252
+ "hard_acc": b["hard"] / max(b["total"], 1),
2253
+ }
2254
+ for k, b in final_buckets.items()
2255
+ },
2256
+ f, indent=2, ensure_ascii=False,
2257
+ )
2258
+ except Exception as _e: # noqa: BLE001
2259
+ final_test_hard = None
2260
+ final_test_soft = None
2261
+ print(f"\n [final skill test FAILED: {_e!r}] "
2262
+ "— will be filled by post-hoc eval")
2263
+
2264
+ # Comparison
2265
+ delta_hard = (test_hard or 0) - (baseline_test_hard or 0)
2266
+ print(f"\n === Improvement vs baseline (init S_0) ===")
2267
+ print(
2268
+ f" [2] best-on-val hard: {baseline_test_hard:.4f} -> {test_hard:.4f} "
2269
+ f"(delta={delta_hard:+.4f})"
2270
+ )
2271
+ if final_test_hard is not None:
2272
+ final_delta_hard = (final_test_hard or 0) - (baseline_test_hard or 0)
2273
+ print(
2274
+ f" [3] final/last hard: {baseline_test_hard:.4f} -> {final_test_hard:.4f} "
2275
+ f"(delta={final_delta_hard:+.4f})"
2276
+ )
2277
+
2278
+ # ── Global summary ───────────────────────────────────────────────
2279
+ total_wall = time.time() - t_loop_start
2280
+ n_accept = sum(1 for h in history if "accept" in h.get("action", ""))
2281
+ n_reject = sum(1 for h in history if h.get("action") == "reject")
2282
+ n_skip = sum(1 for h in history if h.get("action") == "skip_no_patches")
2283
+
2284
+ token_summary = get_token_summary()
2285
+
2286
+ # Epoch-level statistics
2287
+ epoch_stats = []
2288
+ for e in range(1, num_epochs + 1):
2289
+ epoch_records = [h for h in history if h.get("epoch") == e]
2290
+ if epoch_records:
2291
+ epoch_stats.append({
2292
+ "epoch": e,
2293
+ "steps": [h["step"] for h in epoch_records],
2294
+ "accepts": sum(1 for h in epoch_records if "accept" in h.get("action", "")),
2295
+ "rejects": sum(1 for h in epoch_records if h.get("action") == "reject"),
2296
+ "skips": sum(1 for h in epoch_records if h.get("action") == "skip_no_patches"),
2297
+ "best_score_at_epoch_end": epoch_records[-1].get("best_score", 0.0),
2298
+ "current_score_at_epoch_end": epoch_records[-1].get("current_score", 0.0),
2299
+ })
2300
+
2301
+ summary = {
2302
+ "version": "skillopt-0.1.0",
2303
+ "config": _redact_cfg(cfg),
2304
+ "baseline_selection_hard": sel_cache.get(
2305
+ skill_hash(skill_init), (None, None),
2306
+ )[0],
2307
+ "best_selection_hard": best_score,
2308
+ "final_selection_hard": final_selection_hard,
2309
+ "final_selection_soft": final_selection_soft,
2310
+ "best_step": best_step,
2311
+ "current_origin": current_origin,
2312
+ "best_origin": best_origin,
2313
+ "total_steps": len(history),
2314
+ "total_accepts": n_accept,
2315
+ "total_rejects": n_reject,
2316
+ "total_skips": n_skip,
2317
+ "epoch_stats": epoch_stats,
2318
+ "baseline_test_hard": baseline_test_hard,
2319
+ "baseline_test_soft": baseline_test_soft,
2320
+ "test_hard": test_hard,
2321
+ "test_soft": test_soft,
2322
+ "final_test_hard": final_test_hard,
2323
+ "final_test_soft": final_test_soft,
2324
+ "test_delta_hard": (
2325
+ (test_hard or 0) - (baseline_test_hard or 0)
2326
+ if test_hard is not None
2327
+ else None
2328
+ ),
2329
+ "final_test_delta_hard": (
2330
+ (final_test_hard or 0) - (baseline_test_hard or 0)
2331
+ if final_test_hard is not None
2332
+ else None
2333
+ ),
2334
+ "total_wall_time_s": round(total_wall, 1),
2335
+ "token_summary": token_summary,
2336
+ }
2337
+ with open(os.path.join(out_root, "summary.json"), "w") as f:
2338
+ json.dump(summary, f, indent=2, ensure_ascii=False)
2339
+
2340
+ print(f"\n{'='*60}")
2341
+ print(" Final Summary")
2342
+ print(f"{'='*60}")
2343
+ print(
2344
+ f" steps={len(history)} accept={n_accept} "
2345
+ f"reject={n_reject} skip={n_skip}"
2346
+ )
2347
+ print(f" best_score={best_score:.4f} (step {best_step}) wall={total_wall:.0f}s")
2348
+ if epoch_stats:
2349
+ for es in epoch_stats:
2350
+ print(
2351
+ f" epoch {es['epoch']}: accept={es['accepts']} reject={es['rejects']} "
2352
+ f"best={es['best_score_at_epoch_end']:.4f}"
2353
+ )
2354
+ if baseline_test_hard is not None:
2355
+ print("\n === TEST scores (3 skills, split=valid_unseen) ===")
2356
+ print(
2357
+ f" [1] init/baseline (S_0) : "
2358
+ f"test_hard={baseline_test_hard:.4f}"
2359
+ )
2360
+ if test_hard is not None:
2361
+ print(
2362
+ f" [2] best-on-val (step {best_step})".ljust(37)
2363
+ + f": test_hard={test_hard:.4f} test_soft={test_soft:.4f}"
2364
+ )
2365
+ if final_test_hard is not None:
2366
+ print(
2367
+ f" [3] final/last skill : "
2368
+ f"test_hard={final_test_hard:.4f} test_soft={final_test_soft:.4f}"
2369
+ )
2370
+ if token_summary.get("_total"):
2371
+ t = token_summary["_total"]
2372
+ print(
2373
+ f" total tokens: {t['total_tokens']:,} "
2374
+ f"(prompt={t['prompt_tokens']:,} "
2375
+ f"completion={t['completion_tokens']:,} "
2376
+ f"calls={t['calls']})"
2377
+ )
2378
+
2379
+ return summary