@danypops/pi-eval-harness 0.1.0 → 0.2.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.
@@ -0,0 +1,35 @@
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
+ }
21
+ /** One config's own trial metrics, plus its delta vs baseline. `delta` is undefined for the baseline itself. */
22
+ export interface AblationResult {
23
+ readonly config: AblationConfig;
24
+ readonly metrics: TrialMetrics;
25
+ readonly delta?: AblationDelta;
26
+ }
27
+ /**
28
+ * Runs `n` trials of every config (sequentially, config by config -- each config's own trials
29
+ * still run at `options.concurrency` internally) and returns side-by-side results. Configs are
30
+ * independent scenarios in the caller's own domain (e.g. real spawned `pi` processes with
31
+ * different extension sets); this function only orchestrates trial counts and diffs.
32
+ */
33
+ export declare function ablate(configs: readonly AblationConfig[], n: number, options?: RunTrialsOptions): Promise<AblationResult[]>;
34
+ /** Renders a human-readable ablation comparison table -- ported from djinn's `FormatAblation()`. */
35
+ export declare function formatAblation(label: string, results: readonly AblationResult[]): string;
@@ -0,0 +1,55 @@
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
+ },
36
+ };
37
+ });
38
+ }
39
+ function signed(value, digits) {
40
+ const rounded = value.toFixed(digits);
41
+ return value >= 0 ? `+${rounded}` : rounded;
42
+ }
43
+ /** Renders a human-readable ablation comparison table -- ported from djinn's `FormatAblation()`. */
44
+ export function formatAblation(label, results) {
45
+ const lines = [`=== Ablation: ${label} ===`];
46
+ for (const result of results) {
47
+ const m = result.metrics;
48
+ 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)}`);
49
+ if (result.delta) {
50
+ const d = result.delta;
51
+ 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)}`);
52
+ }
53
+ }
54
+ return lines.join("\n");
55
+ }
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
- /** Runtime context passed to a Checker -- the real completed tool executions from one run. */
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;
@@ -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
+ }
@@ -0,0 +1,61 @@
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
+ readonly costUsd: number;
15
+ readonly error?: string;
16
+ }
17
+ /** Aggregate statistics across N trials of one scenario. */
18
+ export interface TrialMetrics {
19
+ readonly trials: number;
20
+ readonly passes: number;
21
+ readonly passRate: number;
22
+ readonly meanScore: number;
23
+ readonly variance: number;
24
+ readonly minScore: number;
25
+ readonly maxScore: number;
26
+ readonly meanDurationMs: number;
27
+ readonly meanTokensIn: number;
28
+ readonly meanTokensOut: number;
29
+ readonly meanCostUsd: number;
30
+ }
31
+ /**
32
+ * Thrown when too many trials errored outright to trust the aggregate as a real measurement --
33
+ * ported from Alef's own `[MaxErrorRate]` guard. Without this, a scenario that errors on every
34
+ * trial would silently aggregate to "0% pass", indistinguishable from a real, measured failure.
35
+ */
36
+ export declare class MaxErrorRateExceeded extends Error {
37
+ readonly errorRate: number;
38
+ readonly maxErrorRate: number;
39
+ readonly errorCount: number;
40
+ readonly totalTrials: number;
41
+ constructor(errorRate: number, maxErrorRate: number, errorCount: number, totalTrials: number, firstError: string | undefined);
42
+ }
43
+ /**
44
+ * Aggregates already-collected trial results into TrialMetrics. Throws MaxErrorRateExceeded if
45
+ * `maxErrorRate` (0-1, default 0 = disabled) is exceeded by the fraction of errored trials.
46
+ */
47
+ export declare function aggregateTrials(results: readonly TrialResult[], options?: {
48
+ readonly maxErrorRate?: number;
49
+ }): TrialMetrics;
50
+ export interface RunTrialsOptions {
51
+ /** Trials run concurrently at once. Default 3, matching Alef's own ALEF_EVAL_CONCURRENCY default -- real LLM providers rate-limit. */
52
+ readonly concurrency?: number;
53
+ readonly maxErrorRate?: number;
54
+ }
55
+ /**
56
+ * Runs `runOne` (one real trial of a scenario) `n` times at a bounded concurrency, then
57
+ * aggregates. A rejected `runOne` call becomes a TrialResult with `error` set (score 0, pass
58
+ * false) rather than aborting the whole batch -- one crashed trial should not lose every other
59
+ * trial's own real data.
60
+ */
61
+ export declare function runTrials(runOne: () => Promise<TrialResult>, n: number, options?: RunTrialsOptions): Promise<TrialMetrics>;
package/dist/trials.js ADDED
@@ -0,0 +1,91 @@
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
+ meanCostUsd: mean(results.map((result) => result.costUsd)),
59
+ };
60
+ }
61
+ /**
62
+ * Runs `runOne` (one real trial of a scenario) `n` times at a bounded concurrency, then
63
+ * aggregates. A rejected `runOne` call becomes a TrialResult with `error` set (score 0, pass
64
+ * false) rather than aborting the whole batch -- one crashed trial should not lose every other
65
+ * trial's own real data.
66
+ */
67
+ export async function runTrials(runOne, n, options = {}) {
68
+ const concurrency = options.concurrency ?? 3;
69
+ const results = [];
70
+ for (let i = 0; i < n; i += concurrency) {
71
+ const batchSize = Math.min(concurrency, n - i);
72
+ const batch = await Promise.all(Array.from({ length: batchSize }, async () => {
73
+ try {
74
+ return await runOne();
75
+ }
76
+ catch (error) {
77
+ return {
78
+ pass: false,
79
+ score: 0,
80
+ durationMs: 0,
81
+ tokensIn: 0,
82
+ tokensOut: 0,
83
+ costUsd: 0,
84
+ error: error instanceof Error ? error.message : String(error),
85
+ };
86
+ }
87
+ }));
88
+ results.push(...batch);
89
+ }
90
+ return aggregateTrials(results, options.maxErrorRate !== undefined ? { maxErrorRate: options.maxErrorRate } : {});
91
+ }
@@ -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
- "name": "@danypops/pi-eval-harness",
3
- "version": "0.1.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
- ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
12
- "./package.json": "./package.json"
13
- },
14
- "scripts": {
15
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
16
- "test": "bun run build && bun test test",
17
- "typecheck": "tsc --noEmit",
18
- "format": "biome format --write ."
19
- },
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/DanyPops/pi-integral.git",
23
- "directory": "packages/pi-eval-harness"
24
- },
25
- "homepage": "https://github.com/DanyPops/pi-integral/tree/main/packages/pi-eval-harness#readme",
26
- "bugs": {
27
- "url": "https://github.com/DanyPops/pi-integral/issues"
28
- },
29
- "peerDependencies": {
30
- "@earendil-works/pi-coding-agent": "*"
31
- },
32
- "devDependencies": {
33
- "@biomejs/biome": "^2.5.6",
34
- "@danypops/pi-process-harness": "workspace:*",
35
- "@earendil-works/pi-ai": "*",
36
- "@earendil-works/pi-coding-agent": "*",
37
- "@types/node": "^22.0.0",
38
- "bun-types": "latest",
39
- "typebox": "*",
40
- "typescript": "^5.9.2"
41
- },
42
- "publishConfig": {
43
- "access": "public"
44
- },
45
- "files": ["src", "dist", "README.md", "LICENSE"],
46
- "keywords": ["pi", "pi-extension", "testing", "eval", "agent-evaluation", "tool-call"]
2
+ "name": "@danypops/pi-eval-harness",
3
+ "version": "0.2.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
  }
@@ -0,0 +1,84 @@
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
+ }
23
+
24
+ /** One config's own trial metrics, plus its delta vs baseline. `delta` is undefined for the baseline itself. */
25
+ export interface AblationResult {
26
+ readonly config: AblationConfig;
27
+ readonly metrics: TrialMetrics;
28
+ readonly delta?: AblationDelta;
29
+ }
30
+
31
+ /**
32
+ * Runs `n` trials of every config (sequentially, config by config -- each config's own trials
33
+ * still run at `options.concurrency` internally) and returns side-by-side results. Configs are
34
+ * independent scenarios in the caller's own domain (e.g. real spawned `pi` processes with
35
+ * different extension sets); this function only orchestrates trial counts and diffs.
36
+ */
37
+ export async function ablate(configs: readonly AblationConfig[], n: number, options: RunTrialsOptions = {}): Promise<AblationResult[]> {
38
+ if (configs.length === 0) return [];
39
+
40
+ const results: AblationResult[] = [];
41
+ for (const config of configs) {
42
+ results.push({ config, metrics: await runTrials(config.runOne, n, options) });
43
+ }
44
+
45
+ const baseline = results[0]?.metrics;
46
+ if (baseline === undefined) return results;
47
+
48
+ return results.map((result, index) => {
49
+ if (index === 0) return result;
50
+ const metrics = result.metrics;
51
+ return {
52
+ ...result,
53
+ delta: {
54
+ passRateDelta: metrics.passRate - baseline.passRate,
55
+ meanScoreDelta: metrics.meanScore - baseline.meanScore,
56
+ meanDurationMsDelta: metrics.meanDurationMs - baseline.meanDurationMs,
57
+ meanTokensInDelta: metrics.meanTokensIn - baseline.meanTokensIn,
58
+ },
59
+ };
60
+ });
61
+ }
62
+
63
+ function signed(value: number, digits: number): string {
64
+ const rounded = value.toFixed(digits);
65
+ return value >= 0 ? `+${rounded}` : rounded;
66
+ }
67
+
68
+ /** Renders a human-readable ablation comparison table -- ported from djinn's `FormatAblation()`. */
69
+ export function formatAblation(label: string, results: readonly AblationResult[]): string {
70
+ const lines: string[] = [`=== Ablation: ${label} ===`];
71
+ for (const result of results) {
72
+ const m = result.metrics;
73
+ lines.push(
74
+ ` ${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)}`,
75
+ );
76
+ if (result.delta) {
77
+ const d = result.delta;
78
+ lines.push(
79
+ ` ${"(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)}`,
80
+ );
81
+ }
82
+ }
83
+ return lines.join("\n");
84
+ }
package/src/checker.ts CHANGED
@@ -9,9 +9,16 @@ export interface CheckerResult {
9
9
  readonly errors: readonly string[];
10
10
  }
11
11
 
12
- /** Runtime context passed to a Checker -- the real completed tool executions from one run. */
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";
@@ -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,132 @@
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
+ readonly costUsd: number;
16
+ readonly error?: string;
17
+ }
18
+
19
+ /** Aggregate statistics across N trials of one scenario. */
20
+ export interface TrialMetrics {
21
+ readonly trials: number;
22
+ readonly passes: number;
23
+ readonly passRate: number;
24
+ readonly meanScore: number;
25
+ readonly variance: number;
26
+ readonly minScore: number;
27
+ readonly maxScore: number;
28
+ readonly meanDurationMs: number;
29
+ readonly meanTokensIn: number;
30
+ readonly meanTokensOut: number;
31
+ readonly meanCostUsd: number;
32
+ }
33
+
34
+ /**
35
+ * Thrown when too many trials errored outright to trust the aggregate as a real measurement --
36
+ * ported from Alef's own `[MaxErrorRate]` guard. Without this, a scenario that errors on every
37
+ * trial would silently aggregate to "0% pass", indistinguishable from a real, measured failure.
38
+ */
39
+ export class MaxErrorRateExceeded extends Error {
40
+ constructor(
41
+ readonly errorRate: number,
42
+ readonly maxErrorRate: number,
43
+ readonly errorCount: number,
44
+ readonly totalTrials: number,
45
+ firstError: string | undefined,
46
+ ) {
47
+ super(
48
+ `${(errorRate * 100).toFixed(0)}% of trials errored (${errorCount}/${totalTrials}), threshold ${(maxErrorRate * 100).toFixed(0)}%. First error: ${firstError ?? "unknown"}`,
49
+ );
50
+ this.name = "MaxErrorRateExceeded";
51
+ }
52
+ }
53
+
54
+ function mean(values: readonly number[]): number {
55
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
56
+ }
57
+
58
+ /**
59
+ * Aggregates already-collected trial results into TrialMetrics. Throws MaxErrorRateExceeded if
60
+ * `maxErrorRate` (0-1, default 0 = disabled) is exceeded by the fraction of errored trials.
61
+ */
62
+ export function aggregateTrials(results: readonly TrialResult[], options: { readonly maxErrorRate?: number } = {}): TrialMetrics {
63
+ if (results.length === 0) throw new Error("aggregateTrials: at least one trial result is required");
64
+
65
+ const maxErrorRate = options.maxErrorRate ?? 0;
66
+ if (maxErrorRate > 0) {
67
+ const errored = results.filter((result) => result.error !== undefined);
68
+ const errorRate = errored.length / results.length;
69
+ if (errorRate > maxErrorRate) {
70
+ throw new MaxErrorRateExceeded(errorRate, maxErrorRate, errored.length, results.length, errored[0]?.error);
71
+ }
72
+ }
73
+
74
+ const scores = results.map((result) => result.score);
75
+ const passes = results.filter((result) => result.pass).length;
76
+ const meanScore = mean(scores);
77
+
78
+ return {
79
+ trials: results.length,
80
+ passes,
81
+ passRate: passes / results.length,
82
+ meanScore,
83
+ variance: mean(scores.map((score) => (score - meanScore) ** 2)),
84
+ minScore: Math.min(...scores),
85
+ maxScore: Math.max(...scores),
86
+ meanDurationMs: mean(results.map((result) => result.durationMs)),
87
+ meanTokensIn: mean(results.map((result) => result.tokensIn)),
88
+ meanTokensOut: mean(results.map((result) => result.tokensOut)),
89
+ meanCostUsd: mean(results.map((result) => result.costUsd)),
90
+ };
91
+ }
92
+
93
+ export interface RunTrialsOptions {
94
+ /** Trials run concurrently at once. Default 3, matching Alef's own ALEF_EVAL_CONCURRENCY default -- real LLM providers rate-limit. */
95
+ readonly concurrency?: number;
96
+ readonly maxErrorRate?: number;
97
+ }
98
+
99
+ /**
100
+ * Runs `runOne` (one real trial of a scenario) `n` times at a bounded concurrency, then
101
+ * aggregates. A rejected `runOne` call becomes a TrialResult with `error` set (score 0, pass
102
+ * false) rather than aborting the whole batch -- one crashed trial should not lose every other
103
+ * trial's own real data.
104
+ */
105
+ export async function runTrials(runOne: () => Promise<TrialResult>, n: number, options: RunTrialsOptions = {}): Promise<TrialMetrics> {
106
+ const concurrency = options.concurrency ?? 3;
107
+ const results: TrialResult[] = [];
108
+
109
+ for (let i = 0; i < n; i += concurrency) {
110
+ const batchSize = Math.min(concurrency, n - i);
111
+ const batch = await Promise.all(
112
+ Array.from({ length: batchSize }, async (): Promise<TrialResult> => {
113
+ try {
114
+ return await runOne();
115
+ } catch (error) {
116
+ return {
117
+ pass: false,
118
+ score: 0,
119
+ durationMs: 0,
120
+ tokensIn: 0,
121
+ tokensOut: 0,
122
+ costUsd: 0,
123
+ error: error instanceof Error ? error.message : String(error),
124
+ };
125
+ }
126
+ }),
127
+ );
128
+ results.push(...batch);
129
+ }
130
+
131
+ return aggregateTrials(results, options.maxErrorRate !== undefined ? { maxErrorRate: options.maxErrorRate } : {});
132
+ }
@@ -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
+ }