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.
@@ -0,0 +1,250 @@
1
+ """Eval-set splitting, model resolution, and candidate-selection helpers."""
2
+
3
+ import math
4
+ import random
5
+
6
+ from skill_optimizer.models import MODEL_ALIASES, EvalQuery, EvalResult
7
+
8
+
9
+ def stratified_split(
10
+ eval_set: list[EvalQuery], test_frac: float, seed: int = 42
11
+ ) -> tuple[list[int], list[int]]:
12
+ """Split query *indices* into train/test, preserving the trigger/no-trigger ratio.
13
+
14
+ A ``test_frac`` of zero disables the held-out set (skill-creator's ``--holdout 0``
15
+ semantics): every index becomes a training index, in original order, and no class
16
+ balancing is required. A positive fraction must be finite and in ``[0, 1)``, each
17
+ trigger class must hold at least two queries, and the seeded per-class allocation
18
+ must leave at least one training and one held-out query in each class; otherwise a
19
+ :class:`ValueError` is raised before any split is produced.
20
+
21
+ Each class is shuffled with a fixed-seed local RNG first, so a positive split is
22
+ reproducible but independent of how the eval file happens to be ordered (otherwise
23
+ the held-out set is just the first N as written, which clusters similar queries).
24
+ Returning positional indices (not query sublists) keeps the dedup invariant intact
25
+ and lets callers slice ``per_query`` by the same indices without matching on text.
26
+
27
+ Args:
28
+ eval_set: All evaluation queries.
29
+ test_frac: Fraction of each class to hold out for testing; ``0`` disables it.
30
+ seed: RNG seed for the per-class shuffle.
31
+
32
+ Returns:
33
+ A ``(train_idx, test_idx)`` tuple of original positional indices into
34
+ ``eval_set``. The two lists are disjoint and together cover every index.
35
+
36
+ Raises:
37
+ ValueError: If ``test_frac`` is non-finite or outside ``[0, 1)``, or (for a
38
+ positive fraction) a class has fewer than two queries or cannot retain a
39
+ training query at the requested fraction.
40
+ """
41
+ if not math.isfinite(test_frac) or not 0 <= test_frac < 1:
42
+ raise ValueError("holdout must be finite and satisfy 0 <= holdout < 1")
43
+ if test_frac == 0:
44
+ # Zero disables the held-out set: all-train, no-test, original order preserved,
45
+ # with no class-balance requirement (mirrors skill-creator's --holdout 0).
46
+ return list(range(len(eval_set))), []
47
+ positive = [i for i, q in enumerate(eval_set) if q["should_trigger"]]
48
+ negative = [i for i, q in enumerate(eval_set) if not q["should_trigger"]]
49
+ for group, label in ((positive, "positive"), (negative, "negative")):
50
+ if len(group) < 2:
51
+ raise ValueError(
52
+ f"{label} class must contain at least 2 queries when holdout > 0"
53
+ )
54
+ # Deterministic, reproducible train/test split -- not a security/crypto use, so
55
+ # the standard (non-cryptographic) RNG is the correct choice here.
56
+ rng = random.Random(seed) # noqa: S311 # nosec B311
57
+ rng.shuffle(positive)
58
+ rng.shuffle(negative)
59
+ train: list[int] = []
60
+ test: list[int] = []
61
+ for group, label in ((positive, "positive"), (negative, "negative")):
62
+ n_test = max(1, round(len(group) * test_frac))
63
+ if n_test >= len(group):
64
+ raise ValueError(
65
+ f"{label} class cannot retain a training query at this holdout"
66
+ )
67
+ test += group[:n_test]
68
+ train += group[n_test:]
69
+ return train, test
70
+
71
+
72
+ def resolve_models(spec: str | None) -> list[str]:
73
+ """Resolve a comma-separated model spec into a list of full model ids.
74
+
75
+ Args:
76
+ spec: Comma-separated aliases/ids (e.g. ``"haiku,sonnet"``), or ``None``.
77
+
78
+ Returns:
79
+ Resolved model ids. Defaults to ``[sonnet]`` when ``spec`` is empty/``None``;
80
+ a spec of only separators (e.g. ``","``) yields an empty list.
81
+ """
82
+ if not spec:
83
+ return [MODEL_ALIASES["sonnet"]]
84
+ return [
85
+ MODEL_ALIASES.get(s.strip(), s.strip()) for s in spec.split(",") if s.strip()
86
+ ]
87
+
88
+
89
+ def _short_model(model: str) -> str:
90
+ """Return a model's short display name (the segment after the first ``-``).
91
+
92
+ Args:
93
+ model: A full model id (e.g. ``"claude-sonnet-5"``) or bare name.
94
+
95
+ Returns:
96
+ The short name (e.g. ``"sonnet"``), or the full name if it has no ``-``.
97
+ """
98
+ return model.split("-")[1] if "-" in model else model
99
+
100
+
101
+ def summarize(tag: str, ev: EvalResult) -> str:
102
+ """Render a one-line summary of an evaluation result.
103
+
104
+ Args:
105
+ tag: Short label for the line (e.g. ``"BASELINE"``).
106
+ ev: The evaluation result to summarize.
107
+
108
+ Returns:
109
+ A line of the form ``"<tag>: mean=… min=… (model=acc …)"`` where each model
110
+ is shown by its short name; a model with no judged query renders as ``n/a``.
111
+ """
112
+ per_model = " ".join(
113
+ f"{_short_model(m)}={'n/a' if a is None else f'{a:.2f}'}"
114
+ for m, a in ev["per_model_accuracy"].items()
115
+ )
116
+ return (
117
+ f"{tag}: mean={ev['mean_accuracy']:.3f} "
118
+ f"min={ev['min_accuracy']:.3f} ({per_model})"
119
+ )
120
+
121
+
122
+ def summarize_verbose(tag: str, ev: EvalResult) -> str:
123
+ """Render a multi-line, skill-creator-style precision/recall/accuracy summary.
124
+
125
+ Leads with the one-line :func:`summarize`, then an aggregate confusion header, a
126
+ per-model precision/recall/accuracy line, and a PASS/FAIL/N-A line per query. A
127
+ fully-unjudged model (its ``per_model_accuracy`` is ``None``) renders as ``n/a``,
128
+ as does the aggregate when every model is unjudged, mirroring :func:`summarize`'s
129
+ tri-state guard instead of the misleading ``100%/100%/0%`` an all-zeros confusion
130
+ matrix would otherwise show.
131
+
132
+ Args:
133
+ tag: Short label for the block (e.g. ``"BASELINE"``).
134
+ ev: The evaluation result to summarize.
135
+
136
+ Returns:
137
+ The multi-line summary.
138
+ """
139
+ c = ev["confusion"]
140
+ judged = c["tp"] + c["fp"] + c["tn"] + c["fn"]
141
+ agg = (
142
+ " aggregate: n/a"
143
+ if judged == 0
144
+ else f" aggregate: {c['tp'] + c['tn']}/{judged} correct "
145
+ f"precision={c['precision']:.0%} recall={c['recall']:.0%} "
146
+ f"accuracy={c['accuracy']:.0%}"
147
+ )
148
+ lines = [summarize(tag, ev), agg]
149
+ acc = ev["per_model_accuracy"]
150
+ for m, cm in ev["per_model_confusion"].items():
151
+ if acc[m] is None:
152
+ lines.append(f" {_short_model(m)}: n/a")
153
+ else:
154
+ lines.append(
155
+ f" {_short_model(m)}: precision={cm['precision']:.0%} "
156
+ f"recall={cm['recall']:.0%} accuracy={cm['accuracy']:.0%}"
157
+ )
158
+ for pq in ev["per_query"]:
159
+ status = (
160
+ "PASS"
161
+ if pq["all_pass"] is True
162
+ else "FAIL"
163
+ if pq["all_pass"] is False
164
+ else "N/A "
165
+ )
166
+ lines.append(
167
+ f" [{status}] expected={pq['should_trigger']}: {pq['query'][:60]}"
168
+ )
169
+ return "\n".join(lines)
170
+
171
+
172
+ def is_better_candidate(
173
+ cand_mean: float,
174
+ cand_min: float,
175
+ best_mean: float,
176
+ best_min: float,
177
+ epsilon: float,
178
+ cand_chars: int | None = None,
179
+ best_chars: int | None = None,
180
+ max_chars: int | None = None,
181
+ *,
182
+ score_label: str = "held-out",
183
+ ) -> tuple[bool, str]:
184
+ """Decide whether a candidate beats the incumbent on the selection score.
185
+
186
+ The primary criterion is the selection *mean* accuracy (selecting on held-out
187
+ rather than train is what avoids overfitting the eval set). But on a small
188
+ selection split a sub-``epsilon`` mean difference is noise, not signal -- so when
189
+ two descriptions tie within ``epsilon``, break the tie in favor of the higher
190
+ *min* (weakest-model) accuracy. Set ``epsilon=0`` for strict mean-only selection.
191
+
192
+ When ``max_chars`` is set the character budget is a HARD constraint that dominates
193
+ the score comparison: an over-budget candidate can never win, a legal candidate
194
+ beats an over-budget incumbent even at an equal-or-slightly-lower mean (the
195
+ incumbent cannot ship), and among two legal ties within ``epsilon`` the shorter
196
+ wins. Passing no ``*_chars`` (the 5-argument call) leaves length out entirely and
197
+ reproduces the pre-budget behavior byte-for-byte.
198
+
199
+ Args:
200
+ cand_mean: Candidate selection mean accuracy.
201
+ cand_min: Candidate selection min (weakest-model) accuracy.
202
+ best_mean: Incumbent selection mean accuracy.
203
+ best_min: Incumbent selection min accuracy.
204
+ epsilon: Mean-accuracy band within which scores count as tied.
205
+ cand_chars: Candidate description length, for the budget constraint.
206
+ best_chars: Incumbent description length, for the budget constraint.
207
+ max_chars: Hard character ceiling, or ``None`` to ignore length entirely.
208
+ score_label: Name of the selection score used in the reason strings
209
+ (``"held-out"`` with a holdout, ``"train"`` when the holdout is disabled).
210
+
211
+ Returns:
212
+ ``(win, reason)`` where ``win`` is ``True`` if the candidate should replace
213
+ the incumbent, and ``reason`` is a short human-readable explanation.
214
+ """
215
+
216
+ def _over(chars: int | None) -> bool:
217
+ return max_chars is not None and chars is not None and chars > max_chars
218
+
219
+ if _over(cand_chars):
220
+ return False, f"over char budget ({cand_chars} > {max_chars})"
221
+ if _over(best_chars):
222
+ # Incumbent can't ship; any legal candidate replaces it -- ideally with a
223
+ # better/tied mean, but even a slightly lower one beats unshippable.
224
+ rel = "non-regressing" if cand_mean >= best_mean - epsilon else "lower"
225
+ return True, (
226
+ f"incumbent over budget ({best_chars} > {max_chars}); adopting legal "
227
+ f"candidate ({cand_chars} chars) with {rel} {score_label} mean "
228
+ f"({cand_mean:.3f} vs {best_mean:.3f})"
229
+ )
230
+ if cand_mean > best_mean + epsilon:
231
+ return True, f"improved {score_label} mean ({cand_mean:.3f} > {best_mean:.3f})"
232
+ if epsilon > 0 and abs(cand_mean - best_mean) <= epsilon and cand_min > best_min:
233
+ return True, (
234
+ f"{score_label} mean within {epsilon:.3f} "
235
+ f"({cand_mean:.3f} vs {best_mean:.3f}); "
236
+ f"better weakest-model min ({cand_min:.3f} > {best_min:.3f})"
237
+ )
238
+ if (
239
+ max_chars is not None
240
+ and abs(cand_mean - best_mean) <= epsilon
241
+ and cand_chars is not None
242
+ and best_chars is not None
243
+ and cand_chars < best_chars
244
+ ):
245
+ return True, (
246
+ f"{score_label} mean within {epsilon:.3f} "
247
+ f"({cand_mean:.3f} vs {best_mean:.3f}); "
248
+ f"shorter description preferred ({cand_chars} < {best_chars} chars)"
249
+ )
250
+ return False, f"no {score_label} gain"
@@ -0,0 +1,123 @@
1
+ """Parsing and writing of a skill's ``SKILL.md`` frontmatter."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ # YAML block/folded scalar indicators that carry no inline text on the ``key:`` line.
7
+ _BLOCK_SCALAR_INDICATORS = frozenset({"|", ">", "|-", ">-", "|+", ">+", ""})
8
+
9
+ # Characters kept verbatim when a skill name is reduced to a filesystem path token.
10
+ # Every other character — path separators, whitespace, shell/format metacharacters —
11
+ # collapses to a single ``_``. A shared skill's ``name:`` is attacker-controlled, so it
12
+ # must never reach a filename verbatim.
13
+ _UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
14
+
15
+ # Cap on the path-token length so a pathologically long ``name:`` cannot produce an
16
+ # over-long filename that the OS rejects with ``ENAMETOOLONG`` mid-run.
17
+ _MAX_NAME_TOKEN_CHARS = 64
18
+
19
+
20
+ def safe_name_token(name: str, *, fallback: str = "skill") -> str:
21
+ """Reduce a skill name to a filesystem-safe token for use as a path component.
22
+
23
+ A shared ``SKILL.md``'s ``name:`` frontmatter is attacker-controlled, so it must
24
+ never reach a filename verbatim: an absolute path, a ``../`` sequence, or a bare path
25
+ separator would let a downstream write escape its intended directory. This collapses
26
+ every run of characters outside ``[A-Za-z0-9._-]`` to a single ``_``, strips leading
27
+ and trailing separators/dots (so a name that is only dots or separators — e.g. ``..``
28
+ or ``/`` — can never yield ``.``, ``..``, or an empty component), caps the length,
29
+ and falls back to a fixed token when nothing usable remains.
30
+
31
+ Args:
32
+ name: The raw, possibly attacker-controlled skill name.
33
+ fallback: Token returned when ``name`` has no filesystem-safe characters.
34
+
35
+ Returns:
36
+ A token drawn only from ``[A-Za-z0-9._-]``, never empty, never ``.`` or ``..``,
37
+ and never containing a path separator.
38
+ """
39
+ token = _UNSAFE_NAME_CHARS.sub("_", name).strip("._-")[:_MAX_NAME_TOKEN_CHARS]
40
+ return token.strip("._-") or fallback
41
+
42
+
43
+ def parse_skill_md(skill_md: Path) -> tuple[str, str, str]:
44
+ """Extract the name, description, and body from a ``SKILL.md`` file.
45
+
46
+ Handles inline, folded (``>``), and block (``|``) scalar description styles, and
47
+ collapses internal whitespace in the description to single spaces.
48
+
49
+ Args:
50
+ skill_md: Path to the ``SKILL.md`` file.
51
+
52
+ Returns:
53
+ A ``(name, description, body)`` tuple. ``name`` is empty when absent.
54
+
55
+ Raises:
56
+ ValueError: If the file has no ``---`` delimited YAML frontmatter.
57
+ """
58
+ text = skill_md.read_text()
59
+ match = re.search(r"^---\n(.*?)\n---\n?(.*)$", text, re.DOTALL)
60
+ if not match:
61
+ raise ValueError(f"No YAML frontmatter in {skill_md}")
62
+ frontmatter, body = match[1], match[2]
63
+
64
+ name = ""
65
+ if name_match := re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE):
66
+ name = name_match[1].strip().strip("'\"")
67
+
68
+ desc_parts: list[str] = []
69
+ capturing = False
70
+ for line in frontmatter.splitlines():
71
+ if not capturing:
72
+ if desc_match := re.match(r"^description:\s*(.*)$", line):
73
+ capturing = True
74
+ rest = desc_match[1].strip()
75
+ if rest not in _BLOCK_SCALAR_INDICATORS:
76
+ desc_parts.append(rest)
77
+ continue
78
+ # Continuation: indented or blank lines belong to the description; the first
79
+ # non-indented, non-blank line is the next top-level key and ends capture.
80
+ if re.match(r"^\s+\S", line) or line.strip() == "":
81
+ desc_parts.append(line.strip())
82
+ else:
83
+ break
84
+
85
+ description = re.sub(r"\s+", " ", " ".join(p for p in desc_parts if p)).strip()
86
+ return name, description, body
87
+
88
+
89
+ def write_description(skill_md: Path, new_description: str) -> None:
90
+ """Rewrite the description in ``SKILL.md`` as a literal block scalar (``|``).
91
+
92
+ The original file is backed up to ``<name>.bak`` first. The new description is
93
+ appended after the remaining frontmatter keys (so key order may change).
94
+
95
+ Args:
96
+ skill_md: Path to the ``SKILL.md`` file to modify.
97
+ new_description: The replacement description text.
98
+
99
+ Raises:
100
+ ValueError: If the file has no ``---`` delimited YAML frontmatter. Note the
101
+ backup is written before this check, so a failed write still leaves a
102
+ ``.bak`` behind.
103
+ """
104
+ text = skill_md.read_text()
105
+ skill_md.with_suffix(f"{skill_md.suffix}.bak").write_text(text)
106
+ match = re.search(r"^---\n(.*?)\n---", text, re.DOTALL)
107
+ if not match:
108
+ raise ValueError(f"No frontmatter in {skill_md}")
109
+ frontmatter = match[1]
110
+ # Drop the existing description block (``description:`` up to the next top-level
111
+ # key or end of frontmatter).
112
+ fm_without_desc = re.sub(
113
+ r"^description:.*?(?=^\w[\w-]*:|\Z)",
114
+ "",
115
+ frontmatter + "\n",
116
+ flags=re.DOTALL | re.MULTILINE,
117
+ ).rstrip("\n")
118
+ folded = "\n ".join(new_description.split("\n"))
119
+ new_frontmatter = (
120
+ fm_without_desc + "\n" if fm_without_desc else ""
121
+ ) + f"description: |\n {folded}"
122
+ new_text = text[: match.start(1)] + new_frontmatter + text[match.end(1) :]
123
+ skill_md.write_text(new_text)