@hue-run/sdk 0.1.2
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/EVALUATIONS.md +141 -0
- package/LICENSE +18 -0
- package/README.md +196 -0
- package/dist/ai-sdk.d.ts +4 -0
- package/dist/ai-sdk.js +10 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +309 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +32 -0
- package/dist/evals/checkpoint.d.ts +9 -0
- package/dist/evals/checkpoint.js +89 -0
- package/dist/evals/client.d.ts +96 -0
- package/dist/evals/client.js +195 -0
- package/dist/evals/json.d.ts +6 -0
- package/dist/evals/json.js +61 -0
- package/dist/evals/runner.d.ts +49 -0
- package/dist/evals/runner.js +369 -0
- package/dist/evals/schema-worker.d.ts +1 -0
- package/dist/evals/schema-worker.js +14 -0
- package/dist/evals/scorers.d.ts +21 -0
- package/dist/evals/scorers.js +212 -0
- package/dist/evals/types.d.ts +301 -0
- package/dist/evals/types.js +1 -0
- package/dist/evals.d.ts +7 -0
- package/dist/evals.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/privacy.d.ts +7 -0
- package/dist/privacy.js +138 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +320 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { validateOptions } from "../config.js";
|
|
2
|
+
import { json, uuid } from "./json.js";
|
|
3
|
+
export class HueApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(status) {
|
|
6
|
+
super(status ? `Hue API request failed (HTTP ${status})` : "Hue API connection or response failed");
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = "HueApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** No implicit mutation retry: callers retain stable idempotency keys for experiments/results. */
|
|
12
|
+
export class EvaluationClient {
|
|
13
|
+
baseUrl;
|
|
14
|
+
apiKey;
|
|
15
|
+
timeoutMillis;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
const validated = validateOptions({
|
|
18
|
+
...options,
|
|
19
|
+
serviceName: "hue-evaluations",
|
|
20
|
+
captureContent: false,
|
|
21
|
+
});
|
|
22
|
+
this.baseUrl = validated.baseUrl;
|
|
23
|
+
this.apiKey = validated.apiKey;
|
|
24
|
+
this.timeoutMillis = validated.timeoutMillis;
|
|
25
|
+
}
|
|
26
|
+
async request(method, path, body) {
|
|
27
|
+
// Optional top-level fields are omitted intentionally; nested undefined remains invalid.
|
|
28
|
+
const payload = body === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: JSON.stringify(json(Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined)), 1024 * 1024));
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
37
|
+
...(payload ? { "Content-Type": "application/json" } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: payload,
|
|
40
|
+
redirect: "error",
|
|
41
|
+
signal: AbortSignal.timeout(this.timeoutMillis),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new HueApiError();
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
throw new HueApiError(response.status);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const reader = response.body?.getReader();
|
|
53
|
+
if (!reader)
|
|
54
|
+
throw new Error("Missing response");
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let size = 0;
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done)
|
|
61
|
+
break;
|
|
62
|
+
size += value.byteLength;
|
|
63
|
+
if (size > 4 * 1024 * 1024)
|
|
64
|
+
throw new Error("Oversized response");
|
|
65
|
+
chunks.push(value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await reader.cancel();
|
|
70
|
+
}
|
|
71
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new HueApiError();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
page(options = {}) {
|
|
78
|
+
const query = new URLSearchParams();
|
|
79
|
+
if (options.after)
|
|
80
|
+
query.set("after", uuid(options.after));
|
|
81
|
+
if (options.limit !== undefined) {
|
|
82
|
+
if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 100)
|
|
83
|
+
throw new RangeError("Page limit must be 1–100");
|
|
84
|
+
query.set("limit", String(options.limit));
|
|
85
|
+
}
|
|
86
|
+
return query.size ? `?${query}` : "";
|
|
87
|
+
}
|
|
88
|
+
checkConnection() {
|
|
89
|
+
return this.request("GET", "/projects/current");
|
|
90
|
+
}
|
|
91
|
+
createDataset(input) {
|
|
92
|
+
return this.request("POST", "/datasets", input);
|
|
93
|
+
}
|
|
94
|
+
getDataset(id) {
|
|
95
|
+
return this.request("GET", `/datasets/${uuid(id)}`);
|
|
96
|
+
}
|
|
97
|
+
listDatasets(page) {
|
|
98
|
+
return this.request("GET", `/datasets${this.page(page)}`);
|
|
99
|
+
}
|
|
100
|
+
createDatasetVersion(id, input = {}) {
|
|
101
|
+
return this.request("POST", `/datasets/${uuid(id)}/versions`, input);
|
|
102
|
+
}
|
|
103
|
+
getDatasetVersion(id) {
|
|
104
|
+
return this.request("GET", `/dataset-versions/${uuid(id)}`);
|
|
105
|
+
}
|
|
106
|
+
listCases(id, page) {
|
|
107
|
+
return this.request("GET", `/dataset-versions/${uuid(id)}/cases${this.page(page)}`);
|
|
108
|
+
}
|
|
109
|
+
addCase(id, input) {
|
|
110
|
+
return this.request("POST", `/dataset-versions/${uuid(id)}/cases`, input);
|
|
111
|
+
}
|
|
112
|
+
freezeDatasetVersion(id, expectedRevision) {
|
|
113
|
+
return this.request("POST", `/dataset-versions/${uuid(id)}/freeze`, {
|
|
114
|
+
expectedRevision,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
createScorer(input) {
|
|
118
|
+
return this.request("POST", "/scorers", input);
|
|
119
|
+
}
|
|
120
|
+
getScorer(id) {
|
|
121
|
+
return this.request("GET", `/scorers/${uuid(id)}`);
|
|
122
|
+
}
|
|
123
|
+
listScorers(page) {
|
|
124
|
+
return this.request("GET", `/scorers${this.page(page)}`);
|
|
125
|
+
}
|
|
126
|
+
publishScorerVersion(id, definition) {
|
|
127
|
+
return this.request("POST", `/scorers/${uuid(id)}/versions`, { definition });
|
|
128
|
+
}
|
|
129
|
+
getScorerVersion(id) {
|
|
130
|
+
return this.request("GET", `/scorer-versions/${uuid(id)}`);
|
|
131
|
+
}
|
|
132
|
+
createExperiment(input) {
|
|
133
|
+
return this.request("POST", "/experiments", input);
|
|
134
|
+
}
|
|
135
|
+
getExperiment(id) {
|
|
136
|
+
return this.request("GET", `/experiments/${uuid(id)}`);
|
|
137
|
+
}
|
|
138
|
+
listExperimentItems(id, page) {
|
|
139
|
+
return this.request("GET", `/experiments/${uuid(id)}/items${this.page(page)}`);
|
|
140
|
+
}
|
|
141
|
+
getExperimentCase(id, caseId) {
|
|
142
|
+
return this.request("GET", `/experiments/${uuid(id)}/items/${uuid(caseId)}`);
|
|
143
|
+
}
|
|
144
|
+
startExecution(id, caseId, input) {
|
|
145
|
+
return this.request("POST", `/experiments/${uuid(id)}/items/${uuid(caseId)}/start`, input);
|
|
146
|
+
}
|
|
147
|
+
getExecution(id) {
|
|
148
|
+
return this.request("GET", `/experiment-executions/${uuid(id)}`);
|
|
149
|
+
}
|
|
150
|
+
completeExecution(id, input) {
|
|
151
|
+
return this.request("POST", `/experiment-executions/${uuid(id)}/complete`, input);
|
|
152
|
+
}
|
|
153
|
+
finishExperiment(id, idempotencyKey) {
|
|
154
|
+
return this.request("POST", `/experiments/${uuid(id)}/finish`, { idempotencyKey });
|
|
155
|
+
}
|
|
156
|
+
createEvaluationRun(input) {
|
|
157
|
+
return this.request("POST", "/evaluation-runs", input);
|
|
158
|
+
}
|
|
159
|
+
getEvaluationRun(id) {
|
|
160
|
+
return this.request("GET", `/evaluation-runs/${uuid(id)}`);
|
|
161
|
+
}
|
|
162
|
+
listEvaluationItems(id, page) {
|
|
163
|
+
return this.request("GET", `/evaluation-runs/${uuid(id)}/items${this.page(page)}`);
|
|
164
|
+
}
|
|
165
|
+
getSubject(id) {
|
|
166
|
+
return this.request("GET", `/evaluation-subjects/${uuid(id)}`);
|
|
167
|
+
}
|
|
168
|
+
submitResults(id, input) {
|
|
169
|
+
return this.request("POST", `/evaluation-runs/${uuid(id)}/results`, input);
|
|
170
|
+
}
|
|
171
|
+
listResults(id, page) {
|
|
172
|
+
return this.request("GET", `/evaluation-runs/${uuid(id)}/results${this.page(page)}`);
|
|
173
|
+
}
|
|
174
|
+
getResult(id) {
|
|
175
|
+
return this.request("GET", `/evaluation-results/${uuid(id)}`);
|
|
176
|
+
}
|
|
177
|
+
createJudgeJobs(id, input) {
|
|
178
|
+
return this.request("POST", `/evaluation-runs/${uuid(id)}/judge-jobs`, input);
|
|
179
|
+
}
|
|
180
|
+
listJudgeJobs(id, page) {
|
|
181
|
+
return this.request("GET", `/evaluation-runs/${uuid(id)}/judge-jobs${this.page(page)}`);
|
|
182
|
+
}
|
|
183
|
+
getJudgeJob(id) {
|
|
184
|
+
return this.request("GET", `/judge-jobs/${uuid(id)}`);
|
|
185
|
+
}
|
|
186
|
+
cancelJudgeJob(id, reason) {
|
|
187
|
+
return this.request("POST", `/judge-jobs/${uuid(id)}/cancel`, { reason });
|
|
188
|
+
}
|
|
189
|
+
getJudgeBudget() {
|
|
190
|
+
return this.request("GET", "/judge-budget");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function createEvaluationClient(options) {
|
|
194
|
+
return new EvaluationClient(options);
|
|
195
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { JsonValue } from "../types.js";
|
|
2
|
+
/** Reject lossy JSON serialization before creating requests/checkpoints. */
|
|
3
|
+
export declare function json(value: unknown, maxBytes?: number): JsonValue;
|
|
4
|
+
export declare function digest(value: unknown): string;
|
|
5
|
+
export declare function sourceDigest(source: string | Uint8Array): string;
|
|
6
|
+
export declare function uuid(value: string): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Reject lossy JSON serialization before creating requests/checkpoints. */
|
|
3
|
+
export function json(value, maxBytes = 200_000) {
|
|
4
|
+
const ancestors = new Set();
|
|
5
|
+
let nodes = 0;
|
|
6
|
+
const visit = (item, depth) => {
|
|
7
|
+
if (++nodes > 20_000 || depth > 32)
|
|
8
|
+
throw new RangeError("JSON exceeds depth/node limits");
|
|
9
|
+
if (item === null || typeof item === "boolean")
|
|
10
|
+
return item;
|
|
11
|
+
if (typeof item === "number" && Number.isFinite(item))
|
|
12
|
+
return item;
|
|
13
|
+
if (typeof item === "string" && item.isWellFormed() && !item.includes("\u0000"))
|
|
14
|
+
return item;
|
|
15
|
+
if (!item || typeof item !== "object" || ancestors.has(item))
|
|
16
|
+
throw new TypeError("Expected finite JSON without cycles or invalid Unicode");
|
|
17
|
+
ancestors.add(item);
|
|
18
|
+
try {
|
|
19
|
+
if (Array.isArray(item)) {
|
|
20
|
+
if (Object.keys(item).length !== item.length)
|
|
21
|
+
throw new TypeError("Sparse/extended arrays are not JSON");
|
|
22
|
+
return item.map((entry) => visit(entry, depth + 1));
|
|
23
|
+
}
|
|
24
|
+
if (Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
|
|
25
|
+
throw new TypeError("JSON objects must be plain objects");
|
|
26
|
+
if (Object.getOwnPropertySymbols(item).length)
|
|
27
|
+
throw new TypeError("JSON cannot contain symbol properties");
|
|
28
|
+
const result = Object.create(null);
|
|
29
|
+
for (const key of Object.keys(item).sort()) {
|
|
30
|
+
visit(key, depth + 1);
|
|
31
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
32
|
+
if (!("value" in descriptor))
|
|
33
|
+
throw new TypeError("JSON cannot contain accessors");
|
|
34
|
+
result[key] = visit(descriptor.value, depth + 1);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
// Repeated siblings serialize independently; only an active ancestor is a cycle.
|
|
40
|
+
ancestors.delete(item);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const result = visit(value, 0);
|
|
44
|
+
if (Buffer.byteLength(JSON.stringify(result)) > maxBytes)
|
|
45
|
+
throw new RangeError("JSON exceeds byte limit");
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
export function digest(value) {
|
|
49
|
+
return createHash("sha256")
|
|
50
|
+
.update(JSON.stringify(json(value, 8 * 1024 * 1024)))
|
|
51
|
+
.digest("hex");
|
|
52
|
+
}
|
|
53
|
+
export function sourceDigest(source) {
|
|
54
|
+
return createHash("sha256").update(source).digest("hex");
|
|
55
|
+
}
|
|
56
|
+
export function uuid(value) {
|
|
57
|
+
if (typeof value !== "string" ||
|
|
58
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
59
|
+
throw new TypeError("Expected a UUID");
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { HueClient } from "../client.js";
|
|
2
|
+
import type { HueSpan } from "../types.js";
|
|
3
|
+
import { EvaluationClient } from "./client.js";
|
|
4
|
+
import type { ExperimentCase, JsonValue, LocalScorer } from "./types.js";
|
|
5
|
+
export declare class UncertainExecutionError extends Error {
|
|
6
|
+
readonly caseId: string;
|
|
7
|
+
readonly executionId?: string | undefined;
|
|
8
|
+
constructor(caseId: string, executionId?: string | undefined);
|
|
9
|
+
}
|
|
10
|
+
export declare class OutcomeSerializationError extends Error {
|
|
11
|
+
readonly executionId: string;
|
|
12
|
+
constructor(executionId: string);
|
|
13
|
+
}
|
|
14
|
+
interface RunnerOptions {
|
|
15
|
+
client: EvaluationClient;
|
|
16
|
+
checkpointDirectory: string;
|
|
17
|
+
persistResultContent: boolean;
|
|
18
|
+
scorers?: LocalScorer[];
|
|
19
|
+
concurrency?: number;
|
|
20
|
+
schemaTimeoutMillis?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface RunExperimentOptions extends RunnerOptions {
|
|
23
|
+
hue: HueClient;
|
|
24
|
+
experimentId: string;
|
|
25
|
+
traceEvidence: {
|
|
26
|
+
mode: "required";
|
|
27
|
+
} | {
|
|
28
|
+
mode: "omit";
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
target(inputs: JsonValue, context: {
|
|
32
|
+
config: JsonValue;
|
|
33
|
+
item: ExperimentCase;
|
|
34
|
+
span: HueSpan;
|
|
35
|
+
}): JsonValue | undefined | Promise<JsonValue | undefined>;
|
|
36
|
+
}
|
|
37
|
+
export interface RescoreOptions extends RunnerOptions {
|
|
38
|
+
runId: string;
|
|
39
|
+
}
|
|
40
|
+
export interface RunnerReport {
|
|
41
|
+
runId: string;
|
|
42
|
+
subjectIds: string[];
|
|
43
|
+
resultIds: string[];
|
|
44
|
+
deferredScorerVersionIds: string[];
|
|
45
|
+
}
|
|
46
|
+
export declare function runExperiment(options: RunExperimentOptions): Promise<RunnerReport>;
|
|
47
|
+
/** Scores existing immutable subjects; this API has no target callback. */
|
|
48
|
+
export declare function rescore(options: RescoreOptions): Promise<RunnerReport>;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
3
|
+
import { HueExportError } from "../transport.js";
|
|
4
|
+
import { CheckpointStore } from "./checkpoint.js";
|
|
5
|
+
import { json, uuid } from "./json.js";
|
|
6
|
+
import { persistedScore, scoreLocally, validateScorerBindings } from "./scorers.js";
|
|
7
|
+
export class UncertainExecutionError extends Error {
|
|
8
|
+
caseId;
|
|
9
|
+
executionId;
|
|
10
|
+
constructor(caseId, executionId) {
|
|
11
|
+
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
|
+
this.caseId = caseId;
|
|
13
|
+
this.executionId = executionId;
|
|
14
|
+
this.name = "UncertainExecutionError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class OutcomeSerializationError extends Error {
|
|
18
|
+
executionId;
|
|
19
|
+
constructor(executionId) {
|
|
20
|
+
super("Target completed, but its output could not be serialized. Resolve completion explicitly; the runner will not invoke the target again.");
|
|
21
|
+
this.executionId = executionId;
|
|
22
|
+
this.name = "OutcomeSerializationError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function settings(options) {
|
|
26
|
+
if (typeof options.persistResultContent !== "boolean")
|
|
27
|
+
throw new TypeError("Choose persistResultContent explicitly: true or false");
|
|
28
|
+
const concurrency = options.concurrency ?? 1;
|
|
29
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16)
|
|
30
|
+
throw new RangeError("concurrency must be 1–16");
|
|
31
|
+
const timeout = options.schemaTimeoutMillis ?? 2000;
|
|
32
|
+
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 60_000)
|
|
33
|
+
throw new RangeError("schemaTimeoutMillis must be 100–60000");
|
|
34
|
+
return concurrency;
|
|
35
|
+
}
|
|
36
|
+
async function allPages(page) {
|
|
37
|
+
const items = [];
|
|
38
|
+
const cursors = new Set();
|
|
39
|
+
let after;
|
|
40
|
+
do {
|
|
41
|
+
const response = await page(after);
|
|
42
|
+
items.push(...response.items);
|
|
43
|
+
if (items.length > 5000)
|
|
44
|
+
throw new RangeError("Runner supports at most 5000 items");
|
|
45
|
+
if (response.nextCursor === null)
|
|
46
|
+
break;
|
|
47
|
+
after = uuid(response.nextCursor);
|
|
48
|
+
if (cursors.has(after))
|
|
49
|
+
throw new Error("API pagination repeated a cursor");
|
|
50
|
+
cursors.add(after);
|
|
51
|
+
} while (after !== undefined);
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
async function pool(items, concurrency, execute) {
|
|
55
|
+
let position = 0;
|
|
56
|
+
const failures = [];
|
|
57
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
58
|
+
while (position < items.length && !failures.length) {
|
|
59
|
+
const item = items[position++];
|
|
60
|
+
try {
|
|
61
|
+
await execute(item);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
failures.push(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
if (failures.length === 1)
|
|
69
|
+
throw failures[0];
|
|
70
|
+
if (failures.length)
|
|
71
|
+
throw new AggregateError(failures, "Multiple case operations failed; resume uses saved outcomes");
|
|
72
|
+
}
|
|
73
|
+
async function scoresFor(versions, context, options) {
|
|
74
|
+
const scores = [];
|
|
75
|
+
for (const version of versions) {
|
|
76
|
+
// Hosted/manual pins remain pending for their authorized executor.
|
|
77
|
+
if (version.definition.kind === "llm_judge" || version.definition.kind === "manual")
|
|
78
|
+
continue;
|
|
79
|
+
const score = persistedScore(await scoreLocally(version, context, options), options.persistResultContent);
|
|
80
|
+
scores.push({
|
|
81
|
+
key: randomUUID(),
|
|
82
|
+
payload: {
|
|
83
|
+
...score,
|
|
84
|
+
scorerVersionId: version.id,
|
|
85
|
+
...(version.definition.kind === "local_code"
|
|
86
|
+
? { sourceDigest: version.definition.sourceDigest }
|
|
87
|
+
: {}),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return scores;
|
|
92
|
+
}
|
|
93
|
+
async function uploadScores(options, runId, scores, save) {
|
|
94
|
+
for (const score of scores) {
|
|
95
|
+
if (score.receipt)
|
|
96
|
+
continue;
|
|
97
|
+
if (!score.payload.evaluationItemId)
|
|
98
|
+
throw new Error("Scoring requires the acknowledged evaluation item identity");
|
|
99
|
+
const result = await options.client.submitResults(runId, {
|
|
100
|
+
idempotencyKey: score.key,
|
|
101
|
+
results: [score.payload],
|
|
102
|
+
});
|
|
103
|
+
score.receipt = result.ids;
|
|
104
|
+
await save();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function runExperiment(options) {
|
|
108
|
+
const concurrency = settings(options);
|
|
109
|
+
if (!options.traceEvidence || !["required", "omit"].includes(options.traceEvidence.mode))
|
|
110
|
+
throw new TypeError("Choose a trace evidence policy explicitly");
|
|
111
|
+
if (options.traceEvidence.mode === "omit" &&
|
|
112
|
+
(!options.traceEvidence.reason?.trim() || options.traceEvidence.reason.length > 4000))
|
|
113
|
+
throw new TypeError("Omitting evidence requires a bounded reason");
|
|
114
|
+
if (options.hue.transport.options.baseUrl !== options.client.baseUrl)
|
|
115
|
+
throw new Error("Telemetry and evaluations must use the same Hue origin");
|
|
116
|
+
const [project, telemetryProject, experiment] = await Promise.all([
|
|
117
|
+
options.client.checkConnection(),
|
|
118
|
+
options.hue.checkConnection(),
|
|
119
|
+
options.client.getExperiment(options.experimentId),
|
|
120
|
+
]);
|
|
121
|
+
if (project.id !== telemetryProject.id)
|
|
122
|
+
throw new Error("Telemetry and evaluations must use the same Hue project");
|
|
123
|
+
const version = await options.client.getDatasetVersion(experiment.datasetVersionId);
|
|
124
|
+
if (!version.frozenAt || !version.contentDigest)
|
|
125
|
+
throw new Error("Experiment dataset must be frozen");
|
|
126
|
+
const versions = experiment.evaluation.scorerVersions;
|
|
127
|
+
validateScorerBindings(versions, options.scorers);
|
|
128
|
+
const items = await allPages((after) => options.client.listExperimentItems(experiment.id, { after }));
|
|
129
|
+
if (items.length !== experiment.caseCount)
|
|
130
|
+
throw new Error("Frozen experiment case count differs from API items");
|
|
131
|
+
const store = await CheckpointStore.acquire(options.checkpointDirectory, {
|
|
132
|
+
kind: "experiment",
|
|
133
|
+
projectId: project.id,
|
|
134
|
+
baseUrl: options.client.baseUrl,
|
|
135
|
+
experimentId: experiment.id,
|
|
136
|
+
datasetVersionId: version.id,
|
|
137
|
+
datasetDigest: version.contentDigest,
|
|
138
|
+
configDigest: experiment.configDigest,
|
|
139
|
+
pins: versions
|
|
140
|
+
.map(({ id, contentDigest }) => ({ id, contentDigest }))
|
|
141
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
142
|
+
persistResultContent: options.persistResultContent,
|
|
143
|
+
captureContent: options.hue.captureContent,
|
|
144
|
+
traceEvidence: options.traceEvidence,
|
|
145
|
+
});
|
|
146
|
+
const report = {
|
|
147
|
+
runId: experiment.evaluation.id,
|
|
148
|
+
subjectIds: [],
|
|
149
|
+
resultIds: [],
|
|
150
|
+
deferredScorerVersionIds: versions
|
|
151
|
+
.filter((version) => ["llm_judge", "manual"].includes(version.definition.kind))
|
|
152
|
+
.map((version) => version.id),
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
await pool(items, concurrency, async (item) => {
|
|
156
|
+
const file = `case-${uuid(item.id)}`;
|
|
157
|
+
let checkpoint = await store.read(file);
|
|
158
|
+
if (checkpoint && checkpoint.stage !== "prepared") {
|
|
159
|
+
if (checkpoint.stage === "serialization_failed")
|
|
160
|
+
throw new OutcomeSerializationError(checkpoint.executionId);
|
|
161
|
+
const execution = checkpoint.stage === "starting"
|
|
162
|
+
? await options.client.startExecution(experiment.id, item.id, {
|
|
163
|
+
idempotencyKey: checkpoint.startKey,
|
|
164
|
+
traceExternalId: checkpoint.traceExternalId,
|
|
165
|
+
})
|
|
166
|
+
: await options.client.getExecution(checkpoint.executionId);
|
|
167
|
+
throw new UncertainExecutionError(item.id, execution.id);
|
|
168
|
+
}
|
|
169
|
+
if (!checkpoint) {
|
|
170
|
+
if (item.execution)
|
|
171
|
+
throw new UncertainExecutionError(item.id, item.execution.id);
|
|
172
|
+
const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
|
|
173
|
+
if (frozenCase.datasetVersionId !== version.id)
|
|
174
|
+
throw new Error("Case is not from the pinned dataset version");
|
|
175
|
+
const failureSequenceBefore = options.hue.transport.getFailureSequence();
|
|
176
|
+
checkpoint = await options.hue.withSpan("hue.experiment.case", async (span) => {
|
|
177
|
+
const start = {
|
|
178
|
+
stage: "starting",
|
|
179
|
+
startKey: randomUUID(),
|
|
180
|
+
traceExternalId: span.traceId,
|
|
181
|
+
};
|
|
182
|
+
await store.write(file, start);
|
|
183
|
+
const execution = await options.client.startExecution(experiment.id, item.id, {
|
|
184
|
+
idempotencyKey: start.startKey,
|
|
185
|
+
traceExternalId: span.traceId,
|
|
186
|
+
});
|
|
187
|
+
await store.write(file, { stage: "running", executionId: execution.id });
|
|
188
|
+
let state = "succeeded";
|
|
189
|
+
let output;
|
|
190
|
+
let targetError;
|
|
191
|
+
try {
|
|
192
|
+
output = await options.target(json(frozenCase.inputs), {
|
|
193
|
+
config: json(experiment.config),
|
|
194
|
+
item: structuredClone(frozenCase),
|
|
195
|
+
span,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
state = "error";
|
|
200
|
+
targetError = error;
|
|
201
|
+
options.hue.recordError(span.span, error);
|
|
202
|
+
}
|
|
203
|
+
// Output validation is outside the target catch: a successful target never becomes an agent error because upload serialization failed.
|
|
204
|
+
if (output !== undefined) {
|
|
205
|
+
try {
|
|
206
|
+
output = json(output);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
await store.write(file, {
|
|
210
|
+
stage: "serialization_failed",
|
|
211
|
+
executionId: execution.id,
|
|
212
|
+
});
|
|
213
|
+
throw new OutcomeSerializationError(execution.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (output !== undefined)
|
|
217
|
+
span.setOutput(output);
|
|
218
|
+
const scores = await scoresFor(versions, {
|
|
219
|
+
inputs: frozenCase.inputs,
|
|
220
|
+
hasExpected: frozenCase.hasExpected,
|
|
221
|
+
...(frozenCase.hasExpected ? { expected: frozenCase.expected } : {}),
|
|
222
|
+
metadata: frozenCase.metadata,
|
|
223
|
+
hasOutput: output !== undefined,
|
|
224
|
+
...(output !== undefined ? { output } : {}),
|
|
225
|
+
executionState: state,
|
|
226
|
+
}, options);
|
|
227
|
+
const complete = {
|
|
228
|
+
idempotencyKey: randomUUID(),
|
|
229
|
+
state,
|
|
230
|
+
...(options.persistResultContent && output !== undefined ? { output } : {}),
|
|
231
|
+
...(state === "error"
|
|
232
|
+
? {
|
|
233
|
+
error: {
|
|
234
|
+
type: "TargetError",
|
|
235
|
+
...(options.persistResultContent && targetError instanceof Error
|
|
236
|
+
? {
|
|
237
|
+
message: targetError.message
|
|
238
|
+
.slice(0, 4000)
|
|
239
|
+
.toWellFormed()
|
|
240
|
+
.replaceAll("\u0000", ""),
|
|
241
|
+
}
|
|
242
|
+
: {}),
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
: {}),
|
|
246
|
+
traceEvidence: options.traceEvidence.mode,
|
|
247
|
+
...(options.traceEvidence.mode === "omit"
|
|
248
|
+
? { omissionReason: options.traceEvidence.reason }
|
|
249
|
+
: {}),
|
|
250
|
+
};
|
|
251
|
+
const prepared = {
|
|
252
|
+
stage: "prepared",
|
|
253
|
+
executionId: execution.id,
|
|
254
|
+
complete,
|
|
255
|
+
scores,
|
|
256
|
+
exportState: "pending",
|
|
257
|
+
};
|
|
258
|
+
await store.write(file, prepared);
|
|
259
|
+
return prepared;
|
|
260
|
+
}, {
|
|
261
|
+
parentContext: ROOT_CONTEXT,
|
|
262
|
+
input: frozenCase.inputs,
|
|
263
|
+
attributes: { "hue.experiment.id": experiment.id, "hue.dataset.case.id": item.id },
|
|
264
|
+
});
|
|
265
|
+
// End root before waiting for both OTLP signals. Export failure leaves the prepared checkpoint intact.
|
|
266
|
+
try {
|
|
267
|
+
await options.hue.flush();
|
|
268
|
+
// Another concurrent flush may already have surfaced this failure. OTLP
|
|
269
|
+
// partial acknowledgements do not identify individual rejected records.
|
|
270
|
+
if (options.hue.transport.getFailureSequence() !== failureSequenceBefore)
|
|
271
|
+
throw new HueExportError(options.hue.transport
|
|
272
|
+
.getIssues()
|
|
273
|
+
.filter((issue) => issue.sequence > failureSequenceBefore && issue.kind !== "warning"), options.hue.transport.getReport());
|
|
274
|
+
checkpoint.exportState = "accepted";
|
|
275
|
+
await store.write(file, checkpoint);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
// The explicitly chosen omission policy can complete without acknowledged telemetry.
|
|
279
|
+
if (options.traceEvidence.mode !== "omit")
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const prepared = checkpoint;
|
|
284
|
+
await options.client.getExecution(prepared.executionId);
|
|
285
|
+
if (prepared.exportState !== "accepted" && prepared.complete.traceEvidence !== "omit")
|
|
286
|
+
throw new Error("Target outcome is saved but trace export acknowledgement is unavailable. Restore/export the trace or explicitly complete with omitted evidence through the client; never rerun the target.");
|
|
287
|
+
const save = () => store.write(file, prepared);
|
|
288
|
+
if (!prepared.completion) {
|
|
289
|
+
prepared.completion = await options.client.completeExecution(prepared.executionId, prepared.complete);
|
|
290
|
+
for (const score of prepared.scores)
|
|
291
|
+
score.payload.evaluationItemId = prepared.completion.evaluationItemId;
|
|
292
|
+
await save();
|
|
293
|
+
}
|
|
294
|
+
await uploadScores(options, experiment.evaluation.id, prepared.scores, save);
|
|
295
|
+
report.subjectIds.push(prepared.completion.subjectId);
|
|
296
|
+
report.resultIds.push(...prepared.scores.flatMap((score) => score.receipt ?? []));
|
|
297
|
+
});
|
|
298
|
+
let finish = await store.read("finish");
|
|
299
|
+
if (!finish) {
|
|
300
|
+
finish = { key: randomUUID() };
|
|
301
|
+
await store.write("finish", finish);
|
|
302
|
+
}
|
|
303
|
+
await options.client.finishExperiment(experiment.id, finish.key);
|
|
304
|
+
return report;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
await store.release();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Scores existing immutable subjects; this API has no target callback. */
|
|
311
|
+
export async function rescore(options) {
|
|
312
|
+
const concurrency = settings(options);
|
|
313
|
+
const [project, run] = await Promise.all([
|
|
314
|
+
options.client.checkConnection(),
|
|
315
|
+
options.client.getEvaluationRun(options.runId),
|
|
316
|
+
]);
|
|
317
|
+
validateScorerBindings(run.scorerVersions, options.scorers);
|
|
318
|
+
const items = await allPages((after) => options.client.listEvaluationItems(run.id, { after }));
|
|
319
|
+
if (items.length !== run.itemCount)
|
|
320
|
+
throw new Error("Frozen evaluation run item count differs from API items");
|
|
321
|
+
const store = await CheckpointStore.acquire(options.checkpointDirectory, {
|
|
322
|
+
kind: "rescore",
|
|
323
|
+
projectId: project.id,
|
|
324
|
+
baseUrl: options.client.baseUrl,
|
|
325
|
+
runId: run.id,
|
|
326
|
+
pins: run.scorerVersions
|
|
327
|
+
.map(({ id, contentDigest }) => ({ id, contentDigest }))
|
|
328
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
329
|
+
persistResultContent: options.persistResultContent,
|
|
330
|
+
});
|
|
331
|
+
const report = {
|
|
332
|
+
runId: run.id,
|
|
333
|
+
subjectIds: [],
|
|
334
|
+
resultIds: [],
|
|
335
|
+
deferredScorerVersionIds: run.scorerVersions
|
|
336
|
+
.filter((version) => ["llm_judge", "manual"].includes(version.definition.kind))
|
|
337
|
+
.map((version) => version.id),
|
|
338
|
+
};
|
|
339
|
+
try {
|
|
340
|
+
await pool(items, concurrency, async (item) => {
|
|
341
|
+
const file = `item-${uuid(item.id)}`;
|
|
342
|
+
let saved = await store.read(file);
|
|
343
|
+
if (!saved) {
|
|
344
|
+
const subject = await options.client.getSubject(item.subjectId);
|
|
345
|
+
const scores = await scoresFor(run.scorerVersions, {
|
|
346
|
+
inputs: subject.inputs,
|
|
347
|
+
hasOutput: subject.hasOutput,
|
|
348
|
+
hasExpected: subject.hasExpected,
|
|
349
|
+
...(subject.hasOutput ? { output: subject.output } : {}),
|
|
350
|
+
...(subject.hasExpected ? { expected: subject.expected } : {}),
|
|
351
|
+
metadata: subject.metadata,
|
|
352
|
+
executionState: subject.executionState,
|
|
353
|
+
}, options);
|
|
354
|
+
for (const score of scores)
|
|
355
|
+
score.payload.evaluationItemId = item.id;
|
|
356
|
+
saved = { scores };
|
|
357
|
+
await store.write(file, saved);
|
|
358
|
+
}
|
|
359
|
+
const current = saved;
|
|
360
|
+
await uploadScores(options, run.id, current.scores, () => store.write(file, current));
|
|
361
|
+
report.subjectIds.push(item.subjectId);
|
|
362
|
+
report.resultIds.push(...current.scores.flatMap((score) => score.receipt ?? []));
|
|
363
|
+
});
|
|
364
|
+
return report;
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
await store.release();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|