agent-skill-description-optimizer 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_skill_description_optimizer-0.2.0.dist-info/METADATA +361 -0
- agent_skill_description_optimizer-0.2.0.dist-info/RECORD +17 -0
- agent_skill_description_optimizer-0.2.0.dist-info/WHEEL +4 -0
- agent_skill_description_optimizer-0.2.0.dist-info/entry_points.txt +3 -0
- agent_skill_description_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
- skill_optimizer/__init__.py +78 -0
- skill_optimizer/__main__.py +6 -0
- skill_optimizer/_process.py +42 -0
- skill_optimizer/cli.py +1450 -0
- skill_optimizer/evaluation.py +475 -0
- skill_optimizer/improver.py +636 -0
- skill_optimizer/interpreter.py +171 -0
- skill_optimizer/models.py +155 -0
- skill_optimizer/py.typed +0 -0
- skill_optimizer/report.py +297 -0
- skill_optimizer/selection.py +250 -0
- skill_optimizer/skill_md.py +123 -0
skill_optimizer/cli.py
ADDED
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
"""Command-line entry point and run orchestration."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from skill_optimizer._process import (
|
|
16
|
+
claude_available,
|
|
17
|
+
claude_bin,
|
|
18
|
+
)
|
|
19
|
+
from skill_optimizer.evaluation import evaluate, subset_result
|
|
20
|
+
from skill_optimizer.improver import (
|
|
21
|
+
ImproverFatalProcessError,
|
|
22
|
+
ImproverRetryableError,
|
|
23
|
+
_LaunchBudget, # pyright: ignore[reportPrivateUsage]
|
|
24
|
+
build_improver_prompt,
|
|
25
|
+
call_improver,
|
|
26
|
+
)
|
|
27
|
+
from skill_optimizer.models import (
|
|
28
|
+
MODEL_ALIASES,
|
|
29
|
+
EvalConfig,
|
|
30
|
+
EvalQuery,
|
|
31
|
+
EvalResult,
|
|
32
|
+
ImproverAttempt,
|
|
33
|
+
PerQuery,
|
|
34
|
+
)
|
|
35
|
+
from skill_optimizer.report import generate_html
|
|
36
|
+
from skill_optimizer.selection import (
|
|
37
|
+
is_better_candidate,
|
|
38
|
+
resolve_models,
|
|
39
|
+
stratified_split,
|
|
40
|
+
summarize,
|
|
41
|
+
summarize_verbose,
|
|
42
|
+
)
|
|
43
|
+
from skill_optimizer.skill_md import parse_skill_md, safe_name_token, write_description
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Upper bound on improve->re-eval rounds. Bounds autonomous cost: composed with the
|
|
48
|
+
# per-slot retry and the internal shortening call, this caps improver child launches
|
|
49
|
+
# (see ``_LaunchBudget``). ``--iterations`` is validated to the inclusive [0, 50] range.
|
|
50
|
+
MAX_ITERATIONS = 50
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_iterations(iterations: int) -> int:
|
|
54
|
+
"""Return an iteration count in ``[0, 50]``, or raise the exact ``ValueError``.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
iterations: The requested iteration count.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``iterations`` unchanged when it lies in the inclusive ``[0, 50]`` range.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If ``iterations`` is outside ``[0, 50]``.
|
|
64
|
+
"""
|
|
65
|
+
if not 0 <= iterations <= MAX_ITERATIONS:
|
|
66
|
+
raise ValueError(f"--iterations must be between 0 and {MAX_ITERATIONS}")
|
|
67
|
+
return iterations
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
"""Build the command-line argument parser.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The configured :class:`argparse.ArgumentParser`.
|
|
75
|
+
"""
|
|
76
|
+
from skill_optimizer import __version__
|
|
77
|
+
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
description="No-API-key skill description optimizer (uses `claude -p`)."
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--skill-path", required=True, help="Path to the skill dir (contains SKILL.md)"
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--eval-set", required=True, help="JSON: [{query, should_trigger}, ...]"
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--out", default=None, help="Output dir for run artifacts (default: a temp dir)"
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--models",
|
|
95
|
+
default=None,
|
|
96
|
+
help="Comma list of eval models (aliases haiku/sonnet/opus or full ids). "
|
|
97
|
+
"Default: sonnet (or --model)",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--improver-model",
|
|
101
|
+
default=None,
|
|
102
|
+
help="Model for the improver (alias or id). Default: opus (or --model)",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--model",
|
|
106
|
+
default=None,
|
|
107
|
+
help="Single model id for BOTH eval and improver (skill-creator "
|
|
108
|
+
"compatibility); --models/--improver-model take precedence",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--description",
|
|
112
|
+
default=None,
|
|
113
|
+
help="Override the starting description instead of reading SKILL.md's",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--improver-effort",
|
|
117
|
+
default="high",
|
|
118
|
+
help="Effort for improver (high/medium/low/none-> omit)",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--repeats",
|
|
122
|
+
"--runs-per-query",
|
|
123
|
+
dest="repeats",
|
|
124
|
+
type=int,
|
|
125
|
+
default=3,
|
|
126
|
+
help="Runs per (query, model)",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--iterations", "--max-iterations", dest="iterations", type=int, default=5
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("--timeout", type=int, default=90)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--workers", "--num-workers", dest="workers", type=int, default=10
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--threshold",
|
|
137
|
+
"--trigger-threshold",
|
|
138
|
+
dest="threshold",
|
|
139
|
+
type=float,
|
|
140
|
+
default=0.5,
|
|
141
|
+
help="Trigger-rate pass threshold",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--test-frac",
|
|
145
|
+
"--holdout",
|
|
146
|
+
dest="test_frac",
|
|
147
|
+
type=float,
|
|
148
|
+
default=0.4,
|
|
149
|
+
help="Held-out fraction (stratified by class), in [0, 1). 0 disables the "
|
|
150
|
+
"holdout and selects on train; a positive fraction needs >=2 queries per "
|
|
151
|
+
"class and must leave a train and a test member in each.",
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"--seed",
|
|
155
|
+
type=int,
|
|
156
|
+
default=42,
|
|
157
|
+
help="RNG seed for the stratified train/test split. Fixed by default so a run "
|
|
158
|
+
"is reproducible; echoed in the output JSON so a run reproduces from its own "
|
|
159
|
+
"record. Vary it to check split robustness.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--select-epsilon",
|
|
163
|
+
type=float,
|
|
164
|
+
default=0.05,
|
|
165
|
+
help="Held-out mean differences within this band count as ties and are "
|
|
166
|
+
"broken by the weakest-model (min) accuracy. 0 = strict mean-only selection.",
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--max-desc-chars",
|
|
170
|
+
type=int,
|
|
171
|
+
default=1024,
|
|
172
|
+
help="Hard character budget for the description (default 1024). Over-budget "
|
|
173
|
+
"candidates can never be selected, and --write refuses an over-budget winner.",
|
|
174
|
+
)
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--disable-plugin",
|
|
177
|
+
action="append",
|
|
178
|
+
default=[],
|
|
179
|
+
help="Disable an installed plugin during eval so it can't out-compete the "
|
|
180
|
+
"injected candidate, e.g. --disable-plugin astral@astral-sh (repeatable)",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--report",
|
|
184
|
+
default="auto",
|
|
185
|
+
help="HTML report: 'auto' (temp file, opened in a browser), 'none' to disable, "
|
|
186
|
+
"or an explicit output path.",
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--dry-run",
|
|
190
|
+
action="store_true",
|
|
191
|
+
help="Validate inputs (eval set, skill, holdout split, claude availability) and "
|
|
192
|
+
"print the run plan as JSON to stdout (with an estimated claude -p call count), "
|
|
193
|
+
"then exit without spending any tokens or writing artifacts.",
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--results-dir",
|
|
197
|
+
default=None,
|
|
198
|
+
help="Save results.json, report.html, and logs/ under a timestamped "
|
|
199
|
+
"subdirectory here. Mutually exclusive with --out.",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--write",
|
|
203
|
+
action="store_true",
|
|
204
|
+
help="Write the best description back into SKILL.md (backs up to SKILL.md.bak)",
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--verbose",
|
|
208
|
+
action="store_true",
|
|
209
|
+
help="Enable the detailed per-model, confusion-matrix, and per-query summaries "
|
|
210
|
+
"(the compact one-line summaries are used otherwise)",
|
|
211
|
+
)
|
|
212
|
+
return parser
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _unwrap_eval_root(data: Any) -> list[Any]:
|
|
216
|
+
"""Return the query list from a bare list or a single-key wrapper object.
|
|
217
|
+
|
|
218
|
+
A wrapper may carry unrelated metadata, which is ignored, but it must contain
|
|
219
|
+
exactly one of the recognized ``queries`` / ``evals`` keys with a list value.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
data: The decoded JSON root.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The (still unvalidated) list of query items.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: With an ``Invalid eval set: ...`` message if the root is neither a
|
|
229
|
+
list nor an object, has neither or both recognized wrapper keys, or the
|
|
230
|
+
recognized wrapper value is not a list.
|
|
231
|
+
"""
|
|
232
|
+
if isinstance(data, list):
|
|
233
|
+
return cast("list[Any]", data)
|
|
234
|
+
if isinstance(data, dict):
|
|
235
|
+
mapping = cast("dict[str, Any]", data)
|
|
236
|
+
present = [key for key in ("queries", "evals") if key in mapping]
|
|
237
|
+
if len(present) != 1:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"Invalid eval set: wrapper must contain exactly one of "
|
|
240
|
+
"'queries' or 'evals'"
|
|
241
|
+
)
|
|
242
|
+
key = present[0]
|
|
243
|
+
value = mapping[key]
|
|
244
|
+
if not isinstance(value, list):
|
|
245
|
+
raise ValueError(f"Invalid eval set: '{key}' must be a list")
|
|
246
|
+
return cast("list[Any]", value)
|
|
247
|
+
raise ValueError("Invalid eval set: root must be a list or wrapper object")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _validate_eval_item(index: int, item: Any) -> None:
|
|
251
|
+
"""Validate one eval item's shape and field runtime types.
|
|
252
|
+
|
|
253
|
+
Requires ``query`` to be a ``str`` and ``should_trigger`` to be exactly a ``bool``
|
|
254
|
+
(integers and truthy values are not coerced). Extra keys are permitted.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
index: Zero-based position of the item, used in the error message.
|
|
258
|
+
item: The decoded item to validate.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
ValueError: With an ``Invalid eval set: item <index> ...`` message if the item
|
|
262
|
+
is not an object or a required field is missing or of the wrong type.
|
|
263
|
+
"""
|
|
264
|
+
if not isinstance(item, dict):
|
|
265
|
+
raise ValueError(f"Invalid eval set: item {index} must be an object")
|
|
266
|
+
mapping = cast("dict[str, Any]", item)
|
|
267
|
+
if not isinstance(mapping.get("query"), str):
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"Invalid eval set: item {index} field 'query' must be a string"
|
|
270
|
+
)
|
|
271
|
+
if not isinstance(mapping.get("should_trigger"), bool):
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Invalid eval set: item {index} field 'should_trigger' must be a boolean"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _load_eval_set(path: Path) -> list[EvalQuery]:
|
|
278
|
+
"""Load and validate the eval set, tolerating recognized wrappers and metadata.
|
|
279
|
+
|
|
280
|
+
Accepts a bare JSON list, or an object with exactly one of ``queries`` / ``evals``
|
|
281
|
+
(a list value; unrelated metadata is ignored). Every item must be an object with a
|
|
282
|
+
string ``query`` and a boolean ``should_trigger``; item order, duplicate queries,
|
|
283
|
+
and extra item keys are preserved. A read error (a missing or unreadable file) and
|
|
284
|
+
invalid JSON are both mapped to a friendly ``Invalid eval set: ...`` message so a
|
|
285
|
+
stdout-parsing caller fails legibly instead of on a raw traceback.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
path: Path to the eval-set JSON file.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
The validated list of evaluation queries.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
ValueError: With an ``Invalid eval set: ...`` message when the file cannot be
|
|
295
|
+
read, on invalid JSON, a bad root/wrapper shape, an empty list, or any item
|
|
296
|
+
that violates the contract.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
text = path.read_text()
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
reason = exc.strerror or exc.__class__.__name__
|
|
302
|
+
raise ValueError(f"Invalid eval set: cannot read {path}: {reason}") from exc
|
|
303
|
+
try:
|
|
304
|
+
data: Any = json.loads(text)
|
|
305
|
+
except json.JSONDecodeError as exc:
|
|
306
|
+
raise ValueError("Invalid eval set: invalid JSON") from exc
|
|
307
|
+
except RecursionError as exc:
|
|
308
|
+
# A pathologically nested eval file overflows json's C-stack recursion guard,
|
|
309
|
+
# surfacing as RecursionError (a RuntimeError, not JSONDecodeError). Map it to
|
|
310
|
+
# the same friendly message so the precondition contract holds (fail legibly,
|
|
311
|
+
# never a mid-run traceback). A separate clause -- not
|
|
312
|
+
# ``except (json.JSONDecodeError, RecursionError)`` -- because ruff-format
|
|
313
|
+
# rewrites that tuple into the invalid Py2 ``except A, B:`` syntax.
|
|
314
|
+
raise ValueError("Invalid eval set: invalid JSON") from exc
|
|
315
|
+
items = _unwrap_eval_root(data)
|
|
316
|
+
if not items:
|
|
317
|
+
raise ValueError("Invalid eval set: must contain at least one query")
|
|
318
|
+
for index, item in enumerate(items):
|
|
319
|
+
_validate_eval_item(index, item)
|
|
320
|
+
return cast("list[EvalQuery]", items)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(frozen=True, slots=True)
|
|
324
|
+
class _LoopInputs:
|
|
325
|
+
"""Static inputs to the improvement loop.
|
|
326
|
+
|
|
327
|
+
Attributes:
|
|
328
|
+
name: Skill name.
|
|
329
|
+
body: Skill body, passed to the improver for context.
|
|
330
|
+
config: Shared evaluation settings.
|
|
331
|
+
improver_model: Resolved improver model id.
|
|
332
|
+
effort: Improver reasoning effort, or ``None``.
|
|
333
|
+
iterations: Maximum improve->re-eval rounds.
|
|
334
|
+
timeout: Per-run eval timeout, in seconds.
|
|
335
|
+
select_epsilon: Held-out tie-break band.
|
|
336
|
+
out: Directory for per-iteration artifacts.
|
|
337
|
+
verbose: Whether to emit the confusion/per-model verbose summaries.
|
|
338
|
+
max_desc_chars: Hard character budget for the description.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
name: str
|
|
342
|
+
body: str
|
|
343
|
+
config: EvalConfig
|
|
344
|
+
improver_model: str
|
|
345
|
+
effort: str | None
|
|
346
|
+
iterations: int
|
|
347
|
+
timeout: int
|
|
348
|
+
select_epsilon: float
|
|
349
|
+
out: Path
|
|
350
|
+
verbose: bool = False
|
|
351
|
+
max_desc_chars: int = 1024
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True, slots=True)
|
|
355
|
+
class _LoopResult:
|
|
356
|
+
"""Outcome of the improvement loop.
|
|
357
|
+
|
|
358
|
+
Attributes:
|
|
359
|
+
best_desc: The selected best description.
|
|
360
|
+
best_test: Its held-out mean accuracy, or ``None`` when the holdout is disabled.
|
|
361
|
+
best_test_min: Its held-out weakest-model accuracy, or ``None`` with no holdout.
|
|
362
|
+
history: Per-stage records (baseline plus each iteration), each with ``is_best``.
|
|
363
|
+
best_train: Training-set view of the selected best description.
|
|
364
|
+
best_test_eval: Held-out view of the selected best description (an empty,
|
|
365
|
+
iterable-compatible result when the holdout is disabled).
|
|
366
|
+
exit_reason: Why the loop stopped (``all_passed``/``max_iterations`` with the
|
|
367
|
+
iteration number).
|
|
368
|
+
iterations_run: Number of proposal slots entered (past the early-exit check),
|
|
369
|
+
whether they produced a candidate or exhausted their retries.
|
|
370
|
+
final_description: The last candidate evaluated (baseline if none).
|
|
371
|
+
improver_failed_iterations: Bounded ledger of slots whose two outer improver
|
|
372
|
+
attempts both failed retryably; empty when none did.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
best_desc: str
|
|
376
|
+
best_test: float | None
|
|
377
|
+
best_test_min: float | None
|
|
378
|
+
history: list[dict[str, Any]]
|
|
379
|
+
best_train: EvalResult
|
|
380
|
+
best_test_eval: EvalResult
|
|
381
|
+
exit_reason: str
|
|
382
|
+
iterations_run: int
|
|
383
|
+
final_description: str
|
|
384
|
+
improver_failed_iterations: list[dict[str, Any]]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass(frozen=True, slots=True)
|
|
388
|
+
class _RetryExhausted(Exception):
|
|
389
|
+
"""Both outer improver attempts for one slot failed retryably.
|
|
390
|
+
|
|
391
|
+
An internal signal (never surfaced publicly): it carries the exact two typed
|
|
392
|
+
retryable errors, distinguishing expected exhaustion from a missing or fatal
|
|
393
|
+
outcome so exhaustion never masquerades as terminal.
|
|
394
|
+
|
|
395
|
+
Attributes:
|
|
396
|
+
errors: The two retryable errors, in attempt order.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
errors: tuple[ImproverRetryableError, ImproverRetryableError]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _improver_failure_record(
|
|
403
|
+
iteration: int, exhausted: _RetryExhausted
|
|
404
|
+
) -> dict[str, Any]:
|
|
405
|
+
"""Build one public ``improver_failed_iterations`` record from an exhaustion.
|
|
406
|
+
|
|
407
|
+
Every field is derived from validated retryable errors (allowlisted kinds and
|
|
408
|
+
messages), so no raw diagnostic can reach this public record.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
iteration: 1-based slot number that exhausted its retries.
|
|
412
|
+
exhausted: The exhaustion signal carrying the two typed errors.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
A record with ``iteration``, ``attempt_count`` (always 2), and an ``errors``
|
|
416
|
+
list of ``{attempt, kind, message}`` in ascending attempt order.
|
|
417
|
+
"""
|
|
418
|
+
return {
|
|
419
|
+
"iteration": iteration,
|
|
420
|
+
"attempt_count": 2,
|
|
421
|
+
"errors": [
|
|
422
|
+
{
|
|
423
|
+
"attempt": 1,
|
|
424
|
+
"kind": exhausted.errors[0].kind,
|
|
425
|
+
"message": exhausted.errors[0].message,
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
"attempt": 2,
|
|
429
|
+
"kind": exhausted.errors[1].kind,
|
|
430
|
+
"message": exhausted.errors[1].message,
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _split_counts(per_query: list[PerQuery]) -> tuple[int, int, int, int]:
|
|
437
|
+
"""Count passed / failed / judged-total / unjudged queries (tri-state aware).
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
per_query: The per-query roll-ups to count.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
``(passed, failed, judged_total, unjudged)`` where ``judged_total`` excludes
|
|
444
|
+
unjudged (``all_pass is None``) queries.
|
|
445
|
+
"""
|
|
446
|
+
passed = sum(pq["all_pass"] is True for pq in per_query)
|
|
447
|
+
failed = sum(pq["all_pass"] is False for pq in per_query)
|
|
448
|
+
unjudged = sum(pq["all_pass"] is None for pq in per_query)
|
|
449
|
+
return passed, failed, passed + failed, unjudged
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _score_str(per_query: list[PerQuery]) -> str:
|
|
453
|
+
"""Render a ``"k/N"`` query-pass score with a judged-query denominator.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
per_query: The per-query roll-ups to score.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
``"k/N"`` (judged-and-passed over judged), with ``" (+u unjudged)"`` appended
|
|
460
|
+
when any query was unjudged, or ``"n/a"`` when no query was judged.
|
|
461
|
+
"""
|
|
462
|
+
passed, _, total, unjudged = _split_counts(per_query)
|
|
463
|
+
if total == 0:
|
|
464
|
+
return "n/a"
|
|
465
|
+
suffix = f" (+{unjudged} unjudged)" if unjudged else ""
|
|
466
|
+
return f"{passed}/{total}{suffix}"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _result_entries(ev: EvalResult) -> list[dict[str, Any]]:
|
|
470
|
+
"""Flatten an eval result's per-query rows into report/history result entries.
|
|
471
|
+
|
|
472
|
+
Each entry is keyed on the original positional ``index`` (dedup-safe) and sums
|
|
473
|
+
``triggers``/``runs``/``errors`` across models while keeping a per-model breakdown.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
ev: The evaluation result to flatten.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
One entry per query: ``{index, query, should_trigger, triggers, runs, errors,
|
|
480
|
+
pass, models}``.
|
|
481
|
+
"""
|
|
482
|
+
entries: list[dict[str, Any]] = []
|
|
483
|
+
for pq in ev["per_query"]:
|
|
484
|
+
models = pq["models"]
|
|
485
|
+
entries.append(
|
|
486
|
+
{
|
|
487
|
+
"index": pq["index"],
|
|
488
|
+
"query": pq["query"],
|
|
489
|
+
"should_trigger": pq["should_trigger"],
|
|
490
|
+
"triggers": sum(models[m]["triggers"] for m in models),
|
|
491
|
+
"runs": sum(models[m]["runs"] for m in models),
|
|
492
|
+
"errors": sum(models[m]["errors"] for m in models),
|
|
493
|
+
"pass": pq["all_pass"],
|
|
494
|
+
"models": {
|
|
495
|
+
m: {"triggers": models[m]["triggers"], "runs": models[m]["runs"]}
|
|
496
|
+
for m in models
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
return entries
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _history_entry(
|
|
504
|
+
iteration: int,
|
|
505
|
+
description: str,
|
|
506
|
+
rationale: str,
|
|
507
|
+
full_mean: float,
|
|
508
|
+
train_ev: EvalResult,
|
|
509
|
+
test_ev: EvalResult,
|
|
510
|
+
has_holdout: bool,
|
|
511
|
+
) -> dict[str, Any]:
|
|
512
|
+
"""Build one history record (baseline or an iteration) for the envelope + report.
|
|
513
|
+
|
|
514
|
+
With no holdout, every held-out *measurement* field is ``None`` and ``test_results``
|
|
515
|
+
is an empty list (the structural collection is retained so report/consumers stay
|
|
516
|
+
iterable); ``test_ev`` is ignored in that case.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
iteration: 0 for the baseline, 1..N for improve iterations.
|
|
520
|
+
description: The description this record scored.
|
|
521
|
+
rationale: The improver rationale (empty for the baseline).
|
|
522
|
+
full_mean: Full-set mean accuracy for this description.
|
|
523
|
+
train_ev: Training-set view of this description.
|
|
524
|
+
test_ev: Held-out view of this description (used only when ``has_holdout``).
|
|
525
|
+
has_holdout: Whether a held-out set exists; ``False`` nulls the test metrics.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
The history entry, with ``is_best`` defaulted to ``False`` (set post-loop).
|
|
529
|
+
"""
|
|
530
|
+
tp, tf, tt, tu = _split_counts(train_ev["per_query"])
|
|
531
|
+
if has_holdout:
|
|
532
|
+
ep, ef, et, eu = _split_counts(test_ev["per_query"])
|
|
533
|
+
test_mean: float | None = test_ev["mean_accuracy"]
|
|
534
|
+
test_min: float | None = test_ev["min_accuracy"]
|
|
535
|
+
test_passed: int | None = ep
|
|
536
|
+
test_failed: int | None = ef
|
|
537
|
+
test_total: int | None = et
|
|
538
|
+
test_unjudged: int | None = eu
|
|
539
|
+
test_score: str | None = _score_str(test_ev["per_query"])
|
|
540
|
+
test_results = _result_entries(test_ev)
|
|
541
|
+
else:
|
|
542
|
+
test_mean = test_min = None
|
|
543
|
+
test_passed = test_failed = test_total = test_unjudged = None
|
|
544
|
+
test_score = None
|
|
545
|
+
test_results = []
|
|
546
|
+
return {
|
|
547
|
+
"stage": "baseline" if iteration == 0 else f"iter{iteration}",
|
|
548
|
+
"iteration": iteration,
|
|
549
|
+
"description": description,
|
|
550
|
+
"rationale": rationale,
|
|
551
|
+
"chars": len(description),
|
|
552
|
+
"full_mean": full_mean,
|
|
553
|
+
"test_mean": test_mean,
|
|
554
|
+
"test_min": test_min,
|
|
555
|
+
"train_passed": tp,
|
|
556
|
+
"train_failed": tf,
|
|
557
|
+
"train_total": tt,
|
|
558
|
+
"train_unjudged": tu,
|
|
559
|
+
"test_passed": test_passed,
|
|
560
|
+
"test_failed": test_failed,
|
|
561
|
+
"test_total": test_total,
|
|
562
|
+
"test_unjudged": test_unjudged,
|
|
563
|
+
"train_score": _score_str(train_ev["per_query"]),
|
|
564
|
+
"test_score": test_score,
|
|
565
|
+
"train_results": _result_entries(train_ev),
|
|
566
|
+
"test_results": test_results,
|
|
567
|
+
"is_best": False,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _call_improver_with_retry(
|
|
572
|
+
inputs: _LoopInputs, prompt: str, it: int, budget: _LaunchBudget
|
|
573
|
+
) -> dict[str, Any]:
|
|
574
|
+
"""Call the improver for one slot, retrying once on a typed retryable failure.
|
|
575
|
+
|
|
576
|
+
Makes at most two outer attempts, each writing a distinct transcript. A fatal or
|
|
577
|
+
unclassified exception from :func:`call_improver` propagates immediately.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
inputs: Static loop inputs.
|
|
581
|
+
prompt: The improver prompt.
|
|
582
|
+
it: 1-based slot number, used for the transcript filenames.
|
|
583
|
+
budget: Shared launch budget threaded to every child spawn.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
The accepted proposal object.
|
|
587
|
+
|
|
588
|
+
Raises:
|
|
589
|
+
_RetryExhausted: When both outer attempts fail with typed retryable errors.
|
|
590
|
+
"""
|
|
591
|
+
log_names = (f"iter{it}_improve.json", f"iter{it}_improve_retry.json")
|
|
592
|
+
errors: list[ImproverRetryableError] = []
|
|
593
|
+
for attempt in (1, 2):
|
|
594
|
+
try:
|
|
595
|
+
return call_improver(
|
|
596
|
+
prompt,
|
|
597
|
+
inputs.improver_model,
|
|
598
|
+
inputs.effort,
|
|
599
|
+
max(inputs.timeout * 4, 300),
|
|
600
|
+
max_chars=inputs.max_desc_chars,
|
|
601
|
+
log_path=inputs.out / log_names[attempt - 1],
|
|
602
|
+
budget=budget,
|
|
603
|
+
)
|
|
604
|
+
except ImproverRetryableError as exc:
|
|
605
|
+
errors.append(exc)
|
|
606
|
+
if attempt == 1:
|
|
607
|
+
logger.warning("Improver retryable attempt 1 failed (%s).", exc.kind)
|
|
608
|
+
raise _RetryExhausted((errors[0], errors[1]))
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _propose_and_score(
|
|
612
|
+
inputs: _LoopInputs,
|
|
613
|
+
best_desc: str,
|
|
614
|
+
train_eval: EvalResult,
|
|
615
|
+
prior_attempts: Sequence[ImproverAttempt],
|
|
616
|
+
queries: list[EvalQuery],
|
|
617
|
+
train_idx: list[int],
|
|
618
|
+
test_idx: list[int],
|
|
619
|
+
it: int,
|
|
620
|
+
budget: _LaunchBudget,
|
|
621
|
+
) -> tuple[str, dict[str, Any], EvalResult, EvalResult, EvalResult]:
|
|
622
|
+
"""Propose a new description and score it, evaluating the full set exactly once.
|
|
623
|
+
|
|
624
|
+
The candidate is evaluated once over the full eval set; the train and held-out
|
|
625
|
+
views are then sliced from that single result with :func:`subset_result`, halving
|
|
626
|
+
the ``claude -p`` call count versus separate train/test/full evaluations.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
inputs: Static loop inputs.
|
|
630
|
+
best_desc: The current best description to improve from.
|
|
631
|
+
train_eval: Training-set view driving the improver.
|
|
632
|
+
prior_attempts: Descriptions already attempted, with train-only results, so the
|
|
633
|
+
improver avoids repeating them (held-out results are never included).
|
|
634
|
+
queries: The full eval set.
|
|
635
|
+
train_idx: Positional indices of the training queries.
|
|
636
|
+
test_idx: Positional indices of the held-out queries.
|
|
637
|
+
it: 1-based iteration number, used for artifact filenames.
|
|
638
|
+
budget: Shared launch budget threaded to every improver child spawn.
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
``(candidate, proposal, full_eval, train_eval, test_eval)``.
|
|
642
|
+
|
|
643
|
+
Raises:
|
|
644
|
+
_RetryExhausted: When both outer improver attempts fail retryably.
|
|
645
|
+
"""
|
|
646
|
+
prompt = build_improver_prompt(
|
|
647
|
+
inputs.name, best_desc, inputs.body, train_eval, prior_attempts
|
|
648
|
+
)
|
|
649
|
+
(inputs.out / f"iter{it}_prompt.txt").write_text(prompt)
|
|
650
|
+
proposal = _call_improver_with_retry(inputs, prompt, it, budget)
|
|
651
|
+
cand = str(proposal["description"]).strip()
|
|
652
|
+
(inputs.out / f"iter{it}_proposal.json").write_text(json.dumps(proposal, indent=2))
|
|
653
|
+
logger.info(" rationale: %s", proposal.get("rationale", ""))
|
|
654
|
+
cand_full = evaluate(queries, inputs.name, cand, inputs.config, verbose=False)
|
|
655
|
+
cand_train = subset_result(cand_full, queries, train_idx, inputs.config.models)
|
|
656
|
+
cand_test = subset_result(cand_full, queries, test_idx, inputs.config.models)
|
|
657
|
+
(inputs.out / f"iter{it}_eval.json").write_text(json.dumps(cand_full, indent=2))
|
|
658
|
+
label = f"ITER {it} (full)"
|
|
659
|
+
if inputs.verbose:
|
|
660
|
+
logger.info("%s", summarize_verbose(label, cand_full))
|
|
661
|
+
else:
|
|
662
|
+
logger.info("%s", summarize(label, cand_full))
|
|
663
|
+
return cand, proposal, cand_full, cand_train, cand_test
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _candidate_wins(
|
|
667
|
+
cand: str,
|
|
668
|
+
cand_selection: EvalResult,
|
|
669
|
+
best_desc: str,
|
|
670
|
+
best_selection: EvalResult,
|
|
671
|
+
inputs: _LoopInputs,
|
|
672
|
+
score_label: str,
|
|
673
|
+
) -> tuple[bool, str]:
|
|
674
|
+
"""Decide whether a scored candidate should replace the incumbent.
|
|
675
|
+
|
|
676
|
+
Applies the selection pre-condition (both the candidate's and the incumbent's
|
|
677
|
+
selection views must be usable) before the mean/min/char-budget comparison. The
|
|
678
|
+
selection view is the held-out result with a holdout, or the training result when
|
|
679
|
+
the holdout is disabled; ``score_label`` names it in the reason strings.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
cand: The candidate description.
|
|
683
|
+
cand_selection: The candidate's selection-view evaluation.
|
|
684
|
+
best_desc: The incumbent best description.
|
|
685
|
+
best_selection: The incumbent's selection-view evaluation.
|
|
686
|
+
inputs: Static loop inputs (epsilon, char budget).
|
|
687
|
+
score_label: ``"held-out"`` with a holdout, ``"train"`` when it is disabled.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
``(win, reason)`` from :func:`is_better_candidate`, or ``(False, ...)`` when the
|
|
691
|
+
selection view is unusable for selection.
|
|
692
|
+
"""
|
|
693
|
+
if not cand_selection["score_valid"] or not best_selection["score_valid"]:
|
|
694
|
+
return False, f"{score_label} set unusable for selection"
|
|
695
|
+
return is_better_candidate(
|
|
696
|
+
cand_selection["mean_accuracy"],
|
|
697
|
+
cand_selection["min_accuracy"],
|
|
698
|
+
best_selection["mean_accuracy"],
|
|
699
|
+
best_selection["min_accuracy"],
|
|
700
|
+
inputs.select_epsilon,
|
|
701
|
+
cand_chars=len(cand),
|
|
702
|
+
best_chars=len(best_desc),
|
|
703
|
+
max_chars=inputs.max_desc_chars,
|
|
704
|
+
score_label=score_label,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@dataclass(slots=True)
|
|
709
|
+
class _BestState:
|
|
710
|
+
"""Mutable running-best across the optimization loop.
|
|
711
|
+
|
|
712
|
+
Attributes:
|
|
713
|
+
desc: The current best description.
|
|
714
|
+
full: Its full-set evaluation (sliced each iteration to drive the improver).
|
|
715
|
+
train: Its training-set view.
|
|
716
|
+
test_eval: Its held-out view (an empty result when the holdout is disabled).
|
|
717
|
+
selection: Its selection view (held-out with a holdout, else train).
|
|
718
|
+
history_idx: Index of its history entry (the row flagged ``is_best``).
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
desc: str
|
|
722
|
+
full: EvalResult
|
|
723
|
+
train: EvalResult
|
|
724
|
+
test_eval: EvalResult
|
|
725
|
+
selection: EvalResult
|
|
726
|
+
history_idx: int
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@dataclass(frozen=True, slots=True)
|
|
730
|
+
class _LoopContext:
|
|
731
|
+
"""Immutable per-run context shared across the improve loop.
|
|
732
|
+
|
|
733
|
+
Attributes:
|
|
734
|
+
inputs: Static loop inputs (models, improver, iteration budget, ...).
|
|
735
|
+
queries: The full eval set.
|
|
736
|
+
train_idx: Positional indices of the training queries.
|
|
737
|
+
test_idx: Positional indices of the held-out queries.
|
|
738
|
+
has_holdout: Whether a held-out set exists.
|
|
739
|
+
score_label: ``"held-out"`` with a holdout, ``"train"`` otherwise.
|
|
740
|
+
budget: Shared improver launch budget.
|
|
741
|
+
emit: Optional live-report callback, or ``None``.
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
inputs: _LoopInputs
|
|
745
|
+
queries: list[EvalQuery]
|
|
746
|
+
train_idx: list[int]
|
|
747
|
+
test_idx: list[int]
|
|
748
|
+
has_holdout: bool
|
|
749
|
+
score_label: str
|
|
750
|
+
budget: _LaunchBudget
|
|
751
|
+
emit: Callable[[list[dict[str, Any]], int, int, list[dict[str, Any]]], None] | None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _log_selection_mean(
|
|
755
|
+
has_holdout: bool, cand_selection: EvalResult, best_selection: EvalResult
|
|
756
|
+
) -> None:
|
|
757
|
+
"""Log the candidate's selection mean against the incumbent, per selection view.
|
|
758
|
+
|
|
759
|
+
Chooses the label so the optional held-out float is never formatted for a
|
|
760
|
+
no-holdout run.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
has_holdout: Whether a held-out set exists.
|
|
764
|
+
cand_selection: The candidate's selection-view evaluation.
|
|
765
|
+
best_selection: The incumbent's selection-view evaluation.
|
|
766
|
+
"""
|
|
767
|
+
label = "held-out test mean" if has_holdout else "train mean"
|
|
768
|
+
logger.info(
|
|
769
|
+
" %s: %.3f (best so far %.3f)",
|
|
770
|
+
label,
|
|
771
|
+
cand_selection["mean_accuracy"],
|
|
772
|
+
best_selection["mean_accuracy"],
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _consider_candidate(
|
|
777
|
+
scored: tuple[str, dict[str, Any], EvalResult, EvalResult, EvalResult],
|
|
778
|
+
it: int,
|
|
779
|
+
ctx: _LoopContext,
|
|
780
|
+
best: _BestState,
|
|
781
|
+
history: list[dict[str, Any]],
|
|
782
|
+
attempts: list[ImproverAttempt],
|
|
783
|
+
) -> str:
|
|
784
|
+
"""Record a scored candidate in history and apply the selection decision.
|
|
785
|
+
|
|
786
|
+
Appends the candidate's train-only attempt record and its history entry, then
|
|
787
|
+
updates ``best`` in place when the candidate wins.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
scored: ``(candidate, proposal, full_eval, train_eval, test_eval)``.
|
|
791
|
+
it: 1-based iteration number.
|
|
792
|
+
ctx: The shared per-run loop context.
|
|
793
|
+
best: The running-best state, mutated in place on a win.
|
|
794
|
+
history: The history list, appended to.
|
|
795
|
+
attempts: The prior-attempt list, appended to.
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
The candidate description (the run's new ``final_description``).
|
|
799
|
+
"""
|
|
800
|
+
cand, proposal, cand_full, cand_train, cand_test = scored
|
|
801
|
+
attempts.append({"description": cand, "train_results": cand_train["per_query"]})
|
|
802
|
+
cand_selection = cand_test if ctx.has_holdout else cand_train
|
|
803
|
+
_log_selection_mean(ctx.has_holdout, cand_selection, best.selection)
|
|
804
|
+
history.append(
|
|
805
|
+
_history_entry(
|
|
806
|
+
it,
|
|
807
|
+
cand,
|
|
808
|
+
proposal.get("rationale", ""),
|
|
809
|
+
cand_full["mean_accuracy"],
|
|
810
|
+
cand_train,
|
|
811
|
+
cand_test,
|
|
812
|
+
ctx.has_holdout,
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
win, reason = _candidate_wins(
|
|
816
|
+
cand, cand_selection, best.desc, best.selection, ctx.inputs, ctx.score_label
|
|
817
|
+
)
|
|
818
|
+
if win:
|
|
819
|
+
best.desc = cand
|
|
820
|
+
best.full = cand_full
|
|
821
|
+
best.train = cand_train
|
|
822
|
+
best.test_eval = cand_test
|
|
823
|
+
best.selection = cand_selection
|
|
824
|
+
best.history_idx = len(history) - 1
|
|
825
|
+
logger.info(" -> new best (%s)", reason)
|
|
826
|
+
else:
|
|
827
|
+
logger.info(" -> rejected (%s)", reason)
|
|
828
|
+
return cand
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _run_improve_loop(
|
|
832
|
+
ctx: _LoopContext,
|
|
833
|
+
best: _BestState,
|
|
834
|
+
history: list[dict[str, Any]],
|
|
835
|
+
attempts: list[ImproverAttempt],
|
|
836
|
+
improver_failed: list[dict[str, Any]],
|
|
837
|
+
) -> tuple[str, int, str]:
|
|
838
|
+
"""Run the improve iterations, mutating ``best``/``history``/``attempts``/ledger.
|
|
839
|
+
|
|
840
|
+
Each slot gets at most two outer improver attempts; a slot whose retries both fail
|
|
841
|
+
retryably records one bounded entry and the loop continues, while a fatal improver
|
|
842
|
+
error propagates.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
ctx: The shared per-run loop context.
|
|
846
|
+
best: The running-best state, mutated in place.
|
|
847
|
+
history: The history list, appended to.
|
|
848
|
+
attempts: The prior-attempt list, appended to.
|
|
849
|
+
improver_failed: The bounded failure ledger, appended to on exhaustion.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
``(exit_reason, iterations_run, final_description)`` where ``iterations_run`` is
|
|
853
|
+
the number of entered slots and ``final_description`` is the last candidate that
|
|
854
|
+
was successfully proposed and scored (the baseline if none was).
|
|
855
|
+
"""
|
|
856
|
+
models = ctx.inputs.config.models
|
|
857
|
+
exit_reason = f"max_iterations ({ctx.inputs.iterations})"
|
|
858
|
+
iterations_run = 0
|
|
859
|
+
final_description = best.desc
|
|
860
|
+
for it in range(1, ctx.inputs.iterations + 1):
|
|
861
|
+
logger.info(
|
|
862
|
+
"\n=== Iteration %d: improver=%s effort=%s ===",
|
|
863
|
+
it,
|
|
864
|
+
ctx.inputs.improver_model,
|
|
865
|
+
ctx.inputs.effort,
|
|
866
|
+
)
|
|
867
|
+
train_eval = subset_result(best.full, ctx.queries, ctx.train_idx, models)
|
|
868
|
+
if train_eval["per_query"] and all(
|
|
869
|
+
pq["all_pass"] is True for pq in train_eval["per_query"]
|
|
870
|
+
):
|
|
871
|
+
exit_reason = f"all_passed (iteration {it})"
|
|
872
|
+
logger.info(
|
|
873
|
+
" all %d train queries pass; stopping early.",
|
|
874
|
+
len(train_eval["per_query"]),
|
|
875
|
+
)
|
|
876
|
+
break
|
|
877
|
+
# This slot is entered: count it before the first outer attempt, so the live
|
|
878
|
+
# count and the failure ledger stay consistent even when a slot is skipped.
|
|
879
|
+
iterations_run = it
|
|
880
|
+
try:
|
|
881
|
+
scored = _propose_and_score(
|
|
882
|
+
ctx.inputs,
|
|
883
|
+
best.desc,
|
|
884
|
+
train_eval,
|
|
885
|
+
attempts,
|
|
886
|
+
ctx.queries,
|
|
887
|
+
ctx.train_idx,
|
|
888
|
+
ctx.test_idx,
|
|
889
|
+
it,
|
|
890
|
+
ctx.budget,
|
|
891
|
+
)
|
|
892
|
+
except _RetryExhausted as exhausted:
|
|
893
|
+
# Preserve the last verified candidate and all prior state; record one
|
|
894
|
+
# bounded public entry, refresh live state, and continue later slots.
|
|
895
|
+
improver_failed.append(_improver_failure_record(it, exhausted))
|
|
896
|
+
logger.warning(
|
|
897
|
+
"Improver retry attempts exhausted for iteration %d; continuing.", it
|
|
898
|
+
)
|
|
899
|
+
if ctx.emit is not None:
|
|
900
|
+
ctx.emit(history, best.history_idx, iterations_run, improver_failed)
|
|
901
|
+
continue
|
|
902
|
+
final_description = _consider_candidate(
|
|
903
|
+
scored, it, ctx, best, history, attempts
|
|
904
|
+
)
|
|
905
|
+
if ctx.emit is not None:
|
|
906
|
+
ctx.emit(history, best.history_idx, iterations_run, improver_failed)
|
|
907
|
+
return exit_reason, iterations_run, final_description
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _optimize(
|
|
911
|
+
inputs: _LoopInputs,
|
|
912
|
+
train_idx: list[int],
|
|
913
|
+
test_idx: list[int],
|
|
914
|
+
queries: list[EvalQuery],
|
|
915
|
+
base_desc: str,
|
|
916
|
+
base_full: EvalResult,
|
|
917
|
+
emit: Callable[[list[dict[str, Any]], int, int, list[dict[str, Any]]], None]
|
|
918
|
+
| None = None,
|
|
919
|
+
) -> _LoopResult:
|
|
920
|
+
"""Run the improve->re-eval loop, selecting the best description by held-out score.
|
|
921
|
+
|
|
922
|
+
Each description is evaluated once over the full set; the train view (used to drive
|
|
923
|
+
the improver) and the held-out view (used for selection) are sliced from the
|
|
924
|
+
current best's full result, so no set is re-evaluated redundantly. The iteration
|
|
925
|
+
budget is validated first, and a shared 200-launch budget bounds all improver
|
|
926
|
+
children before any slot runs.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
inputs: Static loop inputs (models, improver, iteration budget, ...).
|
|
930
|
+
train_idx: Positional indices of the training queries.
|
|
931
|
+
test_idx: Positional indices of the held-out queries.
|
|
932
|
+
queries: The full eval set.
|
|
933
|
+
base_desc: The starting description.
|
|
934
|
+
base_full: Full-set evaluation of ``base_desc``.
|
|
935
|
+
emit: Optional callback ``(history, best_history_idx, iterations_run,
|
|
936
|
+
improver_failed_iterations)`` invoked after every scored candidate and every
|
|
937
|
+
exhausted slot to refresh a live report.
|
|
938
|
+
|
|
939
|
+
Returns:
|
|
940
|
+
The best description found and its held-out scores, plus per-stage history and
|
|
941
|
+
the bounded improver-failure ledger.
|
|
942
|
+
"""
|
|
943
|
+
_validate_iterations(inputs.iterations)
|
|
944
|
+
models = inputs.config.models
|
|
945
|
+
has_holdout = bool(test_idx)
|
|
946
|
+
base_test = subset_result(base_full, queries, test_idx, models)
|
|
947
|
+
base_train = subset_result(base_full, queries, train_idx, models)
|
|
948
|
+
# Selection view: the held-out result with a holdout, else the training result.
|
|
949
|
+
best = _BestState(
|
|
950
|
+
desc=base_desc,
|
|
951
|
+
full=base_full,
|
|
952
|
+
train=base_train,
|
|
953
|
+
test_eval=base_test,
|
|
954
|
+
selection=base_test if has_holdout else base_train,
|
|
955
|
+
history_idx=0,
|
|
956
|
+
)
|
|
957
|
+
history: list[dict[str, Any]] = [
|
|
958
|
+
_history_entry(
|
|
959
|
+
0,
|
|
960
|
+
base_desc,
|
|
961
|
+
"",
|
|
962
|
+
base_full["mean_accuracy"],
|
|
963
|
+
base_train,
|
|
964
|
+
base_test,
|
|
965
|
+
has_holdout,
|
|
966
|
+
)
|
|
967
|
+
]
|
|
968
|
+
# Train-only attempt records (blinding: held-out results never reach the improver).
|
|
969
|
+
attempts: list[ImproverAttempt] = [
|
|
970
|
+
{"description": base_desc, "train_results": base_train["per_query"]}
|
|
971
|
+
]
|
|
972
|
+
improver_failed: list[dict[str, Any]] = []
|
|
973
|
+
ctx = _LoopContext(
|
|
974
|
+
inputs=inputs,
|
|
975
|
+
queries=queries,
|
|
976
|
+
train_idx=train_idx,
|
|
977
|
+
test_idx=test_idx,
|
|
978
|
+
has_holdout=has_holdout,
|
|
979
|
+
score_label="held-out" if has_holdout else "train",
|
|
980
|
+
# 50 slots * 2 outer attempts * 2 children (initial + shortening) = 200. Shared
|
|
981
|
+
# across the whole run so no 201st improver child can start.
|
|
982
|
+
budget=_LaunchBudget(MAX_ITERATIONS * 2 * 2),
|
|
983
|
+
emit=emit,
|
|
984
|
+
)
|
|
985
|
+
exit_reason, iterations_run, final_description = _run_improve_loop(
|
|
986
|
+
ctx, best, history, attempts, improver_failed
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
for i, entry in enumerate(history):
|
|
990
|
+
entry["is_best"] = i == best.history_idx
|
|
991
|
+
|
|
992
|
+
best_test = best.test_eval["mean_accuracy"] if has_holdout else None
|
|
993
|
+
best_test_min = best.test_eval["min_accuracy"] if has_holdout else None
|
|
994
|
+
return _LoopResult(
|
|
995
|
+
best.desc,
|
|
996
|
+
best_test,
|
|
997
|
+
best_test_min,
|
|
998
|
+
history,
|
|
999
|
+
best.train,
|
|
1000
|
+
best.test_eval,
|
|
1001
|
+
exit_reason,
|
|
1002
|
+
iterations_run,
|
|
1003
|
+
final_description,
|
|
1004
|
+
improver_failed,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _resolve_config(args: argparse.Namespace) -> tuple[str, EvalConfig]:
|
|
1009
|
+
"""Resolve the improver model and the shared :class:`EvalConfig` from CLI args.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
args: Parsed CLI arguments.
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
``(improver_model, config)``.
|
|
1016
|
+
"""
|
|
1017
|
+
eval_spec: str = args.models or args.model or "sonnet"
|
|
1018
|
+
improver_spec: str = args.improver_model or args.model or "opus"
|
|
1019
|
+
improver_model = MODEL_ALIASES.get(improver_spec, improver_spec)
|
|
1020
|
+
settings_json = (
|
|
1021
|
+
json.dumps({"enabledPlugins": {p: False for p in args.disable_plugin}})
|
|
1022
|
+
if args.disable_plugin
|
|
1023
|
+
else None
|
|
1024
|
+
)
|
|
1025
|
+
config = EvalConfig(
|
|
1026
|
+
models=tuple(resolve_models(eval_spec)),
|
|
1027
|
+
repeats=args.repeats,
|
|
1028
|
+
timeout=args.timeout,
|
|
1029
|
+
workers=args.workers,
|
|
1030
|
+
threshold=args.threshold,
|
|
1031
|
+
settings_json=settings_json,
|
|
1032
|
+
)
|
|
1033
|
+
return improver_model, config
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _build_plan(
|
|
1037
|
+
name: str,
|
|
1038
|
+
queries: list[EvalQuery],
|
|
1039
|
+
train_idx: list[int],
|
|
1040
|
+
test_idx: list[int],
|
|
1041
|
+
config: EvalConfig,
|
|
1042
|
+
iterations: int,
|
|
1043
|
+
improver_model: str,
|
|
1044
|
+
effort: str | None,
|
|
1045
|
+
holdout: float,
|
|
1046
|
+
seed: int,
|
|
1047
|
+
) -> dict[str, Any]:
|
|
1048
|
+
"""Build the machine-readable run plan (also the ``--dry-run`` payload).
|
|
1049
|
+
|
|
1050
|
+
``estimated_eval_calls`` is an upper bound: it assumes every improve iteration runs
|
|
1051
|
+
(the loop exits early once all train queries pass) and counts the baseline plus one
|
|
1052
|
+
full-set evaluation per iteration. ``estimated_improver_calls`` is the typical one
|
|
1053
|
+
call per iteration (a call may retry, bounded by the launch budget). A consumer can
|
|
1054
|
+
read ``estimated_claude_calls`` to budget a run before spending any tokens.
|
|
1055
|
+
|
|
1056
|
+
Args:
|
|
1057
|
+
name: Skill name.
|
|
1058
|
+
queries: The full eval set.
|
|
1059
|
+
train_idx: Positional indices of the training queries.
|
|
1060
|
+
test_idx: Positional indices of the held-out queries.
|
|
1061
|
+
config: Resolved evaluation settings (models, repeats, threshold, ...).
|
|
1062
|
+
iterations: Maximum improve->re-eval rounds.
|
|
1063
|
+
improver_model: Resolved improver model id.
|
|
1064
|
+
effort: Improver reasoning effort, or ``None``.
|
|
1065
|
+
holdout: The held-out fraction (``--test-frac``).
|
|
1066
|
+
seed: Split RNG seed.
|
|
1067
|
+
|
|
1068
|
+
Returns:
|
|
1069
|
+
The plan dict: a resolved-config echo plus ``estimated_eval_calls`` /
|
|
1070
|
+
``estimated_improver_calls`` / ``estimated_claude_calls``.
|
|
1071
|
+
"""
|
|
1072
|
+
n = len(queries)
|
|
1073
|
+
eval_calls = n * len(config.models) * config.repeats * (iterations + 1)
|
|
1074
|
+
return {
|
|
1075
|
+
"skill": name,
|
|
1076
|
+
"queries": n,
|
|
1077
|
+
"train_size": len(train_idx),
|
|
1078
|
+
"test_size": len(test_idx),
|
|
1079
|
+
"holdout": holdout,
|
|
1080
|
+
"seed": seed,
|
|
1081
|
+
"models": list(config.models),
|
|
1082
|
+
"improver_model": improver_model,
|
|
1083
|
+
"improver_effort": effort,
|
|
1084
|
+
"repeats": config.repeats,
|
|
1085
|
+
"iterations": iterations,
|
|
1086
|
+
"threshold": config.threshold,
|
|
1087
|
+
"estimated_eval_calls": eval_calls,
|
|
1088
|
+
"estimated_improver_calls": iterations,
|
|
1089
|
+
"estimated_claude_calls": eval_calls + iterations,
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _plan_summary(plan: dict[str, Any]) -> str:
|
|
1094
|
+
"""Render a one-line stderr summary of a run plan.
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
plan: A plan dict from :func:`_build_plan`.
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
A compact ``Plan: ...`` line naming the estimate, split sizes, and seed.
|
|
1101
|
+
"""
|
|
1102
|
+
return (
|
|
1103
|
+
f"Plan: {plan['queries']} queries x {len(plan['models'])} model(s) x "
|
|
1104
|
+
f"{plan['repeats']} repeats over <={plan['iterations'] + 1} evals "
|
|
1105
|
+
f"~= {plan['estimated_claude_calls']} claude calls "
|
|
1106
|
+
f"(train={plan['train_size']} test={plan['test_size']} seed={plan['seed']})"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
_PLACEHOLDER_HTML = (
|
|
1111
|
+
"<html><body><h1>Starting optimization loop…</h1>"
|
|
1112
|
+
"<meta http-equiv='refresh' content='5'></body></html>"
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _report_paths(
|
|
1117
|
+
args: argparse.Namespace, skill_name: str, timestamp: str
|
|
1118
|
+
) -> tuple[Path, Path | None, Path | None]:
|
|
1119
|
+
"""Resolve the artifact dir, results dir, and live-report path from the flags.
|
|
1120
|
+
|
|
1121
|
+
Args:
|
|
1122
|
+
args: Parsed CLI arguments.
|
|
1123
|
+
skill_name: Skill name, used in the auto report filename.
|
|
1124
|
+
timestamp: Run timestamp for the results-dir / auto-report name.
|
|
1125
|
+
|
|
1126
|
+
Returns:
|
|
1127
|
+
``(out, results_dir, live_report_path)`` where ``out`` is the per-iteration
|
|
1128
|
+
artifact dir (created), ``results_dir`` is the timestamped results dir or
|
|
1129
|
+
``None``, and ``live_report_path`` is where the HTML report is written or
|
|
1130
|
+
``None`` (``--report none``). The mutually-exclusive ``--out`` / ``--results-dir``
|
|
1131
|
+
combination is rejected earlier, in :func:`run`'s preflight.
|
|
1132
|
+
"""
|
|
1133
|
+
results_dir: Path | None = None
|
|
1134
|
+
if args.results_dir:
|
|
1135
|
+
results_dir = Path(args.results_dir) / timestamp
|
|
1136
|
+
out = results_dir / "logs"
|
|
1137
|
+
elif args.out:
|
|
1138
|
+
out = Path(args.out)
|
|
1139
|
+
else:
|
|
1140
|
+
out = Path(tempfile.mkdtemp(prefix="skilldesc-"))
|
|
1141
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1142
|
+
|
|
1143
|
+
if args.report == "none":
|
|
1144
|
+
live: Path | None = None
|
|
1145
|
+
elif args.report == "auto":
|
|
1146
|
+
live = (
|
|
1147
|
+
results_dir / "report.html"
|
|
1148
|
+
if results_dir is not None
|
|
1149
|
+
# ``skill_name`` is attacker-controlled SKILL.md frontmatter; sanitize it
|
|
1150
|
+
# before it becomes a filename so a hostile name cannot traverse out of the
|
|
1151
|
+
# temp dir when writing the auto report.
|
|
1152
|
+
else Path(tempfile.gettempdir())
|
|
1153
|
+
/ f"skill_description_report_{safe_name_token(skill_name)}_{timestamp}.html"
|
|
1154
|
+
)
|
|
1155
|
+
else:
|
|
1156
|
+
live = Path(args.report)
|
|
1157
|
+
return out, results_dir, live
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _write_placeholder_and_open(live_report_path: Path) -> None:
|
|
1161
|
+
"""Write the initial placeholder report and open it in a browser (best-effort).
|
|
1162
|
+
|
|
1163
|
+
The browser open is wrapped so a headless/CI environment (no browser) never aborts
|
|
1164
|
+
the run.
|
|
1165
|
+
|
|
1166
|
+
Args:
|
|
1167
|
+
live_report_path: Where to write the placeholder HTML.
|
|
1168
|
+
"""
|
|
1169
|
+
try:
|
|
1170
|
+
live_report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1171
|
+
live_report_path.write_text(_PLACEHOLDER_HTML)
|
|
1172
|
+
except OSError:
|
|
1173
|
+
logger.debug("could not write placeholder report", exc_info=True)
|
|
1174
|
+
return
|
|
1175
|
+
try:
|
|
1176
|
+
webbrowser.open(str(live_report_path))
|
|
1177
|
+
except Exception: # noqa: BLE001 - no browser in headless/CI; never fatal
|
|
1178
|
+
logger.debug("could not open browser for report", exc_info=True)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _write_html(path: Path, report: dict[str, Any], name: str, refresh: bool) -> None:
|
|
1182
|
+
"""Render and write an HTML report, swallowing write errors (headless/CI safe).
|
|
1183
|
+
|
|
1184
|
+
Args:
|
|
1185
|
+
path: Destination HTML path.
|
|
1186
|
+
report: The report dict passed to :func:`generate_html`.
|
|
1187
|
+
name: Skill name for the report title.
|
|
1188
|
+
refresh: Whether to embed the auto-refresh meta tag.
|
|
1189
|
+
"""
|
|
1190
|
+
try:
|
|
1191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1192
|
+
path.write_text(generate_html(report, auto_refresh=refresh, skill_name=name))
|
|
1193
|
+
except OSError:
|
|
1194
|
+
logger.debug("could not write HTML report to %s", path, exc_info=True)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def run(args: argparse.Namespace) -> None:
|
|
1198
|
+
"""Run the optimization loop described by parsed CLI arguments.
|
|
1199
|
+
|
|
1200
|
+
With ``--dry-run`` the run stops after preflight: it prints the ``{"dry_run": true,
|
|
1201
|
+
...}`` plan JSON (including ``estimated_claude_calls``) to stdout and returns without
|
|
1202
|
+
evaluating, spending tokens, or writing artifacts.
|
|
1203
|
+
|
|
1204
|
+
Args:
|
|
1205
|
+
args: Parsed arguments from :func:`build_parser`.
|
|
1206
|
+
|
|
1207
|
+
Raises:
|
|
1208
|
+
SystemExit: If no ``SKILL.md`` exists at ``--skill-path``; if both ``--out`` and
|
|
1209
|
+
``--results-dir`` are supplied; if ``--iterations``, the eval set, or the
|
|
1210
|
+
holdout split is invalid; or if the ``claude`` CLI is not found or not
|
|
1211
|
+
executable.
|
|
1212
|
+
"""
|
|
1213
|
+
skill_md = Path(args.skill_path) / "SKILL.md"
|
|
1214
|
+
if not skill_md.exists():
|
|
1215
|
+
raise SystemExit(f"No SKILL.md at {skill_md}")
|
|
1216
|
+
name, base_desc, body = parse_skill_md(skill_md)
|
|
1217
|
+
name = name or Path(args.skill_path).name
|
|
1218
|
+
if args.description:
|
|
1219
|
+
base_desc = args.description
|
|
1220
|
+
|
|
1221
|
+
# Complete input preflight before any config/path/report/browser/evaluator side
|
|
1222
|
+
# effect, so invalid user input is cheap and artifact-free (findings 3, 4, 8).
|
|
1223
|
+
try:
|
|
1224
|
+
_validate_iterations(args.iterations)
|
|
1225
|
+
except ValueError as exc:
|
|
1226
|
+
raise SystemExit(str(exc)) from exc
|
|
1227
|
+
try:
|
|
1228
|
+
queries = _load_eval_set(Path(args.eval_set))
|
|
1229
|
+
except ValueError as exc:
|
|
1230
|
+
raise SystemExit(str(exc)) from exc
|
|
1231
|
+
if args.out and args.results_dir:
|
|
1232
|
+
raise SystemExit("--out and --results-dir are mutually exclusive")
|
|
1233
|
+
try:
|
|
1234
|
+
train_idx, test_idx = stratified_split(queries, args.test_frac, seed=args.seed)
|
|
1235
|
+
except ValueError as exc:
|
|
1236
|
+
raise SystemExit(f"Invalid holdout split: {exc}") from exc
|
|
1237
|
+
# Both loop halves shell out to ``claude -p``; verify the CLI is invocable up front
|
|
1238
|
+
# so a missing/non-executable binary fails as one legible setup error here (like the
|
|
1239
|
+
# SKILL.md and eval-set checks) rather than as a mid-run FileNotFoundError traceback
|
|
1240
|
+
# or a silent all-unjudged "success" when --iterations is 0.
|
|
1241
|
+
if not claude_available():
|
|
1242
|
+
raise SystemExit(
|
|
1243
|
+
f"claude CLI not found or not executable: {claude_bin()!r}. Install it and "
|
|
1244
|
+
"log in, or set SKILL_OPTIMIZER_CLAUDE_BIN to the CLI path."
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
improver_model, config = _resolve_config(args)
|
|
1248
|
+
effort = None if args.improver_effort.lower() == "none" else args.improver_effort
|
|
1249
|
+
|
|
1250
|
+
# Machine-readable run plan: the --dry-run payload and, on a real run, a startup
|
|
1251
|
+
# estimate a consumer can budget against. Built only after preflight, so it always
|
|
1252
|
+
# describes a runnable config.
|
|
1253
|
+
plan = _build_plan(
|
|
1254
|
+
name,
|
|
1255
|
+
queries,
|
|
1256
|
+
train_idx,
|
|
1257
|
+
test_idx,
|
|
1258
|
+
config,
|
|
1259
|
+
args.iterations,
|
|
1260
|
+
improver_model,
|
|
1261
|
+
effort,
|
|
1262
|
+
args.test_frac,
|
|
1263
|
+
args.seed,
|
|
1264
|
+
)
|
|
1265
|
+
if args.dry_run:
|
|
1266
|
+
logger.info("Dry run (no tokens spent, no artifacts). %s", _plan_summary(plan))
|
|
1267
|
+
print(json.dumps({"dry_run": True, **plan}, indent=2))
|
|
1268
|
+
return
|
|
1269
|
+
logger.info("%s", _plan_summary(plan))
|
|
1270
|
+
|
|
1271
|
+
timestamp = time.strftime("%Y-%m-%d_%H%M%S")
|
|
1272
|
+
out, results_dir, live_report_path = _report_paths(args, name, timestamp)
|
|
1273
|
+
if not args.out and not args.results_dir:
|
|
1274
|
+
logger.info("No --out/--results-dir given; writing run artifacts to %s", out)
|
|
1275
|
+
if live_report_path is not None:
|
|
1276
|
+
_write_placeholder_and_open(live_report_path)
|
|
1277
|
+
|
|
1278
|
+
logger.info(
|
|
1279
|
+
"Skill '%s': %d queries (train=%d, test=%d), models=%s, repeats=%d",
|
|
1280
|
+
name,
|
|
1281
|
+
len(queries),
|
|
1282
|
+
len(train_idx),
|
|
1283
|
+
len(test_idx),
|
|
1284
|
+
list(config.models),
|
|
1285
|
+
config.repeats,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
base_full = evaluate(queries, name, base_desc, config)
|
|
1289
|
+
(out / "baseline.json").write_text(json.dumps(base_full, indent=2))
|
|
1290
|
+
if args.verbose:
|
|
1291
|
+
logger.info("%s", summarize_verbose("BASELINE (full)", base_full))
|
|
1292
|
+
else:
|
|
1293
|
+
logger.info("%s", summarize("BASELINE (full)", base_full))
|
|
1294
|
+
|
|
1295
|
+
inputs = _LoopInputs(
|
|
1296
|
+
name=name,
|
|
1297
|
+
body=body,
|
|
1298
|
+
config=config,
|
|
1299
|
+
improver_model=improver_model,
|
|
1300
|
+
effort=effort,
|
|
1301
|
+
iterations=args.iterations,
|
|
1302
|
+
timeout=args.timeout,
|
|
1303
|
+
select_epsilon=args.select_epsilon,
|
|
1304
|
+
out=out,
|
|
1305
|
+
verbose=args.verbose,
|
|
1306
|
+
max_desc_chars=args.max_desc_chars,
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
def _emit_live(
|
|
1310
|
+
history: list[dict[str, Any]],
|
|
1311
|
+
best_idx: int,
|
|
1312
|
+
iterations_run: int,
|
|
1313
|
+
improver_failed: list[dict[str, Any]],
|
|
1314
|
+
) -> None:
|
|
1315
|
+
# Defensive/type-narrowing guard: ``_emit_live`` is only wired into the loop when
|
|
1316
|
+
# ``live_report_path`` is not None (see the ``emit=`` argument below), so this
|
|
1317
|
+
# early return is unreachable at runtime -- it is kept solely so the report
|
|
1318
|
+
# writes further down narrow ``Path | None`` to ``Path`` for the type checker.
|
|
1319
|
+
if live_report_path is None: # pragma: no cover - unreachable; narrows the type
|
|
1320
|
+
return
|
|
1321
|
+
marked = [{**h, "is_best": i == best_idx} for i, h in enumerate(history)]
|
|
1322
|
+
best = marked[best_idx]
|
|
1323
|
+
live = {
|
|
1324
|
+
"original_description": base_desc,
|
|
1325
|
+
"best_description": best["description"],
|
|
1326
|
+
"best_score": best["test_score"] if test_idx else best["train_score"],
|
|
1327
|
+
"best_train_score": best["train_score"],
|
|
1328
|
+
"best_test_score": best["test_score"] if test_idx else None,
|
|
1329
|
+
# Explicit entered-slot count: a skipped (exhausted) slot has no history row,
|
|
1330
|
+
# so this must not be derived from len(history) - 1.
|
|
1331
|
+
"iterations_run": iterations_run,
|
|
1332
|
+
"holdout": args.test_frac,
|
|
1333
|
+
"train_size": len(train_idx),
|
|
1334
|
+
"test_size": len(test_idx),
|
|
1335
|
+
"history": marked,
|
|
1336
|
+
# Copy so a later append cannot mutate this emitted snapshot.
|
|
1337
|
+
"improver_failed_iterations": list(improver_failed),
|
|
1338
|
+
}
|
|
1339
|
+
_write_html(live_report_path, live, name, refresh=True)
|
|
1340
|
+
|
|
1341
|
+
result = _optimize(
|
|
1342
|
+
inputs,
|
|
1343
|
+
train_idx,
|
|
1344
|
+
test_idx,
|
|
1345
|
+
queries,
|
|
1346
|
+
base_desc,
|
|
1347
|
+
base_full,
|
|
1348
|
+
emit=_emit_live if live_report_path is not None else None,
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
best_train_score = _score_str(result.best_train["per_query"])
|
|
1352
|
+
best_test_score = (
|
|
1353
|
+
_score_str(result.best_test_eval["per_query"]) if test_idx else None
|
|
1354
|
+
)
|
|
1355
|
+
report: dict[str, Any] = {
|
|
1356
|
+
"skill": name,
|
|
1357
|
+
"skill_path": str(args.skill_path),
|
|
1358
|
+
"models": list(config.models),
|
|
1359
|
+
"improver": {"model": improver_model, "effort": effort},
|
|
1360
|
+
"baseline_description": base_desc,
|
|
1361
|
+
"best_description": result.best_desc,
|
|
1362
|
+
"best_test_mean": result.best_test,
|
|
1363
|
+
"best_test_min": result.best_test_min,
|
|
1364
|
+
"select_epsilon": args.select_epsilon,
|
|
1365
|
+
"history": result.history,
|
|
1366
|
+
# Additive skill-creator envelope (nothing above is removed).
|
|
1367
|
+
"original_description": base_desc,
|
|
1368
|
+
"final_description": result.final_description,
|
|
1369
|
+
"exit_reason": result.exit_reason,
|
|
1370
|
+
"iterations_run": result.iterations_run,
|
|
1371
|
+
"improver_failed_iterations": result.improver_failed_iterations,
|
|
1372
|
+
"holdout": args.test_frac,
|
|
1373
|
+
"seed": args.seed,
|
|
1374
|
+
"estimated_claude_calls": plan["estimated_claude_calls"],
|
|
1375
|
+
"train_size": len(train_idx),
|
|
1376
|
+
"test_size": len(test_idx),
|
|
1377
|
+
"baseline_chars": len(base_desc),
|
|
1378
|
+
"best_chars": len(result.best_desc),
|
|
1379
|
+
"best_train_score": best_train_score,
|
|
1380
|
+
"best_test_score": best_test_score,
|
|
1381
|
+
"best_score": best_test_score if test_idx else best_train_score,
|
|
1382
|
+
}
|
|
1383
|
+
(out / "report.json").write_text(json.dumps(report, indent=2))
|
|
1384
|
+
if results_dir is not None:
|
|
1385
|
+
(results_dir / "results.json").write_text(json.dumps(report, indent=2))
|
|
1386
|
+
logger.info("\n=== DONE ===")
|
|
1387
|
+
if test_idx:
|
|
1388
|
+
logger.info(
|
|
1389
|
+
"Best held-out mean: %.3f (%s)", result.best_test, result.exit_reason
|
|
1390
|
+
)
|
|
1391
|
+
else:
|
|
1392
|
+
logger.info(
|
|
1393
|
+
"Best train mean: %.3f (%s)",
|
|
1394
|
+
result.best_train["mean_accuracy"],
|
|
1395
|
+
result.exit_reason,
|
|
1396
|
+
)
|
|
1397
|
+
logger.info("\nBEST DESCRIPTION:\n%s\n", result.best_desc)
|
|
1398
|
+
logger.info("Report: %s", out / "report.json")
|
|
1399
|
+
# Machine-readable result on stdout (stderr carries progress) — consumers read
|
|
1400
|
+
# `best_description` from this, matching skill-creator's run_loop contract.
|
|
1401
|
+
print(json.dumps(report, indent=2))
|
|
1402
|
+
|
|
1403
|
+
if live_report_path is not None:
|
|
1404
|
+
_write_html(live_report_path, report, name, refresh=False)
|
|
1405
|
+
if results_dir is not None and args.report != "none":
|
|
1406
|
+
report_html = results_dir / "report.html"
|
|
1407
|
+
if live_report_path != report_html:
|
|
1408
|
+
_write_html(report_html, report, name, refresh=False)
|
|
1409
|
+
|
|
1410
|
+
over_budget = len(result.best_desc) > args.max_desc_chars
|
|
1411
|
+
if args.write and over_budget:
|
|
1412
|
+
logger.warning(
|
|
1413
|
+
"Refusing --write: best description is %d chars, over the %d-char budget; "
|
|
1414
|
+
"%s left unchanged.",
|
|
1415
|
+
len(result.best_desc),
|
|
1416
|
+
args.max_desc_chars,
|
|
1417
|
+
skill_md,
|
|
1418
|
+
)
|
|
1419
|
+
elif args.write and result.best_desc.strip() != base_desc.strip():
|
|
1420
|
+
write_description(skill_md, result.best_desc)
|
|
1421
|
+
logger.info(
|
|
1422
|
+
"Wrote best description into %s (backup at %s.bak)", skill_md, skill_md
|
|
1423
|
+
)
|
|
1424
|
+
elif args.write:
|
|
1425
|
+
logger.info("No change to write (best == baseline).")
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1429
|
+
"""CLI entry point: parse arguments, configure logging, and run.
|
|
1430
|
+
|
|
1431
|
+
Catch :class:`ImproverFatalProcessError` (a fatal improver failure — a completed
|
|
1432
|
+
nonzero child exit or launch-budget exhaustion, which :func:`run` raises by design)
|
|
1433
|
+
and present it as one ``stderr`` line with exit code 1, no traceback, and no stdout,
|
|
1434
|
+
so a stdout-parsing caller fails legibly. The raw child returncode/stderr remain in
|
|
1435
|
+
the per-iteration improver transcript.
|
|
1436
|
+
|
|
1437
|
+
Args:
|
|
1438
|
+
argv: Argument vector (defaults to ``sys.argv[1:]``).
|
|
1439
|
+
|
|
1440
|
+
Returns:
|
|
1441
|
+
Process exit code (``0`` on success, ``1`` on a fatal improver failure).
|
|
1442
|
+
"""
|
|
1443
|
+
logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(message)s")
|
|
1444
|
+
args = build_parser().parse_args(argv)
|
|
1445
|
+
try:
|
|
1446
|
+
run(args)
|
|
1447
|
+
except ImproverFatalProcessError as exc:
|
|
1448
|
+
logger.error("Improver failed fatally: %s (no result written).", exc)
|
|
1449
|
+
return 1
|
|
1450
|
+
return 0
|