@danypops/pi-eval-harness 0.1.0 → 0.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 +14 -0
- package/dist/ablation.d.ts +37 -0
- package/dist/ablation.js +57 -0
- package/dist/checker.d.ts +8 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mistakes.d.ts +30 -0
- package/dist/mistakes.js +32 -0
- package/dist/trials.d.ts +66 -0
- package/dist/trials.js +95 -0
- package/dist/turns.d.ts +3 -0
- package/dist/turns.js +2 -0
- package/dist/workspace-checkers.d.ts +16 -0
- package/dist/workspace-checkers.js +92 -0
- package/package.json +60 -45
- package/src/ablation.ts +88 -0
- package/src/checker.ts +8 -1
- package/src/index.ts +17 -0
- package/src/mistakes.ts +60 -0
- package/src/trials.ts +141 -0
- package/src/turns.ts +5 -0
- package/src/workspace-checkers.ts +92 -0
package/README.md
CHANGED
|
@@ -48,6 +48,20 @@ would only hide the assertion, not simplify it.
|
|
|
48
48
|
A `Checker` that only ever gets exercised against a real, expensive `pi-process-harness` run has
|
|
49
49
|
no fast, deterministic proof it is correct in isolation -- write the fixture test first.
|
|
50
50
|
|
|
51
|
+
## Real live-LLM smoke test (opt-in, real cost -- never run automatically)
|
|
52
|
+
|
|
53
|
+
`scripts/real-llm-smoke-test.ts` spawns a genuine `pi` process against a real model (no faux
|
|
54
|
+
provider, no scripted tool calls) and runs the captured trace through this package's own
|
|
55
|
+
matching/rollup functions -- confirming they handle a real model's own event shape, not just the
|
|
56
|
+
faux provider's conveniences every other test here relies on. Run explicitly:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
bun scripts/real-llm-smoke-test.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires real provider credentials already configured in your own ambient Pi profile (this
|
|
63
|
+
script deliberately runs with `isolatedHome: false`) and incurs a real, billed API call.
|
|
64
|
+
|
|
51
65
|
## License
|
|
52
66
|
|
|
53
67
|
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one scenario under several named tool-availability configs and diffs the results --
|
|
3
|
+
* ported from djinn's own `testkit/eval_ablation.go` (`AblationConfig`/`AblationResult`/
|
|
4
|
+
* `AblationDelta`/`Ablate()`) and `eval_report.go`'s `FormatAblation()`. The first config is the
|
|
5
|
+
* baseline; every later config's own TrialMetrics get a delta computed against it.
|
|
6
|
+
*/
|
|
7
|
+
import { type RunTrialsOptions, type TrialMetrics, type TrialResult } from "./trials.js";
|
|
8
|
+
/** One named variant of the scenario -- e.g. "baseline" (Pi's own built-in tools) vs "with-lector" (the same run, Lector's tools also registered). */
|
|
9
|
+
export interface AblationConfig {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
/** Runs one real trial under this config. Whatever varies between arms (which extensions load, which tools are registered) lives inside this closure. */
|
|
12
|
+
readonly runOne: () => Promise<TrialResult>;
|
|
13
|
+
}
|
|
14
|
+
/** The performance difference of one config's TrialMetrics vs the baseline (first) config's. */
|
|
15
|
+
export interface AblationDelta {
|
|
16
|
+
readonly passRateDelta: number;
|
|
17
|
+
readonly meanScoreDelta: number;
|
|
18
|
+
readonly meanDurationMsDelta: number;
|
|
19
|
+
readonly meanTokensInDelta: number;
|
|
20
|
+
readonly meanCacheReadTokensDelta: number;
|
|
21
|
+
readonly meanCacheWriteTokensDelta: number;
|
|
22
|
+
}
|
|
23
|
+
/** One config's own trial metrics, plus its delta vs baseline. `delta` is undefined for the baseline itself. */
|
|
24
|
+
export interface AblationResult {
|
|
25
|
+
readonly config: AblationConfig;
|
|
26
|
+
readonly metrics: TrialMetrics;
|
|
27
|
+
readonly delta?: AblationDelta;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Runs `n` trials of every config (sequentially, config by config -- each config's own trials
|
|
31
|
+
* still run at `options.concurrency` internally) and returns side-by-side results. Configs are
|
|
32
|
+
* independent scenarios in the caller's own domain (e.g. real spawned `pi` processes with
|
|
33
|
+
* different extension sets); this function only orchestrates trial counts and diffs.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ablate(configs: readonly AblationConfig[], n: number, options?: RunTrialsOptions): Promise<AblationResult[]>;
|
|
36
|
+
/** Renders a human-readable ablation comparison table -- ported from djinn's `FormatAblation()`. */
|
|
37
|
+
export declare function formatAblation(label: string, results: readonly AblationResult[]): string;
|
package/dist/ablation.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one scenario under several named tool-availability configs and diffs the results --
|
|
3
|
+
* ported from djinn's own `testkit/eval_ablation.go` (`AblationConfig`/`AblationResult`/
|
|
4
|
+
* `AblationDelta`/`Ablate()`) and `eval_report.go`'s `FormatAblation()`. The first config is the
|
|
5
|
+
* baseline; every later config's own TrialMetrics get a delta computed against it.
|
|
6
|
+
*/
|
|
7
|
+
import { runTrials } from "./trials.js";
|
|
8
|
+
/**
|
|
9
|
+
* Runs `n` trials of every config (sequentially, config by config -- each config's own trials
|
|
10
|
+
* still run at `options.concurrency` internally) and returns side-by-side results. Configs are
|
|
11
|
+
* independent scenarios in the caller's own domain (e.g. real spawned `pi` processes with
|
|
12
|
+
* different extension sets); this function only orchestrates trial counts and diffs.
|
|
13
|
+
*/
|
|
14
|
+
export async function ablate(configs, n, options = {}) {
|
|
15
|
+
if (configs.length === 0)
|
|
16
|
+
return [];
|
|
17
|
+
const results = [];
|
|
18
|
+
for (const config of configs) {
|
|
19
|
+
results.push({ config, metrics: await runTrials(config.runOne, n, options) });
|
|
20
|
+
}
|
|
21
|
+
const baseline = results[0]?.metrics;
|
|
22
|
+
if (baseline === undefined)
|
|
23
|
+
return results;
|
|
24
|
+
return results.map((result, index) => {
|
|
25
|
+
if (index === 0)
|
|
26
|
+
return result;
|
|
27
|
+
const metrics = result.metrics;
|
|
28
|
+
return {
|
|
29
|
+
...result,
|
|
30
|
+
delta: {
|
|
31
|
+
passRateDelta: metrics.passRate - baseline.passRate,
|
|
32
|
+
meanScoreDelta: metrics.meanScore - baseline.meanScore,
|
|
33
|
+
meanDurationMsDelta: metrics.meanDurationMs - baseline.meanDurationMs,
|
|
34
|
+
meanTokensInDelta: metrics.meanTokensIn - baseline.meanTokensIn,
|
|
35
|
+
meanCacheReadTokensDelta: metrics.meanCacheReadTokens - baseline.meanCacheReadTokens,
|
|
36
|
+
meanCacheWriteTokensDelta: metrics.meanCacheWriteTokens - baseline.meanCacheWriteTokens,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function signed(value, digits) {
|
|
42
|
+
const rounded = value.toFixed(digits);
|
|
43
|
+
return value >= 0 ? `+${rounded}` : rounded;
|
|
44
|
+
}
|
|
45
|
+
/** Renders a human-readable ablation comparison table -- ported from djinn's `FormatAblation()`. */
|
|
46
|
+
export function formatAblation(label, results) {
|
|
47
|
+
const lines = [`=== Ablation: ${label} ===`];
|
|
48
|
+
for (const result of results) {
|
|
49
|
+
const m = result.metrics;
|
|
50
|
+
lines.push(` ${result.config.name.padEnd(14)} pass_rate=${m.passRate.toFixed(2)} mean_score=${m.meanScore.toFixed(2)} mean_duration_ms=${m.meanDurationMs.toFixed(0)} tokens_in=${m.meanTokensIn.toFixed(0)} cache_read=${m.meanCacheReadTokens.toFixed(0)} cache_write=${m.meanCacheWriteTokens.toFixed(0)}`);
|
|
51
|
+
if (result.delta) {
|
|
52
|
+
const d = result.delta;
|
|
53
|
+
lines.push(` ${"(vs baseline)".padEnd(14)} Δpass_rate=${signed(d.passRateDelta, 2)} Δscore=${signed(d.meanScoreDelta, 2)} Δduration_ms=${signed(d.meanDurationMsDelta, 0)} Δtokens_in=${signed(d.meanTokensInDelta, 0)} Δcache_read=${signed(d.meanCacheReadTokensDelta, 0)} Δcache_write=${signed(d.meanCacheWriteTokensDelta, 0)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
package/dist/checker.d.ts
CHANGED
|
@@ -7,9 +7,16 @@ export interface CheckerResult {
|
|
|
7
7
|
readonly score: number;
|
|
8
8
|
readonly errors: readonly string[];
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Runtime context passed to a Checker -- the real completed tool executions from one run, plus
|
|
12
|
+
* (when the run actually had a real workspace directory) its absolute path, for a checker that
|
|
13
|
+
* verifies real post-run workspace state rather than which tools were called. Optional: a
|
|
14
|
+
* checker that only inspects `executions` (matching/rollup checks) never needs it, and every
|
|
15
|
+
* existing construction of this context predates the field.
|
|
16
|
+
*/
|
|
11
17
|
export interface CheckerContext {
|
|
12
18
|
readonly executions: readonly ToolExecution[];
|
|
19
|
+
readonly workspace?: string;
|
|
13
20
|
}
|
|
14
21
|
/** A pure, deterministic verifier over one run's real tool executions. */
|
|
15
22
|
export interface Checker {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
export { type AblationConfig, type AblationDelta, type AblationResult, ablate, formatAblation, } from "./ablation.js";
|
|
1
2
|
export { all, type Checker, type CheckerContext, type CheckerResult, expectsAll, expectsAny } from "./checker.js";
|
|
3
|
+
export { detectLoop, type LoopDetectionResult, type MistakeSummary, summarizeMistakes } from "./mistakes.js";
|
|
2
4
|
export { describeToolCall, matchesToolCall, type ToolCall } from "./tool-call.js";
|
|
3
5
|
export { extractToolExecutions, type ToolExecution } from "./tool-executions.js";
|
|
6
|
+
export { aggregateTrials, MaxErrorRateExceeded, type RunTrialsOptions, runTrials, type TrialMetrics, type TrialResult, } from "./trials.js";
|
|
4
7
|
export { deriveTurns, type RunUsageSummary, summarizeRunUsage, type Turn } from "./turns.js";
|
|
8
|
+
export { any, fileContains, fileExists, lintPasses } from "./workspace-checkers.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
export { ablate, formatAblation, } from "./ablation.js";
|
|
1
2
|
export { all, expectsAll, expectsAny } from "./checker.js";
|
|
3
|
+
export { detectLoop, summarizeMistakes } from "./mistakes.js";
|
|
2
4
|
export { describeToolCall, matchesToolCall } from "./tool-call.js";
|
|
3
5
|
export { extractToolExecutions } from "./tool-executions.js";
|
|
6
|
+
export { aggregateTrials, MaxErrorRateExceeded, runTrials, } from "./trials.js";
|
|
4
7
|
export { deriveTurns, summarizeRunUsage } from "./turns.js";
|
|
8
|
+
export { any, fileContains, fileExists, lintPasses } from "./workspace-checkers.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mistake-rate and loop-detection rollups over one run's real tool executions -- ported from
|
|
3
|
+
* djinn's own `LoopThreshold`/`LoopDetected`/`LoopTool` (`testkit/eval_evaluation.go`/
|
|
4
|
+
* `eval_runner.go`): the exact edit-build-fail-retry failure mode the JetBrains Rider blog
|
|
5
|
+
* post's own worked example showed in detail (11 edit/build cycles fighting the compiler).
|
|
6
|
+
*/
|
|
7
|
+
import type { ToolExecution } from "./tool-executions.js";
|
|
8
|
+
/** Real, per-execution error signal rolled up across a whole run. */
|
|
9
|
+
export interface MistakeSummary {
|
|
10
|
+
readonly totalExecutions: number;
|
|
11
|
+
/** Count of tool_execution_end events with isError: true. */
|
|
12
|
+
readonly errorCount: number;
|
|
13
|
+
/** errorCount / totalExecutions; 0 for a run with no executions at all. */
|
|
14
|
+
readonly errorRate: number;
|
|
15
|
+
}
|
|
16
|
+
/** Rolls up isError across every completed execution in a run. */
|
|
17
|
+
export declare function summarizeMistakes(executions: readonly ToolExecution[]): MistakeSummary;
|
|
18
|
+
export interface LoopDetectionResult {
|
|
19
|
+
readonly loopDetected: boolean;
|
|
20
|
+
/** The tool name that triggered detection. Present only when loopDetected is true. */
|
|
21
|
+
readonly loopTool?: string;
|
|
22
|
+
/** The real highest same-tool call count observed, regardless of whether it crossed the threshold. */
|
|
23
|
+
readonly maxToolCallCount: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Detects a real repeated-tool-call pattern: the same tool name called more than `threshold`
|
|
27
|
+
* times in one run (default 10, matching djinn's own default). A simple total-count threshold
|
|
28
|
+
* per tool name, not a strict-consecutive-run check -- matching djinn's own definition.
|
|
29
|
+
*/
|
|
30
|
+
export declare function detectLoop(executions: readonly ToolExecution[], threshold?: number): LoopDetectionResult;
|
package/dist/mistakes.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Rolls up isError across every completed execution in a run. */
|
|
2
|
+
export function summarizeMistakes(executions) {
|
|
3
|
+
const errorCount = executions.filter((execution) => execution.isError).length;
|
|
4
|
+
return {
|
|
5
|
+
totalExecutions: executions.length,
|
|
6
|
+
errorCount,
|
|
7
|
+
errorRate: executions.length === 0 ? 0 : errorCount / executions.length,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Detects a real repeated-tool-call pattern: the same tool name called more than `threshold`
|
|
12
|
+
* times in one run (default 10, matching djinn's own default). A simple total-count threshold
|
|
13
|
+
* per tool name, not a strict-consecutive-run check -- matching djinn's own definition.
|
|
14
|
+
*/
|
|
15
|
+
export function detectLoop(executions, threshold = 10) {
|
|
16
|
+
const counts = new Map();
|
|
17
|
+
for (const execution of executions) {
|
|
18
|
+
counts.set(execution.toolName, (counts.get(execution.toolName) ?? 0) + 1);
|
|
19
|
+
}
|
|
20
|
+
let maxToolCallCount = 0;
|
|
21
|
+
let loopTool;
|
|
22
|
+
for (const [toolName, count] of counts) {
|
|
23
|
+
if (count > maxToolCallCount) {
|
|
24
|
+
maxToolCallCount = count;
|
|
25
|
+
loopTool = toolName;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (maxToolCallCount > threshold && loopTool !== undefined) {
|
|
29
|
+
return { loopDetected: true, loopTool, maxToolCallCount };
|
|
30
|
+
}
|
|
31
|
+
return { loopDetected: false, maxToolCallCount };
|
|
32
|
+
}
|
package/dist/trials.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-trial aggregation over N real, non-deterministic runs of the same scenario -- ported
|
|
3
|
+
* from Alef's own `EvaluationRunner.runN` (pass@k, variance, min/max score, a bounded
|
|
4
|
+
* concurrency cap) and djinn's `TrialMetrics` (pass rate, mean score/latency/tokens) shape.
|
|
5
|
+
* A single real live-LLM trial's score is noise, not signal -- this is what turns it into one.
|
|
6
|
+
*/
|
|
7
|
+
/** One real trial's own outcome. `error` set means the trial itself failed to run to completion -- a real infrastructure/runtime failure, distinct from a checker legitimately scoring it 0. */
|
|
8
|
+
export interface TrialResult {
|
|
9
|
+
readonly pass: boolean;
|
|
10
|
+
readonly score: number;
|
|
11
|
+
readonly durationMs: number;
|
|
12
|
+
readonly tokensIn: number;
|
|
13
|
+
readonly tokensOut: number;
|
|
14
|
+
/** Real cache-read/cache-write tokens -- under prompt caching, the dominant real component of total context size; tokensIn alone (a provider's own incremental, non-cached count) understates it. */
|
|
15
|
+
readonly cacheReadTokens: number;
|
|
16
|
+
readonly cacheWriteTokens: number;
|
|
17
|
+
readonly costUsd: number;
|
|
18
|
+
readonly error?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Aggregate statistics across N trials of one scenario. */
|
|
21
|
+
export interface TrialMetrics {
|
|
22
|
+
readonly trials: number;
|
|
23
|
+
readonly passes: number;
|
|
24
|
+
readonly passRate: number;
|
|
25
|
+
readonly meanScore: number;
|
|
26
|
+
readonly variance: number;
|
|
27
|
+
readonly minScore: number;
|
|
28
|
+
readonly maxScore: number;
|
|
29
|
+
readonly meanDurationMs: number;
|
|
30
|
+
readonly meanTokensIn: number;
|
|
31
|
+
readonly meanTokensOut: number;
|
|
32
|
+
readonly meanCacheReadTokens: number;
|
|
33
|
+
readonly meanCacheWriteTokens: number;
|
|
34
|
+
readonly meanCostUsd: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Thrown when too many trials errored outright to trust the aggregate as a real measurement --
|
|
38
|
+
* ported from Alef's own `[MaxErrorRate]` guard. Without this, a scenario that errors on every
|
|
39
|
+
* trial would silently aggregate to "0% pass", indistinguishable from a real, measured failure.
|
|
40
|
+
*/
|
|
41
|
+
export declare class MaxErrorRateExceeded extends Error {
|
|
42
|
+
readonly errorRate: number;
|
|
43
|
+
readonly maxErrorRate: number;
|
|
44
|
+
readonly errorCount: number;
|
|
45
|
+
readonly totalTrials: number;
|
|
46
|
+
constructor(errorRate: number, maxErrorRate: number, errorCount: number, totalTrials: number, firstError: string | undefined);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Aggregates already-collected trial results into TrialMetrics. Throws MaxErrorRateExceeded if
|
|
50
|
+
* `maxErrorRate` (0-1, default 0 = disabled) is exceeded by the fraction of errored trials.
|
|
51
|
+
*/
|
|
52
|
+
export declare function aggregateTrials(results: readonly TrialResult[], options?: {
|
|
53
|
+
readonly maxErrorRate?: number;
|
|
54
|
+
}): TrialMetrics;
|
|
55
|
+
export interface RunTrialsOptions {
|
|
56
|
+
/** Trials run concurrently at once. Default 3, matching Alef's own ALEF_EVAL_CONCURRENCY default -- real LLM providers rate-limit. */
|
|
57
|
+
readonly concurrency?: number;
|
|
58
|
+
readonly maxErrorRate?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Runs `runOne` (one real trial of a scenario) `n` times at a bounded concurrency, then
|
|
62
|
+
* aggregates. A rejected `runOne` call becomes a TrialResult with `error` set (score 0, pass
|
|
63
|
+
* false) rather than aborting the whole batch -- one crashed trial should not lose every other
|
|
64
|
+
* trial's own real data.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runTrials(runOne: () => Promise<TrialResult>, n: number, options?: RunTrialsOptions): Promise<TrialMetrics>;
|
package/dist/trials.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-trial aggregation over N real, non-deterministic runs of the same scenario -- ported
|
|
3
|
+
* from Alef's own `EvaluationRunner.runN` (pass@k, variance, min/max score, a bounded
|
|
4
|
+
* concurrency cap) and djinn's `TrialMetrics` (pass rate, mean score/latency/tokens) shape.
|
|
5
|
+
* A single real live-LLM trial's score is noise, not signal -- this is what turns it into one.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Thrown when too many trials errored outright to trust the aggregate as a real measurement --
|
|
9
|
+
* ported from Alef's own `[MaxErrorRate]` guard. Without this, a scenario that errors on every
|
|
10
|
+
* trial would silently aggregate to "0% pass", indistinguishable from a real, measured failure.
|
|
11
|
+
*/
|
|
12
|
+
export class MaxErrorRateExceeded extends Error {
|
|
13
|
+
errorRate;
|
|
14
|
+
maxErrorRate;
|
|
15
|
+
errorCount;
|
|
16
|
+
totalTrials;
|
|
17
|
+
constructor(errorRate, maxErrorRate, errorCount, totalTrials, firstError) {
|
|
18
|
+
super(`${(errorRate * 100).toFixed(0)}% of trials errored (${errorCount}/${totalTrials}), threshold ${(maxErrorRate * 100).toFixed(0)}%. First error: ${firstError ?? "unknown"}`);
|
|
19
|
+
this.errorRate = errorRate;
|
|
20
|
+
this.maxErrorRate = maxErrorRate;
|
|
21
|
+
this.errorCount = errorCount;
|
|
22
|
+
this.totalTrials = totalTrials;
|
|
23
|
+
this.name = "MaxErrorRateExceeded";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function mean(values) {
|
|
27
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Aggregates already-collected trial results into TrialMetrics. Throws MaxErrorRateExceeded if
|
|
31
|
+
* `maxErrorRate` (0-1, default 0 = disabled) is exceeded by the fraction of errored trials.
|
|
32
|
+
*/
|
|
33
|
+
export function aggregateTrials(results, options = {}) {
|
|
34
|
+
if (results.length === 0)
|
|
35
|
+
throw new Error("aggregateTrials: at least one trial result is required");
|
|
36
|
+
const maxErrorRate = options.maxErrorRate ?? 0;
|
|
37
|
+
if (maxErrorRate > 0) {
|
|
38
|
+
const errored = results.filter((result) => result.error !== undefined);
|
|
39
|
+
const errorRate = errored.length / results.length;
|
|
40
|
+
if (errorRate > maxErrorRate) {
|
|
41
|
+
throw new MaxErrorRateExceeded(errorRate, maxErrorRate, errored.length, results.length, errored[0]?.error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const scores = results.map((result) => result.score);
|
|
45
|
+
const passes = results.filter((result) => result.pass).length;
|
|
46
|
+
const meanScore = mean(scores);
|
|
47
|
+
return {
|
|
48
|
+
trials: results.length,
|
|
49
|
+
passes,
|
|
50
|
+
passRate: passes / results.length,
|
|
51
|
+
meanScore,
|
|
52
|
+
variance: mean(scores.map((score) => (score - meanScore) ** 2)),
|
|
53
|
+
minScore: Math.min(...scores),
|
|
54
|
+
maxScore: Math.max(...scores),
|
|
55
|
+
meanDurationMs: mean(results.map((result) => result.durationMs)),
|
|
56
|
+
meanTokensIn: mean(results.map((result) => result.tokensIn)),
|
|
57
|
+
meanTokensOut: mean(results.map((result) => result.tokensOut)),
|
|
58
|
+
meanCacheReadTokens: mean(results.map((result) => result.cacheReadTokens)),
|
|
59
|
+
meanCacheWriteTokens: mean(results.map((result) => result.cacheWriteTokens)),
|
|
60
|
+
meanCostUsd: mean(results.map((result) => result.costUsd)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Runs `runOne` (one real trial of a scenario) `n` times at a bounded concurrency, then
|
|
65
|
+
* aggregates. A rejected `runOne` call becomes a TrialResult with `error` set (score 0, pass
|
|
66
|
+
* false) rather than aborting the whole batch -- one crashed trial should not lose every other
|
|
67
|
+
* trial's own real data.
|
|
68
|
+
*/
|
|
69
|
+
export async function runTrials(runOne, n, options = {}) {
|
|
70
|
+
const concurrency = options.concurrency ?? 3;
|
|
71
|
+
const results = [];
|
|
72
|
+
for (let i = 0; i < n; i += concurrency) {
|
|
73
|
+
const batchSize = Math.min(concurrency, n - i);
|
|
74
|
+
const batch = await Promise.all(Array.from({ length: batchSize }, async () => {
|
|
75
|
+
try {
|
|
76
|
+
return await runOne();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
pass: false,
|
|
81
|
+
score: 0,
|
|
82
|
+
durationMs: 0,
|
|
83
|
+
tokensIn: 0,
|
|
84
|
+
tokensOut: 0,
|
|
85
|
+
cacheReadTokens: 0,
|
|
86
|
+
cacheWriteTokens: 0,
|
|
87
|
+
costUsd: 0,
|
|
88
|
+
error: error instanceof Error ? error.message : String(error),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
results.push(...batch);
|
|
93
|
+
}
|
|
94
|
+
return aggregateTrials(results, options.maxErrorRate !== undefined ? { maxErrorRate: options.maxErrorRate } : {});
|
|
95
|
+
}
|
package/dist/turns.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface Turn {
|
|
|
14
14
|
readonly tokensIn: number;
|
|
15
15
|
readonly tokensOut: number;
|
|
16
16
|
readonly cacheReadTokens: number;
|
|
17
|
+
/** New context tokens written to the provider's cache this turn -- the other real component of total context size under prompt caching, alongside cacheReadTokens. */
|
|
18
|
+
readonly cacheWriteTokens: number;
|
|
17
19
|
/** Real cost in USD from Usage.cost.total, when the provider reports pricing. */
|
|
18
20
|
readonly costUsd: number;
|
|
19
21
|
/** Number of tool calls dispatched from this turn. */
|
|
@@ -29,6 +31,7 @@ export interface RunUsageSummary {
|
|
|
29
31
|
readonly tokensIn: number;
|
|
30
32
|
readonly tokensOut: number;
|
|
31
33
|
readonly cacheReadTokens: number;
|
|
34
|
+
readonly cacheWriteTokens: number;
|
|
32
35
|
readonly costUsd: number;
|
|
33
36
|
readonly toolCalls: number;
|
|
34
37
|
/** Every tool name called across the whole run, in dispatch order. */
|
package/dist/turns.js
CHANGED
|
@@ -15,6 +15,7 @@ export function deriveTurns(events) {
|
|
|
15
15
|
tokensIn: usage?.input ?? 0,
|
|
16
16
|
tokensOut: usage?.output ?? 0,
|
|
17
17
|
cacheReadTokens: usage?.cacheRead ?? 0,
|
|
18
|
+
cacheWriteTokens: usage?.cacheWrite ?? 0,
|
|
18
19
|
costUsd: usage?.cost.total ?? 0,
|
|
19
20
|
toolCalls: event.toolResults.length,
|
|
20
21
|
toolNames: event.toolResults.map((result) => result.toolName),
|
|
@@ -29,6 +30,7 @@ export function summarizeRunUsage(turns) {
|
|
|
29
30
|
tokensIn: turns.reduce((sum, turn) => sum + turn.tokensIn, 0),
|
|
30
31
|
tokensOut: turns.reduce((sum, turn) => sum + turn.tokensOut, 0),
|
|
31
32
|
cacheReadTokens: turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0),
|
|
33
|
+
cacheWriteTokens: turns.reduce((sum, turn) => sum + turn.cacheWriteTokens, 0),
|
|
32
34
|
costUsd: turns.reduce((sum, turn) => sum + turn.costUsd, 0),
|
|
33
35
|
toolCalls: turns.reduce((sum, turn) => sum + turn.toolCalls, 0),
|
|
34
36
|
toolNames: turns.flatMap((turn) => turn.toolNames),
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Checker } from "./checker.js";
|
|
2
|
+
/** A Checker verifying a file exists in the run's workspace. Existence alone scores 0.5 -- content is unchecked. */
|
|
3
|
+
export declare function fileExists(relativePath: string): Checker;
|
|
4
|
+
/**
|
|
5
|
+
* A Checker verifying a file contains every required substring. Graduated: 1.0 all present,
|
|
6
|
+
* 0.5 some present, 0.0 none present or the file is missing.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fileContains(relativePath: string, ...required: readonly string[]): Checker;
|
|
9
|
+
/**
|
|
10
|
+
* A Checker running a real command in the run's workspace and asserting exit code 0 -- an
|
|
11
|
+
* outcome checker: verifies what the agent DID (does it still build/lint/pass tests), not what
|
|
12
|
+
* it said.
|
|
13
|
+
*/
|
|
14
|
+
export declare function lintPasses(cmd: string, args?: readonly string[]): Checker;
|
|
15
|
+
/** Composes several checkers, returning the maximum score (lenient OR) -- complements `all()`'s min-score AND. */
|
|
16
|
+
export declare function any(...checkers: readonly Checker[]): Checker;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome checkers verifying real post-run workspace state -- what the agent actually DID, not
|
|
3
|
+
* which tools it called. Ported from Alef's own `packages/core/eval/src/checker.ts`
|
|
4
|
+
* (`fileExists`/`fileContains`/`lintPasses`/`any`), unchanged in scoring semantics.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
function missingWorkspace(label) {
|
|
10
|
+
return { pass: false, score: 0, errors: [`${label}: no workspace in CheckerContext`] };
|
|
11
|
+
}
|
|
12
|
+
/** A Checker verifying a file exists in the run's workspace. Existence alone scores 0.5 -- content is unchecked. */
|
|
13
|
+
export function fileExists(relativePath) {
|
|
14
|
+
return {
|
|
15
|
+
async check({ workspace }) {
|
|
16
|
+
if (workspace === undefined)
|
|
17
|
+
return missingWorkspace("fileExists");
|
|
18
|
+
try {
|
|
19
|
+
await readFile(join(workspace, relativePath), "utf-8");
|
|
20
|
+
return { pass: true, score: 0.5, errors: [] };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { pass: false, score: 0, errors: [`File not found: ${relativePath}`] };
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A Checker verifying a file contains every required substring. Graduated: 1.0 all present,
|
|
30
|
+
* 0.5 some present, 0.0 none present or the file is missing.
|
|
31
|
+
*/
|
|
32
|
+
export function fileContains(relativePath, ...required) {
|
|
33
|
+
return {
|
|
34
|
+
async check({ workspace }) {
|
|
35
|
+
if (workspace === undefined)
|
|
36
|
+
return missingWorkspace("fileContains");
|
|
37
|
+
let content;
|
|
38
|
+
try {
|
|
39
|
+
content = await readFile(join(workspace, relativePath), "utf-8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { pass: false, score: 0, errors: [`File not found: ${relativePath}`] };
|
|
43
|
+
}
|
|
44
|
+
const missing = required.filter((value) => !content.includes(value));
|
|
45
|
+
if (missing.length === 0)
|
|
46
|
+
return { pass: true, score: 1, errors: [] };
|
|
47
|
+
const found = required.length - missing.length;
|
|
48
|
+
const score = found > 0 ? 0.5 : 0;
|
|
49
|
+
return { pass: false, score, errors: missing.map((value) => `'${value}' not found in ${relativePath}`) };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A Checker running a real command in the run's workspace and asserting exit code 0 -- an
|
|
55
|
+
* outcome checker: verifies what the agent DID (does it still build/lint/pass tests), not what
|
|
56
|
+
* it said.
|
|
57
|
+
*/
|
|
58
|
+
export function lintPasses(cmd, args = []) {
|
|
59
|
+
return {
|
|
60
|
+
check({ workspace }) {
|
|
61
|
+
if (workspace === undefined)
|
|
62
|
+
return Promise.resolve(missingWorkspace("lintPasses"));
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const child = spawn(cmd, [...args], { cwd: workspace, stdio: "pipe" });
|
|
65
|
+
const stderr = [];
|
|
66
|
+
child.stderr?.on("data", (chunk) => stderr.push(chunk.toString()));
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
if (code === 0) {
|
|
69
|
+
resolve({ pass: true, score: 1, errors: [] });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
resolve({ pass: false, score: 0, errors: [`${cmd} exited ${code}:\n${stderr.join("").trim()}`] });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
child.on("error", (error) => {
|
|
76
|
+
resolve({ pass: false, score: 0, errors: [`Failed to run ${cmd}: ${error.message}`] });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Composes several checkers, returning the maximum score (lenient OR) -- complements `all()`'s min-score AND. */
|
|
83
|
+
export function any(...checkers) {
|
|
84
|
+
return {
|
|
85
|
+
async check(context) {
|
|
86
|
+
const results = await Promise.all(checkers.map((checker) => checker.check(context)));
|
|
87
|
+
if (results.length === 0)
|
|
88
|
+
return { pass: true, score: 1, errors: [] };
|
|
89
|
+
return results.reduce((best, current) => (current.score >= best.score ? current : best));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,47 +1,62 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
2
|
+
"name": "@danypops/pi-eval-harness",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Scores a real agent run's own tool-call behavior -- AND/OR tool-call matching, graduated checker composition, and turn/tool-call/token-usage rollups -- over Pi's own real AgentSessionEvent stream (e.g. from @danypops/pi-process-harness).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
19
|
+
"test": "bun run build && bun test test",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"format": "biome format --write ."
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/DanyPops/pi-integral.git",
|
|
26
|
+
"directory": "packages/pi-eval-harness"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/DanyPops/pi-integral/tree/main/packages/pi-eval-harness#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/DanyPops/pi-integral/issues"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.5.6",
|
|
37
|
+
"@danypops/pi-process-harness": "0.2.0",
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"bun-types": "latest",
|
|
42
|
+
"typebox": "*",
|
|
43
|
+
"typescript": "^5.9.2"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"src",
|
|
50
|
+
"dist",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE"
|
|
53
|
+
],
|
|
54
|
+
"keywords": [
|
|
55
|
+
"pi",
|
|
56
|
+
"pi-extension",
|
|
57
|
+
"testing",
|
|
58
|
+
"eval",
|
|
59
|
+
"agent-evaluation",
|
|
60
|
+
"tool-call"
|
|
61
|
+
]
|
|
47
62
|
}
|
package/src/ablation.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one scenario under several named tool-availability configs and diffs the results --
|
|
3
|
+
* ported from djinn's own `testkit/eval_ablation.go` (`AblationConfig`/`AblationResult`/
|
|
4
|
+
* `AblationDelta`/`Ablate()`) and `eval_report.go`'s `FormatAblation()`. The first config is the
|
|
5
|
+
* baseline; every later config's own TrialMetrics get a delta computed against it.
|
|
6
|
+
*/
|
|
7
|
+
import { type RunTrialsOptions, runTrials, type TrialMetrics, type TrialResult } from "./trials.js";
|
|
8
|
+
|
|
9
|
+
/** One named variant of the scenario -- e.g. "baseline" (Pi's own built-in tools) vs "with-lector" (the same run, Lector's tools also registered). */
|
|
10
|
+
export interface AblationConfig {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/** Runs one real trial under this config. Whatever varies between arms (which extensions load, which tools are registered) lives inside this closure. */
|
|
13
|
+
readonly runOne: () => Promise<TrialResult>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The performance difference of one config's TrialMetrics vs the baseline (first) config's. */
|
|
17
|
+
export interface AblationDelta {
|
|
18
|
+
readonly passRateDelta: number;
|
|
19
|
+
readonly meanScoreDelta: number;
|
|
20
|
+
readonly meanDurationMsDelta: number;
|
|
21
|
+
readonly meanTokensInDelta: number;
|
|
22
|
+
readonly meanCacheReadTokensDelta: number;
|
|
23
|
+
readonly meanCacheWriteTokensDelta: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One config's own trial metrics, plus its delta vs baseline. `delta` is undefined for the baseline itself. */
|
|
27
|
+
export interface AblationResult {
|
|
28
|
+
readonly config: AblationConfig;
|
|
29
|
+
readonly metrics: TrialMetrics;
|
|
30
|
+
readonly delta?: AblationDelta;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs `n` trials of every config (sequentially, config by config -- each config's own trials
|
|
35
|
+
* still run at `options.concurrency` internally) and returns side-by-side results. Configs are
|
|
36
|
+
* independent scenarios in the caller's own domain (e.g. real spawned `pi` processes with
|
|
37
|
+
* different extension sets); this function only orchestrates trial counts and diffs.
|
|
38
|
+
*/
|
|
39
|
+
export async function ablate(configs: readonly AblationConfig[], n: number, options: RunTrialsOptions = {}): Promise<AblationResult[]> {
|
|
40
|
+
if (configs.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
const results: AblationResult[] = [];
|
|
43
|
+
for (const config of configs) {
|
|
44
|
+
results.push({ config, metrics: await runTrials(config.runOne, n, options) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const baseline = results[0]?.metrics;
|
|
48
|
+
if (baseline === undefined) return results;
|
|
49
|
+
|
|
50
|
+
return results.map((result, index) => {
|
|
51
|
+
if (index === 0) return result;
|
|
52
|
+
const metrics = result.metrics;
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
delta: {
|
|
56
|
+
passRateDelta: metrics.passRate - baseline.passRate,
|
|
57
|
+
meanScoreDelta: metrics.meanScore - baseline.meanScore,
|
|
58
|
+
meanDurationMsDelta: metrics.meanDurationMs - baseline.meanDurationMs,
|
|
59
|
+
meanTokensInDelta: metrics.meanTokensIn - baseline.meanTokensIn,
|
|
60
|
+
meanCacheReadTokensDelta: metrics.meanCacheReadTokens - baseline.meanCacheReadTokens,
|
|
61
|
+
meanCacheWriteTokensDelta: metrics.meanCacheWriteTokens - baseline.meanCacheWriteTokens,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function signed(value: number, digits: number): string {
|
|
68
|
+
const rounded = value.toFixed(digits);
|
|
69
|
+
return value >= 0 ? `+${rounded}` : rounded;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Renders a human-readable ablation comparison table -- ported from djinn's `FormatAblation()`. */
|
|
73
|
+
export function formatAblation(label: string, results: readonly AblationResult[]): string {
|
|
74
|
+
const lines: string[] = [`=== Ablation: ${label} ===`];
|
|
75
|
+
for (const result of results) {
|
|
76
|
+
const m = result.metrics;
|
|
77
|
+
lines.push(
|
|
78
|
+
` ${result.config.name.padEnd(14)} pass_rate=${m.passRate.toFixed(2)} mean_score=${m.meanScore.toFixed(2)} mean_duration_ms=${m.meanDurationMs.toFixed(0)} tokens_in=${m.meanTokensIn.toFixed(0)} cache_read=${m.meanCacheReadTokens.toFixed(0)} cache_write=${m.meanCacheWriteTokens.toFixed(0)}`,
|
|
79
|
+
);
|
|
80
|
+
if (result.delta) {
|
|
81
|
+
const d = result.delta;
|
|
82
|
+
lines.push(
|
|
83
|
+
` ${"(vs baseline)".padEnd(14)} Δpass_rate=${signed(d.passRateDelta, 2)} Δscore=${signed(d.meanScoreDelta, 2)} Δduration_ms=${signed(d.meanDurationMsDelta, 0)} Δtokens_in=${signed(d.meanTokensInDelta, 0)} Δcache_read=${signed(d.meanCacheReadTokensDelta, 0)} Δcache_write=${signed(d.meanCacheWriteTokensDelta, 0)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
package/src/checker.ts
CHANGED
|
@@ -9,9 +9,16 @@ export interface CheckerResult {
|
|
|
9
9
|
readonly errors: readonly string[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Runtime context passed to a Checker -- the real completed tool executions from one run, plus
|
|
14
|
+
* (when the run actually had a real workspace directory) its absolute path, for a checker that
|
|
15
|
+
* verifies real post-run workspace state rather than which tools were called. Optional: a
|
|
16
|
+
* checker that only inspects `executions` (matching/rollup checks) never needs it, and every
|
|
17
|
+
* existing construction of this context predates the field.
|
|
18
|
+
*/
|
|
13
19
|
export interface CheckerContext {
|
|
14
20
|
readonly executions: readonly ToolExecution[];
|
|
21
|
+
readonly workspace?: string;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
/** A pure, deterministic verifier over one run's real tool executions. */
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type AblationConfig,
|
|
3
|
+
type AblationDelta,
|
|
4
|
+
type AblationResult,
|
|
5
|
+
ablate,
|
|
6
|
+
formatAblation,
|
|
7
|
+
} from "./ablation.js";
|
|
1
8
|
export { all, type Checker, type CheckerContext, type CheckerResult, expectsAll, expectsAny } from "./checker.js";
|
|
9
|
+
export { detectLoop, type LoopDetectionResult, type MistakeSummary, summarizeMistakes } from "./mistakes.js";
|
|
2
10
|
export { describeToolCall, matchesToolCall, type ToolCall } from "./tool-call.js";
|
|
3
11
|
export { extractToolExecutions, type ToolExecution } from "./tool-executions.js";
|
|
12
|
+
export {
|
|
13
|
+
aggregateTrials,
|
|
14
|
+
MaxErrorRateExceeded,
|
|
15
|
+
type RunTrialsOptions,
|
|
16
|
+
runTrials,
|
|
17
|
+
type TrialMetrics,
|
|
18
|
+
type TrialResult,
|
|
19
|
+
} from "./trials.js";
|
|
4
20
|
export { deriveTurns, type RunUsageSummary, summarizeRunUsage, type Turn } from "./turns.js";
|
|
21
|
+
export { any, fileContains, fileExists, lintPasses } from "./workspace-checkers.js";
|
package/src/mistakes.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mistake-rate and loop-detection rollups over one run's real tool executions -- ported from
|
|
3
|
+
* djinn's own `LoopThreshold`/`LoopDetected`/`LoopTool` (`testkit/eval_evaluation.go`/
|
|
4
|
+
* `eval_runner.go`): the exact edit-build-fail-retry failure mode the JetBrains Rider blog
|
|
5
|
+
* post's own worked example showed in detail (11 edit/build cycles fighting the compiler).
|
|
6
|
+
*/
|
|
7
|
+
import type { ToolExecution } from "./tool-executions.js";
|
|
8
|
+
|
|
9
|
+
/** Real, per-execution error signal rolled up across a whole run. */
|
|
10
|
+
export interface MistakeSummary {
|
|
11
|
+
readonly totalExecutions: number;
|
|
12
|
+
/** Count of tool_execution_end events with isError: true. */
|
|
13
|
+
readonly errorCount: number;
|
|
14
|
+
/** errorCount / totalExecutions; 0 for a run with no executions at all. */
|
|
15
|
+
readonly errorRate: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Rolls up isError across every completed execution in a run. */
|
|
19
|
+
export function summarizeMistakes(executions: readonly ToolExecution[]): MistakeSummary {
|
|
20
|
+
const errorCount = executions.filter((execution) => execution.isError).length;
|
|
21
|
+
return {
|
|
22
|
+
totalExecutions: executions.length,
|
|
23
|
+
errorCount,
|
|
24
|
+
errorRate: executions.length === 0 ? 0 : errorCount / executions.length,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LoopDetectionResult {
|
|
29
|
+
readonly loopDetected: boolean;
|
|
30
|
+
/** The tool name that triggered detection. Present only when loopDetected is true. */
|
|
31
|
+
readonly loopTool?: string;
|
|
32
|
+
/** The real highest same-tool call count observed, regardless of whether it crossed the threshold. */
|
|
33
|
+
readonly maxToolCallCount: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detects a real repeated-tool-call pattern: the same tool name called more than `threshold`
|
|
38
|
+
* times in one run (default 10, matching djinn's own default). A simple total-count threshold
|
|
39
|
+
* per tool name, not a strict-consecutive-run check -- matching djinn's own definition.
|
|
40
|
+
*/
|
|
41
|
+
export function detectLoop(executions: readonly ToolExecution[], threshold = 10): LoopDetectionResult {
|
|
42
|
+
const counts = new Map<string, number>();
|
|
43
|
+
for (const execution of executions) {
|
|
44
|
+
counts.set(execution.toolName, (counts.get(execution.toolName) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let maxToolCallCount = 0;
|
|
48
|
+
let loopTool: string | undefined;
|
|
49
|
+
for (const [toolName, count] of counts) {
|
|
50
|
+
if (count > maxToolCallCount) {
|
|
51
|
+
maxToolCallCount = count;
|
|
52
|
+
loopTool = toolName;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (maxToolCallCount > threshold && loopTool !== undefined) {
|
|
57
|
+
return { loopDetected: true, loopTool, maxToolCallCount };
|
|
58
|
+
}
|
|
59
|
+
return { loopDetected: false, maxToolCallCount };
|
|
60
|
+
}
|
package/src/trials.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-trial aggregation over N real, non-deterministic runs of the same scenario -- ported
|
|
3
|
+
* from Alef's own `EvaluationRunner.runN` (pass@k, variance, min/max score, a bounded
|
|
4
|
+
* concurrency cap) and djinn's `TrialMetrics` (pass rate, mean score/latency/tokens) shape.
|
|
5
|
+
* A single real live-LLM trial's score is noise, not signal -- this is what turns it into one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One real trial's own outcome. `error` set means the trial itself failed to run to completion -- a real infrastructure/runtime failure, distinct from a checker legitimately scoring it 0. */
|
|
9
|
+
export interface TrialResult {
|
|
10
|
+
readonly pass: boolean;
|
|
11
|
+
readonly score: number;
|
|
12
|
+
readonly durationMs: number;
|
|
13
|
+
readonly tokensIn: number;
|
|
14
|
+
readonly tokensOut: number;
|
|
15
|
+
/** Real cache-read/cache-write tokens -- under prompt caching, the dominant real component of total context size; tokensIn alone (a provider's own incremental, non-cached count) understates it. */
|
|
16
|
+
readonly cacheReadTokens: number;
|
|
17
|
+
readonly cacheWriteTokens: number;
|
|
18
|
+
readonly costUsd: number;
|
|
19
|
+
readonly error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Aggregate statistics across N trials of one scenario. */
|
|
23
|
+
export interface TrialMetrics {
|
|
24
|
+
readonly trials: number;
|
|
25
|
+
readonly passes: number;
|
|
26
|
+
readonly passRate: number;
|
|
27
|
+
readonly meanScore: number;
|
|
28
|
+
readonly variance: number;
|
|
29
|
+
readonly minScore: number;
|
|
30
|
+
readonly maxScore: number;
|
|
31
|
+
readonly meanDurationMs: number;
|
|
32
|
+
readonly meanTokensIn: number;
|
|
33
|
+
readonly meanTokensOut: number;
|
|
34
|
+
readonly meanCacheReadTokens: number;
|
|
35
|
+
readonly meanCacheWriteTokens: number;
|
|
36
|
+
readonly meanCostUsd: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thrown when too many trials errored outright to trust the aggregate as a real measurement --
|
|
41
|
+
* ported from Alef's own `[MaxErrorRate]` guard. Without this, a scenario that errors on every
|
|
42
|
+
* trial would silently aggregate to "0% pass", indistinguishable from a real, measured failure.
|
|
43
|
+
*/
|
|
44
|
+
export class MaxErrorRateExceeded extends Error {
|
|
45
|
+
constructor(
|
|
46
|
+
readonly errorRate: number,
|
|
47
|
+
readonly maxErrorRate: number,
|
|
48
|
+
readonly errorCount: number,
|
|
49
|
+
readonly totalTrials: number,
|
|
50
|
+
firstError: string | undefined,
|
|
51
|
+
) {
|
|
52
|
+
super(
|
|
53
|
+
`${(errorRate * 100).toFixed(0)}% of trials errored (${errorCount}/${totalTrials}), threshold ${(maxErrorRate * 100).toFixed(0)}%. First error: ${firstError ?? "unknown"}`,
|
|
54
|
+
);
|
|
55
|
+
this.name = "MaxErrorRateExceeded";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mean(values: readonly number[]): number {
|
|
60
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Aggregates already-collected trial results into TrialMetrics. Throws MaxErrorRateExceeded if
|
|
65
|
+
* `maxErrorRate` (0-1, default 0 = disabled) is exceeded by the fraction of errored trials.
|
|
66
|
+
*/
|
|
67
|
+
export function aggregateTrials(results: readonly TrialResult[], options: { readonly maxErrorRate?: number } = {}): TrialMetrics {
|
|
68
|
+
if (results.length === 0) throw new Error("aggregateTrials: at least one trial result is required");
|
|
69
|
+
|
|
70
|
+
const maxErrorRate = options.maxErrorRate ?? 0;
|
|
71
|
+
if (maxErrorRate > 0) {
|
|
72
|
+
const errored = results.filter((result) => result.error !== undefined);
|
|
73
|
+
const errorRate = errored.length / results.length;
|
|
74
|
+
if (errorRate > maxErrorRate) {
|
|
75
|
+
throw new MaxErrorRateExceeded(errorRate, maxErrorRate, errored.length, results.length, errored[0]?.error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const scores = results.map((result) => result.score);
|
|
80
|
+
const passes = results.filter((result) => result.pass).length;
|
|
81
|
+
const meanScore = mean(scores);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
trials: results.length,
|
|
85
|
+
passes,
|
|
86
|
+
passRate: passes / results.length,
|
|
87
|
+
meanScore,
|
|
88
|
+
variance: mean(scores.map((score) => (score - meanScore) ** 2)),
|
|
89
|
+
minScore: Math.min(...scores),
|
|
90
|
+
maxScore: Math.max(...scores),
|
|
91
|
+
meanDurationMs: mean(results.map((result) => result.durationMs)),
|
|
92
|
+
meanTokensIn: mean(results.map((result) => result.tokensIn)),
|
|
93
|
+
meanTokensOut: mean(results.map((result) => result.tokensOut)),
|
|
94
|
+
meanCacheReadTokens: mean(results.map((result) => result.cacheReadTokens)),
|
|
95
|
+
meanCacheWriteTokens: mean(results.map((result) => result.cacheWriteTokens)),
|
|
96
|
+
meanCostUsd: mean(results.map((result) => result.costUsd)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RunTrialsOptions {
|
|
101
|
+
/** Trials run concurrently at once. Default 3, matching Alef's own ALEF_EVAL_CONCURRENCY default -- real LLM providers rate-limit. */
|
|
102
|
+
readonly concurrency?: number;
|
|
103
|
+
readonly maxErrorRate?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Runs `runOne` (one real trial of a scenario) `n` times at a bounded concurrency, then
|
|
108
|
+
* aggregates. A rejected `runOne` call becomes a TrialResult with `error` set (score 0, pass
|
|
109
|
+
* false) rather than aborting the whole batch -- one crashed trial should not lose every other
|
|
110
|
+
* trial's own real data.
|
|
111
|
+
*/
|
|
112
|
+
export async function runTrials(runOne: () => Promise<TrialResult>, n: number, options: RunTrialsOptions = {}): Promise<TrialMetrics> {
|
|
113
|
+
const concurrency = options.concurrency ?? 3;
|
|
114
|
+
const results: TrialResult[] = [];
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < n; i += concurrency) {
|
|
117
|
+
const batchSize = Math.min(concurrency, n - i);
|
|
118
|
+
const batch = await Promise.all(
|
|
119
|
+
Array.from({ length: batchSize }, async (): Promise<TrialResult> => {
|
|
120
|
+
try {
|
|
121
|
+
return await runOne();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
pass: false,
|
|
125
|
+
score: 0,
|
|
126
|
+
durationMs: 0,
|
|
127
|
+
tokensIn: 0,
|
|
128
|
+
tokensOut: 0,
|
|
129
|
+
cacheReadTokens: 0,
|
|
130
|
+
cacheWriteTokens: 0,
|
|
131
|
+
costUsd: 0,
|
|
132
|
+
error: error instanceof Error ? error.message : String(error),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
results.push(...batch);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return aggregateTrials(results, options.maxErrorRate !== undefined ? { maxErrorRate: options.maxErrorRate } : {});
|
|
141
|
+
}
|
package/src/turns.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface Turn {
|
|
|
15
15
|
readonly tokensIn: number;
|
|
16
16
|
readonly tokensOut: number;
|
|
17
17
|
readonly cacheReadTokens: number;
|
|
18
|
+
/** New context tokens written to the provider's cache this turn -- the other real component of total context size under prompt caching, alongside cacheReadTokens. */
|
|
19
|
+
readonly cacheWriteTokens: number;
|
|
18
20
|
/** Real cost in USD from Usage.cost.total, when the provider reports pricing. */
|
|
19
21
|
readonly costUsd: number;
|
|
20
22
|
/** Number of tool calls dispatched from this turn. */
|
|
@@ -39,6 +41,7 @@ export function deriveTurns(events: readonly AgentSessionEvent[]): Turn[] {
|
|
|
39
41
|
tokensIn: usage?.input ?? 0,
|
|
40
42
|
tokensOut: usage?.output ?? 0,
|
|
41
43
|
cacheReadTokens: usage?.cacheRead ?? 0,
|
|
44
|
+
cacheWriteTokens: usage?.cacheWrite ?? 0,
|
|
42
45
|
costUsd: usage?.cost.total ?? 0,
|
|
43
46
|
toolCalls: event.toolResults.length,
|
|
44
47
|
toolNames: event.toolResults.map((result) => result.toolName),
|
|
@@ -53,6 +56,7 @@ export interface RunUsageSummary {
|
|
|
53
56
|
readonly tokensIn: number;
|
|
54
57
|
readonly tokensOut: number;
|
|
55
58
|
readonly cacheReadTokens: number;
|
|
59
|
+
readonly cacheWriteTokens: number;
|
|
56
60
|
readonly costUsd: number;
|
|
57
61
|
readonly toolCalls: number;
|
|
58
62
|
/** Every tool name called across the whole run, in dispatch order. */
|
|
@@ -66,6 +70,7 @@ export function summarizeRunUsage(turns: readonly Turn[]): RunUsageSummary {
|
|
|
66
70
|
tokensIn: turns.reduce((sum, turn) => sum + turn.tokensIn, 0),
|
|
67
71
|
tokensOut: turns.reduce((sum, turn) => sum + turn.tokensOut, 0),
|
|
68
72
|
cacheReadTokens: turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0),
|
|
73
|
+
cacheWriteTokens: turns.reduce((sum, turn) => sum + turn.cacheWriteTokens, 0),
|
|
69
74
|
costUsd: turns.reduce((sum, turn) => sum + turn.costUsd, 0),
|
|
70
75
|
toolCalls: turns.reduce((sum, turn) => sum + turn.toolCalls, 0),
|
|
71
76
|
toolNames: turns.flatMap((turn) => turn.toolNames),
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome checkers verifying real post-run workspace state -- what the agent actually DID, not
|
|
3
|
+
* which tools it called. Ported from Alef's own `packages/core/eval/src/checker.ts`
|
|
4
|
+
* (`fileExists`/`fileContains`/`lintPasses`/`any`), unchanged in scoring semantics.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { Checker, CheckerContext, CheckerResult } from "./checker.js";
|
|
10
|
+
|
|
11
|
+
function missingWorkspace(label: string): CheckerResult {
|
|
12
|
+
return { pass: false, score: 0, errors: [`${label}: no workspace in CheckerContext`] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A Checker verifying a file exists in the run's workspace. Existence alone scores 0.5 -- content is unchecked. */
|
|
16
|
+
export function fileExists(relativePath: string): Checker {
|
|
17
|
+
return {
|
|
18
|
+
async check({ workspace }: CheckerContext): Promise<CheckerResult> {
|
|
19
|
+
if (workspace === undefined) return missingWorkspace("fileExists");
|
|
20
|
+
try {
|
|
21
|
+
await readFile(join(workspace, relativePath), "utf-8");
|
|
22
|
+
return { pass: true, score: 0.5, errors: [] };
|
|
23
|
+
} catch {
|
|
24
|
+
return { pass: false, score: 0, errors: [`File not found: ${relativePath}`] };
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A Checker verifying a file contains every required substring. Graduated: 1.0 all present,
|
|
32
|
+
* 0.5 some present, 0.0 none present or the file is missing.
|
|
33
|
+
*/
|
|
34
|
+
export function fileContains(relativePath: string, ...required: readonly string[]): Checker {
|
|
35
|
+
return {
|
|
36
|
+
async check({ workspace }: CheckerContext): Promise<CheckerResult> {
|
|
37
|
+
if (workspace === undefined) return missingWorkspace("fileContains");
|
|
38
|
+
let content: string;
|
|
39
|
+
try {
|
|
40
|
+
content = await readFile(join(workspace, relativePath), "utf-8");
|
|
41
|
+
} catch {
|
|
42
|
+
return { pass: false, score: 0, errors: [`File not found: ${relativePath}`] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const missing = required.filter((value) => !content.includes(value));
|
|
46
|
+
if (missing.length === 0) return { pass: true, score: 1, errors: [] };
|
|
47
|
+
|
|
48
|
+
const found = required.length - missing.length;
|
|
49
|
+
const score = found > 0 ? 0.5 : 0;
|
|
50
|
+
return { pass: false, score, errors: missing.map((value) => `'${value}' not found in ${relativePath}`) };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A Checker running a real command in the run's workspace and asserting exit code 0 -- an
|
|
57
|
+
* outcome checker: verifies what the agent DID (does it still build/lint/pass tests), not what
|
|
58
|
+
* it said.
|
|
59
|
+
*/
|
|
60
|
+
export function lintPasses(cmd: string, args: readonly string[] = []): Checker {
|
|
61
|
+
return {
|
|
62
|
+
check({ workspace }: CheckerContext): Promise<CheckerResult> {
|
|
63
|
+
if (workspace === undefined) return Promise.resolve(missingWorkspace("lintPasses"));
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const child = spawn(cmd, [...args], { cwd: workspace, stdio: "pipe" });
|
|
66
|
+
const stderr: string[] = [];
|
|
67
|
+
child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk.toString()));
|
|
68
|
+
child.on("close", (code) => {
|
|
69
|
+
if (code === 0) {
|
|
70
|
+
resolve({ pass: true, score: 1, errors: [] });
|
|
71
|
+
} else {
|
|
72
|
+
resolve({ pass: false, score: 0, errors: [`${cmd} exited ${code}:\n${stderr.join("").trim()}`] });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
child.on("error", (error) => {
|
|
76
|
+
resolve({ pass: false, score: 0, errors: [`Failed to run ${cmd}: ${error.message}`] });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Composes several checkers, returning the maximum score (lenient OR) -- complements `all()`'s min-score AND. */
|
|
84
|
+
export function any(...checkers: readonly Checker[]): Checker {
|
|
85
|
+
return {
|
|
86
|
+
async check(context: CheckerContext): Promise<CheckerResult> {
|
|
87
|
+
const results = await Promise.all(checkers.map((checker) => checker.check(context)));
|
|
88
|
+
if (results.length === 0) return { pass: true, score: 1, errors: [] };
|
|
89
|
+
return results.reduce((best, current) => (current.score >= best.score ? current : best));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|