@fede0089/skill-eval 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/dist/commands/functional.js +5 -8
- package/dist/commands/trigger.js +4 -7
- package/dist/core/anomalies.js +63 -0
- package/dist/core/eval-runner.js +28 -12
- package/dist/core/statistics.js +11 -8
- package/dist/core/trial-utils.js +45 -1
- package/dist/index.js +1 -1
- package/dist/reporters/html-reporter.js +970 -324
- package/dist/reporters/index.js +0 -1
- package/dist/utils/ndjson.js +19 -0
- package/package.json +1 -1
- package/dist/reporters/json-reporter.js +0 -10
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
|
|
|
92
92
|
| `--eval-file <name>` | no | all | Run only the evals from this file in `evals/` (e.g. `edge-cases.json`) |
|
|
93
93
|
| `--compare-ref [refs...]` | no | — | Git references to compare against (variadic — put `[agent]` before it, not after) |
|
|
94
94
|
| `--compare-baseline` | no | `false` | Also run the no-skill baseline alongside the skill |
|
|
95
|
-
| `-v, --debug` | no | `false` |
|
|
95
|
+
| `-v, --debug` | no | `false` | Print verbose logs to the console (trial transcripts are always saved) |
|
|
96
96
|
| `[agent]` | no | `gemini-cli` | Agent backend to use |
|
|
97
97
|
|
|
98
98
|
Supported runners:
|
|
@@ -222,24 +222,55 @@ Refer to your runner's documentation for the full set of settings and policy key
|
|
|
222
222
|
|
|
223
223
|
## Reports
|
|
224
224
|
|
|
225
|
-
Each run writes to `.project-skill-evals/runs/<timestamp
|
|
225
|
+
Each run writes to `.project-skill-evals/runs/<timestamp>/`: one log per trial and a self-contained HTML report you can open in any browser.
|
|
226
|
+
|
|
227
|
+
Expanding an eval gives you three sections:
|
|
228
|
+
|
|
229
|
+
- **Summary** — success rate, average tokens and average time, per variant.
|
|
230
|
+
- **Trials** — one row per trial, with its score, anomaly flags, cost and an exclude control. Expanding a row shows the agent's final output, its stats and a link to the full transcript.
|
|
231
|
+
- **Expectations** — one row per expectation, with per-variant pass rates. Clicking a cell shows the judge's verdict for every trial.
|
|
232
|
+
|
|
233
|
+
The report carries the full run data and computes every figure in the browser, so excluding a trial updates all of them at once.
|
|
226
234
|
|
|
227
235
|
A published sample report is available at [fede0089.github.io/skill-eval/sample-report.html](https://fede0089.github.io/skill-eval/sample-report.html), generated from this project root with:
|
|
228
236
|
|
|
229
237
|
```sh
|
|
230
|
-
skill-eval functional --workspace . --skill mock-skill --trials 2 --compare-baseline
|
|
238
|
+
skill-eval functional --workspace . --skill mock-skill --trials 2 --compare-baseline claude-code
|
|
231
239
|
```
|
|
232
240
|
|
|
233
241
|

|
|
234
242
|
|
|
235
|
-
###
|
|
243
|
+
### Trial transcripts
|
|
236
244
|
|
|
237
|
-
|
|
245
|
+
Every trial writes a `task_<id>_<variant>_trial_<n>.log` file inside the run directory, with two sections appended in order:
|
|
238
246
|
|
|
239
247
|
- `# SECTION: <MODE> AGENT RUN` — the initial prompt sent to the agent and its raw streamed response.
|
|
240
248
|
- `# SECTION: <MODE> JUDGE RUN` — the prompt sent to the LLM judge and its verdict (only present for `functional` runs; `trigger` is graded programmatically and produces no judge section).
|
|
241
249
|
|
|
242
|
-
|
|
250
|
+
These are always written, and the report links to each one from its trial row, so you can go from a suspicious number to exactly what the agent — or the judge — saw. `-v` / `--debug` is unrelated: it only makes the console output verbose.
|
|
251
|
+
|
|
252
|
+
## Excluding trials
|
|
253
|
+
|
|
254
|
+
Agents are non-deterministic, and a trial sometimes fails for reasons that have nothing to do with the skill: the model degenerates into a single word, stops before answering, or trips over the environment. Those trials drag the score down and, worse, compress the very difference an A/B run exists to measure.
|
|
255
|
+
|
|
256
|
+
The report flags the likely ones and lets you drop them. Each flag compares a trial against its own cohort — the sibling trials of the same eval and variant — rather than against a fixed threshold:
|
|
257
|
+
|
|
258
|
+
| Flag | Meaning |
|
|
259
|
+
|------|---------|
|
|
260
|
+
| `degenerate-output` | The final output is a fraction of the cohort's median length |
|
|
261
|
+
| `zero-assertions` | Nothing passed, while the cohort median is well above zero |
|
|
262
|
+
| `premature-stop` | The run ended without a success status, or produced no output |
|
|
263
|
+
| `resource-outlier` | Token spend far above the cohort median |
|
|
264
|
+
|
|
265
|
+
Flags never exclude anything on their own — no threshold can reliably separate "the skill failed" from "the model went off the rails this time", so the call is yours. Open a trial row, read what the agent actually produced, and press **Exclude**; the reason is pre-filled from the strongest flag and can be changed, along with a free-text note.
|
|
266
|
+
|
|
267
|
+
Excluding recomputes every figure in the report, under a few rules that keep the result honest:
|
|
268
|
+
|
|
269
|
+
- **The raw number stays visible.** Every adjusted rate is shown next to the unadjusted one and the effective sample size (`raw 36% · n=3/5`). Below three usable trials the figure is marked low-confidence.
|
|
270
|
+
- **Unbalanced exclusions raise a warning.** Dropping more trials from one variant than another moves the delta you are measuring, so the report says so.
|
|
271
|
+
- **The exclusion rate is itself a result.** If four of ten trials degenerated, that is a finding about the model or the prompt, not noise to sweep away — so it is shown at the top.
|
|
272
|
+
|
|
273
|
+
Exclusions are kept in the browser for that run. **Download reviewed copy** writes a `report-reviewed.html` with them baked in, for sharing or committing alongside the run.
|
|
243
274
|
|
|
244
275
|
## Try it out
|
|
245
276
|
|
|
@@ -11,10 +11,10 @@ import { preflight } from '../core/preflight.js';
|
|
|
11
11
|
import { ConfigError } from '../core/errors.js';
|
|
12
12
|
import { withRetry } from '../core/trial-utils.js';
|
|
13
13
|
import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.js';
|
|
14
|
-
import {
|
|
14
|
+
import { HtmlReporter } from '../reporters/index.js';
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { git } from '../utils/git.js';
|
|
17
|
-
export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new
|
|
17
|
+
export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new HtmlReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false, evalFile) {
|
|
18
18
|
if (!injectedSuite)
|
|
19
19
|
preflight(agent, workspace, skillPath);
|
|
20
20
|
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
|
|
@@ -45,8 +45,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
|
|
|
45
45
|
const cleanup = () => { env.teardown().finally(() => process.exit(1)); };
|
|
46
46
|
process.once('SIGINT', cleanup);
|
|
47
47
|
process.once('SIGTERM', cleanup);
|
|
48
|
-
//
|
|
49
|
-
const debug = !!process.env.DEBUG;
|
|
48
|
+
// Every run gets its own directory: trial logs and the report always land here.
|
|
50
49
|
const startTime = new Date();
|
|
51
50
|
const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
|
|
52
51
|
const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
|
|
@@ -55,7 +54,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
|
|
|
55
54
|
const variantRunners = new Map();
|
|
56
55
|
// 1. Local Runner
|
|
57
56
|
variantRunners.set('local', new EvalRunner({
|
|
58
|
-
agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false,
|
|
57
|
+
agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, timeoutMs,
|
|
59
58
|
variant: 'local'
|
|
60
59
|
}));
|
|
61
60
|
// 2. Historical Runners
|
|
@@ -71,14 +70,13 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
|
|
|
71
70
|
skillName: skill_name,
|
|
72
71
|
runDir,
|
|
73
72
|
isBaseline: false,
|
|
74
|
-
debug,
|
|
75
73
|
timeoutMs,
|
|
76
74
|
variant: `ref:${ref}`
|
|
77
75
|
}));
|
|
78
76
|
}
|
|
79
77
|
// 3. Baseline Runner
|
|
80
78
|
const withoutSkillRunner = compareBaseline ? new EvalRunner({
|
|
81
|
-
agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: true,
|
|
79
|
+
agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: true, timeoutMs,
|
|
82
80
|
variant: 'baseline'
|
|
83
81
|
}) : undefined;
|
|
84
82
|
const taskResults = [];
|
|
@@ -273,7 +271,6 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
|
|
|
273
271
|
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
274
272
|
renderFunctionalTable(report);
|
|
275
273
|
Logger.write('\n');
|
|
276
|
-
new JsonReporter().generate(report, runDir);
|
|
277
274
|
reporter.generate(report, runDir);
|
|
278
275
|
}
|
|
279
276
|
finally {
|
package/dist/commands/trigger.js
CHANGED
|
@@ -10,10 +10,10 @@ import { aggregatePassAtK, aggregateAssertionPassRate, aggregateTokenStats, aggr
|
|
|
10
10
|
import { preflight } from '../core/preflight.js';
|
|
11
11
|
import { withRetry } from '../core/trial-utils.js';
|
|
12
12
|
import { renderTriggerTable, renderRunHeader } from '../utils/table-renderer.js';
|
|
13
|
-
import {
|
|
13
|
+
import { HtmlReporter } from '../reporters/index.js';
|
|
14
14
|
import chalk from 'chalk';
|
|
15
15
|
import { git } from '../utils/git.js';
|
|
16
|
-
export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new
|
|
16
|
+
export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new HtmlReporter(), timeoutMs, evalId, compareRefs = [], evalFile) {
|
|
17
17
|
if (!injectedSuite)
|
|
18
18
|
preflight(agent, workspace, skillPath);
|
|
19
19
|
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
|
|
@@ -32,8 +32,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
|
|
|
32
32
|
const cleanup = () => { env.teardown().finally(() => process.exit(1)); };
|
|
33
33
|
process.once('SIGINT', cleanup);
|
|
34
34
|
process.once('SIGTERM', cleanup);
|
|
35
|
-
//
|
|
36
|
-
const debug = !!process.env.DEBUG;
|
|
35
|
+
// Every run gets its own directory: trial logs and the report always land here.
|
|
37
36
|
const startTime = new Date();
|
|
38
37
|
const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
|
|
39
38
|
const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
|
|
@@ -42,7 +41,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
|
|
|
42
41
|
const variantRunners = new Map();
|
|
43
42
|
// 1. Local Runner
|
|
44
43
|
variantRunners.set('local', new EvalRunner({
|
|
45
|
-
agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false,
|
|
44
|
+
agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, timeoutMs,
|
|
46
45
|
variant: 'local'
|
|
47
46
|
}));
|
|
48
47
|
// 2. Historical Runners
|
|
@@ -58,7 +57,6 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
|
|
|
58
57
|
skillName: skill_name,
|
|
59
58
|
runDir,
|
|
60
59
|
isBaseline: false,
|
|
61
|
-
debug,
|
|
62
60
|
timeoutMs,
|
|
63
61
|
variant: `ref:${ref}`
|
|
64
62
|
}));
|
|
@@ -203,7 +201,6 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
|
|
|
203
201
|
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
204
202
|
renderTriggerTable(report);
|
|
205
203
|
Logger.write('\n\n');
|
|
206
|
-
new JsonReporter().generate(report, runDir);
|
|
207
204
|
reporter.generate(report, runDir);
|
|
208
205
|
}
|
|
209
206
|
finally {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Fraction of the cohort's median output length below which text reads as degenerate. */
|
|
2
|
+
const DEGENERATE_OUTPUT_RATIO = 0.15;
|
|
3
|
+
/** Multiple of the cohort's median token spend above which a trial is an outlier. */
|
|
4
|
+
const RESOURCE_OUTLIER_RATIO = 2.5;
|
|
5
|
+
function median(values) {
|
|
6
|
+
if (values.length === 0)
|
|
7
|
+
return 0;
|
|
8
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
9
|
+
const mid = Math.floor(sorted.length / 2);
|
|
10
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
11
|
+
}
|
|
12
|
+
function passedAssertions(trial) {
|
|
13
|
+
return trial.assertionResults.filter(r => r.passed).length;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Flags a trial against its cohort — the sibling trials of the same eval and
|
|
17
|
+
* variant. Comparisons are relative to the cohort median rather than absolute
|
|
18
|
+
* thresholds, so the signals hold whether an eval has 3 assertions or 30 and
|
|
19
|
+
* whether a task normally takes 20K tokens or 2M.
|
|
20
|
+
*
|
|
21
|
+
* Infrastructure errors return no anomalies: they are already excluded from
|
|
22
|
+
* every metric, and flagging them again would only pad the review queue.
|
|
23
|
+
*
|
|
24
|
+
* @param trial The trial to inspect.
|
|
25
|
+
* @param cohort All trials of the same eval and variant, including `trial`.
|
|
26
|
+
*/
|
|
27
|
+
export function detectAnomalies(trial, cohort) {
|
|
28
|
+
if (trial.isError || !trial.summary)
|
|
29
|
+
return [];
|
|
30
|
+
// Errored siblings produced no output; letting them into the baseline would
|
|
31
|
+
// drag the median down and mask the degenerate trials this exists to catch.
|
|
32
|
+
const peers = cohort.filter(t => !t.isError && t.summary);
|
|
33
|
+
const anomalies = [];
|
|
34
|
+
const medianOutputLen = median(peers.map(t => t.summary.outputLen));
|
|
35
|
+
if (medianOutputLen > 0 && trial.summary.outputLen < medianOutputLen * DEGENERATE_OUTPUT_RATIO) {
|
|
36
|
+
anomalies.push({
|
|
37
|
+
tag: 'degenerate-output',
|
|
38
|
+
reason: `Final output is ${trial.summary.outputLen} characters; the cohort median is ${Math.round(medianOutputLen)}.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const medianPassed = median(peers.map(passedAssertions));
|
|
42
|
+
if (trial.assertionResults.length > 0 && passedAssertions(trial) === 0 && medianPassed > 0) {
|
|
43
|
+
anomalies.push({
|
|
44
|
+
tag: 'zero-assertions',
|
|
45
|
+
reason: `Passed 0 of ${trial.assertionResults.length} assertions; the cohort median is ${medianPassed}.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (trial.summary.outputLen === 0 || trial.summary.stopStatus !== 'success') {
|
|
49
|
+
anomalies.push({
|
|
50
|
+
tag: 'premature-stop',
|
|
51
|
+
reason: `Run ended with status "${trial.summary.stopStatus ?? 'unknown'}" and ${trial.summary.outputLen} characters of output.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const medianTokens = median(peers.filter(t => t.tokenStats).map(t => t.tokenStats.totalTokens));
|
|
55
|
+
if (trial.tokenStats && medianTokens > 0 &&
|
|
56
|
+
trial.tokenStats.totalTokens > medianTokens * RESOURCE_OUTLIER_RATIO) {
|
|
57
|
+
anomalies.push({
|
|
58
|
+
tag: 'resource-outlier',
|
|
59
|
+
reason: `Spent ${trial.tokenStats.totalTokens.toLocaleString('en-US')} tokens against a cohort median of ${Math.round(medianTokens).toLocaleString('en-US')}.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return anomalies;
|
|
63
|
+
}
|
package/dist/core/eval-runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import { executor } from '../utils/exec.js';
|
|
|
4
4
|
import { EvalEnvironment } from './environment.js';
|
|
5
5
|
import { RunnerFactory } from '../runners/index.js';
|
|
6
6
|
import { TriggerGrader, ModelBasedGrader } from './evaluator.js';
|
|
7
|
+
import { buildTrialSummary } from './trial-utils.js';
|
|
7
8
|
import { parseStreamResult, parseTokenStats } from '../utils/ndjson.js';
|
|
8
9
|
// Replace filesystem-unsafe characters so variants like 'ref:main' or 'feature/x' can be used in filenames.
|
|
9
10
|
function slugifyVariant(v) {
|
|
@@ -26,7 +27,7 @@ export class EvalRunner {
|
|
|
26
27
|
async runTriggerTask(task, index, trialId, uiCtx, attempt = 0) {
|
|
27
28
|
const variantSlug = slugifyVariant(this.options.variant ?? 'local');
|
|
28
29
|
const logFileName = `task_${task.id}_${variantSlug}_trial_${trialId}.log`;
|
|
29
|
-
const logPath =
|
|
30
|
+
const logPath = path.join(this.options.runDir, logFileName);
|
|
30
31
|
let worktreePath;
|
|
31
32
|
let transcript = null;
|
|
32
33
|
let durationMs;
|
|
@@ -65,6 +66,8 @@ export class EvalRunner {
|
|
|
65
66
|
const tokenStats = transcript
|
|
66
67
|
? parseTokenStats(transcript.response || '') ?? undefined
|
|
67
68
|
: undefined;
|
|
69
|
+
// Compact evidence for the report. The full stream stays in the trial log.
|
|
70
|
+
const summary = buildTrialSummary(transcript?.response || '', logFileName);
|
|
68
71
|
// Negative evals (should_trigger: false) assert the opposite: the skill must NOT activate.
|
|
69
72
|
const shouldTrigger = task.should_trigger !== false;
|
|
70
73
|
const assertionLabel = shouldTrigger ? 'Skill was triggered' : 'Skill was not triggered';
|
|
@@ -99,7 +102,8 @@ export class EvalRunner {
|
|
|
99
102
|
trialPassed: false,
|
|
100
103
|
isError: true,
|
|
101
104
|
tokenStats,
|
|
102
|
-
durationMs
|
|
105
|
+
durationMs,
|
|
106
|
+
summary
|
|
103
107
|
};
|
|
104
108
|
}
|
|
105
109
|
else {
|
|
@@ -129,7 +133,8 @@ export class EvalRunner {
|
|
|
129
133
|
trialPassed: false,
|
|
130
134
|
isError: true,
|
|
131
135
|
tokenStats,
|
|
132
|
-
durationMs
|
|
136
|
+
durationMs,
|
|
137
|
+
summary
|
|
133
138
|
};
|
|
134
139
|
}
|
|
135
140
|
return {
|
|
@@ -138,7 +143,8 @@ export class EvalRunner {
|
|
|
138
143
|
assertionResults: assertionResults,
|
|
139
144
|
trialPassed,
|
|
140
145
|
tokenStats,
|
|
141
|
-
durationMs
|
|
146
|
+
durationMs,
|
|
147
|
+
summary
|
|
142
148
|
};
|
|
143
149
|
}
|
|
144
150
|
async runFunctionalTask(task, index, trialId, uiCtx, attempt = 0) {
|
|
@@ -149,7 +155,7 @@ export class EvalRunner {
|
|
|
149
155
|
: `${task.prompt}\n\nIMPORTANT: You must use the '${this.options.skillName}' skill/tool to solve this task.`;
|
|
150
156
|
const variantSlug = slugifyVariant(this.options.variant ?? (skillDisabled ? 'baseline' : 'local'));
|
|
151
157
|
const logFileName = `task_${task.id}_${variantSlug}_trial_${trialId}.log`;
|
|
152
|
-
const logPath =
|
|
158
|
+
const logPath = path.join(this.options.runDir, logFileName);
|
|
153
159
|
let worktreePath;
|
|
154
160
|
let assertionResults = [];
|
|
155
161
|
let trialPassed = false;
|
|
@@ -188,6 +194,8 @@ export class EvalRunner {
|
|
|
188
194
|
const tokenStats = transcript
|
|
189
195
|
? parseTokenStats(transcript.response || '') ?? undefined
|
|
190
196
|
: undefined;
|
|
197
|
+
// Compact evidence for the report. The full stream stays in the trial log.
|
|
198
|
+
const summary = buildTrialSummary(transcript?.response || '', logFileName);
|
|
191
199
|
if (transcript && !transcript.error) {
|
|
192
200
|
if (skillDisabled && this.triggerGrader.detectSkillAttempt(transcript)) {
|
|
193
201
|
return {
|
|
@@ -201,7 +209,8 @@ export class EvalRunner {
|
|
|
201
209
|
}],
|
|
202
210
|
trialPassed: false,
|
|
203
211
|
tokenStats,
|
|
204
|
-
durationMs
|
|
212
|
+
durationMs,
|
|
213
|
+
summary
|
|
205
214
|
};
|
|
206
215
|
}
|
|
207
216
|
if (!skillDisabled && !this.triggerGrader.gradeTrigger(transcript)) {
|
|
@@ -216,7 +225,8 @@ export class EvalRunner {
|
|
|
216
225
|
}],
|
|
217
226
|
trialPassed: false,
|
|
218
227
|
tokenStats,
|
|
219
|
-
durationMs
|
|
228
|
+
durationMs,
|
|
229
|
+
summary
|
|
220
230
|
};
|
|
221
231
|
}
|
|
222
232
|
let context = 'No changes detected or git not available.';
|
|
@@ -273,7 +283,8 @@ export class EvalRunner {
|
|
|
273
283
|
})),
|
|
274
284
|
trialPassed: false,
|
|
275
285
|
tokenStats,
|
|
276
|
-
durationMs
|
|
286
|
+
durationMs,
|
|
287
|
+
summary
|
|
277
288
|
};
|
|
278
289
|
}
|
|
279
290
|
trialPassed = assertionResults.every(r => r.passed);
|
|
@@ -287,7 +298,8 @@ export class EvalRunner {
|
|
|
287
298
|
assertionResults: assertionResults,
|
|
288
299
|
trialPassed,
|
|
289
300
|
tokenStats,
|
|
290
|
-
durationMs
|
|
301
|
+
durationMs,
|
|
302
|
+
summary
|
|
291
303
|
};
|
|
292
304
|
}
|
|
293
305
|
else {
|
|
@@ -308,7 +320,8 @@ export class EvalRunner {
|
|
|
308
320
|
trialPassed: false,
|
|
309
321
|
isError: true,
|
|
310
322
|
tokenStats,
|
|
311
|
-
durationMs
|
|
323
|
+
durationMs,
|
|
324
|
+
summary
|
|
312
325
|
};
|
|
313
326
|
}
|
|
314
327
|
}
|
|
@@ -322,13 +335,16 @@ export class EvalRunner {
|
|
|
322
335
|
graderType: 'model-based'
|
|
323
336
|
}));
|
|
324
337
|
}
|
|
325
|
-
// isError return — finally still runs cleanup
|
|
338
|
+
// isError return — finally still runs cleanup.
|
|
339
|
+
// The agent crashed before a summary could be built, but the log may hold
|
|
340
|
+
// a partial stream, so keep the pointer to it.
|
|
326
341
|
return {
|
|
327
342
|
id: trialId,
|
|
328
343
|
transcript: { error: errorMsg },
|
|
329
344
|
assertionResults,
|
|
330
345
|
trialPassed: false,
|
|
331
|
-
isError: true
|
|
346
|
+
isError: true,
|
|
347
|
+
summary: buildTrialSummary('', logFileName)
|
|
332
348
|
};
|
|
333
349
|
}
|
|
334
350
|
finally {
|
package/dist/core/statistics.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { isCounted } from './trial-utils.js';
|
|
1
2
|
/**
|
|
2
|
-
* Computes the fraction of individual assertions that passed across all
|
|
3
|
-
* Returns 0 if there are no
|
|
3
|
+
* Computes the fraction of individual assertions that passed across all counted trials.
|
|
4
|
+
* Returns 0 if there are no counted trials or no assertions.
|
|
4
5
|
*/
|
|
5
6
|
export function computeAssertionPassRate(trials) {
|
|
6
|
-
const relevant = trials.filter(
|
|
7
|
+
const relevant = trials.filter(isCounted);
|
|
7
8
|
if (relevant.length === 0)
|
|
8
9
|
return 0;
|
|
9
10
|
const total = relevant.reduce((s, t) => s + t.assertionResults.length, 0);
|
|
@@ -21,14 +22,16 @@ export function aggregateAssertionPassRate(results, trialSelector) {
|
|
|
21
22
|
return results.reduce((sum, r) => sum + computeAssertionPassRate(trialSelector(r)), 0) / results.length;
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
|
-
* Computes the pass rate (pass@1) for a set of trials.
|
|
25
|
-
*
|
|
25
|
+
* Computes the pass rate (pass@1) for a set of trials: c / n over counted trials.
|
|
26
|
+
* Infrastructure errors leave the denominator rather than counting as failures,
|
|
27
|
+
* matching computeAssertionPassRate — see isCounted().
|
|
28
|
+
* Returns 0 when no trial counted.
|
|
26
29
|
*/
|
|
27
30
|
export function computePassAtK(trials, _k = 1) {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
31
|
+
const relevant = trials.filter(isCounted);
|
|
32
|
+
if (relevant.length === 0)
|
|
30
33
|
return 0;
|
|
31
|
-
return
|
|
34
|
+
return relevant.filter(t => t.trialPassed).length / relevant.length;
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* Aggregates pass@1 across all task results.
|
package/dist/core/trial-utils.js
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
import { parseStreamResult, parseStreamStats } from '../utils/ndjson.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cap on the agent text carried into the report. The full text always stays in
|
|
4
|
+
* the trial log, which the report links to; this bound keeps a run with dozens
|
|
5
|
+
* of trials from producing a multi-megabyte HTML file.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_SUMMARY_OUTPUT = 4000;
|
|
8
|
+
/**
|
|
9
|
+
* Builds the compact record the report shows for a trial.
|
|
10
|
+
* `rawStream` is the agent's raw NDJSON stdout; an empty or unparsable stream
|
|
11
|
+
* yields an empty output, which is itself the signal that the agent produced
|
|
12
|
+
* nothing usable.
|
|
13
|
+
*/
|
|
14
|
+
export function buildTrialSummary(rawStream, logFile) {
|
|
15
|
+
const parsed = parseStreamResult(rawStream);
|
|
16
|
+
const text = parsed && 'response' in parsed ? parsed.response : '';
|
|
17
|
+
const { toolCalls, status } = parseStreamStats(rawStream);
|
|
18
|
+
return {
|
|
19
|
+
output: text.slice(0, MAX_SUMMARY_OUTPUT),
|
|
20
|
+
outputLen: text.length,
|
|
21
|
+
toolCalls,
|
|
22
|
+
stopStatus: status,
|
|
23
|
+
logFile,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
1
26
|
/**
|
|
2
27
|
* Returns true when a trial represents an infrastructure failure (timeout, blocked
|
|
3
28
|
* interactive prompt, runner crash, etc.) rather than a legitimate judge verdict.
|
|
@@ -6,6 +31,21 @@
|
|
|
6
31
|
export function isTrialError(trial) {
|
|
7
32
|
return trial.isError === true;
|
|
8
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a trial contributes to metrics.
|
|
36
|
+
*
|
|
37
|
+
* Infrastructure errors (timeout, blocked interactive prompt, runner crash)
|
|
38
|
+
* never reached a verdict, so they leave the denominator entirely instead of
|
|
39
|
+
* counting as failures: a harness timeout says nothing about the skill. The
|
|
40
|
+
* report shows the effective n alongside every rate so an incomplete run is
|
|
41
|
+
* still visible as one.
|
|
42
|
+
*
|
|
43
|
+
* The report applies the same rule to trials a reviewer excluded. Exclusions
|
|
44
|
+
* are made after the run, against the report, so they are not visible here.
|
|
45
|
+
*/
|
|
46
|
+
export function isCounted(trial) {
|
|
47
|
+
return !trial.isError;
|
|
48
|
+
}
|
|
9
49
|
/**
|
|
10
50
|
* Runs fn(), retrying up to maxRetries additional times with exponential backoff
|
|
11
51
|
* whenever the result is an infrastructure-error trial (isTrialError returns true).
|
|
@@ -33,7 +73,11 @@ export async function withRetry(fn, maxRetries = 2, baseDelayMs = 1000, onRetry)
|
|
|
33
73
|
}
|
|
34
74
|
/**
|
|
35
75
|
* Pads the trials array up to targetCount when a trial loop aborts early.
|
|
36
|
-
*
|
|
76
|
+
*
|
|
77
|
+
* The padding records that N trials were requested but never ran, so the report
|
|
78
|
+
* can show "n=2/5" instead of silently presenting a two-trial measurement as if
|
|
79
|
+
* it were the full run. It does not push the rates down: padded entries are
|
|
80
|
+
* infrastructure errors, and isCounted() keeps those out of every denominator.
|
|
37
81
|
*
|
|
38
82
|
* @param trials Trials collected so far (may be shorter than targetCount).
|
|
39
83
|
* @param targetCount The requested number of trials (numTrials).
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,7 @@ program
|
|
|
41
41
|
.name('skill-eval')
|
|
42
42
|
.description('CLI to evaluate agent skills triggering and functionality')
|
|
43
43
|
.version(pkg.version)
|
|
44
|
-
.option('-v, --debug', '
|
|
44
|
+
.option('-v, --debug', 'Print verbose logs to the console (trial transcripts are always saved)', false);
|
|
45
45
|
program.on('option:debug', () => {
|
|
46
46
|
process.env.DEBUG = 'true';
|
|
47
47
|
});
|