@hue-run/sdk 0.4.2 → 0.5.1
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/CLI.md +195 -4
- package/ENVIRONMENTS.md +29 -8
- package/EVALUATIONS.md +126 -5
- package/README.md +27 -2
- package/dist/cli/eval-direct.d.ts +47 -0
- package/dist/cli/eval-direct.js +171 -0
- package/dist/cli/eval.d.ts +28 -0
- package/dist/cli/eval.js +985 -0
- package/dist/cli/login.d.ts +32 -0
- package/dist/cli/login.js +744 -0
- package/dist/cli/mcp.d.ts +76 -0
- package/dist/cli/mcp.js +466 -0
- package/dist/evals/client.d.ts +30 -1
- package/dist/evals/client.js +138 -1
- package/dist/evals/files.d.ts +50 -0
- package/dist/evals/files.js +223 -0
- package/dist/evals/local-worker.d.ts +49 -4
- package/dist/evals/local-worker.js +63 -12
- package/dist/evals/runner.d.ts +30 -10
- package/dist/evals/runner.js +216 -68
- package/dist/evals/scenarios.d.ts +79 -0
- package/dist/evals/scenarios.js +169 -0
- package/dist/evals/scorers.d.ts +13 -0
- package/dist/evals/scorers.js +21 -3
- package/dist/evals/simulation.d.ts +12 -1
- package/dist/evals/simulation.js +36 -0
- package/dist/evals/types.d.ts +174 -1
- package/dist/evals/types.js +17 -1
- package/dist/evals/verdicts.d.ts +161 -0
- package/dist/evals/verdicts.js +192 -0
- package/dist/evals.d.ts +9 -3
- package/dist/evals.js +5 -1
- package/dist/setup/cli.js +13 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/evals/runner.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { HueClient } from "../client.js";
|
|
2
2
|
import type { HueSpan } from "../types.js";
|
|
3
3
|
import { EvaluationClient } from "./client.js";
|
|
4
|
-
import type
|
|
4
|
+
import { TargetResult, type ExperimentCase, type JsonValue, type LocalFile, type LocalScorer } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Thrown when a case has a started attempt without a saved outcome. The runner never reruns the
|
|
7
7
|
* target; inspect the execution and authorize a new attempt explicitly through `startExecution`.
|
|
@@ -46,12 +46,36 @@ interface RunnerOptions {
|
|
|
46
46
|
persistResultContent: boolean;
|
|
47
47
|
/** Local callbacks bound to `local_code` scorer pins by digest. */
|
|
48
48
|
scorers?: LocalScorer[];
|
|
49
|
+
/** Leave pinned `local_code` versions this process has no binding for to another executor
|
|
50
|
+
* (for example a Hue-operated grading worker that owns the evaluator source) instead of
|
|
51
|
+
* refusing the run. Their IDs are reported in `deferredScorerVersionIds`. */
|
|
52
|
+
deferUnboundLocalScorers?: boolean;
|
|
49
53
|
/** Cases in flight at once, 1–16. Default 1. */
|
|
50
54
|
concurrency?: number;
|
|
51
55
|
/** Deadline for JSON Schema scoring in its worker, 100–60000 ms. Default 2000. */
|
|
52
56
|
schemaTimeoutMillis?: number;
|
|
53
|
-
/** Resolve sealed
|
|
54
|
-
environmentEvidence?: "required";
|
|
57
|
+
/** Resolve sealed evidence by execution identity. when_pinned skips known direct cases. */
|
|
58
|
+
environmentEvidence?: "required" | "when_pinned";
|
|
59
|
+
/** Verified input copies and generated files live here; defaults to `<checkpointDirectory>/files`.
|
|
60
|
+
* Generated files are always saved and uploaded: they are the execution's evidence. */
|
|
61
|
+
filesDirectory?: string;
|
|
62
|
+
}
|
|
63
|
+
/** Immutable case context passed to a direct experiment target. */
|
|
64
|
+
export interface RunExperimentTargetContext {
|
|
65
|
+
/** Frozen experiment configuration, validated as JSON. */
|
|
66
|
+
config: JsonValue;
|
|
67
|
+
/** The frozen case, cloned before invocation. */
|
|
68
|
+
item: ExperimentCase;
|
|
69
|
+
/** The `hue.experiment.case` span this attempt runs inside. */
|
|
70
|
+
span: HueSpan;
|
|
71
|
+
/** `executionId` identifies this attempt. Deriving an environment run's idempotency
|
|
72
|
+
* key from it keeps a resumed upload bound to the same world. */
|
|
73
|
+
executionId: string;
|
|
74
|
+
/** Verified copies of the case's pinned input files meant for the agent. Evaluator-only
|
|
75
|
+
* organization templates are withheld, as in the managed protocol. */
|
|
76
|
+
files: LocalFile[];
|
|
77
|
+
/** A private scratch directory for this case; return generated files with `withFiles`. */
|
|
78
|
+
outputDirectory: string;
|
|
55
79
|
}
|
|
56
80
|
/** Options for {@link runExperiment}. */
|
|
57
81
|
export interface RunExperimentOptions extends RunnerOptions {
|
|
@@ -69,13 +93,9 @@ export interface RunExperimentOptions extends RunnerOptions {
|
|
|
69
93
|
/** Why evidence is omitted, up to 4000 characters. */
|
|
70
94
|
reason: string;
|
|
71
95
|
};
|
|
72
|
-
/** Runs the application for one frozen case; return the output,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
item: ExperimentCase;
|
|
76
|
-
span: HueSpan;
|
|
77
|
-
executionId: string;
|
|
78
|
-
}): JsonValue | undefined | Promise<JsonValue | undefined>;
|
|
96
|
+
/** Runs the application for one frozen case; return the output, `withFiles(output, files)`
|
|
97
|
+
* when it generated files, or `undefined` when unavailable. */
|
|
98
|
+
target(inputs: JsonValue, context: RunExperimentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
|
|
79
99
|
}
|
|
80
100
|
/** Options for {@link rescore}. */
|
|
81
101
|
export interface RescoreOptions extends RunnerOptions {
|
package/dist/evals/runner.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
2
4
|
import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
3
5
|
import { HueExportError } from "../transport.js";
|
|
6
|
+
import { HueApiError } from "./client.js";
|
|
4
7
|
import { loadEnvironmentEvidence } from "./environment-evidence.js";
|
|
5
8
|
import { CheckpointStore } from "./checkpoint.js";
|
|
9
|
+
import { downloadCaseFiles, localOutputFiles, OutputFileError, stageOutputFiles, targetFileRoles, uploadOutputFiles, } from "./files.js";
|
|
6
10
|
import { json, uuid } from "./json.js";
|
|
7
|
-
import { persistedScore, scoreLocally, validateScorerBindings
|
|
11
|
+
import { executableHere, persistedScore, scoreLocally, validateScorerBindings } from "./scorers.js";
|
|
12
|
+
import { TargetResult, } from "./types.js";
|
|
8
13
|
/**
|
|
9
14
|
* Thrown when a case has a started attempt without a saved outcome. The runner never reruns the
|
|
10
15
|
* target; inspect the execution and authorize a new attempt explicitly through `startExecution`.
|
|
@@ -52,9 +57,26 @@ export class TargetOutcomeUncertainError extends Error {
|
|
|
52
57
|
this.name = "TargetOutcomeUncertainError";
|
|
53
58
|
}
|
|
54
59
|
}
|
|
60
|
+
/** What {@link RunExperimentOptions.target} receives for one frozen case. */
|
|
61
|
+
/**
|
|
62
|
+
* Pinned inputs this process must download. The target receives the agent-visible roles;
|
|
63
|
+
* scorer-only roles (organization templates, evaluator references such as a legal corpus) are
|
|
64
|
+
* fetched only when a bound code evaluator will grade here. A customer running `hue eval` with
|
|
65
|
+
* grading deferred to Hue never receives them.
|
|
66
|
+
*/
|
|
67
|
+
function neededInputFiles(files, versions, options) {
|
|
68
|
+
if (!files?.length)
|
|
69
|
+
return [];
|
|
70
|
+
const codeEvaluatorRunsHere = versions.some((version) => version.definition.kind === "local_code" && executableHere(version.definition, options));
|
|
71
|
+
return codeEvaluatorRunsHere
|
|
72
|
+
? files
|
|
73
|
+
: files.filter((file) => targetFileRoles.includes(file.role));
|
|
74
|
+
}
|
|
55
75
|
function settings(options) {
|
|
56
|
-
if (options.environmentEvidence !== undefined &&
|
|
57
|
-
|
|
76
|
+
if (options.environmentEvidence !== undefined &&
|
|
77
|
+
options.environmentEvidence !== "required" &&
|
|
78
|
+
options.environmentEvidence !== "when_pinned")
|
|
79
|
+
throw new TypeError("environmentEvidence must be required or when_pinned when supplied");
|
|
58
80
|
if (typeof options.persistResultContent !== "boolean")
|
|
59
81
|
throw new TypeError("Choose persistResultContent explicitly: true or false");
|
|
60
82
|
const concurrency = options.concurrency ?? 1;
|
|
@@ -65,15 +87,15 @@ function settings(options) {
|
|
|
65
87
|
throw new RangeError("schemaTimeoutMillis must be 100–60000");
|
|
66
88
|
return concurrency;
|
|
67
89
|
}
|
|
68
|
-
async function allPages(page) {
|
|
90
|
+
async function allPages(page, maximum = 5000) {
|
|
69
91
|
const items = [];
|
|
70
92
|
const cursors = new Set();
|
|
71
93
|
let after;
|
|
72
94
|
do {
|
|
73
95
|
const response = await page(after);
|
|
74
96
|
items.push(...response.items);
|
|
75
|
-
if (items.length >
|
|
76
|
-
throw new RangeError(
|
|
97
|
+
if (items.length > maximum)
|
|
98
|
+
throw new RangeError(`Runner supports at most ${maximum} items`);
|
|
77
99
|
if (response.nextCursor === null)
|
|
78
100
|
break;
|
|
79
101
|
after = uuid(response.nextCursor);
|
|
@@ -102,24 +124,28 @@ async function pool(items, concurrency, execute) {
|
|
|
102
124
|
if (failures.length)
|
|
103
125
|
throw new AggregateError(failures, "Multiple case operations failed; resume uses saved outcomes");
|
|
104
126
|
}
|
|
105
|
-
async function scoresFor(versions, context, options, executionId) {
|
|
127
|
+
async function scoresFor(versions, context, options, executionId, hasEnvironment = true) {
|
|
106
128
|
const scores = [];
|
|
107
129
|
let environmentUnavailable = false;
|
|
108
|
-
if (options.environmentEvidence === "required"
|
|
109
|
-
|
|
130
|
+
if ((options.environmentEvidence === "required" ||
|
|
131
|
+
(options.environmentEvidence === "when_pinned" && hasEnvironment)) &&
|
|
132
|
+
versions.some((version) => executableHere(version.definition, options))) {
|
|
110
133
|
try {
|
|
111
134
|
context = {
|
|
112
135
|
...context,
|
|
113
136
|
environment: await loadEnvironmentEvidence(options.client, executionId),
|
|
114
137
|
};
|
|
115
138
|
}
|
|
116
|
-
catch {
|
|
117
|
-
|
|
139
|
+
catch (error) {
|
|
140
|
+
// Generic targets may attach a world independently of the case pin. Preserve
|
|
141
|
+
// required evidence lookups, including the optional 404 for an unpinned case.
|
|
142
|
+
if (!(error instanceof HueApiError && error.status === 404 && !hasEnvironment))
|
|
143
|
+
environmentUnavailable = true;
|
|
118
144
|
}
|
|
119
145
|
}
|
|
120
146
|
for (const version of versions) {
|
|
121
147
|
// Every pin without a local implementation belongs to another executor.
|
|
122
|
-
if (!
|
|
148
|
+
if (!executableHere(version.definition, options))
|
|
123
149
|
continue;
|
|
124
150
|
const score = persistedScore(environmentUnavailable && version.definition.kind === "local_code"
|
|
125
151
|
? { state: "error", error: { type: "EnvironmentEvidenceUnavailable" } }
|
|
@@ -137,25 +163,35 @@ async function scoresFor(versions, context, options, executionId) {
|
|
|
137
163
|
}
|
|
138
164
|
return scores;
|
|
139
165
|
}
|
|
140
|
-
async function uploadScores(options, runId, scores, save, versions) {
|
|
166
|
+
async function uploadScores(options, runId, scores, save, versions, resolveConflict) {
|
|
141
167
|
// A previous SDK may have checkpointed a placeholder for an unknown kind.
|
|
142
168
|
// Keep its evidence intact, but never upload or report it as a local result.
|
|
143
169
|
const local = scores.filter((score) => {
|
|
144
170
|
const pin = versions.find((version) => version.id === score.payload.scorerVersionId);
|
|
145
171
|
if (!pin)
|
|
146
172
|
throw new Error("Saved result references an unpinned scorer version");
|
|
147
|
-
return
|
|
173
|
+
return executableHere(pin.definition, options);
|
|
148
174
|
});
|
|
149
175
|
for (const score of local) {
|
|
150
176
|
if (score.receipt)
|
|
151
177
|
continue;
|
|
152
178
|
if (!score.payload.evaluationItemId)
|
|
153
179
|
throw new Error("Scoring requires the acknowledged evaluation item identity");
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
180
|
+
try {
|
|
181
|
+
const result = await options.client.submitResults(runId, {
|
|
182
|
+
idempotencyKey: score.key,
|
|
183
|
+
results: [score.payload],
|
|
184
|
+
});
|
|
185
|
+
score.receipt = result.ids;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const receipt = error instanceof HueApiError && error.status === 409
|
|
189
|
+
? await resolveConflict?.(score)
|
|
190
|
+
: undefined;
|
|
191
|
+
if (!receipt)
|
|
192
|
+
throw error;
|
|
193
|
+
score.receipt = receipt;
|
|
194
|
+
}
|
|
159
195
|
await save();
|
|
160
196
|
}
|
|
161
197
|
return local.flatMap((score) => score.receipt ?? []);
|
|
@@ -189,7 +225,8 @@ export async function runExperiment(options) {
|
|
|
189
225
|
if (!version.frozenAt || !version.contentDigest)
|
|
190
226
|
throw new Error("Experiment dataset must be frozen");
|
|
191
227
|
const versions = experiment.evaluation.scorerVersions;
|
|
192
|
-
|
|
228
|
+
if (!options.deferUnboundLocalScorers)
|
|
229
|
+
validateScorerBindings(versions, options.scorers);
|
|
193
230
|
const items = await allPages((after) => options.client.listExperimentItems(experiment.id, { after }));
|
|
194
231
|
if (items.length !== experiment.caseCount)
|
|
195
232
|
throw new Error("Frozen experiment case count differs from API items");
|
|
@@ -214,14 +251,70 @@ export async function runExperiment(options) {
|
|
|
214
251
|
subjectIds: [],
|
|
215
252
|
resultIds: [],
|
|
216
253
|
deferredScorerVersionIds: versions
|
|
217
|
-
.filter((version) => !
|
|
254
|
+
.filter((version) => !executableHere(version.definition, options))
|
|
218
255
|
.map((version) => version.id),
|
|
219
256
|
};
|
|
220
257
|
try {
|
|
258
|
+
const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
|
|
259
|
+
const sanitize = (message) => message.slice(0, 4000).toWellFormed().replaceAll("\u0000", "");
|
|
260
|
+
const errorPayload = (error) => ({
|
|
261
|
+
type: "TargetError",
|
|
262
|
+
...(options.persistResultContent && error instanceof Error
|
|
263
|
+
? { message: sanitize(error.message) }
|
|
264
|
+
: {}),
|
|
265
|
+
});
|
|
266
|
+
/** Publish staged files, score with every verified file on disk and save the completion. */
|
|
267
|
+
async function prepare(file, saved, frozenCase, caseDirectory,
|
|
268
|
+
/** The target's output, in memory when result content is not persisted. */
|
|
269
|
+
output) {
|
|
270
|
+
const needed = neededInputFiles(frozenCase.inputFiles, versions, options);
|
|
271
|
+
const inputs = needed.length
|
|
272
|
+
? await downloadCaseFiles(options.client, needed, join(caseDirectory, "inputs"))
|
|
273
|
+
: [];
|
|
274
|
+
await uploadOutputFiles(options.client, saved.executionId, saved.files, () => store.write(file, saved));
|
|
275
|
+
const outputs = localOutputFiles(saved.files);
|
|
276
|
+
const primary = outputs.find((item) => item.primary);
|
|
277
|
+
const scores = await scoresFor(versions, {
|
|
278
|
+
inputs: frozenCase.inputs,
|
|
279
|
+
hasExpected: frozenCase.hasExpected,
|
|
280
|
+
...(frozenCase.hasExpected ? { expected: frozenCase.expected } : {}),
|
|
281
|
+
metadata: frozenCase.metadata,
|
|
282
|
+
hasOutput: saved.hasOutput,
|
|
283
|
+
...(saved.hasOutput ? { output: output } : {}),
|
|
284
|
+
executionState: saved.state,
|
|
285
|
+
...(inputs.length || outputs.length ? { files: [...inputs, ...outputs] } : {}),
|
|
286
|
+
}, options, saved.executionId, Boolean(frozenCase.environmentVersionId));
|
|
287
|
+
const complete = {
|
|
288
|
+
idempotencyKey: randomUUID(),
|
|
289
|
+
state: saved.state,
|
|
290
|
+
...(options.persistResultContent && saved.hasOutput ? { output: output } : {}),
|
|
291
|
+
...(saved.error ? { error: saved.error } : {}),
|
|
292
|
+
...(outputs.length
|
|
293
|
+
? {
|
|
294
|
+
artifactIds: outputs.map((item) => item.artifactId),
|
|
295
|
+
...(primary ? { primaryArtifactId: primary.artifactId } : {}),
|
|
296
|
+
}
|
|
297
|
+
: {}),
|
|
298
|
+
traceEvidence: options.traceEvidence.mode,
|
|
299
|
+
...(options.traceEvidence.mode === "omit"
|
|
300
|
+
? { omissionReason: options.traceEvidence.reason }
|
|
301
|
+
: {}),
|
|
302
|
+
};
|
|
303
|
+
const prepared = {
|
|
304
|
+
stage: "prepared",
|
|
305
|
+
executionId: saved.executionId,
|
|
306
|
+
complete,
|
|
307
|
+
scores,
|
|
308
|
+
exportState: "pending",
|
|
309
|
+
};
|
|
310
|
+
await store.write(file, prepared);
|
|
311
|
+
return prepared;
|
|
312
|
+
}
|
|
221
313
|
await pool(items, concurrency, async (item) => {
|
|
222
314
|
const file = `case-${uuid(item.id)}`;
|
|
315
|
+
const caseDirectory = join(filesRoot, `case-${uuid(item.id)}`);
|
|
223
316
|
let checkpoint = await store.read(file);
|
|
224
|
-
if (checkpoint && checkpoint.stage !== "prepared") {
|
|
317
|
+
if (checkpoint && checkpoint.stage !== "prepared" && checkpoint.stage !== "uploading") {
|
|
225
318
|
if (checkpoint.stage === "serialization_failed")
|
|
226
319
|
throw new OutcomeSerializationError(checkpoint.executionId);
|
|
227
320
|
const execution = checkpoint.stage === "starting"
|
|
@@ -232,14 +325,31 @@ export async function runExperiment(options) {
|
|
|
232
325
|
: await options.client.getExecution(checkpoint.executionId);
|
|
233
326
|
throw new UncertainExecutionError(item.id, execution.id);
|
|
234
327
|
}
|
|
328
|
+
if (checkpoint?.stage === "uploading") {
|
|
329
|
+
// The target finished and its files are staged: publish and score them without a
|
|
330
|
+
// second invocation. Metadata-only mode discarded the output, so its outcome is lost.
|
|
331
|
+
if (checkpoint.hasOutput && checkpoint.output === undefined)
|
|
332
|
+
throw new UncertainExecutionError(item.id, checkpoint.executionId);
|
|
333
|
+
const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
|
|
334
|
+
checkpoint = await prepare(file, checkpoint, frozenCase, caseDirectory, checkpoint.output);
|
|
335
|
+
}
|
|
235
336
|
if (!checkpoint) {
|
|
236
337
|
if (item.execution)
|
|
237
338
|
throw new UncertainExecutionError(item.id, item.execution.id);
|
|
238
339
|
const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
|
|
239
340
|
if (frozenCase.datasetVersionId !== version.id)
|
|
240
341
|
throw new Error("Case is not from the pinned dataset version");
|
|
342
|
+
// Validate before creating a remote execution. SDK/input failures are not
|
|
343
|
+
// target failures and cannot consume a case's execution slot.
|
|
241
344
|
const targetInputs = json(frozenCase.inputs);
|
|
242
345
|
const targetConfig = json(experiment.config);
|
|
346
|
+
// Pinned input files are verified on disk before an execution exists for the same reason.
|
|
347
|
+
const needed = neededInputFiles(frozenCase.inputFiles, versions, options);
|
|
348
|
+
const inputFiles = needed.length
|
|
349
|
+
? await downloadCaseFiles(options.client, needed, join(caseDirectory, "inputs"))
|
|
350
|
+
: [];
|
|
351
|
+
const outputDirectory = join(caseDirectory, "work");
|
|
352
|
+
await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
|
|
243
353
|
const failureSequenceBefore = options.hue.transport.getFailureSequence();
|
|
244
354
|
checkpoint = await options.hue.withSpan("hue.experiment.case", async (span) => {
|
|
245
355
|
const start = {
|
|
@@ -255,14 +365,23 @@ export async function runExperiment(options) {
|
|
|
255
365
|
await store.write(file, { stage: "running", executionId: execution.id });
|
|
256
366
|
let state = "succeeded";
|
|
257
367
|
let output;
|
|
368
|
+
let generated;
|
|
258
369
|
let targetError;
|
|
259
370
|
try {
|
|
260
|
-
|
|
371
|
+
const result = await options.target(targetInputs, {
|
|
261
372
|
config: targetConfig,
|
|
262
373
|
item: structuredClone(frozenCase),
|
|
263
374
|
span,
|
|
264
375
|
executionId: execution.id,
|
|
376
|
+
files: structuredClone(inputFiles.filter((entry) => targetFileRoles.includes(entry.role))),
|
|
377
|
+
outputDirectory,
|
|
265
378
|
});
|
|
379
|
+
if (result instanceof TargetResult) {
|
|
380
|
+
output = result.output;
|
|
381
|
+
generated = result.files;
|
|
382
|
+
}
|
|
383
|
+
else
|
|
384
|
+
output = result;
|
|
266
385
|
}
|
|
267
386
|
catch (error) {
|
|
268
387
|
if (error instanceof TargetOutcomeUncertainError)
|
|
@@ -286,48 +405,36 @@ export async function runExperiment(options) {
|
|
|
286
405
|
}
|
|
287
406
|
if (output !== undefined)
|
|
288
407
|
span.setOutput(output);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
408
|
+
let staged = [];
|
|
409
|
+
// An empty declared list (a direct case answered only through stdout, or
|
|
410
|
+
// `withFiles(output, [])`) means no generated files, not a missing-files error.
|
|
411
|
+
if (generated !== undefined && generated.length > 0 && state === "succeeded") {
|
|
412
|
+
try {
|
|
413
|
+
staged = await stageOutputFiles(generated, join(caseDirectory, "outputs"));
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
// Files the target declared but did not deliver are its own failure; the
|
|
417
|
+
// outcome is still saved instead of leaving the execution uncertain.
|
|
418
|
+
if (!(error instanceof OutputFileError))
|
|
419
|
+
throw error;
|
|
420
|
+
state = "error";
|
|
421
|
+
targetError = error;
|
|
422
|
+
options.hue.recordError(span.span, error);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const uploading = {
|
|
426
|
+
stage: "uploading",
|
|
427
|
+
executionId: execution.id,
|
|
300
428
|
state,
|
|
429
|
+
hasOutput: output !== undefined,
|
|
301
430
|
...(options.persistResultContent && output !== undefined ? { output } : {}),
|
|
302
|
-
...(state === "error"
|
|
303
|
-
|
|
304
|
-
error: {
|
|
305
|
-
type: "TargetError",
|
|
306
|
-
...(options.persistResultContent && targetError instanceof Error
|
|
307
|
-
? {
|
|
308
|
-
message: targetError.message
|
|
309
|
-
.slice(0, 4000)
|
|
310
|
-
.toWellFormed()
|
|
311
|
-
.replaceAll("\u0000", ""),
|
|
312
|
-
}
|
|
313
|
-
: {}),
|
|
314
|
-
},
|
|
315
|
-
}
|
|
316
|
-
: {}),
|
|
317
|
-
traceEvidence: options.traceEvidence.mode,
|
|
318
|
-
...(options.traceEvidence.mode === "omit"
|
|
319
|
-
? { omissionReason: options.traceEvidence.reason }
|
|
320
|
-
: {}),
|
|
431
|
+
...(state === "error" ? { error: errorPayload(targetError) } : {}),
|
|
432
|
+
files: staged,
|
|
321
433
|
};
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
scores,
|
|
327
|
-
exportState: "pending",
|
|
328
|
-
};
|
|
329
|
-
await store.write(file, prepared);
|
|
330
|
-
return prepared;
|
|
434
|
+
// Without persisted result content a restart cannot reconstruct the outcome; the
|
|
435
|
+
// saved stage then reports the execution as uncertain instead of guessing.
|
|
436
|
+
await store.write(file, uploading);
|
|
437
|
+
return prepare(file, uploading, frozenCase, caseDirectory, output);
|
|
331
438
|
}, {
|
|
332
439
|
parentContext: ROOT_CONTEXT,
|
|
333
440
|
input: frozenCase.inputs,
|
|
@@ -385,7 +492,8 @@ export async function rescore(options) {
|
|
|
385
492
|
options.client.checkConnection(),
|
|
386
493
|
options.client.getEvaluationRun(options.runId),
|
|
387
494
|
]);
|
|
388
|
-
|
|
495
|
+
if (!options.deferUnboundLocalScorers)
|
|
496
|
+
validateScorerBindings(run.scorerVersions, options.scorers);
|
|
389
497
|
const items = await allPages((after) => options.client.listEvaluationItems(run.id, { after }));
|
|
390
498
|
if (items.length !== run.itemCount)
|
|
391
499
|
throw new Error("Frozen evaluation run item count differs from API items");
|
|
@@ -405,16 +513,40 @@ export async function rescore(options) {
|
|
|
405
513
|
subjectIds: [],
|
|
406
514
|
resultIds: [],
|
|
407
515
|
deferredScorerVersionIds: run.scorerVersions
|
|
408
|
-
.filter((version) => !
|
|
516
|
+
.filter((version) => !executableHere(version.definition, options))
|
|
409
517
|
.map((version) => version.id),
|
|
410
518
|
};
|
|
519
|
+
const filesRoot = resolve(options.filesDirectory ?? join(options.checkpointDirectory, "files"));
|
|
411
520
|
try {
|
|
521
|
+
// Grade again can also schedule Hue's built-in checks. Terminal results are immutable:
|
|
522
|
+
// preserve their receipts, including when another executor wins during local scoring.
|
|
523
|
+
const resultKey = (itemId, scorerVersionId) => `${itemId}:${scorerVersionId}`;
|
|
524
|
+
const receipts = new Map();
|
|
525
|
+
const refreshReceipts = async () => {
|
|
526
|
+
const results = await allPages((after) => options.client.listResults(run.id, { after }), 5000 * 32);
|
|
527
|
+
for (const result of results)
|
|
528
|
+
receipts.set(resultKey(result.itemId, result.scorerVersionId), result.id);
|
|
529
|
+
};
|
|
530
|
+
await refreshReceipts();
|
|
412
531
|
await pool(items, concurrency, async (item) => {
|
|
413
532
|
const file = `item-${uuid(item.id)}`;
|
|
533
|
+
const pending = run.scorerVersions.filter((version) => !receipts.has(resultKey(item.id, version.id)));
|
|
414
534
|
let saved = await store.read(file);
|
|
535
|
+
if (!saved && !pending.some((version) => executableHere(version.definition, options)))
|
|
536
|
+
saved = { scores: [] };
|
|
415
537
|
if (!saved) {
|
|
416
538
|
const subject = await options.client.getSubject(item.subjectId);
|
|
417
|
-
|
|
539
|
+
// The frozen manifest holds the case inputs and the target's documents; a code
|
|
540
|
+
// evaluator grades the saved bytes, verified against their pinned identities.
|
|
541
|
+
// Built-ins grade the JSON output alone, so no file — least of all a scorer-only
|
|
542
|
+
// organization template or evaluator reference — is fetched onto this machine for them.
|
|
543
|
+
const codeEvaluatorRunsHere = pending.some((version) => version.definition.kind === "local_code" && executableHere(version.definition, options));
|
|
544
|
+
const files = codeEvaluatorRunsHere && subject.files?.length
|
|
545
|
+
? await downloadCaseFiles(options.client, subject.files, join(filesRoot, `subject-${uuid(item.subjectId)}`), subject.primaryArtifactId)
|
|
546
|
+
: [];
|
|
547
|
+
// Older servers omit the world pin; keep their previous behaviour.
|
|
548
|
+
const hasEnvironment = subject.environmentVersionId === undefined ? true : subject.environmentVersionId !== null;
|
|
549
|
+
const scores = await scoresFor(pending, {
|
|
418
550
|
inputs: subject.inputs,
|
|
419
551
|
hasOutput: subject.hasOutput,
|
|
420
552
|
hasExpected: subject.hasExpected,
|
|
@@ -422,16 +554,32 @@ export async function rescore(options) {
|
|
|
422
554
|
...(subject.hasExpected ? { expected: subject.expected } : {}),
|
|
423
555
|
metadata: subject.metadata,
|
|
424
556
|
executionState: subject.executionState,
|
|
425
|
-
|
|
557
|
+
...(files.length ? { files } : {}),
|
|
558
|
+
}, options, subject.executionId, hasEnvironment);
|
|
426
559
|
for (const score of scores)
|
|
427
560
|
score.payload.evaluationItemId = item.id;
|
|
428
561
|
saved = { scores };
|
|
429
562
|
await store.write(file, saved);
|
|
430
563
|
}
|
|
431
564
|
const current = saved;
|
|
432
|
-
const
|
|
565
|
+
for (const score of current.scores) {
|
|
566
|
+
const receipt = receipts.get(resultKey(item.id, score.payload.scorerVersionId));
|
|
567
|
+
if (receipt)
|
|
568
|
+
score.receipt = [receipt];
|
|
569
|
+
}
|
|
570
|
+
const results = await uploadScores(options, run.id, current.scores, () => store.write(file, current), run.scorerVersions, async (score) => {
|
|
571
|
+
await refreshReceipts();
|
|
572
|
+
const receipt = receipts.get(resultKey(item.id, score.payload.scorerVersionId));
|
|
573
|
+
return receipt ? [receipt] : undefined;
|
|
574
|
+
});
|
|
433
575
|
report.subjectIds.push(item.subjectId);
|
|
434
|
-
report.resultIds.push(...
|
|
576
|
+
report.resultIds.push(...new Set([
|
|
577
|
+
...results,
|
|
578
|
+
...run.scorerVersions.flatMap((version) => {
|
|
579
|
+
const receipt = receipts.get(resultKey(item.id, version.id));
|
|
580
|
+
return receipt && executableHere(version.definition, options) ? [receipt] : [];
|
|
581
|
+
}),
|
|
582
|
+
]));
|
|
435
583
|
});
|
|
436
584
|
return report;
|
|
437
585
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { EvaluationClient } from "./client.js";
|
|
2
|
+
import type { CaseConversion, CaseConversionSummary, Page, PageOptions } from "./types.js";
|
|
3
|
+
/** Immutable pins resolved from a published Scenario or a saved eval set. */
|
|
4
|
+
export interface ScenarioPins {
|
|
5
|
+
/** Scenario ID, or `null` when the pins came from an eval set. */
|
|
6
|
+
scenarioId: string | null;
|
|
7
|
+
/** Display name of the dataset behind the pins. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Dataset holding the pinned version. */
|
|
10
|
+
datasetId: string;
|
|
11
|
+
/** Pinned dataset version; frozen only when `saved` is true. */
|
|
12
|
+
datasetVersionId: string;
|
|
13
|
+
/** Pinned scorer versions; a Scenario pins exactly one. */
|
|
14
|
+
scorerVersionIds: string[];
|
|
15
|
+
/** Pinned simulated-world version, or `null` when the selection does not pin one. */
|
|
16
|
+
environmentVersionId: string | null;
|
|
17
|
+
/** Whether the dataset version is frozen (`frozenAt` is set); experiments require a saved version. */
|
|
18
|
+
saved: boolean;
|
|
19
|
+
/** Current optimistic revision of the dataset version, needed to freeze an unsaved draft. */
|
|
20
|
+
revision: number;
|
|
21
|
+
}
|
|
22
|
+
/** Subset of {@link EvaluationClient} used to resolve Scenario pins. */
|
|
23
|
+
export type ScenarioClient = Pick<EvaluationClient, "listCaseConversions" | "getCaseConversion" | "getDataset" | "getDatasetVersion" | "listDatasets">;
|
|
24
|
+
/** Lists Scenarios of the project; requires a Read and write key. */
|
|
25
|
+
export declare function listScenarios(client: Pick<EvaluationClient, "listCaseConversions">, page?: PageOptions): Promise<Page<CaseConversionSummary>>;
|
|
26
|
+
/** Reads one Scenario with its publication pins; requires a Read and write key. */
|
|
27
|
+
export declare function getScenario(client: Pick<EvaluationClient, "getCaseConversion">, id: string): Promise<CaseConversion>;
|
|
28
|
+
/** How a selector was interpreted: a UUID, a Hue URL or a display name. */
|
|
29
|
+
export type ScenarioSelector = {
|
|
30
|
+
/** The selector is an ID or a URL naming one. */
|
|
31
|
+
kind: "id";
|
|
32
|
+
/** Lowercase UUID. */
|
|
33
|
+
id: string;
|
|
34
|
+
} | {
|
|
35
|
+
/** The selector is a display name to match. */
|
|
36
|
+
kind: "name";
|
|
37
|
+
/** Trimmed name. */
|
|
38
|
+
name: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Interprets a selector as a UUID, a Hue URL containing `/<segment>/<uuid>` (for example
|
|
42
|
+
* `/scenarios/<uuid>`, query parameters ignored) or a display name.
|
|
43
|
+
*
|
|
44
|
+
* @throws TypeError for an empty selector or a URL without the expected segment.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseScenarioSelector(selector: string, segments?: string[]): ScenarioSelector;
|
|
47
|
+
/** A candidate with a display name, such as a dataset or Scenario. */
|
|
48
|
+
export interface NamedCandidate {
|
|
49
|
+
/** Display name compared case-insensitively. */
|
|
50
|
+
name: string;
|
|
51
|
+
}
|
|
52
|
+
/** Outcome of {@link matchByName}. */
|
|
53
|
+
export interface NameMatch<T extends NamedCandidate> {
|
|
54
|
+
/** Candidates that matched; one means an unambiguous selection. */
|
|
55
|
+
matches: T[];
|
|
56
|
+
/** Whether the matches are exact (case-insensitive) rather than prefix or substring matches. */
|
|
57
|
+
exact: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** Case-insensitive exact matches first, then unique prefix/substring matches. */
|
|
60
|
+
export declare function matchByName<T extends NamedCandidate>(candidates: T[], name: string): NameMatch<T>;
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a published Scenario's immutable pins from its ID, its Hue URL or its name. A name
|
|
63
|
+
* matches the dataset name of published Scenarios case-insensitively: exact matches first, then
|
|
64
|
+
* a unique prefix or substring.
|
|
65
|
+
*
|
|
66
|
+
* @throws Error when no Scenario matches, several match, or the Scenario is an unpublished draft.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveScenarioPins(client: ScenarioClient, selector: string): Promise<ScenarioPins>;
|
|
69
|
+
/**
|
|
70
|
+
* Resolves an eval set (dataset) by ID, Hue URL or name to its latest saved version. When the
|
|
71
|
+
* set has no saved version, the latest draft is returned with `saved: false` so a caller can
|
|
72
|
+
* freeze it explicitly. Scorer versions are not pinned by a set; supply them separately.
|
|
73
|
+
*
|
|
74
|
+
* @throws Error when no set matches, several match, or the set has no versions.
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveEvalSetPins(client: ScenarioClient, selector: string, options?: {
|
|
77
|
+
/** Scorer versions to pin alongside the dataset version. */
|
|
78
|
+
scorerVersionIds?: string[];
|
|
79
|
+
}): Promise<ScenarioPins>;
|