@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.
@@ -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 { ExperimentCase, JsonValue, LocalScorer } from "./types.js";
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 world evidence for local scoring and historical rescoring. */
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, or `undefined` when unavailable. */
73
- target(inputs: JsonValue, context: {
74
- config: JsonValue;
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 {
@@ -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, isLocallyExecutable, } from "./scorers.js";
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 && options.environmentEvidence !== "required")
57
- throw new TypeError("environmentEvidence must be required when supplied");
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 > 5000)
76
- throw new RangeError("Runner supports at most 5000 items");
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
- versions.some((version) => isLocallyExecutable(version.definition))) {
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
- environmentUnavailable = true;
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 (!isLocallyExecutable(version.definition))
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 isLocallyExecutable(pin.definition);
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
- const result = await options.client.submitResults(runId, {
155
- idempotencyKey: score.key,
156
- results: [score.payload],
157
- });
158
- score.receipt = result.ids;
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
- validateScorerBindings(versions, options.scorers);
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) => !isLocallyExecutable(version.definition))
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
- output = await options.target(targetInputs, {
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
- const scores = await scoresFor(versions, {
290
- inputs: frozenCase.inputs,
291
- hasExpected: frozenCase.hasExpected,
292
- ...(frozenCase.hasExpected ? { expected: frozenCase.expected } : {}),
293
- metadata: frozenCase.metadata,
294
- hasOutput: output !== undefined,
295
- ...(output !== undefined ? { output } : {}),
296
- executionState: state,
297
- }, options, execution.id);
298
- const complete = {
299
- idempotencyKey: randomUUID(),
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
- const prepared = {
323
- stage: "prepared",
324
- executionId: execution.id,
325
- complete,
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
- validateScorerBindings(run.scorerVersions, options.scorers);
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) => !isLocallyExecutable(version.definition))
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
- const scores = await scoresFor(run.scorerVersions, {
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
- }, options, subject.executionId);
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 results = await uploadScores(options, run.id, current.scores, () => store.write(file, current), run.scorerVersions);
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(...results);
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>;