@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.
@@ -21,7 +21,7 @@
21
21
  * and never allowed to mask the attempt's result.
22
22
  */
23
23
 
24
- import { cp, mkdtemp, rm } from "node:fs/promises";
24
+ import { mkdtemp, rm } from "node:fs/promises";
25
25
  import { tmpdir } from "node:os";
26
26
  import { join } from "node:path";
27
27
  import type {
@@ -33,10 +33,12 @@ import type {
33
33
  import { ClaudeCodeAdapter } from "../adapters/claude.ts";
34
34
  import { CodexAdapter } from "../adapters/codex.ts";
35
35
  import { EchoAdapter } from "../adapters/echo.ts";
36
+ import { OracleAdapter, OracleContainerAdapter } from "../adapters/oracle.ts";
36
37
  import { validEvalResult } from "../evals/result.ts";
37
38
  import { importEvalScorer } from "../evals/discovery.ts";
38
39
  import { deadline, StageTimeoutError } from "./deadline.ts";
39
40
  import { scoreInWorker } from "./score-worker.ts";
41
+ import { prepWorkspaceSource } from "./workspace-source.ts";
40
42
  import type { EvalResult, LoadedEval } from "../evals/types.ts";
41
43
  import type { AgentSpec, Experiment, LoadedExperiment, Runtime } from "../experiments/types.ts";
42
44
  import { canonicalJson } from "../identity-diff.ts";
@@ -70,7 +72,7 @@ import type { AttemptPlan } from "../plan.ts";
70
72
  import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
71
73
  import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
72
74
  import { ArtifactsStore } from "../store/artifacts.ts";
73
- import { activeAttempts, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
75
+ import { activeAttempts, type ErrorKind, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
74
76
  import { acquireRunLock } from "./run-lock.ts";
75
77
  import { orderedPlan, schedule } from "./scheduler.ts";
76
78
  import { compareGroups } from "../experiments/groups.ts";
@@ -136,17 +138,46 @@ interface AttemptDisposition {
136
138
  status: "completed" | "incomplete" | "error";
137
139
  result?: EvalResult;
138
140
  error?: string;
141
+ /** Which stage failed; set on every `status: "error"` disposition. */
142
+ errorKind?: ErrorKind;
143
+ }
144
+
145
+ /**
146
+ * An error tagged with the attempt stage it was thrown from. Stages wrap
147
+ * their own boundary (`inStage`) so helpers stay unaware of the taxonomy;
148
+ * the message is the cause's, so recorded errors read exactly as before.
149
+ */
150
+ export class StageFailure extends Error {
151
+ constructor(readonly kind: ErrorKind, cause: unknown) {
152
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
153
+ this.name = "StageFailure";
154
+ }
155
+ }
156
+
157
+ /** The kind an error reaching a catch-all carries: its stage, else `harness`. */
158
+ export function errorKindOf(err: unknown): ErrorKind {
159
+ return err instanceof StageFailure ? err.kind : "harness";
160
+ }
161
+
162
+ async function inStage<T>(kind: ErrorKind, work: () => Promise<T>): Promise<T> {
163
+ try {
164
+ return await work();
165
+ } catch (err) {
166
+ throw err instanceof StageFailure ? err : new StageFailure(kind, err);
167
+ }
139
168
  }
140
169
 
141
170
  function defaultHostAdapters(): Record<string, AgentAdapter> {
142
171
  const echo = new EchoAdapter();
143
- return { [echo.name]: echo };
172
+ const oracle = new OracleAdapter();
173
+ return { [echo.name]: echo, [oracle.name]: oracle };
144
174
  }
145
175
 
146
176
  function defaultContainerAdapters(): Record<string, (agent: AgentSpec) => ContainerAgentAdapter> {
147
177
  return {
148
178
  claude: (agent) => new ClaudeCodeAdapter("claude", { model: agent.model, reasoning: agent.reasoning }),
149
179
  codex: (agent) => new CodexAdapter("codex", { model: agent.model, reasoning: agent.reasoning }),
180
+ oracle: () => new OracleContainerAdapter(),
150
181
  };
151
182
  }
152
183
 
@@ -310,6 +341,12 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
310
341
  );
311
342
  }
312
343
  if (experiment.runtime.kind === "container") {
344
+ if ((experiment.runtime.command ?? []).length > 0 && experiment.agent.adapter === "oracle") {
345
+ throw new Error(
346
+ `experiment "${experiment.id}" selects the oracle adapter but sets runtime.command, which bypasses it — ` +
347
+ `drop runtime.command so the reference solution is what gets scored`,
348
+ );
349
+ }
313
350
  if ((experiment.runtime.command ?? []).length === 0 && !containerAgents.has(experiment.id)) {
314
351
  const adapter = resolveContainerAdapter(experiment, containerAdapterFactories);
315
352
  // Provider keys are validated here — before any run row, sidecar, or
@@ -334,6 +371,26 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
334
371
  }
335
372
  }
336
373
 
374
+ // Reference-solution preflight: an adapter that starts from the solution
375
+ // overlay (the oracle) cannot run an eval without solution/. Every
376
+ // offending pair is named at once, before any run row exists.
377
+ const missingSolutions: string[] = [];
378
+ for (const entry of plan) {
379
+ const experiment = experimentsById.get(entry.experimentId) as Experiment;
380
+ const startingState =
381
+ experiment.runtime.kind === "host"
382
+ ? hostAdapters[experiment.agent.adapter]?.startingState
383
+ : containerAgents.get(experiment.id)?.startingState;
384
+ if (startingState === "solution" && (evalsById.get(entry.evalId) as LoadedEval).solutionDir === null) {
385
+ missingSolutions.push(`${entry.experimentId} × ${entry.evalId}`);
386
+ }
387
+ }
388
+ if (missingSolutions.length > 0) {
389
+ throw new Error(
390
+ `the oracle adapter needs solution/ — add it or drop the eval from the run: ${[...new Set(missingSolutions)].join(", ")}`,
391
+ );
392
+ }
393
+
337
394
  // Live key preflight: a key that is present but rejected by the provider
338
395
  // would burn a container per attempt just to 401 inside it. Probe each
339
396
  // unique agent's endpoint once — before any docker, sidecar, or run-row
@@ -449,6 +506,18 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
449
506
  // ON THE RUN: the manifest holds runtime config only as an opaque hash,
450
507
  // so the report could never recompute this after the fact.
451
508
  groups: compareGroups(options.experiments.map((entry) => entry.experiment), manifest),
509
+ // Roles come from the resolved adapter, never its name: a scripted
510
+ // container command is an agent whatever `agent.adapter` says.
511
+ roles: Object.fromEntries(
512
+ [...experimentsById.values()].map((experiment) => [
513
+ experiment.id,
514
+ experiment.runtime.kind === "host"
515
+ ? (hostAdapters[experiment.agent.adapter]?.role ?? "agent")
516
+ : (experiment.runtime.command ?? []).length > 0
517
+ ? "agent"
518
+ : (containerAgents.get(experiment.id)?.role ?? "agent"),
519
+ ]),
520
+ ),
452
521
  ...(offeredSurfaces !== undefined ? { surfaces: offeredSurfaces } : {}),
453
522
  };
454
523
 
@@ -507,10 +576,14 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
507
576
  // An attempt failure never aborts the run: persist the error and move on.
508
577
  const message = err instanceof Error ? err.message : String(err);
509
578
  await artifacts.writeError(runId, attemptId, message);
510
- disposition = { status: "error", error: message };
579
+ disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
511
580
  }
512
581
 
513
582
  persisted.status = disposition.status;
583
+ persisted.errorKind = disposition.status === "error" ? (disposition.errorKind ?? "harness") : null;
584
+ // A limit hit means the workspace it left was graded; an unscored
585
+ // attempt (incomplete, or a later stage errored) carries none.
586
+ if (disposition.status !== "completed") persisted.limitHit = null;
514
587
  if (disposition.judgeRecords) persisted.judgeRecords = disposition.judgeRecords;
515
588
  if (disposition.result !== undefined) persisted.result = disposition.result;
516
589
  if (disposition.error !== undefined) persisted.error = disposition.error;
@@ -556,15 +629,6 @@ async function mustExec(
556
629
  return result;
557
630
  }
558
631
 
559
- /** Prepare the attempt's starting state: a copy of `local/`, or empty (tools-only evals). */
560
- async function prepWorkspaceSource(loadedEval: LoadedEval): Promise<string> {
561
- const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
562
- if (loadedEval.localDir !== null) {
563
- await cp(loadedEval.localDir, sourceDir, { recursive: true });
564
- }
565
- return sourceDir;
566
- }
567
-
568
632
  interface AttemptArgs {
569
633
  runId: string;
570
634
  attemptId: string;
@@ -585,10 +649,11 @@ async function runHostAttemptV3(
585
649
  const { runId, attemptId, persisted } = args;
586
650
  const adapter = hostAdapters[experiment.agent.adapter] as AgentAdapter;
587
651
 
588
- const sourceDir = await prepWorkspaceSource(loadedEval);
589
- const scratchDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-"));
652
+ const sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, adapter.startingState ?? "fixture"));
653
+ const scratchDir = await inStage("environment", () => mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-")));
590
654
  try {
591
- let outcome: AttemptOutcome;
655
+ let outcome: AttemptOutcome | undefined;
656
+ persisted.agentStartedAt = Date.now();
592
657
  try {
593
658
  outcome = await deadline("agent", args.attemptTimeoutMs, (signal) => adapter.runAttempt({
594
659
  signal,
@@ -600,9 +665,19 @@ async function runHostAttemptV3(
600
665
  trialIndex: entry.trialIndex,
601
666
  }));
602
667
  } catch (err) {
603
- if (!(err instanceof StageTimeoutError)) throw err;
604
- await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms`);
605
- return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
668
+ if (!(err instanceof StageTimeoutError)) throw new StageFailure("agent", err);
669
+ // Graded timeout: the adapter never returned (no transcript, diff, or
670
+ // metrics), but the scratch workspace it was writing into exists — the
671
+ // tree it left behind is exported and scored like any other.
672
+ await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms; workspace graded as left`);
673
+ persisted.limitHit = "timeout";
674
+ } finally {
675
+ persisted.agentFinishedAt = Date.now();
676
+ }
677
+
678
+ if (outcome === undefined) {
679
+ const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
680
+ return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir });
606
681
  }
607
682
 
608
683
  persisted.transcriptRef = await artifacts.writeTranscript(runId, attemptId, outcome.transcript);
@@ -614,7 +689,7 @@ async function runHostAttemptV3(
614
689
 
615
690
  // Export FIRST, then score against the export (no live sandbox exists on
616
691
  // the host path — exec runs in the exported workspace directory).
617
- const workspaceDir = await artifacts.exportWorkspace(runId, attemptId, scratchDir);
692
+ const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
618
693
  return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir, agentOutput: { finalReport: outcome.transcript.filter((e) => e.role === "assistant").at(-1)?.content ?? null, transcript: JSON.stringify(outcome.transcript).slice(0, 131072), truncated: JSON.stringify(outcome.transcript).length > 131072 } });
619
694
  } finally {
620
695
  await rm(scratchDir, { recursive: true, force: true });
@@ -682,7 +757,7 @@ async function runContainerAttemptV3(
682
757
  const teardownFailure = await attemptRuntimeTeardown(runtime, undefined, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
683
758
  const error = teardownFailure !== undefined ? `${message}; ${teardownFailure}` : message;
684
759
  await artifacts.writeError(runId, attemptId, error);
685
- return { status: "error", error };
760
+ return { status: "error", error, errorKind: "environment" };
686
761
  }
687
762
  }
688
763
 
@@ -691,19 +766,22 @@ async function runContainerAttemptV3(
691
766
  let disposition: AttemptDisposition;
692
767
  try {
693
768
  disposition = await (async (): Promise<AttemptDisposition> => {
694
- sourceDir = await prepWorkspaceSource(loadedEval);
769
+ sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, agentAdapter?.startingState ?? "fixture"));
695
770
  const resolvedImage = environment?.image;
696
771
  const image = resolvedImage && resolvedImage.id !== "unresolved" ? resolvedImage.id : resolvedImage?.tag ?? runtime.image ?? (runtime.containerImage?.(loadedEval.metadata) as string);
697
772
  if (environment?.egress.proxy) {
698
- const networkName = await createRunNetwork(`${runId}-${attemptId}`);
699
- try {
700
- const sidecar = await startEgressSidecar({ runId, networkName, allowlist: environment.egress.allowlist, image: EGRESS_PROXY_IMAGE });
701
- egress = { networkName, sidecar };
702
- } catch (err) { await removeRunNetwork(networkName); throw err; }
703
- if (treatments.mcp?.sidecar) {
704
- mcpNetwork = networkName;
705
- mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
706
- }
773
+ const egressPolicy = environment.egress;
774
+ await inStage("environment", async () => {
775
+ const networkName = await createRunNetwork(`${runId}-${attemptId}`);
776
+ try {
777
+ const sidecar = await startEgressSidecar({ runId, networkName, allowlist: egressPolicy.allowlist, image: EGRESS_PROXY_IMAGE });
778
+ egress = { networkName, sidecar };
779
+ } catch (err) { await removeRunNetwork(networkName); throw err; }
780
+ if (treatments.mcp?.sidecar) {
781
+ mcpNetwork = networkName;
782
+ mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
783
+ }
784
+ });
707
785
  }
708
786
 
709
787
  // --- treatment env: everything rides the 0600 env-file, never argv ---
@@ -748,17 +826,18 @@ async function runContainerAttemptV3(
748
826
  Object.assign(env, proxyEnv);
749
827
  }
750
828
 
751
- const paths = await artifacts.prepareAttempt(runId, attemptId);
752
- const container = await createAttemptContainer({
829
+ const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
830
+ const fixtureDir = sourceDir;
831
+ const container = await inStage("environment", () => createAttemptContainer({
753
832
  image,
754
833
  env,
755
834
  resultsDir: paths.attemptDir,
756
835
  attemptId,
757
- fixtureDir: sourceDir,
836
+ fixtureDir,
758
837
  // Under the egress treatment the internal network replaces the
759
838
  // default bridge: the proxy sidecar is the only path to the world.
760
839
  ...(egress ? { network: egress.networkName } : {}),
761
- });
840
+ }));
762
841
  try {
763
842
  let command: string[];
764
843
  let baselineSha: string | undefined;
@@ -768,15 +847,17 @@ async function runContainerAttemptV3(
768
847
  // final workspace against this exact commit. The SHA is recorded
769
848
  // (not just HEAD) so an agent that makes its own commits cannot
770
849
  // move the baseline out from under the diff.
771
- const gitAvailable = (await container.execInWorkspace(["git", "--version"])).exitCode === 0;
850
+ const gitAvailable = await inStage("environment", async () => (await container.execInWorkspace(["git", "--version"])).exitCode === 0);
772
851
  if (gitAvailable) {
773
- await mustExec(container, ["git", "init", "-q"]);
774
- await mustExec(container, ["git", "add", "-A"]);
775
- await mustExec(container, [
776
- "git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
777
- "commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
778
- ]);
779
- baselineSha = (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
852
+ baselineSha = await inStage("environment", async () => {
853
+ await mustExec(container, ["git", "init", "-q"]);
854
+ await mustExec(container, ["git", "add", "-A"]);
855
+ await mustExec(container, [
856
+ "git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
857
+ "commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
858
+ ]);
859
+ return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
860
+ });
780
861
  } else {
781
862
  (options.log ?? console.log)(
782
863
  `attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
@@ -794,15 +875,23 @@ async function runContainerAttemptV3(
794
875
  };
795
876
  // Treatment config files (e.g. the MCP treatment's vendor config).
796
877
  // The prompt itself is byte-identical across experiments.
797
- for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
798
- await container.writeFile(path, content);
799
- }
878
+ await inStage("environment", async () => {
879
+ for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
880
+ await container.writeFile(path, content);
881
+ }
882
+ });
800
883
  command = agentAdapter.command(promptCtx);
801
884
  } else {
802
885
  command = runtime.command as string[];
803
886
  }
804
887
 
805
- const agentResult = await container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs });
888
+ persisted.agentStartedAt = Date.now();
889
+ let agentResult: Awaited<ReturnType<typeof container.execInWorkspace>>;
890
+ try {
891
+ agentResult = await inStage("agent", () => container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs }));
892
+ } finally {
893
+ persisted.agentFinishedAt = Date.now();
894
+ }
806
895
  // The raw stream is the transcript artifact (redacted) — persisted
807
896
  // even when the agent timed out or failed, so there is always evidence.
808
897
  const redactedStream = redactSecrets(agentResult.stdout, secretEnv);
@@ -845,24 +934,34 @@ async function runContainerAttemptV3(
845
934
  }
846
935
  }
847
936
  if (agentResult.timedOut) {
848
- await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container killed`);
849
- return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
850
- }
851
- if (agentResult.exitCode !== 0) {
937
+ // Graded timeout: runDocker restarted the killed container, so the
938
+ // tree the agent left behind is exported and scored below.
939
+ // captureDiff already ran above.
940
+ await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container killed; workspace graded as left`);
941
+ persisted.limitHit = "timeout";
942
+ } else if (agentResult.exitCode !== 0) {
852
943
  const message = `agent command exited ${agentResult.exitCode}\n--- stderr ---\n${agentResult.stderr}`;
853
944
  await artifacts.writeError(runId, attemptId, redactSecrets(message, secretEnv));
854
- return { status: "error", error: `agent command exited ${agentResult.exitCode}` };
945
+ return { status: "error", error: `agent command exited ${agentResult.exitCode}`, errorKind: "agent" };
946
+ } else {
947
+ await inStage("environment", captureDiff);
855
948
  }
856
949
 
857
- await captureDiff();
858
-
859
950
  // Export the workspace BEFORE scoring, with the container still alive:
860
951
  // file checks read the export, ctx.exec reaches into the live sandbox.
861
- await deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
862
- const kill = () => { void container.teardown(); };
863
- signal.addEventListener("abort", kill, { once: true });
864
- try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
865
- });
952
+ try {
953
+ await inStage("environment", () => deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
954
+ const kill = () => { void container.teardown(); };
955
+ signal.addEventListener("abort", kill, { once: true });
956
+ try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
957
+ }));
958
+ } catch (err) {
959
+ if (persisted.limitHit !== "timeout") throw err;
960
+ // Nothing to score: `incomplete` keeps its meaning "no workspace".
961
+ const error = `attempt timed out after ${args.attemptTimeoutMs}ms; workspace could not be exported`;
962
+ await artifacts.writeError(runId, attemptId, `${error}: ${err instanceof Error ? err.message : String(err)}`);
963
+ return { status: "incomplete", error };
964
+ }
866
965
  return await scoreAttempt(options, loadedEval, experiment, entry, {
867
966
  runId,
868
967
  attemptId,
@@ -899,7 +998,7 @@ async function runContainerAttemptV3(
899
998
  } catch (err) {
900
999
  const message = err instanceof Error ? err.message : String(err);
901
1000
  await artifacts.writeError(runId, attemptId, message);
902
- disposition = { status: "error", error: message };
1001
+ disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
903
1002
  } finally {
904
1003
  if (mcpNetwork && treatments.mcp?.sidecar) {
905
1004
  try { await disconnectContainerNetwork(treatments.mcp.sidecar.id, mcpNetwork); } catch { containerTeardownFailure = "MCP network disconnect failed"; }
@@ -983,7 +1082,7 @@ async function scoreAttempt(
983
1082
  await options.artifacts.writeError(args.runId, args.attemptId, message);
984
1083
  const judgments = (err as { judgments?: import("../evals/types.ts").JudgeRecord[] })?.judgments;
985
1084
  const judgeRecords = judgments && validEvalResult({passed:false,checks:[],judgments}) ? judgments : undefined;
986
- return { status: "error", error: message, ...(judgeRecords ? {judgeRecords} : {}) };
1085
+ return { status: "error", error: message, errorKind: "scorer", ...(judgeRecords ? {judgeRecords} : {}) };
987
1086
  }
988
1087
  }
989
1088
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `validate --oracle`: score each eval's reference solution (`solution/`
3
+ * overlaid on `local/`) on the host, with no agent and no runtime.
4
+ *
5
+ * `exec`, `query` and `getClient` are runtime-only here: each records that it
6
+ * was called and throws {@link RuntimeCapabilityError}. Tracking is by
7
+ * invocation on the parent side of the scorer worker (the worker RPCs every
8
+ * capability call back to this context), so a scorer that swallows the error
9
+ * is still reported as deferred. Evals that defer are proven by running an
10
+ * oracle experiment under the real runtime, which costs no model spend.
11
+ */
12
+
13
+ import { rm } from "node:fs/promises";
14
+ import { DEFAULT_BUDGETS } from "../execution-policy.ts";
15
+ import { validEvalResult } from "../evals/result.ts";
16
+ import type { EvalExecResult, LoadedEval } from "../evals/types.ts";
17
+ import { buildEvalContext, RuntimeCapabilityError } from "./context.ts";
18
+ import { deadline, StageTimeoutError } from "./deadline.ts";
19
+ import { scoreInWorker } from "./score-worker.ts";
20
+ import { prepWorkspaceSource } from "./workspace-source.ts";
21
+
22
+ export type OracleOutcome =
23
+ | { kind: "pass"; ungradedFailures: string[] }
24
+ | { kind: "deferred"; capabilities: string[] }
25
+ | { kind: "failed"; failingChecks: string[]; error?: string }
26
+ | { kind: "missing" };
27
+
28
+ export interface ValidateOracleOptions {
29
+ /** Scoring deadline; defaults to the runner's scoring budget. */
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ const EXPERIMENT_ID = "validate-oracle";
34
+
35
+ /** Score one eval's reference solution; see the module doc for the outcomes. */
36
+ export async function validateOracle(loaded: LoadedEval, opts: ValidateOracleOptions = {}): Promise<OracleOutcome> {
37
+ if (loaded.solutionDir === null) return { kind: "missing" };
38
+
39
+ const used = new Set<"exec" | "query" | "getClient">();
40
+ const runtimeOnly = (capability: "exec" | "query" | "getClient"): never => {
41
+ used.add(capability);
42
+ throw new RuntimeCapabilityError(
43
+ capability,
44
+ `${capability}() is runtime-only under validate --oracle — run the oracle experiment under the real runtime`,
45
+ );
46
+ };
47
+
48
+ const workspaceDir = await prepWorkspaceSource(loaded, "solution");
49
+ try {
50
+ let result;
51
+ try {
52
+ result = await deadline("scoring", opts.timeoutMs ?? DEFAULT_BUDGETS.scoreMs, (signal) =>
53
+ scoreInWorker(
54
+ loaded.scorerPath,
55
+ buildEvalContext({
56
+ signal,
57
+ evalId: loaded.metadata.id,
58
+ experimentId: EXPERIMENT_ID,
59
+ trialIndex: 0,
60
+ ...(loaded.metadata.framework !== undefined ? { framework: loaded.metadata.framework } : {}),
61
+ workspaceDir,
62
+ exec: async (): Promise<EvalExecResult> => runtimeOnly("exec"),
63
+ query: async () => runtimeOnly("query"),
64
+ getClient: () => runtimeOnly("getClient"),
65
+ }),
66
+ signal,
67
+ ),
68
+ );
69
+ if (!validEvalResult(result)) throw new Error("scorer returned an invalid EvalResult");
70
+ } catch (err) {
71
+ // A scorer that runs past its deadline failed, whatever it touched first.
72
+ if (used.size > 0 && !(err instanceof StageTimeoutError)) return { kind: "deferred", capabilities: [...used].sort() };
73
+ return { kind: "failed", failingChecks: [], error: err instanceof Error ? err.message : String(err) };
74
+ }
75
+
76
+ // A scorer that reached for a runtime-only capability has not been proven
77
+ // here, even when it swallowed the error and returned passing checks.
78
+ if (used.size > 0) return { kind: "deferred", capabilities: [...used].sort() };
79
+ const graded = result.checks.filter((check) => check.graded !== false);
80
+ const failingGraded = graded.filter((check) => !check.passed).map((check) => check.name);
81
+ if (result.passed && failingGraded.length === 0) {
82
+ const ungradedFailures = result.checks.filter((check) => check.graded === false && !check.passed).map((check) => check.name);
83
+ return { kind: "pass", ungradedFailures };
84
+ }
85
+ return failingGraded.length > 0
86
+ ? { kind: "failed", failingChecks: failingGraded }
87
+ : { kind: "failed", failingChecks: [], error: "scorer returned passed: false" };
88
+ } finally {
89
+ await rm(workspaceDir, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ /** One `validate --oracle` output line for an eval (without the leading bullet). */
94
+ export function formatOracleOutcome(evalId: string, outcome: OracleOutcome): string {
95
+ switch (outcome.kind) {
96
+ case "pass":
97
+ return outcome.ungradedFailures.length > 0
98
+ ? `${evalId}: 1.0 (ungraded checks failing: ${outcome.ungradedFailures.join(", ")})`
99
+ : `${evalId}: 1.0`;
100
+ case "deferred":
101
+ return `${evalId}: deferred (scorer needs ${outcome.capabilities.join(", ")} — run the oracle experiment under the real runtime)`;
102
+ case "failed": {
103
+ const detail = outcome.failingChecks.length > 0 ? outcome.failingChecks.join(", ") : outcome.error ?? "scorer failed";
104
+ return `${evalId}: FAILED (${detail})`;
105
+ }
106
+ case "missing":
107
+ return `${evalId}: MISSING (no solution/)`;
108
+ }
109
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Workspace-source preparation shared by both runner paths (host and
3
+ * container) and by `validate --oracle`: build the attempt's starting tree in
4
+ * a fresh temp directory. The host path copies it into the attempt scratch
5
+ * dir; the container path `docker cp`s it whole into /workspace.
6
+ */
7
+
8
+ import { cp, mkdtemp } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type { StartingState } from "../adapters/types.ts";
12
+ import type { LoadedEval } from "../evals/types.ts";
13
+
14
+ export type { StartingState } from "../adapters/types.ts";
15
+
16
+ /**
17
+ * Prepare the attempt's starting state: a copy of `local/`, or empty
18
+ * (tools-only evals). With `startingState: "solution"` the eval's
19
+ * `solution/` is then copied over it — solution files win; solutions are
20
+ * additive overlays and cannot delete fixture files.
21
+ */
22
+ export async function prepWorkspaceSource(loadedEval: LoadedEval, startingState: StartingState = "fixture"): Promise<string> {
23
+ const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
24
+ if (loadedEval.localDir !== null) {
25
+ await cp(loadedEval.localDir, sourceDir, { recursive: true });
26
+ }
27
+ if (startingState === "solution") {
28
+ if (loadedEval.solutionDir === null) {
29
+ throw new Error(`eval "${loadedEval.metadata.id}" has no solution/ — the oracle adapter needs one`);
30
+ }
31
+ await cp(loadedEval.solutionDir, sourceDir, { recursive: true, force: true });
32
+ }
33
+ return sourceDir;
34
+ }