self-evolve-framework 1.3.0 → 1.4.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 (57) hide show
  1. package/package.json +1 -1
  2. package/template/skills/ponytail/SKILL.md +16 -0
  3. package/template/skills/ponytail/scripts/hooks/claude-codex-hooks.json +44 -0
  4. package/template/skills/ponytail/scripts/hooks/copilot-hooks.json +21 -0
  5. package/template/skills/ponytail/scripts/hooks/ponytail-activate.js +91 -0
  6. package/template/skills/ponytail/scripts/hooks/ponytail-config.js +122 -0
  7. package/template/skills/ponytail/scripts/hooks/ponytail-instructions.js +94 -0
  8. package/template/skills/ponytail/scripts/hooks/ponytail-mode-tracker.js +55 -0
  9. package/template/skills/ponytail/scripts/hooks/ponytail-runtime.js +68 -0
  10. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.ps1 +21 -0
  11. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.sh +12 -0
  12. package/template/skills/ponytail/scripts/hooks/ponytail-subagent.js +22 -0
  13. package/template/skills/ponytail/scripts/mcp/README.md +46 -0
  14. package/template/skills/ponytail/scripts/mcp/index.js +48 -0
  15. package/template/skills/ponytail/scripts/mcp/instructions.js +26 -0
  16. package/template/skills/ponytail/scripts/mcp/package.json +13 -0
  17. package/template/skills/ponytail/scripts/mcp/test/instructions.test.js +22 -0
  18. package/template/skills/skillopt-sleep/SKILL.md +48 -34
  19. package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
  20. package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
  21. package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
  22. package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
  23. package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
  24. package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
  25. package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
  26. package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
  27. package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
  28. package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
  29. package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
  30. package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
  31. package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
  32. package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
  33. package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
  34. package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
  35. package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
  36. package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
  37. package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
  38. package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
  39. package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
  40. package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
  41. package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
  42. package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
  43. package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
  44. package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
  45. package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
  46. package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
  47. package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
  48. package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
  49. package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
  50. package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
  51. package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
  52. package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
  53. package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
  54. package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
  55. package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
  56. package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
  57. package/template/skills/skillopt-sleep/scripts/shell/train.py +556 -0
@@ -0,0 +1,75 @@
1
+ """SkillOpt-Sleep — budget controller.
2
+
3
+ Lets the user say how much they're willing to spend on a night's "dreaming",
4
+ in tokens or wall-clock minutes, and the engine schedules depth (how many
5
+ rollouts × how many nights) within that budget. Stops cleanly when exhausted
6
+ and reports what it skipped (no silent truncation).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+
14
+ @dataclass
15
+ class Budget:
16
+ max_tokens: Optional[int] = None # None = unlimited
17
+ max_minutes: Optional[float] = None # None = unlimited
18
+ _start_time: Optional[float] = None
19
+ _tokens_at_start: int = 0
20
+
21
+ def start(self, clock_fn, tokens_now: int) -> None:
22
+ self._start_time = clock_fn()
23
+ self._tokens_at_start = tokens_now
24
+
25
+ def tokens_spent(self, tokens_now: int) -> int:
26
+ return max(0, tokens_now - self._tokens_at_start)
27
+
28
+ def minutes_elapsed(self, clock_fn) -> float:
29
+ if self._start_time is None:
30
+ return 0.0
31
+ return (clock_fn() - self._start_time) / 60.0
32
+
33
+ def remaining_fraction(self, *, tokens_now: int, clock_fn) -> float:
34
+ """Smallest remaining fraction across all active limits (1.0 = fresh)."""
35
+ fracs = [1.0]
36
+ if self.max_tokens:
37
+ fracs.append(max(0.0, 1.0 - self.tokens_spent(tokens_now) / self.max_tokens))
38
+ if self.max_minutes:
39
+ fracs.append(max(0.0, 1.0 - self.minutes_elapsed(clock_fn) / self.max_minutes))
40
+ return min(fracs)
41
+
42
+ def exhausted(self, *, tokens_now: int, clock_fn) -> bool:
43
+ if self.max_tokens and self.tokens_spent(tokens_now) >= self.max_tokens:
44
+ return True
45
+ if self.max_minutes and self.minutes_elapsed(clock_fn) >= self.max_minutes:
46
+ return True
47
+ return False
48
+
49
+ def status(self, *, tokens_now: int, clock_fn) -> str:
50
+ parts = []
51
+ if self.max_tokens:
52
+ parts.append(f"tokens {self.tokens_spent(tokens_now)}/{self.max_tokens}")
53
+ if self.max_minutes:
54
+ parts.append(f"minutes {self.minutes_elapsed(clock_fn):.1f}/{self.max_minutes}")
55
+ return ", ".join(parts) or "unbounded"
56
+
57
+
58
+ def plan_depth(budget: Budget, *, n_tasks: int,
59
+ default_nights: int = 2, default_k: int = 1) -> tuple:
60
+ """Heuristically choose (nights, rollouts_per_task) from a token budget.
61
+
62
+ Rough cost model: one rollout ≈ 1 unit; a night does ~n_tasks*k rollouts
63
+ plus reflect/gate (~2*n_tasks). We scale k and nights up with more budget.
64
+ Returns (nights, k). With no budget set, returns the defaults.
65
+ """
66
+ if not budget.max_tokens:
67
+ return default_nights, default_k
68
+ # assume ~1.5k tokens per rollout as a planning constant
69
+ rollouts_affordable = budget.max_tokens / 1500.0
70
+ per_night = max(1, n_tasks) * 3 # rollouts + reflect + gate, k=1
71
+ nights = max(1, min(4, int(rollouts_affordable // per_night)))
72
+ # spend surplus on more rollouts-per-task (contrastive signal)
73
+ surplus = rollouts_affordable - nights * per_night
74
+ k = max(1, min(5, 1 + int(surplus // max(1, n_tasks))))
75
+ return nights, k
@@ -0,0 +1,162 @@
1
+ """SkillOpt-Sleep — configuration.
2
+
3
+ Config is JSON-first (yaml optional) so the engine and the deterministic
4
+ experiment run with zero external dependencies. Defaults are safe:
5
+ review-gated adoption, single-project scope, bounded token/task budgets.
6
+
7
+ Resolution order (later wins):
8
+ 1. built-in DEFAULTS
9
+ 2. ~/.skillopt-sleep/config.json (or .yaml if PyYAML available)
10
+ 3. explicit overrides passed to load_config(**overrides)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, Optional
18
+
19
+ HOME_STATE_DIR = os.path.expanduser("~/.skillopt-sleep")
20
+ CLAUDE_HOME = os.path.expanduser("~/.claude")
21
+ CODEX_HOME = os.path.expanduser("~/.codex")
22
+
23
+
24
+ DEFAULTS: Dict[str, Any] = {
25
+ # ── scope ──────────────────────────────────────────────────────────────
26
+ "claude_home": CLAUDE_HOME,
27
+ "codex_home": CODEX_HOME,
28
+ "transcript_source": "claude", # "claude" | "codex" | "auto"
29
+ "projects": "invoked", # "invoked" | "all" | [list of abs paths]
30
+ "invoked_project": "", # filled at runtime (cwd) when projects == "invoked"
31
+ "lookback_hours": 72, # harvest window when no prior sleep recorded
32
+ # ── budgets ────────────────────────────────────────────────────────────
33
+ "max_tasks_per_night": 40,
34
+ "max_tokens_per_night": 400_000,
35
+ "holdout_fraction": 0.34, # legacy alias for val_fraction
36
+ "val_fraction": 0.34, # real tasks reserved to gate updates
37
+ "test_fraction": 0.0, # real tasks reserved as the final held-out measure
38
+ # ── optimizer ──────────────────────────────────────────────────────────
39
+ "backend": "mock", # "mock" | "claude" | "codex" | "copilot"
40
+ "model": "", # backend-specific; "" => backend default
41
+ "gate_mode": "on", # "on" (validation-gated) | "off" (greedy, no hard filter)
42
+ "codex_path": "", # "" => auto-detect the real @openai/codex binary
43
+ "edit_budget": 4, # textual learning rate (max edits/night)
44
+ "gate_metric": "mixed", # hard | soft | mixed (mixed best for tiny holdouts)
45
+ "gate_mixed_weight": 0.5,
46
+ "replay_mode": "mock", # "mock" (sandboxed prompt) | "fresh" (worktree)
47
+ # ── dream + recall (opt-in; defaults reproduce the prior single-shot loop) ─
48
+ "dream_rollouts": 1, # >1 => multi-rollout contrastive reflection per task
49
+ "dream_factor": 0, # >0 => add N synthetic variants of each task to the dream
50
+ "recall_k": 0, # >0 => recall the K most-similar past tasks into the dream
51
+ "evolve_memory": True, # consolidate CLAUDE.md
52
+ "evolve_skill": True, # consolidate the managed SKILL.md
53
+ "llm_mine": True, # use the backend to mine checkable tasks (real backends)
54
+ "target_skill_path": "", # explicit SKILL.md target for repo-scoped agents
55
+ "target_task_filter": True, # prefer mined tasks matching target_skill_path/text
56
+ "progress": False, # print phase progress to stderr
57
+ # ── adoption / safety ──────────────────────────────────────────────────
58
+ "auto_adopt": False, # default: stage + require explicit `adopt`
59
+ "managed_skill_name": "skillopt-sleep-learned",
60
+ "redact_secrets": True,
61
+ "seed": 42,
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class SleepConfig:
67
+ data: Dict[str, Any] = field(default_factory=lambda: dict(DEFAULTS))
68
+
69
+ # convenient attribute access -------------------------------------------
70
+ def __getattr__(self, name: str) -> Any:
71
+ # only called when normal attribute lookup fails
72
+ data = object.__getattribute__(self, "data")
73
+ if name in data:
74
+ return data[name]
75
+ raise AttributeError(name)
76
+
77
+ def get(self, key: str, default: Any = None) -> Any:
78
+ return self.data.get(key, default)
79
+
80
+ def to_dict(self) -> Dict[str, Any]:
81
+ return dict(self.data)
82
+
83
+ # paths ------------------------------------------------------------------
84
+ @property
85
+ def state_dir(self) -> str:
86
+ # Allow full isolation: if the caller overrides state_dir explicitly,
87
+ # honor it; else derive from claude_home's parent so a single
88
+ # --claude-home flag isolates transcripts AND state together; else the
89
+ # default ~/.skillopt-sleep.
90
+ explicit = self.data.get("state_dir")
91
+ if explicit:
92
+ return explicit
93
+ ch = self.data.get("claude_home", CLAUDE_HOME)
94
+ if os.path.abspath(ch) != os.path.abspath(CLAUDE_HOME):
95
+ return os.path.join(os.path.dirname(os.path.abspath(ch)), ".skillopt-sleep")
96
+ return HOME_STATE_DIR
97
+
98
+ @property
99
+ def state_path(self) -> str:
100
+ return os.path.join(self.state_dir, "state.json")
101
+
102
+ @property
103
+ def transcripts_dir(self) -> str:
104
+ return os.path.join(self.data["claude_home"], "projects")
105
+
106
+ @property
107
+ def codex_archived_sessions_dir(self) -> str:
108
+ return os.path.join(self.data["codex_home"], "archived_sessions")
109
+
110
+ @property
111
+ def history_path(self) -> str:
112
+ return os.path.join(self.data["claude_home"], "history.jsonl")
113
+
114
+ @property
115
+ def skills_dir(self) -> str:
116
+ return os.path.join(self.data["claude_home"], "skills")
117
+
118
+ def managed_skill_path(self) -> str:
119
+ target = self.data.get("target_skill_path") or ""
120
+ if target:
121
+ target = os.path.expanduser(str(target))
122
+ if not os.path.isabs(target):
123
+ base = self.data.get("invoked_project") or os.getcwd()
124
+ target = os.path.join(base, target)
125
+ return os.path.abspath(target)
126
+ return os.path.join(
127
+ self.skills_dir, self.data["managed_skill_name"], "SKILL.md"
128
+ )
129
+
130
+
131
+ def _user_config_path() -> Optional[str]:
132
+ for name in ("config.json", "config.yaml", "config.yml"):
133
+ p = os.path.join(HOME_STATE_DIR, name)
134
+ if os.path.exists(p):
135
+ return p
136
+ return None
137
+
138
+
139
+ def _load_file(path: str) -> Dict[str, Any]:
140
+ if path.endswith((".yaml", ".yml")):
141
+ try:
142
+ import yaml # optional
143
+ with open(path) as f:
144
+ return yaml.safe_load(f) or {}
145
+ except Exception:
146
+ return {}
147
+ with open(path) as f:
148
+ return json.load(f)
149
+
150
+
151
+ def load_config(**overrides: Any) -> SleepConfig:
152
+ data = dict(DEFAULTS)
153
+ path = _user_config_path()
154
+ if path:
155
+ try:
156
+ data.update(_load_file(path) or {})
157
+ except Exception:
158
+ pass
159
+ data.update({k: v for k, v in overrides.items() if v is not None})
160
+ if data.get("projects") == "invoked" and not data.get("invoked_project"):
161
+ data["invoked_project"] = os.getcwd()
162
+ return SleepConfig(data=data)
@@ -0,0 +1,238 @@
1
+ """SkillOpt-Sleep — Stage 4: consolidate (one SkillOpt epoch).
2
+
3
+ This is the core that makes nightly evolution *safe*: it proposes bounded
4
+ edits from replayed failures, applies them to a candidate skill/memory, then
5
+ **gates** the candidate on a held-out slice of the user's own tasks. Only a
6
+ candidate that strictly improves the held-out score is accepted — the SkillOpt
7
+ validation gate, vendored self-contained in ``skillopt_sleep.gate``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import List, Optional, Tuple
14
+
15
+ from skillopt_sleep.backend import Backend
16
+ from skillopt_sleep.memory import apply_edits
17
+ from skillopt_sleep.replay import aggregate_scores, replay_batch
18
+ from skillopt_sleep.types import EditRecord, ReplayResult, TaskRecord
19
+
20
+
21
+ # Self-contained validation gate (vendored from SkillOpt; zero dependency on the
22
+ # research package, so this open-source tool stays decoupled from the paper code).
23
+ from skillopt_sleep.gate import evaluate_gate, select_gate_score
24
+ _HAVE_REPO_GATE = True
25
+
26
+
27
+ @dataclass
28
+ class ConsolidationResult:
29
+ accepted: bool
30
+ gate_action: str
31
+ baseline_score: float
32
+ candidate_score: float
33
+ new_skill: str
34
+ new_memory: str
35
+ applied_edits: List[EditRecord]
36
+ rejected_edits: List[EditRecord]
37
+ holdout_baseline: float
38
+ holdout_candidate: float
39
+
40
+
41
+ def _split(tasks: List[TaskRecord]) -> Tuple[List[TaskRecord], List[TaskRecord]]:
42
+ """Return (train_tasks, val_tasks).
43
+
44
+ train drives reflect; val gates updates. test is held out entirely from
45
+ consolidation and is scored by the caller. Accepts legacy split names
46
+ (replay->train, holdout->val) for robustness.
47
+ """
48
+ def _norm(s: str) -> str:
49
+ return {"replay": "train", "holdout": "val"}.get(s, s)
50
+
51
+ train = [t for t in tasks if _norm(t.split) == "train"]
52
+ val = [t for t in tasks if _norm(t.split) == "val"]
53
+ # be robust if a split is empty: fall back so a night still does something,
54
+ # but never silently use test as val.
55
+ test = [t for t in tasks if _norm(t.split) == "test"]
56
+ if not val:
57
+ # prefer train as the gate reference over nothing; last resort all-but-test
58
+ val = train or [t for t in tasks if _norm(t.split) != "test"] or tasks
59
+ if not train:
60
+ train = val
61
+ return train, val
62
+
63
+
64
+ def consolidate(
65
+ backend: Backend,
66
+ tasks: List[TaskRecord],
67
+ skill: str,
68
+ memory: str,
69
+ *,
70
+ edit_budget: int = 4,
71
+ gate_metric: str = "mixed",
72
+ gate_mixed_weight: float = 0.5,
73
+ gate_mode: str = "on", # "on" (hard/soft per gate_metric) | "off" (greedy)
74
+ rollouts_k: int = 1, # >1 => multi-rollout contrastive reflection
75
+ evolve_skill: bool = True,
76
+ evolve_memory: bool = True,
77
+ night: int = 1,
78
+ ) -> ConsolidationResult:
79
+ """Run one consolidation epoch: reflect -> bounded edit -> gate.
80
+
81
+ train tasks drive reflect; val tasks gate the update (test is held out by the
82
+ caller). With ``gate_mode='off'`` edits are accepted greedily (no val-improve
83
+ requirement) — the user opts out of hard filtering — but val scores are still
84
+ recorded so the report shows whether quality moved.
85
+
86
+ Skill and memory are evolved in sequence (skill first if both enabled).
87
+ """
88
+ train_tasks, val_tasks = _split(tasks)
89
+ gate_off = str(gate_mode).strip().lower() in {"off", "none", "false", "greedy"}
90
+
91
+ # ── baseline on the VAL slice (the gate reference) ────────────────────
92
+ # When the gate is OFF the user has opted out of holding out a validation set
93
+ # (the daily-use design): we accept edits greedily and judge quality only on
94
+ # the real test set, scored by the caller. So we SKIP all val scoring — it is
95
+ # both wasted cost and contrary to the "no val set required" design.
96
+ if gate_off:
97
+ base_hard, base_soft = 0.0, 0.0
98
+ else:
99
+ base_pairs = replay_batch(backend, val_tasks, skill, memory)
100
+ base_hard, base_soft = aggregate_scores(base_pairs)
101
+ base_score = select_gate_score(base_hard, base_soft, gate_metric, gate_mixed_weight)
102
+
103
+ # ── reflect over TRAIN-split failures/successes ───────────────────────
104
+ train_pairs = replay_batch(backend, train_tasks, skill, memory)
105
+ failures = [(t, r) for (t, r) in train_pairs if r.hard < 1.0]
106
+ successes = [(t, r) for (t, r) in train_pairs if r.hard >= 1.0]
107
+
108
+ cand_skill, cand_memory = skill, memory
109
+ all_applied: List[EditRecord] = []
110
+ all_rejected: List[EditRecord] = []
111
+
112
+ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
113
+ nonlocal cand_skill, cand_memory, base_score, all_applied, all_rejected
114
+ if not edits:
115
+ return doc
116
+ new_doc, applied = apply_edits(doc, edits)
117
+ if not applied:
118
+ return doc
119
+ # gate OFF: accept greedily with NO val scoring (the daily-use path)
120
+ if gate_off:
121
+ all_applied.extend(applied)
122
+ return new_doc
123
+ # gate ON: score the candidate on the VAL slice, keep only if it improves
124
+ trial_skill = new_doc if which == "skill" else cand_skill
125
+ trial_memory = new_doc if which == "memory" else cand_memory
126
+ pairs = replay_batch(backend, val_tasks, trial_skill, trial_memory)
127
+ h, s = aggregate_scores(pairs)
128
+ cand_score = select_gate_score(h, s, gate_metric, gate_mixed_weight)
129
+ if cand_score > base_score:
130
+ base_score = max(base_score, cand_score)
131
+ all_applied.extend(applied)
132
+ return new_doc
133
+ all_rejected.extend(applied)
134
+ return doc
135
+
136
+ if evolve_skill:
137
+ if rollouts_k > 1:
138
+ # multi-rollout contrastive reflection: run each train task K times
139
+ # and distill a rule from the good-vs-bad contrast (the imagination signal).
140
+ from skillopt_sleep.rollout import multi_rollout, contrastive_reflect
141
+ # Parallelize across tasks (each multi_rollout also parallelizes its K
142
+ # attempts). This dream phase is the dominant cost; serial execution
143
+ # times out on real backends. Cap total in-flight at the worker env.
144
+ import os
145
+ from concurrent.futures import ThreadPoolExecutor
146
+ try:
147
+ _w = int(os.environ.get("SKILLOPT_SLEEP_WORKERS", "1"))
148
+ except ValueError:
149
+ _w = 1
150
+ if _w > 1 and len(train_tasks) > 1:
151
+ # split the worker budget between task-parallelism and per-task K
152
+ task_workers = max(1, min(len(train_tasks), _w))
153
+ per_task = max(1, _w // task_workers)
154
+ with ThreadPoolExecutor(max_workers=task_workers) as ex:
155
+ sets = list(ex.map(
156
+ lambda t: multi_rollout(backend, t, cand_skill, cand_memory,
157
+ k=rollouts_k, workers=per_task),
158
+ train_tasks))
159
+ else:
160
+ sets = [multi_rollout(backend, t, cand_skill, cand_memory,
161
+ k=rollouts_k, workers=1)
162
+ for t in train_tasks]
163
+ edits = contrastive_reflect(
164
+ backend, sets, cand_skill, cand_memory,
165
+ edit_budget=edit_budget, target="skill",
166
+ )
167
+ # fall back to single-shot reflect if contrast yielded nothing
168
+ if not edits:
169
+ edits = backend.reflect(
170
+ failures, successes, cand_skill, cand_memory,
171
+ edit_budget=edit_budget, evolve_skill=True, evolve_memory=False,
172
+ )
173
+ else:
174
+ edits = backend.reflect(
175
+ failures, successes, cand_skill, cand_memory,
176
+ edit_budget=edit_budget, evolve_skill=True, evolve_memory=False,
177
+ )
178
+ cand_skill = _gate_apply(cand_skill, edits, "skill")
179
+
180
+ if evolve_memory:
181
+ # re-evaluate failures under the (possibly improved) skill
182
+ train_pairs2 = replay_batch(backend, train_tasks, cand_skill, cand_memory)
183
+ failures2 = [(t, r) for (t, r) in train_pairs2 if r.hard < 1.0]
184
+ successes2 = [(t, r) for (t, r) in train_pairs2 if r.hard >= 1.0]
185
+ edits_m = backend.reflect(
186
+ failures2, successes2, cand_skill, cand_memory,
187
+ edit_budget=edit_budget, evolve_skill=False, evolve_memory=True,
188
+ )
189
+ cand_memory = _gate_apply(cand_memory, edits_m, "memory")
190
+
191
+ # ── final decision ────────────────────────────────────────────────────
192
+ if gate_off:
193
+ # greedy mode: no val scoring at all. Keep whatever edits we applied; the
194
+ # caller measures real quality on the test set. We report holdout_candidate
195
+ # as 0.0 (val intentionally not computed in this variant).
196
+ final_hard, final_soft = 0.0, 0.0
197
+ final_score = 0.0
198
+ accepted = bool(all_applied)
199
+ action = "greedy_applied" if all_applied else "greedy_noop"
200
+ base_gate_score = 0.0
201
+ else:
202
+ # scored on the VAL slice (the gate reference)
203
+ final_pairs = replay_batch(backend, val_tasks, cand_skill, cand_memory)
204
+ final_hard, final_soft = aggregate_scores(final_pairs)
205
+ final_score = select_gate_score(final_hard, final_soft, gate_metric, gate_mixed_weight)
206
+ base_gate_score = select_gate_score(base_hard, base_soft, gate_metric, gate_mixed_weight)
207
+ if _HAVE_REPO_GATE:
208
+ gate = evaluate_gate(
209
+ candidate_skill=cand_skill,
210
+ cand_hard=final_hard,
211
+ current_skill=skill,
212
+ current_score=base_gate_score,
213
+ best_skill=skill,
214
+ best_score=base_gate_score,
215
+ best_step=night - 1,
216
+ global_step=night,
217
+ cand_soft=final_soft,
218
+ metric=gate_metric,
219
+ mixed_weight=gate_mixed_weight,
220
+ )
221
+ action = gate.action
222
+ accepted = bool(all_applied) and final_score > base_gate_score
223
+ else:
224
+ action = "accept" if final_score > base_gate_score else "reject"
225
+ accepted = bool(all_applied) and final_score > base_gate_score
226
+
227
+ return ConsolidationResult(
228
+ accepted=accepted,
229
+ gate_action=action,
230
+ baseline_score=base_gate_score,
231
+ candidate_score=final_score,
232
+ new_skill=cand_skill if accepted else skill,
233
+ new_memory=cand_memory if accepted else memory,
234
+ applied_edits=all_applied,
235
+ rejected_edits=all_rejected,
236
+ holdout_baseline=base_hard,
237
+ holdout_candidate=final_hard,
238
+ )