@hue-run/sdk 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/ENVIRONMENTS.md +182 -0
  2. package/EVALUATIONS.md +12 -0
  3. package/README.md +194 -18
  4. package/dist/ai-sdk.d.ts +9 -1
  5. package/dist/ai-sdk.js +34 -8
  6. package/dist/client.d.ts +121 -6
  7. package/dist/client.js +329 -56
  8. package/dist/config.d.ts +11 -2
  9. package/dist/config.js +36 -7
  10. package/dist/environment/client.d.ts +73 -0
  11. package/dist/environment/client.js +209 -0
  12. package/dist/environment/tools.d.ts +30 -0
  13. package/dist/environment/tools.js +24 -0
  14. package/dist/environment/types.d.ts +429 -0
  15. package/dist/environment/types.js +1 -0
  16. package/dist/environment.d.ts +5 -0
  17. package/dist/environment.js +2 -0
  18. package/dist/evals/attempt.d.ts +454 -0
  19. package/dist/evals/attempt.js +687 -0
  20. package/dist/evals/client.d.ts +99 -5
  21. package/dist/evals/client.js +136 -7
  22. package/dist/evals/environment-evidence.d.ts +6 -0
  23. package/dist/evals/environment-evidence.js +123 -0
  24. package/dist/evals/environment-json.d.ts +3 -0
  25. package/dist/evals/environment-json.js +76 -0
  26. package/dist/evals/json.d.ts +9 -1
  27. package/dist/evals/json.js +14 -6
  28. package/dist/evals/runner.d.ts +61 -2
  29. package/dist/evals/runner.js +71 -9
  30. package/dist/evals/scorer-publication.d.ts +2 -0
  31. package/dist/evals/scorer-publication.js +84 -0
  32. package/dist/evals/scorers.d.ts +11 -0
  33. package/dist/evals/scorers.js +56 -5
  34. package/dist/evals/simulation.d.ts +184 -0
  35. package/dist/evals/simulation.js +603 -0
  36. package/dist/evals/types.d.ts +304 -0
  37. package/dist/evals.d.ts +5 -1
  38. package/dist/evals.js +3 -1
  39. package/dist/experimental-telemetry.d.ts +8 -0
  40. package/dist/experimental-telemetry.js +13 -0
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.js +2 -0
  43. package/dist/managed.d.ts +51 -1
  44. package/dist/managed.js +11 -1
  45. package/dist/privacy.d.ts +2 -0
  46. package/dist/privacy.js +16 -1
  47. package/dist/receipt.d.ts +12 -1
  48. package/dist/receipt.js +10 -1
  49. package/dist/safety.d.ts +1 -2
  50. package/dist/snapshot.js +4 -0
  51. package/dist/transport.d.ts +41 -9
  52. package/dist/transport.js +80 -22
  53. package/dist/types.d.ts +144 -8
  54. package/dist/version.d.ts +2 -0
  55. package/dist/version.js +3 -0
  56. package/package.json +51 -15
@@ -0,0 +1,76 @@
1
+ function validText(value) {
2
+ return value.isWellFormed() && !value.includes("\u0000");
3
+ }
4
+ function record(value) {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+ export function environmentJson(value, maxBytes = 200_000, maxDepth = 32, maxNodes = 20_000) {
8
+ const pending = [{ value, depth: 0 }];
9
+ const seen = new Set();
10
+ let nodes = 0;
11
+ while (pending.length) {
12
+ const current = pending.pop();
13
+ if (++nodes > maxNodes || current.depth > maxDepth)
14
+ throw new RangeError("Environment JSON exceeds depth/node limits");
15
+ const item = current.value;
16
+ if (item === null || typeof item === "boolean")
17
+ continue;
18
+ if (typeof item === "number" && Number.isFinite(item))
19
+ continue;
20
+ if (typeof item === "string" && validText(item))
21
+ continue;
22
+ if (!item || typeof item !== "object" || seen.has(item))
23
+ throw new TypeError("Expected finite environment JSON without cycles or invalid Unicode");
24
+ seen.add(item);
25
+ const keys = Object.keys(item);
26
+ if (Array.isArray(item)) {
27
+ if (keys.length !== item.length)
28
+ throw new TypeError("Sparse/extended arrays are not JSON");
29
+ }
30
+ else if (Object.getPrototypeOf(item) !== Object.prototype &&
31
+ Object.getPrototypeOf(item) !== null)
32
+ throw new TypeError("Environment JSON objects must be plain objects");
33
+ if (Object.getOwnPropertySymbols(item).length)
34
+ throw new TypeError("Environment JSON cannot contain symbol properties");
35
+ for (const key of keys) {
36
+ if (!validText(key))
37
+ throw new TypeError("Invalid environment JSON key");
38
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
39
+ if (!("value" in descriptor))
40
+ throw new TypeError("Environment JSON cannot contain accessors");
41
+ pending.push({ value: descriptor.value, depth: current.depth + 1 });
42
+ }
43
+ }
44
+ if (Buffer.byteLength(JSON.stringify(value)) > maxBytes)
45
+ throw new RangeError("Environment JSON exceeds byte limit");
46
+ }
47
+ export function validateEvidenceWorld(value) {
48
+ environmentJson(value, 200_000, 35, 200_000);
49
+ if (!record(value) || Object.keys(value).length !== 1 || !record(value.collections))
50
+ throw new TypeError("Expected an environment world");
51
+ const collections = Object.entries(value.collections);
52
+ if (collections.length > 64)
53
+ throw new RangeError("Environment world exceeds 64 collections");
54
+ let entities = 0;
55
+ for (const [name, entries] of collections) {
56
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(name) || !record(entries))
57
+ throw new TypeError("Invalid environment collection");
58
+ for (const [id, entity] of Object.entries(entries)) {
59
+ if (++entities > 2000)
60
+ throw new RangeError("Environment world exceeds 2000 entities");
61
+ if (!id.length || id.length > 200 || id === "__proto__" || !record(entity))
62
+ throw new TypeError("Invalid environment entity");
63
+ environmentJson(entity);
64
+ }
65
+ }
66
+ }
67
+ export function validateEvidenceArguments(value) {
68
+ environmentJson(value, 256 * 1024, 33, 256 * 1024);
69
+ if (!record(value))
70
+ throw new TypeError("Expected environment argument object");
71
+ for (const [name, argument] of Object.entries(value)) {
72
+ if (!name.length || name.length > 64)
73
+ throw new TypeError("Invalid environment argument name");
74
+ environmentJson(argument);
75
+ }
76
+ }
@@ -1,6 +1,14 @@
1
1
  import type { JsonValue } from "../types.js";
2
+ export type JsonBounds = {
3
+ bytes: number;
4
+ nodes: number;
5
+ depth: number;
6
+ };
7
+ export declare const valueBounds: JsonBounds;
8
+ export declare function aggregateBounds(bytes: number): JsonBounds;
2
9
  /** Reject lossy JSON serialization before creating requests/checkpoints. */
3
- export declare function json(value: unknown, maxBytes?: number): JsonValue;
10
+ export declare function json(value: unknown, requested?: JsonBounds | number): JsonValue;
4
11
  export declare function digest(value: unknown): string;
12
+ /** SHA-256 hex digest of scorer source, as declared in a `local_code` definition. */
5
13
  export declare function sourceDigest(source: string | Uint8Array): string;
6
14
  export declare function uuid(value: string): string;
@@ -1,16 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
+ export const valueBounds = { bytes: 200_000, nodes: 20_000, depth: 32 };
3
+ export function aggregateBounds(bytes) {
4
+ return { bytes, nodes: Math.ceil(bytes / 2), depth: valueBounds.depth + 8 };
5
+ }
6
+ const isText = (item) => item.isWellFormed() && !item.includes("\u0000");
2
7
  /** Reject lossy JSON serialization before creating requests/checkpoints. */
3
- export function json(value, maxBytes = 200_000) {
8
+ export function json(value, requested = valueBounds) {
9
+ const bounds = typeof requested === "number" ? { ...valueBounds, bytes: requested } : requested;
4
10
  const ancestors = new Set();
5
11
  let nodes = 0;
6
12
  const visit = (item, depth) => {
7
- if (++nodes > 20_000 || depth > 32)
13
+ if (++nodes > bounds.nodes || depth > bounds.depth)
8
14
  throw new RangeError("JSON exceeds depth/node limits");
9
15
  if (item === null || typeof item === "boolean")
10
16
  return item;
11
17
  if (typeof item === "number" && Number.isFinite(item))
12
18
  return item;
13
- if (typeof item === "string" && item.isWellFormed() && !item.includes("\u0000"))
19
+ if (typeof item === "string" && isText(item))
14
20
  return item;
15
21
  if (!item || typeof item !== "object" || ancestors.has(item))
16
22
  throw new TypeError("Expected finite JSON without cycles or invalid Unicode");
@@ -27,7 +33,8 @@ export function json(value, maxBytes = 200_000) {
27
33
  throw new TypeError("JSON cannot contain symbol properties");
28
34
  const result = Object.create(null);
29
35
  for (const key of Object.keys(item).sort()) {
30
- visit(key, depth + 1);
36
+ if (!isText(key))
37
+ throw new TypeError("Expected finite JSON without cycles or invalid Unicode");
31
38
  const descriptor = Object.getOwnPropertyDescriptor(item, key);
32
39
  if (!("value" in descriptor))
33
40
  throw new TypeError("JSON cannot contain accessors");
@@ -41,15 +48,16 @@ export function json(value, maxBytes = 200_000) {
41
48
  }
42
49
  };
43
50
  const result = visit(value, 0);
44
- if (Buffer.byteLength(JSON.stringify(result)) > maxBytes)
51
+ if (Buffer.byteLength(JSON.stringify(result)) > bounds.bytes)
45
52
  throw new RangeError("JSON exceeds byte limit");
46
53
  return result;
47
54
  }
48
55
  export function digest(value) {
49
56
  return createHash("sha256")
50
- .update(JSON.stringify(json(value, 8 * 1024 * 1024)))
57
+ .update(JSON.stringify(json(value, aggregateBounds(8 * 1024 * 1024))))
51
58
  .digest("hex");
52
59
  }
60
+ /** SHA-256 hex digest of scorer source, as declared in a `local_code` definition. */
53
61
  export function sourceDigest(source) {
54
62
  return createHash("sha256").update(source).digest("hex");
55
63
  }
@@ -2,47 +2,106 @@ import type { HueClient } from "../client.js";
2
2
  import type { HueSpan } from "../types.js";
3
3
  import { EvaluationClient } from "./client.js";
4
4
  import type { ExperimentCase, JsonValue, LocalScorer } from "./types.js";
5
+ /**
6
+ * Thrown when a case has a started attempt without a saved outcome. The runner never reruns the
7
+ * target; inspect the execution and authorize a new attempt explicitly through `startExecution`.
8
+ */
5
9
  export declare class UncertainExecutionError extends Error {
10
+ /** The affected case (experiment item) ID. */
6
11
  readonly caseId: string;
12
+ /** The execution without a saved outcome, when known. */
7
13
  readonly executionId?: string | undefined;
8
- constructor(caseId: string, executionId?: string | undefined);
14
+ constructor(
15
+ /** The affected case (experiment item) ID. */
16
+ caseId: string,
17
+ /** The execution without a saved outcome, when known. */
18
+ executionId?: string | undefined);
9
19
  }
20
+ /** Thrown when a completed target's output is not serializable JSON; the target is not invoked again. */
10
21
  export declare class OutcomeSerializationError extends Error {
22
+ /** The execution whose output could not be serialized. */
11
23
  readonly executionId: string;
12
- constructor(executionId: string);
24
+ constructor(
25
+ /** The execution whose output could not be serialized. */
26
+ executionId: string);
27
+ }
28
+ /** Thrown when cooperative caller cancellation stops target execution. */
29
+ export declare class TargetCancelledError extends Error {
30
+ constructor();
31
+ }
32
+ /** Thrown when the target or world may have committed but acknowledgement is unavailable. */
33
+ export declare class TargetOutcomeUncertainError extends Error {
34
+ /** Execution whose target outcome must never be replayed automatically. */
35
+ readonly executionId: string;
36
+ constructor(
37
+ /** Execution whose target outcome must never be replayed automatically. */
38
+ executionId: string, options?: ErrorOptions);
13
39
  }
14
40
  interface RunnerOptions {
41
+ /** Evaluation API client for the same project and origin as `hue`. */
15
42
  client: EvaluationClient;
43
+ /** Dedicated directory (mode 0700) for resumable checkpoints; one per experiment or rescore run. */
16
44
  checkpointDirectory: string;
45
+ /** Whether outputs, error messages, evidence and explanations are stored in Hue and in checkpoints. Required. */
17
46
  persistResultContent: boolean;
47
+ /** Local callbacks bound to `local_code` scorer pins by digest. */
18
48
  scorers?: LocalScorer[];
49
+ /** Cases in flight at once, 1–16. Default 1. */
19
50
  concurrency?: number;
51
+ /** Deadline for JSON Schema scoring in its worker, 100–60000 ms. Default 2000. */
20
52
  schemaTimeoutMillis?: number;
53
+ /** Resolve sealed world evidence for local scoring and historical rescoring. */
54
+ environmentEvidence?: "required";
21
55
  }
56
+ /** Options for {@link runExperiment}. */
22
57
  export interface RunExperimentOptions extends RunnerOptions {
58
+ /** Hue tracing client; each case runs inside a `hue.experiment.case` span. */
23
59
  hue: HueClient;
60
+ /** Experiment to run; its dataset version must be frozen. */
24
61
  experimentId: string;
62
+ /** Whether each case waits for acknowledged trace export or explicitly omits evidence. Required. */
25
63
  traceEvidence: {
64
+ /** Wait for trace and log acknowledgement after the case span ends. */
26
65
  mode: "required";
27
66
  } | {
67
+ /** Store the declared trace ID without evidence. */
28
68
  mode: "omit";
69
+ /** Why evidence is omitted, up to 4000 characters. */
29
70
  reason: string;
30
71
  };
72
+ /** Runs the application for one frozen case; return the output, or `undefined` when unavailable. */
31
73
  target(inputs: JsonValue, context: {
32
74
  config: JsonValue;
33
75
  item: ExperimentCase;
34
76
  span: HueSpan;
77
+ executionId: string;
35
78
  }): JsonValue | undefined | Promise<JsonValue | undefined>;
36
79
  }
80
+ /** Options for {@link rescore}. */
37
81
  export interface RescoreOptions extends RunnerOptions {
82
+ /** Evaluation run created with `createEvaluationRun` over existing subjects. */
38
83
  runId: string;
39
84
  }
85
+ /** Outcome of a runner call. */
40
86
  export interface RunnerReport {
87
+ /** Evaluation run the results belong to. */
41
88
  runId: string;
89
+ /** Subjects created or scored, in completion order. */
42
90
  subjectIds: string[];
91
+ /** Result IDs uploaded by this call. */
43
92
  resultIds: string[];
93
+ /** `llm_judge` and `manual` pins left pending for hosted or human scoring. */
44
94
  deferredScorerVersionIds: string[];
45
95
  }
96
+ /**
97
+ * Runs every case of a frozen experiment through `target` on this machine, completes each
98
+ * execution, scores it with local scorers and uploads the results, checkpointing so an interrupted
99
+ * run resumes without invoking the target twice.
100
+ *
101
+ * @throws UncertainExecutionError when a case has a started attempt without a saved outcome.
102
+ * @throws OutcomeSerializationError when a completed target's output is not serializable.
103
+ * @throws HueApiError for evaluation API failures; the checkpoint keeps prepared payloads for a retry.
104
+ */
46
105
  export declare function runExperiment(options: RunExperimentOptions): Promise<RunnerReport>;
47
106
  /** Scores existing immutable subjects; this API has no target callback. */
48
107
  export declare function rescore(options: RescoreOptions): Promise<RunnerReport>;
@@ -1,28 +1,60 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { ROOT_CONTEXT } from "@opentelemetry/api";
3
3
  import { HueExportError } from "../transport.js";
4
+ import { loadEnvironmentEvidence } from "./environment-evidence.js";
4
5
  import { CheckpointStore } from "./checkpoint.js";
5
6
  import { json, uuid } from "./json.js";
6
7
  import { persistedScore, scoreLocally, validateScorerBindings } from "./scorers.js";
8
+ /**
9
+ * Thrown when a case has a started attempt without a saved outcome. The runner never reruns the
10
+ * target; inspect the execution and authorize a new attempt explicitly through `startExecution`.
11
+ */
7
12
  export class UncertainExecutionError extends Error {
8
13
  caseId;
9
14
  executionId;
10
- constructor(caseId, executionId) {
15
+ constructor(
16
+ /** The affected case (experiment item) ID. */
17
+ caseId,
18
+ /** The execution without a saved outcome, when known. */
19
+ executionId) {
11
20
  super("Execution has no saved outcome. The target will not run again. Inspect it and explicitly authorize a new attempt through startExecution if required.");
12
21
  this.caseId = caseId;
13
22
  this.executionId = executionId;
14
23
  this.name = "UncertainExecutionError";
15
24
  }
16
25
  }
26
+ /** Thrown when a completed target's output is not serializable JSON; the target is not invoked again. */
17
27
  export class OutcomeSerializationError extends Error {
18
28
  executionId;
19
- constructor(executionId) {
29
+ constructor(
30
+ /** The execution whose output could not be serialized. */
31
+ executionId) {
20
32
  super("Target completed, but its output could not be serialized. Resolve completion explicitly; the runner will not invoke the target again.");
21
33
  this.executionId = executionId;
22
34
  this.name = "OutcomeSerializationError";
23
35
  }
24
36
  }
37
+ /** Thrown when cooperative caller cancellation stops target execution. */
38
+ export class TargetCancelledError extends Error {
39
+ constructor() {
40
+ super("Target execution was cancelled");
41
+ this.name = "TargetCancelledError";
42
+ }
43
+ }
44
+ /** Thrown when the target or world may have committed but acknowledgement is unavailable. */
45
+ export class TargetOutcomeUncertainError extends Error {
46
+ executionId;
47
+ constructor(
48
+ /** Execution whose target outcome must never be replayed automatically. */
49
+ executionId, options) {
50
+ super(`Target outcome or environment finalization is uncertain for execution ${executionId}; resume will not invoke the target again`, options);
51
+ this.executionId = executionId;
52
+ this.name = "TargetOutcomeUncertainError";
53
+ }
54
+ }
25
55
  function settings(options) {
56
+ if (options.environmentEvidence !== undefined && options.environmentEvidence !== "required")
57
+ throw new TypeError("environmentEvidence must be required when supplied");
26
58
  if (typeof options.persistResultContent !== "boolean")
27
59
  throw new TypeError("Choose persistResultContent explicitly: true or false");
28
60
  const concurrency = options.concurrency ?? 1;
@@ -70,13 +102,27 @@ async function pool(items, concurrency, execute) {
70
102
  if (failures.length)
71
103
  throw new AggregateError(failures, "Multiple case operations failed; resume uses saved outcomes");
72
104
  }
73
- async function scoresFor(versions, context, options) {
105
+ async function scoresFor(versions, context, options, executionId) {
74
106
  const scores = [];
107
+ let environmentUnavailable = false;
108
+ if (options.environmentEvidence === "required") {
109
+ try {
110
+ context = {
111
+ ...context,
112
+ environment: await loadEnvironmentEvidence(options.client, executionId),
113
+ };
114
+ }
115
+ catch {
116
+ environmentUnavailable = true;
117
+ }
118
+ }
75
119
  for (const version of versions) {
76
120
  // Hosted/manual pins remain pending for their authorized executor.
77
121
  if (version.definition.kind === "llm_judge" || version.definition.kind === "manual")
78
122
  continue;
79
- const score = persistedScore(await scoreLocally(version, context, options), options.persistResultContent);
123
+ const score = persistedScore(environmentUnavailable && version.definition.kind === "local_code"
124
+ ? { state: "error", error: { type: "EnvironmentEvidenceUnavailable" } }
125
+ : await scoreLocally(version, context, options), options.persistResultContent);
80
126
  scores.push({
81
127
  key: randomUUID(),
82
128
  payload: {
@@ -104,6 +150,15 @@ async function uploadScores(options, runId, scores, save) {
104
150
  await save();
105
151
  }
106
152
  }
153
+ /**
154
+ * Runs every case of a frozen experiment through `target` on this machine, completes each
155
+ * execution, scores it with local scorers and uploads the results, checkpointing so an interrupted
156
+ * run resumes without invoking the target twice.
157
+ *
158
+ * @throws UncertainExecutionError when a case has a started attempt without a saved outcome.
159
+ * @throws OutcomeSerializationError when a completed target's output is not serializable.
160
+ * @throws HueApiError for evaluation API failures; the checkpoint keeps prepared payloads for a retry.
161
+ */
107
162
  export async function runExperiment(options) {
108
163
  const concurrency = settings(options);
109
164
  if (!options.traceEvidence || !["required", "omit"].includes(options.traceEvidence.mode))
@@ -142,6 +197,7 @@ export async function runExperiment(options) {
142
197
  persistResultContent: options.persistResultContent,
143
198
  captureContent: options.hue.captureContent,
144
199
  traceEvidence: options.traceEvidence,
200
+ ...(options.environmentEvidence ? { environmentEvidence: options.environmentEvidence } : {}),
145
201
  });
146
202
  const report = {
147
203
  runId: experiment.evaluation.id,
@@ -172,6 +228,8 @@ export async function runExperiment(options) {
172
228
  const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
173
229
  if (frozenCase.datasetVersionId !== version.id)
174
230
  throw new Error("Case is not from the pinned dataset version");
231
+ const targetInputs = json(frozenCase.inputs);
232
+ const targetConfig = json(experiment.config);
175
233
  const failureSequenceBefore = options.hue.transport.getFailureSequence();
176
234
  checkpoint = await options.hue.withSpan("hue.experiment.case", async (span) => {
177
235
  const start = {
@@ -189,14 +247,17 @@ export async function runExperiment(options) {
189
247
  let output;
190
248
  let targetError;
191
249
  try {
192
- output = await options.target(json(frozenCase.inputs), {
193
- config: json(experiment.config),
250
+ output = await options.target(targetInputs, {
251
+ config: targetConfig,
194
252
  item: structuredClone(frozenCase),
195
253
  span,
254
+ executionId: execution.id,
196
255
  });
197
256
  }
198
257
  catch (error) {
199
- state = "error";
258
+ if (error instanceof TargetOutcomeUncertainError)
259
+ throw error;
260
+ state = error instanceof TargetCancelledError ? "cancelled" : "error";
200
261
  targetError = error;
201
262
  options.hue.recordError(span.span, error);
202
263
  }
@@ -223,7 +284,7 @@ export async function runExperiment(options) {
223
284
  hasOutput: output !== undefined,
224
285
  ...(output !== undefined ? { output } : {}),
225
286
  executionState: state,
226
- }, options);
287
+ }, options, execution.id);
227
288
  const complete = {
228
289
  idempotencyKey: randomUUID(),
229
290
  state,
@@ -327,6 +388,7 @@ export async function rescore(options) {
327
388
  .map(({ id, contentDigest }) => ({ id, contentDigest }))
328
389
  .sort((a, b) => a.id.localeCompare(b.id)),
329
390
  persistResultContent: options.persistResultContent,
391
+ ...(options.environmentEvidence ? { environmentEvidence: options.environmentEvidence } : {}),
330
392
  });
331
393
  const report = {
332
394
  runId: run.id,
@@ -350,7 +412,7 @@ export async function rescore(options) {
350
412
  ...(subject.hasExpected ? { expected: subject.expected } : {}),
351
413
  metadata: subject.metadata,
352
414
  executionState: subject.executionState,
353
- }, options);
415
+ }, options, subject.executionId);
354
416
  for (const score of scores)
355
417
  score.payload.evaluationItemId = item.id;
356
418
  saved = { scores };
@@ -0,0 +1,2 @@
1
+ import type { ScorerDefinition } from "./types.js";
2
+ export declare function normalizeScorerDefinitionForPublication(definition: unknown): ScorerDefinition;
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ import { json } from "./json.js";
3
+ const metricName = z
4
+ .string()
5
+ .min(1)
6
+ .max(64)
7
+ .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/);
8
+ const metric = z.discriminatedUnion("type", [
9
+ z.strictObject({ name: metricName, type: z.literal("boolean") }),
10
+ z.strictObject({ name: metricName, type: z.literal("text") }),
11
+ z.strictObject({
12
+ name: metricName,
13
+ type: z.literal("number"),
14
+ min: z.number().finite().optional(),
15
+ max: z.number().finite().optional(),
16
+ }),
17
+ z.strictObject({
18
+ name: metricName,
19
+ type: z.literal("category"),
20
+ categories: z.array(z.string()).min(1),
21
+ }),
22
+ ]);
23
+ const metrics = z.array(metric).min(1);
24
+ const jsonValue = z.custom((value) => {
25
+ try {
26
+ json(value);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ });
33
+ const judgeConfig = z.strictObject({
34
+ model: z.string(),
35
+ provider: z.string(),
36
+ rubric: z.string().trim(),
37
+ bindings: z.array(z.strictObject({
38
+ name: z.string(),
39
+ path: z.string(),
40
+ required: z.boolean().default(true),
41
+ })),
42
+ maxOutputTokens: z.number().default(1024),
43
+ timeoutMs: z.number().default(60_000),
44
+ temperature: z.number().optional(),
45
+ });
46
+ /** Closed client-side publication contract. The server remains authoritative for
47
+ * full validation; this parser only applies defaults that affect immutable content
48
+ * identity and rejects server-only scorer kinds before any write. A differential
49
+ * test against the server schema guards this deliberately duplicated boundary.
50
+ */
51
+ const sdkScorerPublication = z.union([
52
+ z.strictObject({
53
+ kind: z.literal("builtin"),
54
+ entry: z.literal("hue.exact_match.v1"),
55
+ config: z.strictObject({}).default({}),
56
+ }),
57
+ z.strictObject({
58
+ kind: z.literal("builtin"),
59
+ entry: z.literal("hue.includes.v1"),
60
+ config: z
61
+ .strictObject({ caseSensitive: z.boolean().default(true) })
62
+ .default({ caseSensitive: true }),
63
+ }),
64
+ z.strictObject({
65
+ kind: z.literal("builtin"),
66
+ entry: z.literal("hue.json_schema.v1"),
67
+ config: z.strictObject({ schema: jsonValue }),
68
+ }),
69
+ z.strictObject({
70
+ kind: z.literal("local_code"),
71
+ language: z.enum(["typescript", "python"]),
72
+ entrypoint: z.string(),
73
+ sourceDigest: z.string(),
74
+ metrics,
75
+ }),
76
+ z.strictObject({ kind: z.literal("manual"), metrics }),
77
+ z.strictObject({ kind: z.literal("llm_judge"), config: judgeConfig, metrics }),
78
+ ]);
79
+ export function normalizeScorerDefinitionForPublication(definition) {
80
+ const normalized = sdkScorerPublication.safeParse(definition);
81
+ if (!normalized.success)
82
+ throw new TypeError("Unsupported or invalid SDK scorer definition");
83
+ return json(normalized.data);
84
+ }
@@ -1,7 +1,11 @@
1
1
  import type { JsonValue, LocalScorer, MetricDefinition, Score, ScoreContext, ScorerDefinition, ScorerVersion } from "./types.js";
2
+ /** Definitions for Hue's built-in scorers, ready to publish with `publishScorerVersion`. */
2
3
  export declare const builtins: {
4
+ /** Exact typed JSON equality with the reference output; key order is ignored. */
3
5
  exactMatch: () => ScorerDefinition;
6
+ /** The string reference output is contained in the string output. */
4
7
  includes: (caseSensitive?: boolean) => ScorerDefinition;
8
+ /** The output satisfies a JSON Schema (draft 2020-12), validated with the optional `ajv` peer in a worker. */
5
9
  jsonSchema: (schema: JsonValue) => ScorerDefinition;
6
10
  };
7
11
  /** The source hash is a caller declaration; closures and installed dependencies are not attested. */
@@ -13,6 +17,13 @@ export declare function defineLocalScorer(options: {
13
17
  }): LocalScorer;
14
18
  /** Check local callbacks before target invocation; never execute downloaded source code. */
15
19
  export declare function validateScorerBindings(versions: ScorerVersion[], scorers?: LocalScorer[]): void;
20
+ /**
21
+ * Scores one subject with a pinned scorer version on this machine: built-ins run here, `local_code`
22
+ * pins run the matching callback from `options.scorers`, `manual` pins are skipped. Failures are
23
+ * returned as sanitized error scores.
24
+ *
25
+ * @throws TypeError for an `llm_judge` pin, which must be dispatched through `createJudgeJobs`.
26
+ */
16
27
  export declare function scoreLocally(version: ScorerVersion, context: ScoreContext, options?: {
17
28
  scorers?: LocalScorer[];
18
29
  schemaTimeoutMillis?: number;
@@ -1,17 +1,23 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import { Worker } from "node:worker_threads";
3
- import { digest, json, sourceDigest } from "./json.js";
4
+ import { aggregateBounds, digest, json, sourceDigest } from "./json.js";
5
+ import { environmentIncompleteReason, validateEnvironmentEvidence, } from "./environment-evidence.js";
6
+ /** Definitions for Hue's built-in scorers, ready to publish with `publishScorerVersion`. */
4
7
  export const builtins = {
8
+ /** Exact typed JSON equality with the reference output; key order is ignored. */
5
9
  exactMatch: () => ({
6
10
  kind: "builtin",
7
11
  entry: "hue.exact_match.v1",
8
12
  config: {},
9
13
  }),
14
+ /** The string reference output is contained in the string output. */
10
15
  includes: (caseSensitive = true) => ({
11
16
  kind: "builtin",
12
17
  entry: "hue.includes.v1",
13
18
  config: { caseSensitive },
14
19
  }),
20
+ /** The output satisfies a JSON Schema (draft 2020-12), validated with the optional `ajv` peer in a worker. */
15
21
  jsonSchema: (schema) => ({
16
22
  kind: "builtin",
17
23
  entry: "hue.json_schema.v1",
@@ -31,7 +37,25 @@ export function defineLocalScorer(options) {
31
37
  score: options.score,
32
38
  };
33
39
  }
40
+ /** ajv is an optional peer dependency: only JSON Schema scoring loads it, inside a worker. */
41
+ function schemaValidatorAvailable() {
42
+ try {
43
+ createRequire(import.meta.url).resolve("ajv/dist/2020.js");
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
34
50
  function schemaScore(schema, output, timeoutMillis) {
51
+ if (!schemaValidatorAvailable())
52
+ return Promise.resolve({
53
+ state: "error",
54
+ error: {
55
+ type: "SchemaValidatorUnavailable",
56
+ message: "JSON Schema scoring requires the optional ajv peer dependency: npm install ajv",
57
+ },
58
+ });
35
59
  return new Promise((resolve) => {
36
60
  const compiled = new URL("./schema-worker.js", import.meta.url);
37
61
  const file = existsSync(compiled) ? compiled : new URL("./schema-worker.ts", import.meta.url);
@@ -75,6 +99,13 @@ export function validateScorerBindings(versions, scorers = []) {
75
99
  throw new Error("A pinned local scorer has no matching language/source/entrypoint/metric binding");
76
100
  }
77
101
  }
102
+ /**
103
+ * Scores one subject with a pinned scorer version on this machine: built-ins run here, `local_code`
104
+ * pins run the matching callback from `options.scorers`, `manual` pins are skipped. Failures are
105
+ * returned as sanitized error scores.
106
+ *
107
+ * @throws TypeError for an `llm_judge` pin, which must be dispatched through `createJudgeJobs`.
108
+ */
78
109
  export async function scoreLocally(version, context, options = {}) {
79
110
  const definition = version.definition;
80
111
  if (definition.kind === "llm_judge")
@@ -82,13 +113,25 @@ export async function scoreLocally(version, context, options = {}) {
82
113
  const timeout = options.schemaTimeoutMillis ?? 2000;
83
114
  if (!Number.isInteger(timeout) || timeout < 100 || timeout > 60_000)
84
115
  throw new RangeError("schemaTimeoutMillis must be 100–60000");
85
- if (!context.hasOutput)
116
+ if (context.environment?.validity === "environment_incomplete") {
117
+ try {
118
+ validateEnvironmentEvidence(context.environment);
119
+ return skip(environmentIncompleteReason);
120
+ }
121
+ catch {
122
+ return { state: "error", error: { type: "LocalScorerError" } };
123
+ }
124
+ }
125
+ if (!context.hasOutput && !(definition.kind === "local_code" && context.environment))
86
126
  return skip("Output evidence is unavailable");
87
- if (context.output === undefined)
127
+ if (context.hasOutput && context.output === undefined)
88
128
  throw new TypeError("hasOutput requires a present JSON output");
89
129
  try {
90
130
  // Clone and validate inputs so a scorer cannot mutate another scorer's evidence.
91
- json(context, 1024 * 1024);
131
+ const { environment, ...ordinaryEvidence } = context;
132
+ json(ordinaryEvidence, aggregateBounds(1024 * 1024));
133
+ if (environment !== undefined)
134
+ validateEnvironmentEvidence(environment);
92
135
  const owned = structuredClone(context);
93
136
  if (definition.kind === "manual")
94
137
  return skip("Manual scoring requires a human session");
@@ -195,9 +238,17 @@ export function persistedScore(score, persistResultContent) {
195
238
  explanation: "Local scoring completed; result content storage disabled",
196
239
  };
197
240
  if (score.state === "error")
198
- return { state: "error", error: { type: "LocalScorerError" } };
241
+ return {
242
+ state: "error",
243
+ error: {
244
+ type: score.error.type === "EnvironmentEvidenceUnavailable"
245
+ ? "EnvironmentEvidenceUnavailable"
246
+ : "LocalScorerError",
247
+ },
248
+ };
199
249
  // Preserve fixed unavailable reasons, never arbitrary caller explanations.
200
250
  const safeReasons = [
251
+ environmentIncompleteReason,
201
252
  "Output evidence is unavailable",
202
253
  "Reference evidence is unavailable",
203
254
  "Includes requires string output and reference",