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
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Triggering evaluation: drive ``claude -p`` per query and aggregate results."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import select
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from skill_optimizer._process import claude_bin, subprocess_env
|
|
18
|
+
from skill_optimizer.interpreter import (
|
|
19
|
+
_interpret_events_status, # pyright: ignore[reportPrivateUsage]
|
|
20
|
+
)
|
|
21
|
+
from skill_optimizer.models import (
|
|
22
|
+
ConfusionMatrix,
|
|
23
|
+
EvalConfig,
|
|
24
|
+
EvalQuery,
|
|
25
|
+
EvalResult,
|
|
26
|
+
ModelResult,
|
|
27
|
+
PerQuery,
|
|
28
|
+
)
|
|
29
|
+
from skill_optimizer.skill_md import safe_name_token
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_READ_CHUNK = 8192
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stream_events(
|
|
37
|
+
proc: subprocess.Popen[bytes], timeout: float
|
|
38
|
+
) -> Iterator[dict[str, Any]]:
|
|
39
|
+
"""Yield parsed stream-json events from a running ``claude -p`` process.
|
|
40
|
+
|
|
41
|
+
Reads stdout incrementally and yields one decoded JSON object per complete line,
|
|
42
|
+
skipping blanks and undecodable lines, until ``timeout`` elapses or the process
|
|
43
|
+
exits with a drained buffer. Pairs with :func:`interpret_events`, which may stop
|
|
44
|
+
consuming early once the outcome is decided.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
proc: The running subprocess, with ``stdout`` piped as bytes.
|
|
48
|
+
timeout: Wall-clock budget in seconds.
|
|
49
|
+
|
|
50
|
+
Yields:
|
|
51
|
+
Decoded stream-json event objects, in order.
|
|
52
|
+
"""
|
|
53
|
+
# Type-narrowing, not a runtime guard: proc is always constructed with
|
|
54
|
+
# stdout=PIPE, which guarantees a non-None stream.
|
|
55
|
+
assert proc.stdout is not None # noqa: S101 # nosec B101
|
|
56
|
+
start = time.monotonic()
|
|
57
|
+
buffer = ""
|
|
58
|
+
while time.monotonic() - start < timeout:
|
|
59
|
+
if proc.poll() is None:
|
|
60
|
+
ready, _, _ = select.select([proc.stdout], [], [], 1.0)
|
|
61
|
+
if not ready:
|
|
62
|
+
continue
|
|
63
|
+
if chunk := os.read(proc.stdout.fileno(), _READ_CHUNK):
|
|
64
|
+
buffer += chunk.decode("utf-8", "replace")
|
|
65
|
+
elif remainder := proc.stdout.read():
|
|
66
|
+
buffer += remainder.decode("utf-8", "replace")
|
|
67
|
+
while "\n" in buffer:
|
|
68
|
+
line, buffer = buffer.split("\n", 1)
|
|
69
|
+
line = line.strip()
|
|
70
|
+
if not line:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
yield json.loads(line)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
continue
|
|
76
|
+
if proc.poll() is not None and "\n" not in buffer:
|
|
77
|
+
break
|
|
78
|
+
if line := buffer.strip():
|
|
79
|
+
try:
|
|
80
|
+
yield json.loads(line)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_single_query(
|
|
86
|
+
query: str,
|
|
87
|
+
skill_name: str,
|
|
88
|
+
description: str,
|
|
89
|
+
model: str | None,
|
|
90
|
+
timeout: int,
|
|
91
|
+
settings_json: str | None,
|
|
92
|
+
) -> bool | None:
|
|
93
|
+
"""Run one eval query and report whether the model invoked the candidate skill.
|
|
94
|
+
|
|
95
|
+
Injects ``description`` as a temporary slash-command in a throwaway project, runs
|
|
96
|
+
``claude -p`` against ``query``, and inspects the streamed tool-call intent.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
query: The task to send to the model.
|
|
100
|
+
skill_name: Name of the skill (used to build the candidate command name).
|
|
101
|
+
description: The candidate description to inject.
|
|
102
|
+
model: Model id, or ``None`` to use the CLI default.
|
|
103
|
+
timeout: Per-run wall-clock budget, in seconds.
|
|
104
|
+
settings_json: Optional ``--settings`` JSON blob.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
``True`` if the model decided to invoke the injected command, ``False`` on a
|
|
108
|
+
decisive non-trigger, or ``None`` when the probe is unjudgeable (timeout, a
|
|
109
|
+
non-zero ``claude -p`` exit, or an empty/incomplete stream with no decisive
|
|
110
|
+
terminal event) so callers can exclude it rather than score it as a miss.
|
|
111
|
+
"""
|
|
112
|
+
rid = uuid.uuid4().hex[:8]
|
|
113
|
+
# ``skill_name`` comes from attacker-controlled SKILL.md frontmatter; sanitize it to
|
|
114
|
+
# a path-safe token before it becomes the slash-command filename, so a hostile name
|
|
115
|
+
# (``/abs/path``, ``../..``) cannot escape the throwaway project dir. The same token
|
|
116
|
+
# is the command name detected in the stream, so filename and detection stay aligned.
|
|
117
|
+
cmd_name = f"{safe_name_token(skill_name)}-cand-{rid}"
|
|
118
|
+
with tempfile.TemporaryDirectory(
|
|
119
|
+
prefix=f"skilleval-{rid}-", ignore_cleanup_errors=True
|
|
120
|
+
) as tmp:
|
|
121
|
+
commands_dir = Path(tmp) / ".claude" / "commands"
|
|
122
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
indented = "\n ".join(description.split("\n"))
|
|
124
|
+
(commands_dir / f"{cmd_name}.md").write_text(
|
|
125
|
+
f"---\ndescription: |\n {indented}\n---\n\n"
|
|
126
|
+
f"# {skill_name}\n\nThis skill handles: {description}\n"
|
|
127
|
+
)
|
|
128
|
+
cmd = [
|
|
129
|
+
claude_bin(),
|
|
130
|
+
"-p",
|
|
131
|
+
query,
|
|
132
|
+
"--output-format",
|
|
133
|
+
"stream-json",
|
|
134
|
+
"--verbose",
|
|
135
|
+
"--include-partial-messages",
|
|
136
|
+
]
|
|
137
|
+
if model:
|
|
138
|
+
cmd += ["--model", model]
|
|
139
|
+
if settings_json:
|
|
140
|
+
cmd += ["--settings", settings_json]
|
|
141
|
+
# cmd is a fixed argv list built from internal constants and CLI-provided
|
|
142
|
+
# model/settings strings, never a shell string -- no shell=True, no injection
|
|
143
|
+
# surface.
|
|
144
|
+
proc = subprocess.Popen( # noqa: S603 # nosec B603
|
|
145
|
+
cmd,
|
|
146
|
+
stdout=subprocess.PIPE,
|
|
147
|
+
stderr=subprocess.DEVNULL,
|
|
148
|
+
cwd=tmp,
|
|
149
|
+
env=subprocess_env(),
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
# A decisive terminal event (True/False) wins regardless of exit code; a
|
|
153
|
+
# ``None`` means no decisive event was seen (timeout, empty/incomplete
|
|
154
|
+
# output, or a failed process) -> the probe is unjudgeable, not a genuine
|
|
155
|
+
# non-trigger, so it propagates as ``None``.
|
|
156
|
+
return _interpret_events_status(_stream_events(proc, timeout), cmd_name)
|
|
157
|
+
finally:
|
|
158
|
+
if proc.poll() is None:
|
|
159
|
+
proc.kill()
|
|
160
|
+
proc.wait()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_query_with_retry(
|
|
164
|
+
query: str,
|
|
165
|
+
skill_name: str,
|
|
166
|
+
description: str,
|
|
167
|
+
model: str | None,
|
|
168
|
+
timeout: int,
|
|
169
|
+
settings_json: str | None,
|
|
170
|
+
retries: int = 1,
|
|
171
|
+
) -> bool | None:
|
|
172
|
+
"""Probe triggering, retrying unjudgeable (``None``) probes up to ``retries`` times.
|
|
173
|
+
|
|
174
|
+
A one-off timeout or CLI hiccup returns ``None`` from :func:`run_single_query`;
|
|
175
|
+
retrying keeps a transient failure from being recorded (and later scored) as a
|
|
176
|
+
non-trigger. A decisive ``True``/``False`` is returned immediately.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
query: The task to send to the model.
|
|
180
|
+
skill_name: Name of the skill (used to build the candidate command name).
|
|
181
|
+
description: The candidate description to inject.
|
|
182
|
+
model: Model id, or ``None`` to use the CLI default.
|
|
183
|
+
timeout: Per-run wall-clock budget, in seconds.
|
|
184
|
+
settings_json: Optional ``--settings`` JSON blob.
|
|
185
|
+
retries: Extra attempts after the first when a probe is unjudgeable.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
``True``/``False`` from the first decisive probe, or ``None`` if every attempt
|
|
189
|
+
was unjudgeable.
|
|
190
|
+
"""
|
|
191
|
+
for _ in range(retries + 1):
|
|
192
|
+
result = run_single_query(
|
|
193
|
+
query, skill_name, description, model, timeout, settings_json
|
|
194
|
+
)
|
|
195
|
+
if result is not None:
|
|
196
|
+
return result
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _confusion_matrix(tp: int, fp: int, tn: int, fn: int) -> ConfusionMatrix:
|
|
201
|
+
"""Build a :class:`ConfusionMatrix` from raw counts.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
tp: True positives.
|
|
205
|
+
fp: False positives.
|
|
206
|
+
tn: True negatives.
|
|
207
|
+
fn: False negatives.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The confusion matrix with precision/recall/accuracy, guarding divide-by-zero
|
|
211
|
+
the same way skill-creator does (empty predicted/expected positives ⇒ ``1.0``).
|
|
212
|
+
"""
|
|
213
|
+
total = tp + fp + tn + fn
|
|
214
|
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
|
|
215
|
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
|
|
216
|
+
accuracy = (tp + tn) / total if total > 0 else 0.0
|
|
217
|
+
return {
|
|
218
|
+
"tp": tp,
|
|
219
|
+
"fp": fp,
|
|
220
|
+
"tn": tn,
|
|
221
|
+
"fn": fn,
|
|
222
|
+
"precision": round(precision, 4),
|
|
223
|
+
"recall": round(recall, 4),
|
|
224
|
+
"accuracy": round(accuracy, 4),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def aggregate(
|
|
229
|
+
raw: Mapping[tuple[int, str], list[bool]],
|
|
230
|
+
eval_set: list[EvalQuery],
|
|
231
|
+
models: tuple[str, ...],
|
|
232
|
+
threshold: float,
|
|
233
|
+
description: str,
|
|
234
|
+
errors: Mapping[tuple[int, str], int] | None = None,
|
|
235
|
+
) -> EvalResult:
|
|
236
|
+
"""Roll raw per-run trigger outcomes up into accuracy scores.
|
|
237
|
+
|
|
238
|
+
Cells with no judged runs (every probe was unjudgeable) are left ``pass=None`` and
|
|
239
|
+
excluded from the accuracy denominators, so a transient CLI outage does not read as
|
|
240
|
+
a wave of misses. Accuracy is meaned/mined only over models that have at least one
|
|
241
|
+
judged query; if no model does, ``score_valid`` is ``False``.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
raw: Trigger booleans keyed by ``(query_index, model)``.
|
|
245
|
+
eval_set: The queries, indexed positionally to match ``raw`` keys.
|
|
246
|
+
models: Models that were evaluated.
|
|
247
|
+
threshold: Trigger-rate at or above which a query counts as triggered.
|
|
248
|
+
description: The description these results belong to (echoed in the output).
|
|
249
|
+
errors: Per-cell unjudged-probe counts keyed by ``(query_index, model)``.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The aggregated :class:`EvalResult`.
|
|
253
|
+
"""
|
|
254
|
+
err_map = errors or {}
|
|
255
|
+
per_query: list[PerQuery] = []
|
|
256
|
+
for i, q in enumerate(eval_set):
|
|
257
|
+
md: dict[str, ModelResult] = {}
|
|
258
|
+
for m in models:
|
|
259
|
+
trig = raw.get((i, m), [])
|
|
260
|
+
n = len(trig)
|
|
261
|
+
triggers = sum(trig)
|
|
262
|
+
if n:
|
|
263
|
+
rate = triggers / n
|
|
264
|
+
passed = (
|
|
265
|
+
(rate >= threshold) if q["should_trigger"] else (rate < threshold)
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
# Every probe for this cell was unjudgeable -> leave it out of scoring.
|
|
269
|
+
rate, passed = 0.0, None
|
|
270
|
+
md[m] = {
|
|
271
|
+
"trigger_rate": round(rate, 3),
|
|
272
|
+
"pass": passed,
|
|
273
|
+
"triggers": triggers,
|
|
274
|
+
"runs": n,
|
|
275
|
+
"errors": err_map.get((i, m), 0),
|
|
276
|
+
}
|
|
277
|
+
judged = [md[m]["pass"] for m in models if md[m]["pass"] is not None]
|
|
278
|
+
per_query.append(
|
|
279
|
+
{
|
|
280
|
+
"index": i,
|
|
281
|
+
"query": q["query"],
|
|
282
|
+
"should_trigger": q["should_trigger"],
|
|
283
|
+
"models": md,
|
|
284
|
+
# None when every model was unjudged: neither a decisive pass nor a
|
|
285
|
+
# decisive failure, so it must not read as a falsey miss downstream.
|
|
286
|
+
"all_pass": all(judged) if judged else None,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
return _rollup_stats(description, per_query, models)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _rollup_stats(
|
|
293
|
+
description: str, per_query: list[PerQuery], models: tuple[str, ...]
|
|
294
|
+
) -> EvalResult:
|
|
295
|
+
"""Roll per-query model outcomes up into accuracy, confusion, and error totals.
|
|
296
|
+
|
|
297
|
+
Shared by :func:`aggregate` (from raw probes) and :func:`subset_result` (from a
|
|
298
|
+
sliced copy) so both derive identical stats from the tri-state ``per_query`` data.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
description: The description these results belong to.
|
|
302
|
+
per_query: The per-query roll-ups to summarize (owned by the caller).
|
|
303
|
+
models: The evaluated model ids.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
The aggregated :class:`EvalResult`.
|
|
307
|
+
"""
|
|
308
|
+
per_model_correct = {m: 0 for m in models}
|
|
309
|
+
per_model_total = {m: 0 for m in models}
|
|
310
|
+
conf = {m: {"tp": 0, "fp": 0, "tn": 0, "fn": 0} for m in models}
|
|
311
|
+
total_errors = 0
|
|
312
|
+
unjudged = 0
|
|
313
|
+
for pq in per_query:
|
|
314
|
+
for m in models:
|
|
315
|
+
mr = pq["models"][m]
|
|
316
|
+
total_errors += mr["errors"]
|
|
317
|
+
if mr["pass"] is None:
|
|
318
|
+
unjudged += 1
|
|
319
|
+
continue
|
|
320
|
+
per_model_total[m] += 1
|
|
321
|
+
per_model_correct[m] += int(mr["pass"])
|
|
322
|
+
if pq["should_trigger"]:
|
|
323
|
+
conf[m]["tp"] += mr["triggers"]
|
|
324
|
+
conf[m]["fn"] += mr["runs"] - mr["triggers"]
|
|
325
|
+
else:
|
|
326
|
+
conf[m]["fp"] += mr["triggers"]
|
|
327
|
+
conf[m]["tn"] += mr["runs"] - mr["triggers"]
|
|
328
|
+
per_model_acc: dict[str, float | None] = {
|
|
329
|
+
m: (round(per_model_correct[m] / per_model_total[m], 4))
|
|
330
|
+
if per_model_total[m]
|
|
331
|
+
else None
|
|
332
|
+
for m in models
|
|
333
|
+
}
|
|
334
|
+
if judged_accs := [a for a in per_model_acc.values() if a is not None]:
|
|
335
|
+
mean_acc = round(sum(judged_accs) / len(judged_accs), 4)
|
|
336
|
+
min_acc = round(min(judged_accs), 4)
|
|
337
|
+
score_valid = True
|
|
338
|
+
else:
|
|
339
|
+
mean_acc = min_acc = 0.0
|
|
340
|
+
score_valid = False
|
|
341
|
+
per_model_confusion = {
|
|
342
|
+
m: _confusion_matrix(conf[m]["tp"], conf[m]["fp"], conf[m]["tn"], conf[m]["fn"])
|
|
343
|
+
for m in models
|
|
344
|
+
}
|
|
345
|
+
agg = _confusion_matrix(
|
|
346
|
+
sum(c["tp"] for c in conf.values()),
|
|
347
|
+
sum(c["fp"] for c in conf.values()),
|
|
348
|
+
sum(c["tn"] for c in conf.values()),
|
|
349
|
+
sum(c["fn"] for c in conf.values()),
|
|
350
|
+
)
|
|
351
|
+
return {
|
|
352
|
+
"description": description,
|
|
353
|
+
"per_model_accuracy": per_model_acc,
|
|
354
|
+
"mean_accuracy": mean_acc,
|
|
355
|
+
"min_accuracy": min_acc,
|
|
356
|
+
"per_query": per_query,
|
|
357
|
+
"errors": total_errors,
|
|
358
|
+
"unjudged": unjudged,
|
|
359
|
+
"score_valid": score_valid,
|
|
360
|
+
"confusion": agg,
|
|
361
|
+
"per_model_confusion": per_model_confusion,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def subset_result(
|
|
366
|
+
full_eval: EvalResult,
|
|
367
|
+
eval_set: list[EvalQuery],
|
|
368
|
+
indices: list[int],
|
|
369
|
+
models: tuple[str, ...],
|
|
370
|
+
) -> EvalResult:
|
|
371
|
+
"""Derive a train/test view of one full-set evaluation, sliced by index.
|
|
372
|
+
|
|
373
|
+
Deep-copies the :data:`PerQuery` entries at ``indices`` (they are mutable, so a
|
|
374
|
+
shallow slice would let train/test/history views cross-mutate) and recomputes every
|
|
375
|
+
stat from the copies, honoring the tri-state contract (unjudged cells excluded).
|
|
376
|
+
Slicing by positional index — never query text — preserves the dedup invariant.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
full_eval: The full-set evaluation to slice.
|
|
380
|
+
eval_set: The full eval set; its length must match ``full_eval["per_query"]``.
|
|
381
|
+
indices: Original positional indices to include (unique, in range).
|
|
382
|
+
models: The evaluated model ids.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
An :class:`EvalResult` over just the selected queries.
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
ValueError: If the per-query length disagrees with ``eval_set``, or an index is
|
|
389
|
+
out of range or duplicated.
|
|
390
|
+
"""
|
|
391
|
+
n = len(eval_set)
|
|
392
|
+
if len(full_eval["per_query"]) != n:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"per_query length {len(full_eval['per_query'])} != eval_set length {n}"
|
|
395
|
+
)
|
|
396
|
+
seen: set[int] = set()
|
|
397
|
+
for i in indices:
|
|
398
|
+
if not 0 <= i < n:
|
|
399
|
+
raise ValueError(f"index {i} out of range [0, {n})")
|
|
400
|
+
if i in seen:
|
|
401
|
+
raise ValueError(f"duplicate index {i}")
|
|
402
|
+
seen.add(i)
|
|
403
|
+
per_query = [copy.deepcopy(full_eval["per_query"][i]) for i in indices]
|
|
404
|
+
return _rollup_stats(full_eval["description"], per_query, models)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def evaluate(
|
|
408
|
+
eval_set: list[EvalQuery],
|
|
409
|
+
skill_name: str,
|
|
410
|
+
description: str,
|
|
411
|
+
config: EvalConfig,
|
|
412
|
+
*,
|
|
413
|
+
verbose: bool = True,
|
|
414
|
+
) -> EvalResult:
|
|
415
|
+
"""Evaluate one description across the eval set and models, concurrently.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
eval_set: Queries to evaluate.
|
|
419
|
+
skill_name: Name of the skill under test.
|
|
420
|
+
description: The description to evaluate.
|
|
421
|
+
config: Shared run settings (models, repeats, timeout, workers, threshold).
|
|
422
|
+
verbose: Whether to log periodic progress.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The aggregated :class:`EvalResult`.
|
|
426
|
+
"""
|
|
427
|
+
jobs = [
|
|
428
|
+
(i, q, m)
|
|
429
|
+
for i, q in enumerate(eval_set)
|
|
430
|
+
for m in config.models
|
|
431
|
+
for _ in range(config.repeats)
|
|
432
|
+
]
|
|
433
|
+
raw: dict[tuple[int, str], list[bool]] = {}
|
|
434
|
+
errors: dict[tuple[int, str], int] = {}
|
|
435
|
+
with ThreadPoolExecutor(max_workers=config.workers) as executor:
|
|
436
|
+
# Bucket by the query's positional index -- the same index aggregate()
|
|
437
|
+
# uses via enumerate(eval_set). (The original used eval_set.index(q),
|
|
438
|
+
# which is O(n^2) and mis-buckets exact-duplicate query dicts to the first
|
|
439
|
+
# match; positional indexing matches skill-creator's unique-query model and
|
|
440
|
+
# reports each entry's true rate.)
|
|
441
|
+
futures = {
|
|
442
|
+
executor.submit(
|
|
443
|
+
run_query_with_retry,
|
|
444
|
+
q["query"],
|
|
445
|
+
skill_name,
|
|
446
|
+
description,
|
|
447
|
+
m,
|
|
448
|
+
config.timeout,
|
|
449
|
+
config.settings_json,
|
|
450
|
+
): (i, m)
|
|
451
|
+
for i, q, m in jobs
|
|
452
|
+
}
|
|
453
|
+
for done, fut in enumerate(as_completed(futures), start=1):
|
|
454
|
+
i, m = futures[fut]
|
|
455
|
+
try:
|
|
456
|
+
result = fut.result()
|
|
457
|
+
except Exception:
|
|
458
|
+
logger.warning("query %d (%s) failed", i, m, exc_info=True)
|
|
459
|
+
result = None
|
|
460
|
+
# A None probe is unjudgeable (timeout/CLI error): tally it separately and
|
|
461
|
+
# keep it out of the trigger-rate bucket rather than scoring it as a miss.
|
|
462
|
+
if result is None:
|
|
463
|
+
errors[(i, m)] = errors.get((i, m), 0) + 1
|
|
464
|
+
else:
|
|
465
|
+
raw.setdefault((i, m), []).append(result)
|
|
466
|
+
if verbose and done % 10 == 0:
|
|
467
|
+
logger.info(" ...%d/%d runs", done, len(jobs))
|
|
468
|
+
if total_errors := sum(errors.values()):
|
|
469
|
+
logger.info(
|
|
470
|
+
" %d probe(s) unjudgeable (timeout/CLI error); excluded from rates.",
|
|
471
|
+
total_errors,
|
|
472
|
+
)
|
|
473
|
+
return aggregate(
|
|
474
|
+
raw, eval_set, config.models, config.threshold, description, errors
|
|
475
|
+
)
|