@workos/quickstudy 0.3.0 → 0.5.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 +4 -0
- package/package.json +1 -1
- package/src/adapters/echo.ts +2 -0
- package/src/adapters/oracle.ts +69 -0
- package/src/adapters/types.ts +27 -0
- package/src/build-info.generated.ts +1 -1
- package/src/cli.ts +26 -3
- package/src/diagnose/excerpt.ts +19 -8
- package/src/diagnose/select.ts +1 -1
- package/src/diagnose/types.ts +5 -2
- package/src/evals/discovery.ts +32 -2
- package/src/evals/result.ts +1 -1
- package/src/evals/types.ts +12 -0
- package/src/export-types.ts +7 -0
- package/src/export.ts +15 -4
- package/src/manifest.ts +6 -0
- package/src/report/aggregate.ts +136 -0
- package/src/report/report.ts +175 -8
- package/src/report/scorer-health.ts +129 -0
- package/src/runner/context.ts +19 -2
- package/src/runner/execute.ts +160 -61
- package/src/runner/oracle-validate.ts +109 -0
- package/src/runner/workspace-source.ts +34 -0
- package/src/store/db.ts +65 -2
- package/src/store/schema.ts +7 -1
- package/ui/dist/index.html +11 -11
package/README.md
CHANGED
|
@@ -95,6 +95,10 @@ quickstudy report <run-id>
|
|
|
95
95
|
writes NOTES.md, so this toy check passes; against real evals every check
|
|
96
96
|
fails, which is exactly what a negative control should do. Completing the
|
|
97
97
|
run → score → persist loop is what it proves.
|
|
98
|
+
The positive control is the built-in `oracle` agent: give an eval a
|
|
99
|
+
`solution/` reference solution and `quickstudy validate --oracle` checks that
|
|
100
|
+
it scores 1.0 before any paid run (see
|
|
101
|
+
[Writing an eval](docs/writing-an-eval.md#prove-the-scorer-with-a-reference-solution)).
|
|
98
102
|
|
|
99
103
|
Real measurements come from a benchmark repository built on quickstudy: an
|
|
100
104
|
`evals/` root of real integration tasks (tiny HTTP apps under `local/`) and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/quickstudy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/adapters/echo.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { AgentAdapter, AttemptContext, AttemptOutcome, TranscriptEvent } fr
|
|
|
13
13
|
|
|
14
14
|
export class EchoAdapter implements AgentAdapter {
|
|
15
15
|
readonly name: string;
|
|
16
|
+
/** Does nothing, so every eval must fail it: a scorer it passes rewards doing nothing. */
|
|
17
|
+
readonly role = "negative-control" as const;
|
|
16
18
|
readonly identity = {
|
|
17
19
|
provider: "local", model: "echo-v1", reasoning: "none", cliVersion: "built-in",
|
|
18
20
|
surfacePolicies: { docs: "no-network", mcp: "no-network", cli: "no-network" },
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oracle adapter: applies the eval's reference solution and does nothing
|
|
3
|
+
* else.
|
|
4
|
+
*
|
|
5
|
+
* A bench is only trustworthy when a reference solution scores 1.0 on every
|
|
6
|
+
* eval (and a no-op — the echo adapter — scores 0). The oracle declares
|
|
7
|
+
* `startingState: "solution"`, so the runner's shared workspace prep overlays
|
|
8
|
+
* `solution/` on `local/` before the adapter runs; both the host and the
|
|
9
|
+
* container variant are then no-ops. No model runs: turns and cost are true
|
|
10
|
+
* zeros, token counts are unavailable (null).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { cp } from "node:fs/promises";
|
|
14
|
+
import type { AgentAdapter, AttemptContext, AttemptMetrics, AttemptOutcome, ContainerAgentAdapter } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const ORACLE_IDENTITY = {
|
|
17
|
+
provider: "local", model: "oracle-v1", reasoning: "none", cliVersion: "built-in",
|
|
18
|
+
surfacePolicies: { docs: "no-network", mcp: "no-network", cli: "no-network" },
|
|
19
|
+
nativeWebControl: "controlled" as const,
|
|
20
|
+
reproducible: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function oracleMetrics(): AttemptMetrics {
|
|
24
|
+
return { tokensIn: null, tokensOut: null, turns: 0, costUsd: 0 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Host variant: the prepared fixture already carries the solution overlay. */
|
|
28
|
+
export class OracleAdapter implements AgentAdapter {
|
|
29
|
+
readonly name = "oracle";
|
|
30
|
+
readonly role = "positive-control" as const;
|
|
31
|
+
readonly identity = ORACLE_IDENTITY;
|
|
32
|
+
readonly startingState = "solution" as const;
|
|
33
|
+
|
|
34
|
+
async runAttempt(ctx: AttemptContext): Promise<AttemptOutcome> {
|
|
35
|
+
await cp(ctx.fixtureDir, ctx.workspaceDir, { recursive: true });
|
|
36
|
+
return {
|
|
37
|
+
transcript: [{ role: "assistant", content: "oracle: applied the reference solution", at: new Date().toISOString() }],
|
|
38
|
+
diff: "",
|
|
39
|
+
metrics: oracleMetrics(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Container variant: the runner `docker cp`s the overlaid source into
|
|
46
|
+
* /workspace, so the agent command is a no-op. Experiments using it must not
|
|
47
|
+
* set `runtime.command` (that bypasses adapter resolution).
|
|
48
|
+
*/
|
|
49
|
+
export class OracleContainerAdapter implements ContainerAgentAdapter {
|
|
50
|
+
readonly kind = "container" as const;
|
|
51
|
+
readonly name = "oracle";
|
|
52
|
+
readonly identity = ORACLE_IDENTITY;
|
|
53
|
+
readonly startingState = "solution" as const;
|
|
54
|
+
readonly role = "positive-control" as const;
|
|
55
|
+
readonly requiredHostEnv: readonly string[] = [];
|
|
56
|
+
|
|
57
|
+
setupFiles(): Record<string, string> {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
command(): string[] {
|
|
62
|
+
return ["true"];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The no-op command prints nothing; input is ignored. */
|
|
66
|
+
parseStream(_raw: string): AttemptMetrics {
|
|
67
|
+
return oracleMetrics();
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -73,10 +73,33 @@ export interface AttemptOutcome {
|
|
|
73
73
|
metrics: AttemptMetrics;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Which tree the runner prepares as the attempt's starting workspace:
|
|
78
|
+
* `"fixture"` (the default) copies the eval's `local/`; `"solution"` copies
|
|
79
|
+
* `local/` and then overlays the eval's `solution/` reference solution (the
|
|
80
|
+
* built-in oracle adapter). A plan pairing a `"solution"` adapter with an
|
|
81
|
+
* eval that has no `solution/` is refused at preflight.
|
|
82
|
+
*/
|
|
83
|
+
export type StartingState = "fixture" | "solution";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What an adapter's attempts are for. "agent" is an arm under measurement; a
|
|
87
|
+
* control checks the instrument instead: "positive-control" must pass (the
|
|
88
|
+
* built-in oracle applies each eval's reference solution) and
|
|
89
|
+
* "negative-control" must fail (the built-in echo adapter does nothing). The
|
|
90
|
+
* runner records the role of the adapter that actually runs each experiment
|
|
91
|
+
* (RunConfig.roles); the report never guesses it from a name.
|
|
92
|
+
*/
|
|
93
|
+
export type AdapterRole = "agent" | "positive-control" | "negative-control";
|
|
94
|
+
|
|
76
95
|
export interface AgentAdapter {
|
|
77
96
|
/** The adapter name experiments select via `agent.adapter` (e.g. "echo"). */
|
|
78
97
|
name: string;
|
|
79
98
|
identity?: AdapterIdentityConfig;
|
|
99
|
+
/** Starting workspace the runner prepares for this adapter (default "fixture"). */
|
|
100
|
+
startingState?: StartingState;
|
|
101
|
+
/** What the adapter's attempts are for (default "agent"). */
|
|
102
|
+
role?: AdapterRole;
|
|
80
103
|
runAttempt(ctx: AttemptContext): Promise<AttemptOutcome>;
|
|
81
104
|
}
|
|
82
105
|
|
|
@@ -115,6 +138,10 @@ export interface ContainerAgentAdapter {
|
|
|
115
138
|
/** The adapter name experiments select via `agent.adapter` (e.g. "claude", "codex"). */
|
|
116
139
|
name: string;
|
|
117
140
|
identity?: AdapterIdentityConfig;
|
|
141
|
+
/** Starting workspace the runner prepares for this adapter (default "fixture"). */
|
|
142
|
+
startingState?: StartingState;
|
|
143
|
+
/** What the adapter's attempts are for (default "agent"). */
|
|
144
|
+
role?: AdapterRole;
|
|
118
145
|
/** Command used during preflight to record the executable's exact version. */
|
|
119
146
|
versionCommand?: readonly string[];
|
|
120
147
|
/**
|
|
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
|
|
|
9
9
|
// (the `x-release-please-version` marker anchors the replacement). It is
|
|
10
10
|
// restored as-is after a binary build (scripts/build-binary.ts), so the
|
|
11
11
|
// committed source `--version` stays in lockstep with package.json.
|
|
12
|
-
version: "0.
|
|
12
|
+
version: "0.5.0", // x-release-please-version
|
|
13
13
|
revision: null,
|
|
14
14
|
dirty: null,
|
|
15
15
|
sourceHash: null,
|
package/src/cli.ts
CHANGED
|
@@ -59,6 +59,7 @@ import { compareGroups } from "./experiments/groups.ts";
|
|
|
59
59
|
import { buildRunManifest } from "./manifest.ts";
|
|
60
60
|
import { filterEvals, planRun } from "./plan.ts";
|
|
61
61
|
import { executeRunV3 } from "./runner/execute.ts";
|
|
62
|
+
import { formatOracleOutcome, validateOracle } from "./runner/oracle-validate.ts";
|
|
62
63
|
import { collectProviderKeys, InvalidProviderKeyError, MissingProviderKeyError } from "./secrets.ts";
|
|
63
64
|
import { serveSite } from "./serve.ts";
|
|
64
65
|
import { UI_INDEX_HTML_BASE64 } from "./ui-bundle.generated.ts";
|
|
@@ -71,6 +72,7 @@ Usage:
|
|
|
71
72
|
quickstudy validate Validate the evals and experiments roots
|
|
72
73
|
[--evals-root <dir>] Evals root (default: evals)
|
|
73
74
|
[--experiments-root <dir>] Experiments root (default: experiments)
|
|
75
|
+
[--oracle] Score each eval's solution/ reference solution (expects 1.0)
|
|
74
76
|
quickstudy run [--eval <id>...] Execute evals against experiments
|
|
75
77
|
[--experiment <id>...] Omit either selector to include every discovered item
|
|
76
78
|
[--evals-root <dir>] Evals root (default: evals)
|
|
@@ -104,7 +106,7 @@ Usage:
|
|
|
104
106
|
`;
|
|
105
107
|
|
|
106
108
|
const COMMAND_HELP: Record<string, string> = {
|
|
107
|
-
validate: `Usage: quickstudy validate [--evals-root <dir>] [--experiments-root <dir>]\n\nDiscover and validate the evals root (frontmatter, scorer default exports)\nand the experiments root (defineExperiment shapes, comparison groups).\nDefaults: evals, experiments.`,
|
|
109
|
+
validate: `Usage: quickstudy validate [--evals-root <dir>] [--experiments-root <dir>] [--oracle]\n\nDiscover and validate the evals root (frontmatter, scorer default exports)\nand the experiments root (defineExperiment shapes, comparison groups).\nDefaults: evals, experiments.\n\n--oracle scores each eval's solution/ reference solution on the host and\nprints 1.0, deferred (the scorer needs a runtime capability), FAILED or\nMISSING per eval; exits 1 on any FAILED or MISSING.`,
|
|
108
110
|
run: `Usage: quickstudy run [--eval <id>...] [--experiment <id>...] [options]\n\nExecute the selected eval × experiment pairs. Omit --eval to select every\ndiscovered eval; omit --experiment to select every discovered experiment.\n\nOptions:\n --eval <id>... --experiment <id>...\n --evals-root <dir> --experiments-root <dir> (defaults: evals, experiments)\n --suite <s>... --framework <f>...\n --trials <n> --egress-proxy\n --concurrency <n> --seed <value> (default concurrency: 1)\n --resume <run-id> --retry-unfinished (explicit error/timeout retries)\n --provision-ms <n> --agent-ms <n> --score-ms <n>\n --export-ms <n> --cleanup-ms <n>\n --db <path> --results <dir>`,
|
|
109
111
|
report: `Usage: quickstudy report <run-id> [options]\n\nBuild report.json into the results dir and print the text report\n(pass \`--latest\` to pick the most recent run; \`--json\` prints the JSON).\n\nOptions:\n --latest --json --strict\n --strict exits non-zero for incomplete runs or any attempt error\n --pricing <file> Estimate missing Codex token costs using runtime pricing JSON\n --db <path> --results <dir>`,
|
|
110
112
|
diagnose: `Usage: quickstudy diagnose <run-id> [options]\n\nSend each failing (eval × experiment) pair's harness-selected evidence to a\nstructured model call and write diagnosis.json beside report.json\n(pass \`--latest\` to pick the most recent run). Findings are hypotheses with\nevidence links — never verdicts. Requires ANTHROPIC_API_KEY; override the\nanalyst model with QUICKSTUDY_ANALYST_MODEL.\n\nOptions:\n --latest\n --db <path> --results <dir>`,
|
|
@@ -126,7 +128,7 @@ function fail(message: string): never {
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
async function commandValidate(args: string[]): Promise<void> {
|
|
129
|
-
let values: { "evals-root": string; "experiments-root": string };
|
|
131
|
+
let values: { "evals-root": string; "experiments-root": string; oracle: boolean };
|
|
130
132
|
let positionals: string[];
|
|
131
133
|
try {
|
|
132
134
|
({ values, positionals } = parseArgs({
|
|
@@ -135,6 +137,7 @@ async function commandValidate(args: string[]): Promise<void> {
|
|
|
135
137
|
options: {
|
|
136
138
|
"evals-root": { type: "string", default: "evals" },
|
|
137
139
|
"experiments-root": { type: "string", default: "experiments" },
|
|
140
|
+
oracle: { type: "boolean", default: false },
|
|
138
141
|
},
|
|
139
142
|
}));
|
|
140
143
|
} catch (err) {
|
|
@@ -157,6 +160,10 @@ async function commandValidate(args: string[]): Promise<void> {
|
|
|
157
160
|
`("${experimentsRoot}") exists\n\n${USAGE}`,
|
|
158
161
|
);
|
|
159
162
|
}
|
|
163
|
+
if (values.oracle && !haveEvals) {
|
|
164
|
+
fail(`validate --oracle: evals root "${evalsRoot}" does not exist`);
|
|
165
|
+
}
|
|
166
|
+
let oracleFailure: string | undefined;
|
|
160
167
|
if (haveEvals) {
|
|
161
168
|
const evals = discoverEvals(evalsRoot);
|
|
162
169
|
// A missing/non-function default export is a validation error HERE, not
|
|
@@ -170,9 +177,24 @@ async function commandValidate(args: string[]): Promise<void> {
|
|
|
170
177
|
` - ${meta.id} (suite: ${meta.suite}` +
|
|
171
178
|
`${meta.product !== undefined ? `; product: ${meta.product}` : ""}` +
|
|
172
179
|
`${meta.framework !== undefined ? `; framework: ${meta.framework}` : ""}` +
|
|
173
|
-
`${loaded.localDir !== null ? "; local/" : ""}
|
|
180
|
+
`${loaded.localDir !== null ? "; local/" : ""}` +
|
|
181
|
+
`${loaded.solutionDir !== null ? "; solution/" : ""})`,
|
|
174
182
|
);
|
|
175
183
|
}
|
|
184
|
+
if (values.oracle) {
|
|
185
|
+
console.log(` oracle:`);
|
|
186
|
+
let failed = 0;
|
|
187
|
+
let missing = 0;
|
|
188
|
+
for (const loaded of evals) {
|
|
189
|
+
const outcome = await validateOracle(loaded);
|
|
190
|
+
if (outcome.kind === "failed") failed++;
|
|
191
|
+
if (outcome.kind === "missing") missing++;
|
|
192
|
+
console.log(` - ${formatOracleOutcome(loaded.metadata.id, outcome)}`);
|
|
193
|
+
}
|
|
194
|
+
if (failed + missing > 0) {
|
|
195
|
+
oracleFailure = `validate --oracle: ${failed} eval(s) FAILED, ${missing} eval(s) MISSING a solution/`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
176
198
|
}
|
|
177
199
|
if (haveExperiments) {
|
|
178
200
|
const experiments = await discoverExperiments(experimentsRoot);
|
|
@@ -200,6 +222,7 @@ async function commandValidate(args: string[]): Promise<void> {
|
|
|
200
222
|
}
|
|
201
223
|
}
|
|
202
224
|
}
|
|
225
|
+
if (oracleFailure !== undefined) fail(oracleFailure);
|
|
203
226
|
}
|
|
204
227
|
|
|
205
228
|
function parsePositiveInt(value: string, flag: string): number {
|
package/src/diagnose/excerpt.ts
CHANGED
|
@@ -29,8 +29,9 @@ export const BUNDLE_DIFF_CAP = 3_000;
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Heuristic error scan. Matches benign prose too ("failed" in discussion) —
|
|
32
|
-
* accepted: the window stays deterministic and human-verifiable, and
|
|
33
|
-
*
|
|
32
|
+
* accepted: the window stays deterministic and human-verifiable, and
|
|
33
|
+
* anchoring on the last marker cluster keeps an early false positive from
|
|
34
|
+
* pinning the window to the transcript's head.
|
|
34
35
|
*/
|
|
35
36
|
export const ERROR_MARKER = /"is_error"\s*:\s*true|error:|failed|unauthorized|timed out|not found|exited [1-9]/i;
|
|
36
37
|
|
|
@@ -42,8 +43,11 @@ export interface ArtifactExcerpt {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
* Deterministically select the excerpt window: context around the
|
|
46
|
-
* error markers (
|
|
46
|
+
* Deterministically select the excerpt window: context around the last
|
|
47
|
+
* cluster of error markers (walking back from the final marker while the
|
|
48
|
+
* window stays within {@link EXCERPT_MAX_LINES}), or the tail when nothing
|
|
49
|
+
* matches. Earlier markers that do not fit are dropped; the window always
|
|
50
|
+
* ends at the last marker plus context.
|
|
47
51
|
*/
|
|
48
52
|
export function excerptTranscript(content: string): ArtifactExcerpt {
|
|
49
53
|
const lines = content.split("\n");
|
|
@@ -63,11 +67,18 @@ export function excerptTranscript(content: string): ArtifactExcerpt {
|
|
|
63
67
|
start = Math.max(0, totalLines - EXCERPT_TAIL_LINES);
|
|
64
68
|
end = totalLines - 1;
|
|
65
69
|
} else {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
// Anchor on the last marker cluster: walk back from the final marker while
|
|
71
|
+
// the span plus context still fits the window. Early markers (often
|
|
72
|
+
// system-prompt prose) no longer pin the window to the transcript's head.
|
|
73
|
+
const last = matches.at(-1) as number;
|
|
74
|
+
let first = last;
|
|
75
|
+
for (let i = matches.length - 2; i >= 0; i -= 1) {
|
|
76
|
+
const candidate = matches[i] as number;
|
|
77
|
+
if (last - candidate + 2 * EXCERPT_CONTEXT_LINES + 1 > EXCERPT_MAX_LINES) break;
|
|
78
|
+
first = candidate;
|
|
70
79
|
}
|
|
80
|
+
start = Math.max(0, first - EXCERPT_CONTEXT_LINES);
|
|
81
|
+
end = Math.min(totalLines - 1, last + EXCERPT_CONTEXT_LINES);
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
return {
|
package/src/diagnose/select.ts
CHANGED
|
@@ -100,7 +100,7 @@ export function selectDiagnosablePairs(attempts: AttemptRecord[]): PairSelection
|
|
|
100
100
|
experimentId: report.experiment_id,
|
|
101
101
|
tallies: report,
|
|
102
102
|
flaky: report.flaky,
|
|
103
|
-
failedChecks: report.checks.filter((check) => check.passed < check.total).map((check) => check.name),
|
|
103
|
+
failedChecks: report.checks.filter((check) => check.graded !== false && check.passed < check.total).map((check) => check.name),
|
|
104
104
|
bundle,
|
|
105
105
|
});
|
|
106
106
|
}
|
package/src/diagnose/types.ts
CHANGED
|
@@ -20,8 +20,11 @@ export class DiagnoseError extends Error {
|
|
|
20
20
|
|
|
21
21
|
export const DIAGNOSIS_SCHEMA_VERSION = 1;
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Bumped whenever the system/user prompt text changes shape. v2: excerpt
|
|
25
|
+
* windows anchor on the last marker cluster.
|
|
26
|
+
*/
|
|
27
|
+
export const DIAGNOSIS_PROMPT_VERSION = 2;
|
|
25
28
|
|
|
26
29
|
/** Fixed self-labeling carried inside every diagnosis.json. */
|
|
27
30
|
export const DIAGNOSIS_DISCLAIMER =
|
package/src/evals/discovery.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Eval discovery: scan each `<root>/<dir>/PROMPT.md`, pair it with its EVAL.ts, and
|
|
3
|
-
* detect the optional `local/` starting-state
|
|
3
|
+
* detect the optional `local/` starting-state and `solution/` reference-solution
|
|
4
|
+
* directories. Discovery is dumb on
|
|
4
5
|
* purpose — one visible expansion (a `frameworks:` frontmatter list yields
|
|
5
6
|
* one eval per framework, id `<dirname>-<framework>`), no placeholders, no
|
|
6
7
|
* binding — and loud: the first error aborts the scan.
|
|
@@ -53,8 +54,18 @@ export function discoverEvals(root: string): LoadedEval[] {
|
|
|
53
54
|
if (frameworks === undefined) {
|
|
54
55
|
const localDir = join(dir, "local");
|
|
55
56
|
const hasLocal = existsSync(localDir) && statSync(localDir).isDirectory();
|
|
57
|
+
const solutionDir = join(dir, "solution");
|
|
58
|
+
const hasSolution = existsSync(solutionDir) && statSync(solutionDir).isDirectory();
|
|
56
59
|
collected.push({
|
|
57
|
-
loaded: {
|
|
60
|
+
loaded: {
|
|
61
|
+
metadata,
|
|
62
|
+
dir,
|
|
63
|
+
promptPath,
|
|
64
|
+
promptBody,
|
|
65
|
+
scorerPath,
|
|
66
|
+
localDir: hasLocal ? localDir : null,
|
|
67
|
+
solutionDir: hasSolution ? solutionDir : null,
|
|
68
|
+
},
|
|
58
69
|
});
|
|
59
70
|
continue;
|
|
60
71
|
}
|
|
@@ -85,6 +96,11 @@ export function discoverEvals(root: string): LoadedEval[] {
|
|
|
85
96
|
);
|
|
86
97
|
}
|
|
87
98
|
}
|
|
99
|
+
// Reference solutions always live under the eval's own solution/ (no
|
|
100
|
+
// shared-path form). When solution/ exists it must cover every listed
|
|
101
|
+
// framework; when absent every variant has no solution.
|
|
102
|
+
const solutionBase = join(dir, "solution");
|
|
103
|
+
const hasSolutionBase = existsSync(solutionBase) && statSync(solutionBase).isDirectory();
|
|
88
104
|
// No starting state at all (no local/ and no `local:`) is valid — every
|
|
89
105
|
// variant starts from an empty workspace, like a tools-only eval.
|
|
90
106
|
for (const framework of frameworks) {
|
|
@@ -101,6 +117,19 @@ export function discoverEvals(root: string): LoadedEval[] {
|
|
|
101
117
|
}
|
|
102
118
|
localDir = frameworkDir;
|
|
103
119
|
}
|
|
120
|
+
let solutionDir: string | null = null;
|
|
121
|
+
if (hasSolutionBase) {
|
|
122
|
+
const frameworkSolution = join(solutionBase, framework);
|
|
123
|
+
if (!existsSync(frameworkSolution) || !statSync(frameworkSolution).isDirectory()) {
|
|
124
|
+
throw new EvalLoadError(
|
|
125
|
+
promptPath,
|
|
126
|
+
1,
|
|
127
|
+
`eval "${metadata.id}" has solution/ but no solution for framework "${framework}" ` +
|
|
128
|
+
`(expected ${frameworkSolution})`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
solutionDir = frameworkSolution;
|
|
132
|
+
}
|
|
104
133
|
collected.push({
|
|
105
134
|
loaded: {
|
|
106
135
|
metadata: { ...metadata, id: `${metadata.id}-${framework}`, framework },
|
|
@@ -109,6 +138,7 @@ export function discoverEvals(root: string): LoadedEval[] {
|
|
|
109
138
|
promptBody,
|
|
110
139
|
scorerPath,
|
|
111
140
|
localDir,
|
|
141
|
+
solutionDir,
|
|
112
142
|
},
|
|
113
143
|
framework,
|
|
114
144
|
});
|
package/src/evals/result.ts
CHANGED
|
@@ -3,7 +3,7 @@ export function validEvalResult(value: unknown): value is EvalResult {
|
|
|
3
3
|
if (!value || typeof value !== "object") return false;
|
|
4
4
|
const result = value as EvalResult;
|
|
5
5
|
if (typeof result.passed !== "boolean" || !Array.isArray(result.checks)) return false;
|
|
6
|
-
if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean")) return false;
|
|
6
|
+
if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean"))) return false;
|
|
7
7
|
if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length) return false;
|
|
8
8
|
return result.judgments === undefined || (Array.isArray(result.judgments) && result.judgments.every((j) => j && typeof j === "object" && j.provenance === "semantic-judge" &&
|
|
9
9
|
typeof j.provider === "string" && typeof j.model === "string" && [j.tokensIn, j.tokensOut, j.costUsd].every((v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0))));
|
package/src/evals/types.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface LoadedEval {
|
|
|
32
32
|
scorerPath: string;
|
|
33
33
|
/** Absolute path of the optional `local/` starting-state directory, or null. */
|
|
34
34
|
localDir: string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Absolute path of the optional `solution/` reference solution (or
|
|
37
|
+
* `solution/<framework>/` for a framework-expanded eval), or null. Overlaid
|
|
38
|
+
* on the starting state only for adapters with `startingState: "solution"`
|
|
39
|
+
* (the built-in oracle) and by `validate --oracle`; agents never see it.
|
|
40
|
+
*/
|
|
41
|
+
solutionDir: string | null;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
/** A pointer at evidence backing a check verdict (workspace-relative path). */
|
|
@@ -44,6 +51,11 @@ export interface EvidenceReference {
|
|
|
44
51
|
export interface CheckResult {
|
|
45
52
|
name: string;
|
|
46
53
|
passed: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* false = recorded but not graded: the report tallies it and derives SDK
|
|
56
|
+
* recall from it, and the scorer must leave it out of `passed`. Absent = true.
|
|
57
|
+
*/
|
|
58
|
+
graded?: boolean;
|
|
47
59
|
notes?: string;
|
|
48
60
|
evidence?: EvidenceReference[];
|
|
49
61
|
}
|
package/src/export-types.ts
CHANGED
|
@@ -89,6 +89,13 @@ export interface DataAttempt {
|
|
|
89
89
|
finished_at: string | null;
|
|
90
90
|
/** Present when status = "error" (and for recorded teardown notes). */
|
|
91
91
|
error: string | null;
|
|
92
|
+
/** "timeout" when the agent hit its wall-clock limit and the tree it left was graded; null otherwise. */
|
|
93
|
+
limit: "timeout" | null;
|
|
94
|
+
/** Which stage failed when status = "error"; null for completed/incomplete rows and for rows written before this field existed. */
|
|
95
|
+
error_kind: "environment" | "agent" | "scorer" | "harness" | null;
|
|
96
|
+
/** Agent phase only (excludes provisioning, container start, export, scoring); null = not recorded. */
|
|
97
|
+
agent_started_at: string | null;
|
|
98
|
+
agent_finished_at: string | null;
|
|
92
99
|
/** Relative to the run's data dir (e.g. `attempts/<id>.transcript.txt`); null = artifact missing. */
|
|
93
100
|
transcript: string | null;
|
|
94
101
|
diff: string | null;
|
package/src/export.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from "./export-types.ts";
|
|
34
34
|
import { trialCoverage } from "./completeness.ts";
|
|
35
35
|
import { hashString } from "./hash.ts";
|
|
36
|
-
import { buildReport, renderReportText, type ReportJson } from "./report/report.ts";
|
|
36
|
+
import { buildReport, REPORT_SCHEMA_VERSION, renderReportText, type ReportJson } from "./report/report.ts";
|
|
37
37
|
import { parsePricing, type Pricing } from "./report/costs.ts";
|
|
38
38
|
import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord } from "./store/db.ts";
|
|
39
39
|
|
|
@@ -162,6 +162,10 @@ function toDataAttempt(
|
|
|
162
162
|
started_at: iso(attempt.startedAt),
|
|
163
163
|
finished_at: iso(attempt.finishedAt),
|
|
164
164
|
error: attempt.error,
|
|
165
|
+
limit: attempt.limitHit ?? null,
|
|
166
|
+
error_kind: attempt.errorKind ?? null,
|
|
167
|
+
agent_started_at: iso(attempt.agentStartedAt ?? null),
|
|
168
|
+
agent_finished_at: iso(attempt.agentFinishedAt ?? null),
|
|
165
169
|
transcript,
|
|
166
170
|
diff,
|
|
167
171
|
};
|
|
@@ -184,11 +188,18 @@ function resolveReport(
|
|
|
184
188
|
const path = join(resultsDir, runId, "report.json");
|
|
185
189
|
if (existsSync(path)) {
|
|
186
190
|
try {
|
|
187
|
-
// Export is an artifact copier, not report validation.
|
|
188
|
-
//
|
|
189
|
-
//
|
|
191
|
+
// Export is an artifact copier, not report validation. A current-schema
|
|
192
|
+
// report is preserved byte-for-byte in meaning; any other schema is
|
|
193
|
+
// rebuilt from the store so the bundled UI reads the shape it knows.
|
|
190
194
|
const stored = JSON.parse(readFileSync(path, "utf8")) as ReportJson;
|
|
191
195
|
const run = store.getRun(runId)!;
|
|
196
|
+
// A report written under another schema is rebuilt so every schema bump
|
|
197
|
+
// reaches the exported site; a stored pricing snapshot is reused.
|
|
198
|
+
if (stored.schema_version !== REPORT_SCHEMA_VERSION) {
|
|
199
|
+
log(`stored report.json for ${runId} is schema ${stored.schema_version ?? "unversioned"} — rebuilding at ${REPORT_SCHEMA_VERSION}`);
|
|
200
|
+
return buildReport({ store, runId, resultsDir, generatedAt: generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)!,
|
|
201
|
+
...(stored.cost_estimate ? { pricing: parsePricing(stored.cost_estimate.pricing) } : {}) });
|
|
202
|
+
}
|
|
192
203
|
// Historical reports retain their methodology. New reports are reused
|
|
193
204
|
// only when the entire source record still matches (including retries).
|
|
194
205
|
if (run.manifest.manifestVersion === "v3" && stored.cost_estimate === undefined) return stored;
|
package/src/manifest.ts
CHANGED
|
@@ -57,6 +57,11 @@ export interface EvalIdentity {
|
|
|
57
57
|
metadata: EvalMetadata;
|
|
58
58
|
components: EvalIdentityComponents;
|
|
59
59
|
hash: string;
|
|
60
|
+
/**
|
|
61
|
+
* Tree hash of `solution/` (the reference solution) when present; null
|
|
62
|
+
* otherwise. Recorded, never hashed into `hash` — agents never see it.
|
|
63
|
+
*/
|
|
64
|
+
solution: string | null;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/** Per-experiment identity components — phase 2's comparison-group diff reads these. */
|
|
@@ -167,6 +172,7 @@ export function buildRunManifest(options: BuildRunManifestOptions): RunManifest
|
|
|
167
172
|
metadata: loaded.metadata,
|
|
168
173
|
components,
|
|
169
174
|
hash: contentHash(components),
|
|
175
|
+
solution: loaded.solutionDir === null ? null : hashTree(loaded.solutionDir).sha256,
|
|
170
176
|
};
|
|
171
177
|
}
|
|
172
178
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-experiment leaderboard aggregate — one row per experiment, derived from
|
|
3
|
+
* the same attempts and (banded) pairs as the rest of the report.
|
|
4
|
+
*
|
|
5
|
+
* Every number is restricted to benchmark-suite attempts, so regression and
|
|
6
|
+
* other suites never move the headline row. Telemetry follows the report's
|
|
7
|
+
* rule: sums and means cover only attempts that reported a value, and are
|
|
8
|
+
* null (never 0) when none did.
|
|
9
|
+
*/
|
|
10
|
+
import type { AttemptRecord } from "../store/db.ts";
|
|
11
|
+
import type { RunCostEstimate } from "./costs.ts";
|
|
12
|
+
import type { ReportExperiment, ReportPair } from "./report.ts";
|
|
13
|
+
|
|
14
|
+
export interface ExperimentAggregate {
|
|
15
|
+
/** Benchmark-suite attempts of this experiment (every status). */
|
|
16
|
+
attempts: number;
|
|
17
|
+
completed: number;
|
|
18
|
+
passed: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
incomplete: number;
|
|
21
|
+
errors: number;
|
|
22
|
+
/** Attempts whose agent phase hit the timeout limit (graded timeouts are also completed). */
|
|
23
|
+
timeouts: number;
|
|
24
|
+
/** passed / (passed + failed) over benchmark-suite pairs; null when nothing scored. */
|
|
25
|
+
pass_rate_all: number | null;
|
|
26
|
+
/** Same, restricted to evals whose band is "discriminating". */
|
|
27
|
+
pass_rate_discriminating: number | null;
|
|
28
|
+
/** Discriminating benchmark evals where this experiment scored at least one attempt. */
|
|
29
|
+
discriminating_evals: number;
|
|
30
|
+
/** Numerator and denominator of pass_rate_discriminating. */
|
|
31
|
+
discriminating_passed: number;
|
|
32
|
+
discriminating_scored: number;
|
|
33
|
+
/** Median of (agent_finished_at - agent_started_at) over scored attempts with both stamps; null when none. */
|
|
34
|
+
median_agent_ms: number | null;
|
|
35
|
+
agent_time_attempts: number;
|
|
36
|
+
/** Each mean divides by the attempts that reported that value. */
|
|
37
|
+
mean_tokens_in: number | null;
|
|
38
|
+
mean_tokens_out: number | null;
|
|
39
|
+
token_attempts: number;
|
|
40
|
+
/** Provider-reported, summed over the attempts that reported it. */
|
|
41
|
+
cost_usd: number | null;
|
|
42
|
+
cost_attempts: number;
|
|
43
|
+
/** Present only when the report was built with --pricing. */
|
|
44
|
+
estimated_cost_usd?: number | null;
|
|
45
|
+
estimated_attempts?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sum = (values: number[]): number => values.reduce((total, v) => total + v, 0);
|
|
49
|
+
const mean = (values: number[]): number | null => (values.length > 0 ? sum(values) / values.length : null);
|
|
50
|
+
|
|
51
|
+
/** Median; an even count takes the mean of the two middle values. null when empty. */
|
|
52
|
+
export function median(values: number[]): number | null {
|
|
53
|
+
if (values.length === 0) return null;
|
|
54
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
55
|
+
const mid = Math.floor(sorted.length / 2);
|
|
56
|
+
return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Aggregate one experiment. `pairs` must already carry their bands (the way
|
|
61
|
+
* `buildReport` assigns them); `costEstimate` is the report's --pricing layer,
|
|
62
|
+
* whose per-attempt entries are keyed by attempt id only.
|
|
63
|
+
*/
|
|
64
|
+
export function collectExperimentAggregate(
|
|
65
|
+
attempts: AttemptRecord[],
|
|
66
|
+
pairs: ReportPair[],
|
|
67
|
+
experimentId: string,
|
|
68
|
+
costEstimate?: RunCostEstimate,
|
|
69
|
+
): ExperimentAggregate {
|
|
70
|
+
const mine = attempts.filter((a) => a.experimentId === experimentId && a.suite === "benchmark");
|
|
71
|
+
const completed = mine.filter((a) => a.status === "completed");
|
|
72
|
+
const passed = completed.filter((a) => a.result?.passed).length;
|
|
73
|
+
|
|
74
|
+
const benchmarkPairs = pairs.filter((p) => p.experiment_id === experimentId && p.suite === "benchmark");
|
|
75
|
+
const scoredAll = sum(benchmarkPairs.map((p) => p.scored));
|
|
76
|
+
const passedAll = sum(benchmarkPairs.map((p) => p.passed));
|
|
77
|
+
const discriminating = benchmarkPairs.filter((p) => p.band === "discriminating" && p.scored > 0);
|
|
78
|
+
const discScored = sum(discriminating.map((p) => p.scored));
|
|
79
|
+
const discPassed = sum(discriminating.map((p) => p.passed));
|
|
80
|
+
|
|
81
|
+
const durations = completed.flatMap((a) =>
|
|
82
|
+
typeof a.agentStartedAt === "number" && typeof a.agentFinishedAt === "number" ? [a.agentFinishedAt - a.agentStartedAt] : [],
|
|
83
|
+
);
|
|
84
|
+
const tokensIn = mine.map((a) => a.tokensIn).filter((v): v is number => v !== null && v !== undefined);
|
|
85
|
+
const tokensOut = mine.map((a) => a.tokensOut).filter((v): v is number => v !== null && v !== undefined);
|
|
86
|
+
const costs = mine.map((a) => a.costUsd).filter((v): v is number => v !== null && v !== undefined);
|
|
87
|
+
|
|
88
|
+
let estimated: Pick<ExperimentAggregate, "estimated_cost_usd" | "estimated_attempts"> = {};
|
|
89
|
+
if (costEstimate !== undefined) {
|
|
90
|
+
const ids = new Set(mine.map((a) => a.id));
|
|
91
|
+
const values = costEstimate.attempts
|
|
92
|
+
.filter((e) => ids.has(e.attempt_id))
|
|
93
|
+
.flatMap((e) => (e.cost_usd === null ? [] : [e.cost_usd]));
|
|
94
|
+
estimated = { estimated_cost_usd: values.length > 0 ? sum(values) : null, estimated_attempts: values.length };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
attempts: mine.length,
|
|
99
|
+
completed: completed.length,
|
|
100
|
+
passed,
|
|
101
|
+
failed: completed.length - passed,
|
|
102
|
+
incomplete: mine.filter((a) => a.status === "incomplete").length,
|
|
103
|
+
errors: mine.filter((a) => a.status === "error").length,
|
|
104
|
+
timeouts: mine.filter((a) => a.status === "completed" && a.limitHit === "timeout").length,
|
|
105
|
+
pass_rate_all: scoredAll > 0 ? passedAll / scoredAll : null,
|
|
106
|
+
pass_rate_discriminating: discScored > 0 ? discPassed / discScored : null,
|
|
107
|
+
discriminating_evals: new Set(discriminating.map((p) => p.eval_id)).size,
|
|
108
|
+
discriminating_passed: discPassed,
|
|
109
|
+
discriminating_scored: discScored,
|
|
110
|
+
median_agent_ms: median(durations),
|
|
111
|
+
agent_time_attempts: durations.length,
|
|
112
|
+
mean_tokens_in: mean(tokensIn),
|
|
113
|
+
mean_tokens_out: mean(tokensOut),
|
|
114
|
+
token_attempts: Math.max(tokensIn.length, tokensOut.length),
|
|
115
|
+
cost_usd: costs.length > 0 ? sum(costs) : null,
|
|
116
|
+
cost_attempts: costs.length,
|
|
117
|
+
...estimated,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Leaderboard order: agent arms before controls (the oracle would otherwise
|
|
123
|
+
* lead every board), then discriminating pass rate desc (nulls last), then the
|
|
124
|
+
* all-eval pass rate desc (nulls last), then experiment id — a total order,
|
|
125
|
+
* so every consumer renders the same rows in the same sequence.
|
|
126
|
+
*/
|
|
127
|
+
export function compareLeaderboard(a: ReportExperiment, b: ReportExperiment): number {
|
|
128
|
+
const rate = (value: number | null | undefined): number => (value === null || value === undefined ? -1 : value);
|
|
129
|
+
const control = (experiment: ReportExperiment): number => ((experiment.role ?? "agent") === "agent" ? 0 : 1);
|
|
130
|
+
return (
|
|
131
|
+
control(a) - control(b) ||
|
|
132
|
+
rate(b.aggregate?.pass_rate_discriminating) - rate(a.aggregate?.pass_rate_discriminating) ||
|
|
133
|
+
rate(b.aggregate?.pass_rate_all) - rate(a.aggregate?.pass_rate_all) ||
|
|
134
|
+
a.experiment_id.localeCompare(b.experiment_id)
|
|
135
|
+
);
|
|
136
|
+
}
|