self-evolve-framework 1.3.0 → 1.5.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.
- package/package.json +1 -1
- package/template/rules/ponytail.mdc +98 -23
- package/template/skills/skillopt-sleep/SKILL.md +48 -34
- package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
- package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
- package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
- package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
- package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
- package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
- package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
- package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
- package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
- package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
- package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
- package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
- package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
- package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
- package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
- package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
- package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
- package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
- package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
- package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
- package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
- package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
- package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
- package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
- package/template/skills/skillopt-sleep/scripts/shell/train.py +556 -0
- package/template/skills/ponytail/SKILL.md +0 -117
- package/template/skills/ponytail-audit/SKILL.md +0 -41
- package/template/skills/ponytail-debt/SKILL.md +0 -44
- package/template/skills/ponytail-gain/SKILL.md +0 -50
- package/template/skills/ponytail-help/SKILL.md +0 -69
- package/template/skills/ponytail-review/SKILL.md +0 -57
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — Stage 2: mine.
|
|
2
|
+
|
|
3
|
+
Turn :class:`SessionDigest` objects into :class:`TaskRecord` training units.
|
|
4
|
+
|
|
5
|
+
Two miners:
|
|
6
|
+
* heuristic_mine — deterministic, no API. Detects retry chains (a prompt
|
|
7
|
+
re-asked after negative feedback => the early attempt failed), extracts
|
|
8
|
+
the user's recurring intents, and labels outcomes from feedback signals.
|
|
9
|
+
* llm_mine — optional; uses an optimizer backend to produce richer
|
|
10
|
+
TaskRecords with checkable references. Falls back to heuristic on error.
|
|
11
|
+
|
|
12
|
+
The heuristic miner is what makes the whole cycle runnable offline and is the
|
|
13
|
+
basis of the deterministic experiment.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from typing import Any, Callable, List, Optional, Set, Tuple
|
|
22
|
+
|
|
23
|
+
from skillopt_sleep.types import SessionDigest, TaskRecord
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tid(project: str, intent: str) -> str:
|
|
27
|
+
h = hashlib.sha256((project + "::" + intent).encode("utf-8")).hexdigest()[:12]
|
|
28
|
+
return "task_" + h
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _short(text: str, n: int = 600) -> str:
|
|
32
|
+
text = (text or "").strip()
|
|
33
|
+
return text if len(text) <= n else text[:n] + " …"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _looks_negative(signals: List[str]) -> bool:
|
|
37
|
+
return any(s.startswith("neg:") for s in signals)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _looks_positive(signals: List[str]) -> bool:
|
|
41
|
+
return any(s.startswith("pos:") for s in signals)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_TARGET_STOPWORDS = {
|
|
45
|
+
"about", "after", "again", "agent", "agents", "all", "also", "always",
|
|
46
|
+
"and", "any", "are", "before", "being", "but", "can", "codex",
|
|
47
|
+
"current", "default", "docs", "does", "done", "each", "file", "files",
|
|
48
|
+
"for", "from", "have", "into", "keep", "must", "not", "only", "path",
|
|
49
|
+
"paths", "project", "read", "repo", "request", "requests", "rule",
|
|
50
|
+
"rules", "same", "should", "skill", "skills", "source", "start",
|
|
51
|
+
"task", "tasks", "that", "the", "their", "then", "this", "unless",
|
|
52
|
+
"update", "user", "users", "when", "with", "work", "workflow",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _target_tokens(text: str) -> List[str]:
|
|
57
|
+
tokens: List[str] = []
|
|
58
|
+
for raw in re.findall(r"[\w][\w.-]*", (text or "").lower(), flags=re.UNICODE):
|
|
59
|
+
parts = [raw] + re.split(r"[\W_]+", raw, flags=re.UNICODE)
|
|
60
|
+
for part in parts:
|
|
61
|
+
if len(part) < 3 or part.isdigit() or part in _TARGET_STOPWORDS:
|
|
62
|
+
continue
|
|
63
|
+
tokens.append(part)
|
|
64
|
+
return tokens
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _expand_target_keywords(keywords: Set[str]) -> None:
|
|
68
|
+
if "mcp" in keywords:
|
|
69
|
+
keywords.update({
|
|
70
|
+
"configure", "configuration", "connect", "connected", "enable",
|
|
71
|
+
"enabled", "install", "installed", "server", "servers",
|
|
72
|
+
"настрой", "настроить", "подключи", "подключить",
|
|
73
|
+
})
|
|
74
|
+
if {"conflict", "conflicts"} & keywords:
|
|
75
|
+
keywords.update({
|
|
76
|
+
"cherry", "conflict", "conflicts", "git", "merge", "rebase",
|
|
77
|
+
"unmerged", "конфликт", "конфликты",
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def target_task_keywords(
|
|
82
|
+
target_skill_text: str,
|
|
83
|
+
target_skill_path: str = "",
|
|
84
|
+
*,
|
|
85
|
+
limit: int = 180,
|
|
86
|
+
) -> Tuple[Set[str], Set[str]]:
|
|
87
|
+
"""Return (strong, weak) keywords that describe a target skill."""
|
|
88
|
+
path_text = (target_skill_path or "").replace(os.sep, " ")
|
|
89
|
+
headings = "\n".join(re.findall(r"(?m)^#+\s+(.+)$", target_skill_text or ""))
|
|
90
|
+
strong = set(_target_tokens(path_text + "\n" + headings))
|
|
91
|
+
weak = set(strong)
|
|
92
|
+
counts = Counter(_target_tokens(target_skill_text or ""))
|
|
93
|
+
for token, _count in counts.most_common(limit):
|
|
94
|
+
weak.add(token)
|
|
95
|
+
_expand_target_keywords(strong)
|
|
96
|
+
_expand_target_keywords(weak)
|
|
97
|
+
return strong, weak
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _task_search_text(task: TaskRecord) -> str:
|
|
101
|
+
return "\n".join([
|
|
102
|
+
task.intent or "",
|
|
103
|
+
task.context_excerpt or "",
|
|
104
|
+
" ".join(task.tags or []),
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def filter_tasks_for_target(
|
|
109
|
+
tasks: List[TaskRecord],
|
|
110
|
+
target_skill_text: str,
|
|
111
|
+
target_skill_path: str = "",
|
|
112
|
+
) -> List[TaskRecord]:
|
|
113
|
+
"""Prefer tasks whose language overlaps the explicit target skill.
|
|
114
|
+
|
|
115
|
+
If nothing matches, return the original list. This keeps a target run useful
|
|
116
|
+
even when transcripts are too sparse or the skill is too generic.
|
|
117
|
+
"""
|
|
118
|
+
strong, weak = target_task_keywords(target_skill_text, target_skill_path)
|
|
119
|
+
if not tasks or not (strong or weak):
|
|
120
|
+
return tasks
|
|
121
|
+
|
|
122
|
+
ranked = []
|
|
123
|
+
for idx, task in enumerate(tasks):
|
|
124
|
+
tokens = set(_target_tokens(_task_search_text(task)))
|
|
125
|
+
strong_hits = tokens & strong
|
|
126
|
+
weak_hits = tokens & weak
|
|
127
|
+
if not strong_hits and len(weak_hits) < 2:
|
|
128
|
+
continue
|
|
129
|
+
score = len(strong_hits) * 3 + len(weak_hits)
|
|
130
|
+
ranked.append((score, idx, task))
|
|
131
|
+
if not ranked:
|
|
132
|
+
return tasks
|
|
133
|
+
ranked.sort(key=lambda item: (-item[0], item[1]))
|
|
134
|
+
return [task for _score, _idx, task in ranked]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def heuristic_mine(
|
|
138
|
+
digests: List[SessionDigest],
|
|
139
|
+
*,
|
|
140
|
+
max_tasks: int = 40,
|
|
141
|
+
) -> List[TaskRecord]:
|
|
142
|
+
"""Deterministic miner — no API calls.
|
|
143
|
+
|
|
144
|
+
Strategy:
|
|
145
|
+
* Each session with >=1 real user prompt yields one TaskRecord whose
|
|
146
|
+
intent is the FIRST substantive prompt (the original ask).
|
|
147
|
+
* Outcome is inferred:
|
|
148
|
+
- negative feedback present and no later positive -> "fail"
|
|
149
|
+
- positive feedback present -> "success"
|
|
150
|
+
- re-asks (multiple user turns) without resolution -> "mixed"
|
|
151
|
+
- otherwise -> "unknown"
|
|
152
|
+
* attempted_solution = the last assistant final (what was produced).
|
|
153
|
+
* reference_kind defaults to "none"; the consolidation step will use a
|
|
154
|
+
rubric judge for these. (Exact refs are added by the experiment data
|
|
155
|
+
or by the LLM miner when it can derive a checkable answer.)
|
|
156
|
+
"""
|
|
157
|
+
tasks: List[TaskRecord] = []
|
|
158
|
+
for d in digests:
|
|
159
|
+
if not d.user_prompts:
|
|
160
|
+
continue
|
|
161
|
+
intent = d.user_prompts[0]
|
|
162
|
+
if len(intent.strip()) < 8:
|
|
163
|
+
continue
|
|
164
|
+
if _looks_positive(d.feedback_signals) and not _looks_negative(d.feedback_signals):
|
|
165
|
+
outcome = "success"
|
|
166
|
+
elif _looks_negative(d.feedback_signals):
|
|
167
|
+
outcome = "fail"
|
|
168
|
+
elif d.n_user_turns >= 3:
|
|
169
|
+
outcome = "mixed"
|
|
170
|
+
else:
|
|
171
|
+
outcome = "unknown"
|
|
172
|
+
|
|
173
|
+
attempted = d.assistant_finals[-1] if d.assistant_finals else ""
|
|
174
|
+
context = ""
|
|
175
|
+
if len(d.user_prompts) > 1:
|
|
176
|
+
# later prompts often carry the corrective detail / real constraints
|
|
177
|
+
context = "Follow-up constraints from the same session:\n- " + "\n- ".join(
|
|
178
|
+
_short(p, 200) for p in d.user_prompts[1:4]
|
|
179
|
+
)
|
|
180
|
+
tags = []
|
|
181
|
+
if d.tools_used:
|
|
182
|
+
tags.append("tools:" + "+".join(d.tools_used[:4]))
|
|
183
|
+
if d.git_branch:
|
|
184
|
+
tags.append("branch:" + d.git_branch)
|
|
185
|
+
|
|
186
|
+
tasks.append(
|
|
187
|
+
TaskRecord(
|
|
188
|
+
id=_tid(d.project, intent),
|
|
189
|
+
project=d.project,
|
|
190
|
+
intent=_short(intent, 800),
|
|
191
|
+
context_excerpt=_short(context, 600),
|
|
192
|
+
attempted_solution=_short(attempted, 600),
|
|
193
|
+
outcome=outcome,
|
|
194
|
+
reference_kind="none",
|
|
195
|
+
reference="",
|
|
196
|
+
tags=tags,
|
|
197
|
+
source_sessions=[d.session_id],
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
if len(tasks) >= max_tasks:
|
|
201
|
+
break
|
|
202
|
+
return tasks
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def dedup_tasks(tasks: List[TaskRecord]) -> List[TaskRecord]:
|
|
206
|
+
"""Merge tasks sharing an id (same project+intent across sessions)."""
|
|
207
|
+
by_id: dict = {}
|
|
208
|
+
for t in tasks:
|
|
209
|
+
if t.id in by_id:
|
|
210
|
+
ex = by_id[t.id]
|
|
211
|
+
ex.source_sessions = list(dict.fromkeys(ex.source_sessions + t.source_sessions))
|
|
212
|
+
# prefer a resolved outcome if either session resolved it
|
|
213
|
+
order = {"success": 3, "fail": 2, "mixed": 1, "unknown": 0}
|
|
214
|
+
if order.get(t.outcome, 0) > order.get(ex.outcome, 0):
|
|
215
|
+
ex.outcome = t.outcome
|
|
216
|
+
else:
|
|
217
|
+
by_id[t.id] = t
|
|
218
|
+
return list(by_id.values())
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def assign_splits(
|
|
222
|
+
tasks: List[TaskRecord],
|
|
223
|
+
*,
|
|
224
|
+
val_fraction: float = 0.34,
|
|
225
|
+
test_fraction: float = 0.0,
|
|
226
|
+
holdout_fraction: float | None = None, # legacy alias for val_fraction
|
|
227
|
+
seed: int = 42,
|
|
228
|
+
) -> List[TaskRecord]:
|
|
229
|
+
"""Deterministically split tasks into train / val / test.
|
|
230
|
+
|
|
231
|
+
Anti-overfitting contract (the user's design):
|
|
232
|
+
* ``val`` and ``test`` are drawn ONLY from REAL mined tasks (origin=='real')
|
|
233
|
+
and never overlap. val gates updates; test is the final held-out measure.
|
|
234
|
+
* ``train`` may include DREAM-augmented tasks (origin=='dream'); those are
|
|
235
|
+
NEVER placed in val/test.
|
|
236
|
+
|
|
237
|
+
A stable hash of the task id keeps the same real task in the same split across
|
|
238
|
+
nights (a fixed held-out gate, like SkillOpt's D_sel/D_test).
|
|
239
|
+
|
|
240
|
+
Back-compat: if ``test_fraction`` is 0 (default), this behaves like the old
|
|
241
|
+
two-way replay/holdout split — real tasks divide into train + val, no test.
|
|
242
|
+
``holdout_fraction`` is accepted as an alias for ``val_fraction``.
|
|
243
|
+
"""
|
|
244
|
+
if holdout_fraction is not None:
|
|
245
|
+
val_fraction = holdout_fraction
|
|
246
|
+
|
|
247
|
+
dream = [t for t in tasks if t.origin == "dream"]
|
|
248
|
+
real = [t for t in tasks if t.origin != "dream"]
|
|
249
|
+
|
|
250
|
+
# all dream tasks go to train, unconditionally
|
|
251
|
+
for t in dream:
|
|
252
|
+
t.split = "train"
|
|
253
|
+
|
|
254
|
+
val_cut = int(round(val_fraction * 100))
|
|
255
|
+
test_cut = val_cut + int(round(test_fraction * 100))
|
|
256
|
+
for t in real:
|
|
257
|
+
bucket = int(hashlib.sha256((str(seed) + t.id).encode()).hexdigest(), 16) % 100
|
|
258
|
+
if bucket < val_cut:
|
|
259
|
+
t.split = "val"
|
|
260
|
+
elif bucket < test_cut:
|
|
261
|
+
t.split = "test"
|
|
262
|
+
else:
|
|
263
|
+
t.split = "train"
|
|
264
|
+
|
|
265
|
+
# guarantee val (the gate) is non-empty when we have >=2 real tasks
|
|
266
|
+
real_splits = {t.split for t in real}
|
|
267
|
+
if len(real) >= 2 and "val" not in real_splits:
|
|
268
|
+
real[-1].split = "val"
|
|
269
|
+
# guarantee a train pool exists (dream or real) when possible
|
|
270
|
+
if not any(t.split == "train" for t in tasks) and len(real) >= 2:
|
|
271
|
+
real[0].split = "train"
|
|
272
|
+
# if test was requested but ended up empty with >=3 real tasks, carve one
|
|
273
|
+
if test_fraction > 0 and len(real) >= 3 and not any(t.split == "test" for t in real):
|
|
274
|
+
for t in real:
|
|
275
|
+
if t.split == "train":
|
|
276
|
+
t.split = "test"
|
|
277
|
+
break
|
|
278
|
+
return tasks
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def normalize_legacy_split(value: str) -> str:
|
|
282
|
+
"""Map old split names to the new vocabulary."""
|
|
283
|
+
return {"replay": "train", "holdout": "val"}.get(value, value)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def mine(
|
|
287
|
+
digests: List[SessionDigest],
|
|
288
|
+
*,
|
|
289
|
+
max_tasks: int = 40,
|
|
290
|
+
candidate_limit: int = 0,
|
|
291
|
+
holdout_fraction: float = 0.34,
|
|
292
|
+
seed: int = 42,
|
|
293
|
+
llm_miner: Optional[Callable[[List[SessionDigest]], List[TaskRecord]]] = None,
|
|
294
|
+
target_skill_text: str = "",
|
|
295
|
+
target_skill_path: str = "",
|
|
296
|
+
) -> List[TaskRecord]:
|
|
297
|
+
"""Top-level miner. Uses ``llm_miner`` if provided, else heuristic."""
|
|
298
|
+
candidate_limit = candidate_limit or max_tasks
|
|
299
|
+
tasks: List[TaskRecord] = []
|
|
300
|
+
if llm_miner is not None:
|
|
301
|
+
try:
|
|
302
|
+
tasks = llm_miner(digests) or []
|
|
303
|
+
except Exception:
|
|
304
|
+
tasks = []
|
|
305
|
+
if not tasks:
|
|
306
|
+
tasks = heuristic_mine(digests, max_tasks=candidate_limit)
|
|
307
|
+
tasks = dedup_tasks(tasks)
|
|
308
|
+
if target_skill_text or target_skill_path:
|
|
309
|
+
tasks = filter_tasks_for_target(tasks, target_skill_text, target_skill_path)
|
|
310
|
+
tasks = tasks[:max_tasks]
|
|
311
|
+
tasks = assign_splits(tasks, holdout_fraction=holdout_fraction, seed=seed)
|
|
312
|
+
return tasks
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — Stage 3: replay.
|
|
2
|
+
|
|
3
|
+
Re-run mined TaskRecords offline under a given (skill, memory) and score
|
|
4
|
+
them, producing the (hard, soft) signal SkillOpt's gate consumes.
|
|
5
|
+
|
|
6
|
+
Single-shot text replay by default. Tasks whose rule judge requires a tool
|
|
7
|
+
call (gbrain's `tool_called`) are run through the backend's real tool loop
|
|
8
|
+
(attempt_with_tools), so tool use is verified honestly rather than self-reported.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List, Tuple
|
|
13
|
+
|
|
14
|
+
from skillopt_sleep.backend import Backend
|
|
15
|
+
from skillopt_sleep.types import ReplayResult, TaskRecord
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _required_tools(task: TaskRecord) -> List[str]:
|
|
19
|
+
"""Tool names a rule judge requires (op == 'tool_called')."""
|
|
20
|
+
if task.reference_kind != "rule" or not task.judge:
|
|
21
|
+
return []
|
|
22
|
+
tools = []
|
|
23
|
+
for c in task.judge.get("checks", []) or []:
|
|
24
|
+
if isinstance(c, dict) and c.get("op") == "tool_called" and c.get("arg"):
|
|
25
|
+
tools.append(str(c["arg"]))
|
|
26
|
+
return tools
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def replay_one(backend: Backend, task: TaskRecord, skill: str, memory: str,
|
|
30
|
+
sample_id: int = 0) -> ReplayResult:
|
|
31
|
+
"""``sample_id`` distinguishes repeated dream rollouts of the same
|
|
32
|
+
(task, skill, memory) in the attempt cache — without it all K rollouts
|
|
33
|
+
collapse to one cached response and the contrastive signal is always 0."""
|
|
34
|
+
import time
|
|
35
|
+
tools = _required_tools(task)
|
|
36
|
+
tools_called: List[str] = []
|
|
37
|
+
t0 = time.time()
|
|
38
|
+
tok_before = backend.tokens_used()
|
|
39
|
+
if tools:
|
|
40
|
+
response, tools_called = backend.attempt_with_tools(task, skill, memory, tools)
|
|
41
|
+
else:
|
|
42
|
+
response = backend.attempt(task, skill, memory, sample_id=sample_id)
|
|
43
|
+
latency_ms = (time.time() - t0) * 1000.0
|
|
44
|
+
tokens = max(0, backend.tokens_used() - tok_before)
|
|
45
|
+
# if the backend doesn't track tokens (e.g. mock), approximate from text length
|
|
46
|
+
if tokens == 0:
|
|
47
|
+
tokens = (len(skill) + len(memory) + len(task.intent) + len(response)) // 4
|
|
48
|
+
|
|
49
|
+
# rule judges may need the detected tool calls; score locally when possible
|
|
50
|
+
if task.reference_kind == "rule" and task.judge:
|
|
51
|
+
from skillopt_sleep.judges import score_rule_judge
|
|
52
|
+
hard, soft, rationale = score_rule_judge(task.judge, response, tools_called)
|
|
53
|
+
else:
|
|
54
|
+
hard, soft, rationale = backend.judge(task, response)
|
|
55
|
+
|
|
56
|
+
return ReplayResult(
|
|
57
|
+
id=task.id,
|
|
58
|
+
hard=float(hard),
|
|
59
|
+
soft=float(soft),
|
|
60
|
+
response=response,
|
|
61
|
+
fail_reason="" if hard >= 1.0 else (rationale or "below threshold"),
|
|
62
|
+
task_type=(task.tags[0] if task.tags else "task"),
|
|
63
|
+
judge_rationale=rationale,
|
|
64
|
+
tools_called=tools_called,
|
|
65
|
+
tokens=int(tokens),
|
|
66
|
+
latency_ms=round(latency_ms, 1),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
import os
|
|
71
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def replay_batch(
|
|
75
|
+
backend: Backend,
|
|
76
|
+
tasks: List[TaskRecord],
|
|
77
|
+
skill: str,
|
|
78
|
+
memory: str,
|
|
79
|
+
*,
|
|
80
|
+
workers: int = 0,
|
|
81
|
+
) -> List[Tuple[TaskRecord, ReplayResult]]:
|
|
82
|
+
"""Replay tasks, optionally in parallel.
|
|
83
|
+
|
|
84
|
+
Real backends are network-bound, so a thread pool gives a large speedup on
|
|
85
|
+
big test sets (like the research harness's --workers). ``workers`` defaults
|
|
86
|
+
to env SKILLOPT_SLEEP_WORKERS or 1 (sequential). Mock stays sequential
|
|
87
|
+
(deterministic) unless asked otherwise.
|
|
88
|
+
"""
|
|
89
|
+
if workers <= 0:
|
|
90
|
+
workers = int(os.environ.get("SKILLOPT_SLEEP_WORKERS", "1") or "1")
|
|
91
|
+
if workers <= 1 or len(tasks) <= 1:
|
|
92
|
+
return [(t, replay_one(backend, t, skill, memory)) for t in tasks]
|
|
93
|
+
results: List = [None] * len(tasks)
|
|
94
|
+
with ThreadPoolExecutor(max_workers=min(workers, len(tasks))) as ex:
|
|
95
|
+
futs = {ex.submit(replay_one, backend, t, skill, memory): i
|
|
96
|
+
for i, t in enumerate(tasks)}
|
|
97
|
+
for fut in futs:
|
|
98
|
+
i = futs[fut]
|
|
99
|
+
results[i] = (tasks[i], fut.result())
|
|
100
|
+
return results
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def aggregate_scores(pairs: List[Tuple[TaskRecord, ReplayResult]]) -> Tuple[float, float]:
|
|
104
|
+
if not pairs:
|
|
105
|
+
return 0.0, 0.0
|
|
106
|
+
hard = sum(r.hard for _t, r in pairs) / len(pairs)
|
|
107
|
+
soft = sum(r.soft for _t, r in pairs) / len(pairs)
|
|
108
|
+
return hard, soft
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def aggregate_cost(pairs: List[Tuple[TaskRecord, ReplayResult]]) -> Tuple[float, float]:
|
|
112
|
+
"""Mean (tokens, latency_ms) per task — the cost objectives."""
|
|
113
|
+
if not pairs:
|
|
114
|
+
return 0.0, 0.0
|
|
115
|
+
tok = sum(r.tokens for _t, r in pairs) / len(pairs)
|
|
116
|
+
lat = sum(r.latency_ms for _t, r in pairs) / len(pairs)
|
|
117
|
+
return tok, lat
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def multi_objective_reward(
|
|
121
|
+
pairs: List[Tuple[TaskRecord, ReplayResult]],
|
|
122
|
+
*,
|
|
123
|
+
w_acc: float = 1.0,
|
|
124
|
+
w_tokens: float = 0.0,
|
|
125
|
+
w_latency: float = 0.0,
|
|
126
|
+
token_ref: float = 2000.0,
|
|
127
|
+
latency_ref_ms: float = 15000.0,
|
|
128
|
+
) -> float:
|
|
129
|
+
"""Weighted reward = accuracy↑, tokens↓, latency↓.
|
|
130
|
+
|
|
131
|
+
Cost terms are normalized against a reference and clamped to [0,1], so a
|
|
132
|
+
response at/under the reference cost contributes ~1.0 and an expensive one
|
|
133
|
+
less. Weights let the user trade off (default = accuracy only, backward
|
|
134
|
+
compatible).
|
|
135
|
+
"""
|
|
136
|
+
if not pairs:
|
|
137
|
+
return 0.0
|
|
138
|
+
acc, _soft = aggregate_scores(pairs)
|
|
139
|
+
tok, lat = aggregate_cost(pairs)
|
|
140
|
+
tok_score = max(0.0, 1.0 - tok / max(1.0, token_ref)) if token_ref else 0.0
|
|
141
|
+
lat_score = max(0.0, 1.0 - lat / max(1.0, latency_ref_ms)) if latency_ref_ms else 0.0
|
|
142
|
+
total_w = w_acc + w_tokens + w_latency
|
|
143
|
+
if total_w <= 0:
|
|
144
|
+
return acc
|
|
145
|
+
return (w_acc * acc + w_tokens * tok_score + w_latency * lat_score) / total_w
|
|
146
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — multi-rollout + contrastive reflection (the imagination core).
|
|
2
|
+
|
|
3
|
+
The core idea: let the agent re-run the SAME task many times, then look at
|
|
4
|
+
which rollouts went well vs badly and distill a rule from the *contrast*. This
|
|
5
|
+
is a much stronger learning signal than a single failure, and it is the essence
|
|
6
|
+
of the offline "dream/imagination" process — train-time rollouts are synthetic,
|
|
7
|
+
so doing many is fine.
|
|
8
|
+
|
|
9
|
+
Pieces:
|
|
10
|
+
* multi_rollout — run one task K times under (skill, memory), return scored attempts
|
|
11
|
+
* contrastive_reflect — given good vs bad attempts of the same tasks, ask the
|
|
12
|
+
optimizer what distinguishes them and propose a general rule
|
|
13
|
+
|
|
14
|
+
Driven through the Backend abstraction (mock/claude/codex), import-light.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from skillopt_sleep.backend import Backend, _extract_json
|
|
22
|
+
from skillopt_sleep.replay import replay_one
|
|
23
|
+
from skillopt_sleep.types import EditRecord, ReplayResult, TaskRecord
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RolloutSet:
|
|
28
|
+
"""K scored attempts at one task under a fixed (skill, memory)."""
|
|
29
|
+
task: TaskRecord
|
|
30
|
+
attempts: List[ReplayResult] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def best(self) -> Optional[ReplayResult]:
|
|
34
|
+
return max(self.attempts, key=lambda r: r.hard, default=None)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def worst(self) -> Optional[ReplayResult]:
|
|
38
|
+
return min(self.attempts, key=lambda r: r.hard, default=None)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def spread(self) -> float:
|
|
42
|
+
if not self.attempts:
|
|
43
|
+
return 0.0
|
|
44
|
+
hs = [r.hard for r in self.attempts]
|
|
45
|
+
return max(hs) - min(hs)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def pass_rate(self) -> float:
|
|
49
|
+
if not self.attempts:
|
|
50
|
+
return 0.0
|
|
51
|
+
return sum(1 for r in self.attempts if r.hard >= 1.0) / len(self.attempts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def multi_rollout(
|
|
55
|
+
backend: Backend,
|
|
56
|
+
task: TaskRecord,
|
|
57
|
+
skill: str,
|
|
58
|
+
memory: str,
|
|
59
|
+
*,
|
|
60
|
+
k: int = 3,
|
|
61
|
+
workers: int = 0,
|
|
62
|
+
) -> RolloutSet:
|
|
63
|
+
"""Run ``task`` K times. replay_one is deterministic for mock; for real
|
|
64
|
+
backends the model's own sampling yields variation across attempts.
|
|
65
|
+
|
|
66
|
+
The K attempts are independent, so they run concurrently (this is the dream
|
|
67
|
+
phase's dominant cost). ``workers`` defaults to the SKILLOPT_SLEEP_WORKERS
|
|
68
|
+
env (capped at k); set to 1 to force serial (used by the mock tests).
|
|
69
|
+
"""
|
|
70
|
+
import os
|
|
71
|
+
rs = RolloutSet(task=task)
|
|
72
|
+
k = max(1, k)
|
|
73
|
+
if workers <= 0:
|
|
74
|
+
try:
|
|
75
|
+
workers = int(os.environ.get("SKILLOPT_SLEEP_WORKERS", "1"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
workers = 1
|
|
78
|
+
workers = max(1, min(workers, k))
|
|
79
|
+
if workers == 1:
|
|
80
|
+
for i in range(k):
|
|
81
|
+
rs.attempts.append(replay_one(backend, task, skill, memory, sample_id=i))
|
|
82
|
+
return rs
|
|
83
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
84
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
85
|
+
futs = [ex.submit(replay_one, backend, task, skill, memory, sample_id=i)
|
|
86
|
+
for i in range(k)]
|
|
87
|
+
for f in futs:
|
|
88
|
+
rs.attempts.append(f.result())
|
|
89
|
+
return rs
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def contrastive_reflect(
|
|
93
|
+
backend: Backend,
|
|
94
|
+
rollout_sets: List[RolloutSet],
|
|
95
|
+
skill: str,
|
|
96
|
+
memory: str,
|
|
97
|
+
*,
|
|
98
|
+
edit_budget: int = 4,
|
|
99
|
+
target: str = "skill",
|
|
100
|
+
) -> List[EditRecord]:
|
|
101
|
+
"""Distill a rule from the contrast between good and bad attempts.
|
|
102
|
+
|
|
103
|
+
We pick tasks with the highest score *spread* (some attempts passed, some
|
|
104
|
+
failed) — those are the most informative — and show the optimizer a
|
|
105
|
+
high-scoring vs a low-scoring attempt of each, asking what general rule makes
|
|
106
|
+
the good behavior reliable.
|
|
107
|
+
"""
|
|
108
|
+
informative = [rs for rs in rollout_sets if rs.spread > 0 and rs.best and rs.worst]
|
|
109
|
+
informative.sort(key=lambda rs: rs.spread, reverse=True)
|
|
110
|
+
informative = informative[:6]
|
|
111
|
+
if not informative:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
blocks = []
|
|
115
|
+
for rs in informative:
|
|
116
|
+
blocks.append(
|
|
117
|
+
f"## Task: {rs.task.intent[:160]}\n"
|
|
118
|
+
f"- GOOD attempt (score {rs.best.hard:.1f}): {rs.best.response[:200]}\n"
|
|
119
|
+
f"- BAD attempt (score {rs.worst.hard:.1f}): {rs.worst.response[:200]}\n"
|
|
120
|
+
f" (bad failed: {rs.worst.fail_reason[:100]})"
|
|
121
|
+
)
|
|
122
|
+
# the output contract the proposed rules must not violate (same guardrail the
|
|
123
|
+
# single-shot reflect uses — prevents harness-violating rules like "return VBA"
|
|
124
|
+
# or "ask the user for the range" on SpreadsheetBench).
|
|
125
|
+
from skillopt_sleep.backend import _task_guardrail
|
|
126
|
+
guard = _task_guardrail([(rs.task, rs.best) for rs in informative])
|
|
127
|
+
prompt = (
|
|
128
|
+
"You are SkillOpt's optimizer doing CONTRASTIVE reflection. For each task "
|
|
129
|
+
"below the agent was run multiple times; some attempts succeeded and some "
|
|
130
|
+
"failed. Identify what the GOOD attempts did that the BAD ones did not, "
|
|
131
|
+
f"and propose at most {edit_budget} SHORT, GENERAL, reusable rules for the "
|
|
132
|
+
f"{target} that would make the good behavior reliable every time. Quote "
|
|
133
|
+
"concrete thresholds/formats verbatim; do not paraphrase vaguely. "
|
|
134
|
+
"Every rule MUST obey the task output contract (if shown) — never propose "
|
|
135
|
+
"a rule that changes the required output format/language or tells the agent "
|
|
136
|
+
"to ask the user a question; such a rule scores ZERO.\n"
|
|
137
|
+
f"{guard}"
|
|
138
|
+
'Return ONLY a JSON array: '
|
|
139
|
+
'[{"op":"add","content":"<rule>","rationale":"<what good did that bad didnt>"}].\n\n'
|
|
140
|
+
+ "\n\n".join(blocks)
|
|
141
|
+
)
|
|
142
|
+
raw = backend._call(prompt, max_tokens=1024) # type: ignore[attr-defined]
|
|
143
|
+
arr = _extract_json(raw, "array")
|
|
144
|
+
edits: List[EditRecord] = []
|
|
145
|
+
if isinstance(arr, list):
|
|
146
|
+
for e in arr[:edit_budget]:
|
|
147
|
+
if isinstance(e, dict) and str(e.get("content", "")).strip():
|
|
148
|
+
edits.append(EditRecord(
|
|
149
|
+
target=target, op=str(e.get("op", "add")).strip().lower(),
|
|
150
|
+
content=str(e["content"]).strip(),
|
|
151
|
+
rationale=str(e.get("rationale", "")).strip(),
|
|
152
|
+
))
|
|
153
|
+
return edits
|