@atlanai/sdk 0.2.3 → 0.2.4
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/dist/client.d.ts +16 -2
- package/dist/client.js +118 -9
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +23 -1
- package/dist/evals.d.ts +130 -7
- package/dist/evals.js +724 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -1
- package/dist/resources.d.ts +10 -0
- package/dist/resources.js +54 -4
- package/dist/tracing/version.d.ts +1 -1
- package/dist/tracing/version.js +1 -1
- package/package.json +1 -1
package/dist/evals.js
CHANGED
|
@@ -4,10 +4,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.EvalRun = exports.CONTEXT_MANIFEST_SCHEMA = void 0;
|
|
5
5
|
exports.createContextManifest = createContextManifest;
|
|
6
6
|
exports.resolveDataset = resolveDataset;
|
|
7
|
+
exports.recordName = recordName;
|
|
8
|
+
exports.pushRecords = pushRecords;
|
|
9
|
+
exports.pushDataset = pushDataset;
|
|
7
10
|
exports.startExperiment = startExperiment;
|
|
11
|
+
exports.experimentTraces = experimentTraces;
|
|
12
|
+
exports.recordExperimentSession = recordExperimentSession;
|
|
8
13
|
exports.Eval = Eval;
|
|
14
|
+
exports.verifyExperiment = verifyExperiment;
|
|
9
15
|
const client_1 = require("./client");
|
|
10
16
|
const errors_1 = require("./errors");
|
|
17
|
+
const AgentSubjectKind_1 = require("./raw/agent/models/AgentSubjectKind");
|
|
11
18
|
exports.CONTEXT_MANIFEST_SCHEMA = "atlan.eval.context-manifest/v1";
|
|
12
19
|
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
13
20
|
const TRACE_ID = /^[0-9a-f]{32}$/;
|
|
@@ -107,6 +114,8 @@ async function createContextManifest(input) {
|
|
|
107
114
|
const digest = "sha256:" + [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
108
115
|
return Object.freeze({ ...body, items: Object.freeze(items), digest });
|
|
109
116
|
}
|
|
117
|
+
class DatasetNotFoundError extends Error {
|
|
118
|
+
}
|
|
110
119
|
/** Resolve one dataset by artifact ID or exact name, never fuzzy matching. */
|
|
111
120
|
async function resolveDataset(client, idOrExactName, workspaceId) {
|
|
112
121
|
if (!idOrExactName || idOrExactName.trim() !== idOrExactName) {
|
|
@@ -141,17 +150,263 @@ async function resolveDataset(client, idOrExactName, workspaceId) {
|
|
|
141
150
|
}
|
|
142
151
|
if (matches.length === 0) {
|
|
143
152
|
const scope = workspace === undefined ? "" : ` in workspace '${workspace}'`;
|
|
144
|
-
throw new
|
|
153
|
+
throw new DatasetNotFoundError(`no dataset named '${idOrExactName}'${scope}`);
|
|
145
154
|
}
|
|
146
155
|
if (matches.length > 1) {
|
|
147
156
|
throw new Error(`multiple datasets are named '${idOrExactName}'; pass the dataset ID`);
|
|
148
157
|
}
|
|
149
158
|
return matches[0];
|
|
150
159
|
}
|
|
160
|
+
// --- Pushing a suite -------------------------------------------------------
|
|
161
|
+
//
|
|
162
|
+
// A record name is unique per `(workspace, kind)`, not per dataset, and
|
|
163
|
+
// archiving never frees it, so a developer's name is spendable once per
|
|
164
|
+
// workspace. `name` is therefore a derived per-dataset handle and
|
|
165
|
+
// `displayName` carries the developer's key. Nothing reads a record by name.
|
|
166
|
+
const RECORD_NAME_DIGEST = 12;
|
|
167
|
+
const BULK_RECORD_LIMIT = 100;
|
|
168
|
+
// `name` is excluded: the gateway accepts it in a patch and ignores it
|
|
169
|
+
// (measured: the old name came back with the ordinal bumped).
|
|
170
|
+
const PUSHED_RECORD_FIELDS = [
|
|
171
|
+
"input",
|
|
172
|
+
"expected",
|
|
173
|
+
"label",
|
|
174
|
+
"categories",
|
|
175
|
+
"description",
|
|
176
|
+
"displayName",
|
|
177
|
+
"extra",
|
|
178
|
+
];
|
|
179
|
+
/**
|
|
180
|
+
* The Registry handle for the case called `key` inside `datasetId`.
|
|
181
|
+
*
|
|
182
|
+
* Deterministic, so pushing the same suite twice lands on the same row, and
|
|
183
|
+
* dataset-scoped, so the same suite can also be pushed into a new dataset.
|
|
184
|
+
*/
|
|
185
|
+
async function recordName(datasetId, key) {
|
|
186
|
+
nonEmpty(datasetId, "datasetId must be a non-empty string");
|
|
187
|
+
nonEmpty(key, "a pushed record needs a non-empty name");
|
|
188
|
+
if (!globalThis.crypto?.subtle) {
|
|
189
|
+
throw new Error("Web Crypto is required to derive a dataset record name");
|
|
190
|
+
}
|
|
191
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${datasetId}\u0000${key}`));
|
|
192
|
+
const digest = [...new Uint8Array(hash)]
|
|
193
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
194
|
+
.join("")
|
|
195
|
+
.slice(0, RECORD_NAME_DIGEST);
|
|
196
|
+
// The digest over the whole key is what keeps two cases apart; the prefix
|
|
197
|
+
// is only readability. The Python SDK derives the same handle.
|
|
198
|
+
return `${slug(key, "record")}-${digest}`;
|
|
199
|
+
}
|
|
200
|
+
function pushKey(record) {
|
|
201
|
+
const key = record.name;
|
|
202
|
+
if (typeof key !== "string" || key.trim().length === 0) {
|
|
203
|
+
throw new Error("every pushed record needs a non-empty 'name' to key it by");
|
|
204
|
+
}
|
|
205
|
+
return key;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The subset of the caller's fields the live row does not already carry.
|
|
209
|
+
*
|
|
210
|
+
* The gateway mints a new `versionOrdinal` for every PATCH it accepts,
|
|
211
|
+
* including one that changes nothing, so an unconditional re-push would pile
|
|
212
|
+
* up empty versions on rows nobody edited.
|
|
213
|
+
*/
|
|
214
|
+
function recordChanges(existing, record) {
|
|
215
|
+
const changes = {};
|
|
216
|
+
for (const name of PUSHED_RECORD_FIELDS) {
|
|
217
|
+
if (!(name in record))
|
|
218
|
+
continue;
|
|
219
|
+
if (canonicalJson(field(existing, name) ?? null) !== canonicalJson(record[name] ?? null)) {
|
|
220
|
+
changes[name] = record[name];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return changes;
|
|
224
|
+
}
|
|
225
|
+
async function listDatasetRecords(resources, datasetId) {
|
|
226
|
+
const items = [];
|
|
227
|
+
let offset = 0;
|
|
228
|
+
while (true) {
|
|
229
|
+
const page = await resources.datasets.records.list({ datasetId, limit: 500, offset });
|
|
230
|
+
const raw = field(page, "items");
|
|
231
|
+
const batch = Array.isArray(raw) ? raw : [];
|
|
232
|
+
items.push(...batch);
|
|
233
|
+
offset += batch.length;
|
|
234
|
+
const total = field(field(page, "page"), "total");
|
|
235
|
+
if (batch.length === 0 || (typeof total === "number" && offset >= total))
|
|
236
|
+
break;
|
|
237
|
+
if (offset > MAX_LIST_OFFSET)
|
|
238
|
+
throw new Error("dataset record listing exceeded 10000 rows");
|
|
239
|
+
}
|
|
240
|
+
return items;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Create or update each case, so the same suite can be pushed repeatedly.
|
|
244
|
+
*
|
|
245
|
+
* Each record's `name` is the developer's key for the case: it becomes the
|
|
246
|
+
* row's `displayName` and keys the Registry handle. A case already in the
|
|
247
|
+
* dataset is patched when its content moved and left alone when it did not.
|
|
248
|
+
*/
|
|
249
|
+
async function pushRecords(client, datasetId, records, workspaceId) {
|
|
250
|
+
const resources = asResources(client);
|
|
251
|
+
const workspace = workspaceId ?? resources.workspace;
|
|
252
|
+
if (typeof workspace !== "string" || workspace.length === 0) {
|
|
253
|
+
throw new Error("pushRecords needs a workspace: pass workspaceId or set one on the client");
|
|
254
|
+
}
|
|
255
|
+
const wanted = new Map();
|
|
256
|
+
for (const record of records) {
|
|
257
|
+
const key = pushKey(record);
|
|
258
|
+
if (record.input === undefined)
|
|
259
|
+
throw new Error(`pushed record '${key}' has no 'input'`);
|
|
260
|
+
const name = await recordName(datasetId, key);
|
|
261
|
+
if (wanted.has(name))
|
|
262
|
+
throw new Error(`the pushed suite has two records named '${key}'`);
|
|
263
|
+
wanted.set(name, { key, record });
|
|
264
|
+
}
|
|
265
|
+
if (wanted.size === 0)
|
|
266
|
+
return [];
|
|
267
|
+
const existing = new Map((await listDatasetRecords(resources, datasetId)).map((item) => [field(item, "name"), item]));
|
|
268
|
+
const pushed = new Map();
|
|
269
|
+
const toCreate = [];
|
|
270
|
+
for (const [name, { key, record }] of wanted) {
|
|
271
|
+
const live = existing.get(name);
|
|
272
|
+
if (live === undefined) {
|
|
273
|
+
const { name: _key, ...rest } = record;
|
|
274
|
+
toCreate.push({
|
|
275
|
+
key,
|
|
276
|
+
name,
|
|
277
|
+
body: {
|
|
278
|
+
...rest,
|
|
279
|
+
workspaceId: workspace,
|
|
280
|
+
name,
|
|
281
|
+
displayName: record.displayName ?? key,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const id = String(field(live, "id"));
|
|
287
|
+
const changes = recordChanges(live, record);
|
|
288
|
+
if (Object.keys(changes).length === 0) {
|
|
289
|
+
pushed.set(name, { key, name, id, action: "unchanged", record: live });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const updated = await resources.datasets.records.update(datasetId, id, changes);
|
|
293
|
+
pushed.set(name, { key, name, id, action: "updated", record: updated });
|
|
294
|
+
}
|
|
295
|
+
for (let start = 0; start < toCreate.length; start += BULK_RECORD_LIMIT) {
|
|
296
|
+
const chunk = toCreate.slice(start, start + BULK_RECORD_LIMIT);
|
|
297
|
+
let response;
|
|
298
|
+
try {
|
|
299
|
+
response = await resources.datasets.records.createBulk(datasetId, {
|
|
300
|
+
items: chunk.map((item) => item.body),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
// The transport refuses an all-rejected batch before the slots are
|
|
305
|
+
// visible, and can only say "the batch". Every case in this chunk was
|
|
306
|
+
// rejected, so they can be named without the slots.
|
|
307
|
+
if (!(error instanceof errors_1.AtlanAPIError) || error.code !== "bulk_all_rejected")
|
|
308
|
+
throw error;
|
|
309
|
+
throw rejectedRecords(error.status, chunk);
|
|
310
|
+
}
|
|
311
|
+
const raw = field(response, "items");
|
|
312
|
+
const slots = Array.isArray(raw) ? raw : [];
|
|
313
|
+
if (slots.length !== chunk.length) {
|
|
314
|
+
throw new Error(`bulk record create returned ${slots.length} slots for ${chunk.length} items`);
|
|
315
|
+
}
|
|
316
|
+
for (const [index, slot] of slots.entries()) {
|
|
317
|
+
const item = chunk[index];
|
|
318
|
+
const created = field(slot, "record");
|
|
319
|
+
const id = field(created, "id");
|
|
320
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
321
|
+
const status = field(slot, "statusCode") ?? field(slot, "status_code");
|
|
322
|
+
throw rejectedRecords(Number(status), [item]);
|
|
323
|
+
}
|
|
324
|
+
pushed.set(item.name, { key: item.key, name: item.name, id, action: "created", record: created });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [...wanted.keys()].map((name) => pushed.get(name));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Name the cases a bulk create refused, rather than blaming "the batch".
|
|
331
|
+
*
|
|
332
|
+
* The batch-level code is all the transport can know; the keys are only known
|
|
333
|
+
* here, and they are what a developer has to go and edit.
|
|
334
|
+
*/
|
|
335
|
+
function rejectedRecords(status, cases) {
|
|
336
|
+
const named = cases.map((item) => `'${item.key}' (registry handle '${item.name}')`).join(", ");
|
|
337
|
+
const spent = status === 409
|
|
338
|
+
? cases.length === 1
|
|
339
|
+
? "; that handle is already spent in this workspace, which a record in another dataset can do"
|
|
340
|
+
: "; those handles are already spent in this workspace, which records in another dataset can do"
|
|
341
|
+
: "";
|
|
342
|
+
return new Error(cases.length === 1
|
|
343
|
+
? `case ${named} was refused with status ${status}${spent}`
|
|
344
|
+
: `${cases.length} cases were refused with status ${status}: ${named}${spent}`);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Push a suite under `name`, creating the dataset the first time only.
|
|
348
|
+
*
|
|
349
|
+
* Idempotent: run it again after correcting an expected value and the
|
|
350
|
+
* correction lands on the same rows, in the same dataset, without a 409.
|
|
351
|
+
*/
|
|
352
|
+
async function pushDataset(client, name, records, options = {}) {
|
|
353
|
+
const resources = asResources(client);
|
|
354
|
+
const workspace = options.workspaceId ?? resources.workspace;
|
|
355
|
+
if (typeof workspace !== "string" || workspace.length === 0) {
|
|
356
|
+
throw new Error("pushDataset needs a workspace: pass workspaceId or set one on the client");
|
|
357
|
+
}
|
|
358
|
+
let dataset;
|
|
359
|
+
let created = false;
|
|
360
|
+
try {
|
|
361
|
+
dataset = await resolveDataset(client, name, workspace);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (!(error instanceof DatasetNotFoundError))
|
|
365
|
+
throw error;
|
|
366
|
+
const body = { workspaceId: workspace, name };
|
|
367
|
+
if (options.displayName !== undefined)
|
|
368
|
+
body.displayName = options.displayName;
|
|
369
|
+
if (options.description !== undefined)
|
|
370
|
+
body.description = options.description;
|
|
371
|
+
try {
|
|
372
|
+
dataset = await resources.datasets.create(body);
|
|
373
|
+
created = true;
|
|
374
|
+
}
|
|
375
|
+
catch (createError) {
|
|
376
|
+
if (!(createError instanceof errors_1.AtlanAPIError) || createError.status !== 409)
|
|
377
|
+
throw createError;
|
|
378
|
+
try {
|
|
379
|
+
dataset = await resolveDataset(client, name, workspace);
|
|
380
|
+
}
|
|
381
|
+
catch (resolveError) {
|
|
382
|
+
if (resolveError instanceof DatasetNotFoundError)
|
|
383
|
+
throw createError;
|
|
384
|
+
throw resolveError;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const datasetId = field(dataset, "id");
|
|
389
|
+
if (typeof datasetId !== "string" || datasetId.length === 0) {
|
|
390
|
+
throw new Error("dataset response did not contain an ID");
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
dataset,
|
|
394
|
+
id: datasetId,
|
|
395
|
+
created,
|
|
396
|
+
records: await pushRecords(client, datasetId, records, workspace),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
151
399
|
class EvalRun {
|
|
152
400
|
experiment;
|
|
153
401
|
dataset;
|
|
154
402
|
contextManifest;
|
|
403
|
+
/**
|
|
404
|
+
* The session id stamped on every span this run emits.
|
|
405
|
+
*
|
|
406
|
+
* It is the only join key the gateway promotes off an eval span that can
|
|
407
|
+
* reach the run's subject: `recordExperimentSession` registers this value
|
|
408
|
+
* against the experiment's subject.
|
|
409
|
+
*/
|
|
155
410
|
constructor(experiment, dataset, contextManifest) {
|
|
156
411
|
this.experiment = experiment;
|
|
157
412
|
this.dataset = dataset;
|
|
@@ -167,10 +422,20 @@ class EvalRun {
|
|
|
167
422
|
get experimentId() {
|
|
168
423
|
return this.id;
|
|
169
424
|
}
|
|
170
|
-
/**
|
|
425
|
+
/** Stable across retries because the experiment is the resumable unit. */
|
|
426
|
+
get sessionId() {
|
|
427
|
+
return this.id;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Options for `propagateAttributes` from `@atlanai/sdk/tracing`.
|
|
431
|
+
*
|
|
432
|
+
* Carries the experiment join and the subject join. A nested
|
|
433
|
+
* `propagateAttributes({ sessionId })` still wins for the spans inside it.
|
|
434
|
+
*/
|
|
171
435
|
get traceOptions() {
|
|
172
436
|
return {
|
|
173
437
|
experimentId: this.id,
|
|
438
|
+
sessionId: this.sessionId,
|
|
174
439
|
...(this.contextManifest === undefined
|
|
175
440
|
? {}
|
|
176
441
|
: { metadata: { context_manifest_digest: this.contextManifest.digest } }),
|
|
@@ -245,6 +510,124 @@ async function startExperiment(client, dataset, body, options = {}) {
|
|
|
245
510
|
}
|
|
246
511
|
return run;
|
|
247
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Every trace an experiment recorded, newest first.
|
|
515
|
+
*
|
|
516
|
+
* **The experiment is the scope eval traces are filed under.** A subject's own
|
|
517
|
+
* Traces tab (`GET /agent/v1/agents/{id}/traces`) resolves on the trace's
|
|
518
|
+
* *creator identity*, so it lists a run only when the run's spans were exported
|
|
519
|
+
* with that agent's own credential. An eval exported with your user key or a
|
|
520
|
+
* service-account key is filed under that identity instead, and the agent's
|
|
521
|
+
* Traces tab reads empty even though every trace exists. That is not the traces
|
|
522
|
+
* going missing; it is a different scope. Read them here, and call
|
|
523
|
+
* `recordExperimentSession` so the run is also reachable from the subject's
|
|
524
|
+
* Sessions tab.
|
|
525
|
+
*
|
|
526
|
+
* To fill the agent's own Traces tab, export the spans as the agent: pass
|
|
527
|
+
* `logger: initLogger({ apiKey: <the agent's credential> })` while the
|
|
528
|
+
* management `client` keeps your own, since the agent's identity is not
|
|
529
|
+
* entitled to create experiments.
|
|
530
|
+
*/
|
|
531
|
+
async function experimentTraces(client, experimentId, options = {}) {
|
|
532
|
+
if (experimentId.trim().length === 0)
|
|
533
|
+
throw new Error("experimentId is required");
|
|
534
|
+
const limit = options.limit ?? 500;
|
|
535
|
+
if (limit <= 0)
|
|
536
|
+
throw new Error("limit must be greater than zero");
|
|
537
|
+
const resources = asResources(client);
|
|
538
|
+
const traces = [];
|
|
539
|
+
let cursor;
|
|
540
|
+
while (traces.length < limit) {
|
|
541
|
+
const page = await resources.experiments.traces.list({
|
|
542
|
+
experimentId,
|
|
543
|
+
limit: Math.min(500, limit - traces.length),
|
|
544
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
545
|
+
});
|
|
546
|
+
const items = field(page, "items");
|
|
547
|
+
const batch = Array.isArray(items) ? items : [];
|
|
548
|
+
traces.push(...batch);
|
|
549
|
+
const next = field(field(page, "page"), "nextCursor");
|
|
550
|
+
cursor = typeof next === "string" && next.length > 0 ? next : undefined;
|
|
551
|
+
if (batch.length === 0 || cursor === undefined)
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
return traces.slice(0, limit);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Bind this run's traces to the experiment's subject, and return the record.
|
|
558
|
+
*
|
|
559
|
+
* The gateway promotes exactly one span attribute that can reach a subject: the
|
|
560
|
+
* session id. This registers a `session` artifact whose `subjectId` is the
|
|
561
|
+
* experiment's subject and whose `externalSessionId` is `run.sessionId`, the
|
|
562
|
+
* value `run.traceOptions` stamps on every span of the run. After it, the run
|
|
563
|
+
* appears on the subject's Sessions tab and `client.sessions.traces.list(id)`
|
|
564
|
+
* returns the run's traces.
|
|
565
|
+
*
|
|
566
|
+
* Returns `undefined` when the experiment names no subject, because then there
|
|
567
|
+
* is nothing to bind the run to. A prior accepted create is reconciled on
|
|
568
|
+
* `409`, so a resume after a lost response does not strand the experiment.
|
|
569
|
+
*/
|
|
570
|
+
// Which subject kinds a `session` artifact can name. Read off the generated
|
|
571
|
+
// enum, so a gateway that later adds one is picked up by a contract sync
|
|
572
|
+
// rather than needing this list edited.
|
|
573
|
+
const SESSION_SUBJECT_KINDS = new Set(Object.values(AgentSubjectKind_1.AgentSubjectKind));
|
|
574
|
+
async function recordExperimentSession(client, run, options = {}) {
|
|
575
|
+
const subjectKind = field(run.experiment, "subjectKind");
|
|
576
|
+
const subjectId = field(run.experiment, "subjectId");
|
|
577
|
+
if (typeof subjectKind !== "string" || typeof subjectId !== "string")
|
|
578
|
+
return undefined;
|
|
579
|
+
if (subjectKind.length === 0 || subjectId.length === 0)
|
|
580
|
+
return undefined;
|
|
581
|
+
// EvalSubjectKind has `skill`, AgentSubjectKind does not, so a skill eval
|
|
582
|
+
// has no session to bind to. Throwing would fail a run whose results are
|
|
583
|
+
// already persisted, with no remedy that could succeed.
|
|
584
|
+
if (!SESSION_SUBJECT_KINDS.has(subjectKind))
|
|
585
|
+
return undefined;
|
|
586
|
+
const resources = asResources(client);
|
|
587
|
+
const workspaceId = field(run.experiment, "workspaceId") ?? resources.workspace;
|
|
588
|
+
if (typeof workspaceId !== "string" || workspaceId.length === 0) {
|
|
589
|
+
throw new Error("experiment has no workspaceId and the client has no default");
|
|
590
|
+
}
|
|
591
|
+
const displayName = field(run.experiment, "displayName") ?? field(run.experiment, "name");
|
|
592
|
+
const body = {
|
|
593
|
+
workspaceId,
|
|
594
|
+
name: `${slug(run.id, "experiment")}-traces`,
|
|
595
|
+
subjectKind,
|
|
596
|
+
subjectId,
|
|
597
|
+
sessionStatus: options.sessionStatus ?? "completed",
|
|
598
|
+
externalSessionId: run.sessionId,
|
|
599
|
+
sourceType: "local",
|
|
600
|
+
};
|
|
601
|
+
if (typeof displayName === "string" && displayName.length > 0)
|
|
602
|
+
body.displayName = displayName;
|
|
603
|
+
if (options.title !== undefined)
|
|
604
|
+
body.title = options.title;
|
|
605
|
+
try {
|
|
606
|
+
return await resources.sessions.createRecord(body);
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (!(error instanceof errors_1.AtlanAPIError) || error.status !== 409)
|
|
610
|
+
throw error;
|
|
611
|
+
const page = await resources.sessions.list({
|
|
612
|
+
limit: 500,
|
|
613
|
+
offset: 0,
|
|
614
|
+
subjectKind,
|
|
615
|
+
subjectId,
|
|
616
|
+
externalSessionId: run.sessionId,
|
|
617
|
+
});
|
|
618
|
+
const items = field(page, "items");
|
|
619
|
+
const matches = (Array.isArray(items) ? items : []).filter((item) => field(item, "workspaceId") === workspaceId &&
|
|
620
|
+
field(item, "subjectKind") === subjectKind &&
|
|
621
|
+
field(item, "subjectId") === subjectId &&
|
|
622
|
+
field(item, "externalSessionId") === run.sessionId);
|
|
623
|
+
if (matches.length === 1)
|
|
624
|
+
return matches[0];
|
|
625
|
+
if (matches.length > 1) {
|
|
626
|
+
throw new Error(`multiple sessions bind experiment ${run.id} to subject ${subjectId}`, { cause: error });
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
248
631
|
// ---------------------------------------------------------------------------
|
|
249
632
|
// Declarative evaluator
|
|
250
633
|
const DEFAULT_GATEWAY_ORIGIN = "https://api.atlan.com";
|
|
@@ -605,7 +988,11 @@ async function traceSpans(resources, experimentId, traceId) {
|
|
|
605
988
|
experimentId,
|
|
606
989
|
traceId,
|
|
607
990
|
limit: 500,
|
|
608
|
-
|
|
991
|
+
// Every check in `missingTraceEvidence` reads a span attribute, and
|
|
992
|
+
// `attributes` is its own projection group: asking for `io` alone
|
|
993
|
+
// returns `attributes: null` on every span, so the gate can only ever
|
|
994
|
+
// fail. `core` carries `spanId`, which the same checks match on.
|
|
995
|
+
fields: "core,io,attributes",
|
|
609
996
|
...(cursor === undefined ? {} : { cursor }),
|
|
610
997
|
});
|
|
611
998
|
const items = field(page, "items");
|
|
@@ -621,16 +1008,34 @@ async function traceSpans(resources, experimentId, traceId) {
|
|
|
621
1008
|
cursor = nextCursor;
|
|
622
1009
|
}
|
|
623
1010
|
}
|
|
624
|
-
|
|
1011
|
+
function artifactUnixSeconds(value) {
|
|
1012
|
+
if (typeof value !== "string" || value.length === 0)
|
|
1013
|
+
return undefined;
|
|
1014
|
+
const milliseconds = Date.parse(value);
|
|
1015
|
+
return Number.isFinite(milliseconds) ? Math.floor(milliseconds / 1_000) : undefined;
|
|
1016
|
+
}
|
|
1017
|
+
function experimentTraceWindow(experiment) {
|
|
1018
|
+
const created = artifactUnixSeconds(field(experiment, "createdAt"));
|
|
1019
|
+
const updated = artifactUnixSeconds(field(experiment, "updatedAt"));
|
|
1020
|
+
const startTimeUnixSeconds = Math.max(0, (created ?? 0) - 60 * 60);
|
|
1021
|
+
const endTimeUnixSeconds = field(experiment, "experimentStatus") === "running"
|
|
1022
|
+
? Math.floor(Date.now() / 1_000)
|
|
1023
|
+
: (updated ?? created ?? Math.floor(Date.now() / 1_000)) + 24 * 60 * 60;
|
|
1024
|
+
return { startTimeUnixSeconds, endTimeUnixSeconds };
|
|
1025
|
+
}
|
|
1026
|
+
async function listedTraceIds(resources, experimentId, window) {
|
|
625
1027
|
const traceIds = new Set();
|
|
626
1028
|
const seenCursors = new Set();
|
|
627
1029
|
let cursor;
|
|
628
1030
|
const windowEnd = Math.floor(Date.now() / 1_000);
|
|
1031
|
+
const bounds = window ?? {
|
|
1032
|
+
startTimeUnixSeconds: windowEnd - 30 * 24 * 60 * 60,
|
|
1033
|
+
endTimeUnixSeconds: windowEnd,
|
|
1034
|
+
};
|
|
629
1035
|
while (true) {
|
|
630
1036
|
const page = await resources.experiments.traces.list({
|
|
631
1037
|
experimentId,
|
|
632
|
-
|
|
633
|
-
endTimeUnixSeconds: windowEnd,
|
|
1038
|
+
...bounds,
|
|
634
1039
|
limit: 500,
|
|
635
1040
|
...(cursor === undefined ? {} : { cursor }),
|
|
636
1041
|
});
|
|
@@ -652,9 +1057,42 @@ async function listedTraceIds(resources, experimentId) {
|
|
|
652
1057
|
cursor = nextCursor;
|
|
653
1058
|
}
|
|
654
1059
|
}
|
|
1060
|
+
/** Rejoin one exploded attribute subtree into dotted keys. */
|
|
1061
|
+
function flattenAttributes(value, prefix, into) {
|
|
1062
|
+
if (isRecord(value)) {
|
|
1063
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1064
|
+
flattenAttributes(child, prefix === "" ? key : `${prefix}.${key}`, into);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
else if (prefix !== "") {
|
|
1068
|
+
into[prefix] = value;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* The span's attributes, keyed the way this module writes them.
|
|
1073
|
+
*
|
|
1074
|
+
* An OTel attribute key is a flat dotted string, and that is what the tracing
|
|
1075
|
+
* side sets. The gateway does not return it that way: it splits the key on
|
|
1076
|
+
* every dot into a nested object and sorts the result into `resource`, `scope`
|
|
1077
|
+
* and `span` envelopes, so `atlan.eval.case_id` arrives as
|
|
1078
|
+
* `{span: {atlan: {eval: {case_id: ...}}}}`. `SpanViewDto.attributes` is
|
|
1079
|
+
* untyped in the contract, so neither shape is wrong; this is the side that has
|
|
1080
|
+
* to adapt. An already-flat mapping is passed through unchanged.
|
|
1081
|
+
*/
|
|
655
1082
|
function spanAttributes(span) {
|
|
656
1083
|
const attributes = field(span, "attributes");
|
|
657
|
-
|
|
1084
|
+
if (!isRecord(attributes))
|
|
1085
|
+
return {};
|
|
1086
|
+
const envelopes = ["resource", "scope", "span"].map((name) => attributes[name]);
|
|
1087
|
+
if (!envelopes.some((envelope) => isRecord(envelope)))
|
|
1088
|
+
return attributes;
|
|
1089
|
+
// Span last: a span attribute outranks the resource-wide one it shadows.
|
|
1090
|
+
const flat = {};
|
|
1091
|
+
for (const envelope of envelopes) {
|
|
1092
|
+
if (isRecord(envelope))
|
|
1093
|
+
flattenAttributes(envelope, "", flat);
|
|
1094
|
+
}
|
|
1095
|
+
return flat;
|
|
658
1096
|
}
|
|
659
1097
|
function missingTraceEvidence(spans, expected) {
|
|
660
1098
|
const missing = [];
|
|
@@ -786,6 +1224,73 @@ function defaultConnections(name, options) {
|
|
|
786
1224
|
* carry the root trace ID, traces flush before summary, and the experiment is
|
|
787
1225
|
* explicitly finalized.
|
|
788
1226
|
*/
|
|
1227
|
+
// The Registry resource each subject kind lives in.
|
|
1228
|
+
const SUBJECT_RESOURCES = {
|
|
1229
|
+
agent: "agents",
|
|
1230
|
+
harness: "harnesses",
|
|
1231
|
+
skill: "skills",
|
|
1232
|
+
};
|
|
1233
|
+
/**
|
|
1234
|
+
* Refuse an experiment whose subject lives in another workspace.
|
|
1235
|
+
*
|
|
1236
|
+
* The dataset is already checked ("dataset ID resolved outside the requested
|
|
1237
|
+
* workspace") and so is every scorer ("scorer resolved outside the evaluation
|
|
1238
|
+
* workspace"). The subject was not, and it is the field most likely to be
|
|
1239
|
+
* wrong: the listings people resolve a subject's name through are not
|
|
1240
|
+
* workspace-scoped, so `agents.list()` answers with agents from every workspace
|
|
1241
|
+
* the caller can see and "reuse the agent called X" quietly picks a stranger's.
|
|
1242
|
+
* The gateway accepts the row, and the run is then attributed to an agent its
|
|
1243
|
+
* own workspace has never heard of.
|
|
1244
|
+
*
|
|
1245
|
+
* A read that fails is not proof of a mismatch, so only a subject that reads
|
|
1246
|
+
* back with a different workspace fails the run. This is a wrong-binding check,
|
|
1247
|
+
* not an authorization gate.
|
|
1248
|
+
*/
|
|
1249
|
+
async function checkSubjectWorkspace(client, subjectKind, subjectId, workspaceId) {
|
|
1250
|
+
const resourceName = SUBJECT_RESOURCES[subjectKind ?? ""];
|
|
1251
|
+
if (resourceName === undefined || subjectId === undefined || subjectId.length === 0)
|
|
1252
|
+
return;
|
|
1253
|
+
const resource = field(client, resourceName);
|
|
1254
|
+
const getter = field(resource, "get");
|
|
1255
|
+
if (typeof getter !== "function")
|
|
1256
|
+
return;
|
|
1257
|
+
let subject;
|
|
1258
|
+
try {
|
|
1259
|
+
subject = await getter.call(resource, subjectId);
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
// See the doc comment: a read that fails is not proof of a mismatch.
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const actual = field(subject, "workspaceId");
|
|
1266
|
+
if (actual !== undefined && actual !== workspaceId) {
|
|
1267
|
+
throw new Error(`subject ${subjectId} lives in workspace ${String(actual)}, not ${workspaceId}; ` +
|
|
1268
|
+
`an experiment cannot be attributed to a subject outside its own workspace`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
function nameOpenExperiment(error, experimentId) {
|
|
1272
|
+
if (!(error instanceof Error))
|
|
1273
|
+
return error;
|
|
1274
|
+
const note = `experiment ${experimentId} was left running; pass ` +
|
|
1275
|
+
`resumeExperimentId: ${JSON.stringify(experimentId)} to continue it`;
|
|
1276
|
+
try {
|
|
1277
|
+
Object.defineProperty(error, "experimentId", {
|
|
1278
|
+
configurable: true,
|
|
1279
|
+
enumerable: true,
|
|
1280
|
+
value: experimentId,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
// Preserve the original error even when a caller froze it.
|
|
1285
|
+
}
|
|
1286
|
+
try {
|
|
1287
|
+
error.message = `${error.message} (${note})`;
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
// Preserve the original error even when its message is read-only.
|
|
1291
|
+
}
|
|
1292
|
+
return error;
|
|
1293
|
+
}
|
|
789
1294
|
async function Eval(name, evaluator, options = {}) {
|
|
790
1295
|
if (!name || /[\r\n]/.test(name))
|
|
791
1296
|
throw new Error("Eval name must be non-empty and single-line");
|
|
@@ -823,6 +1328,7 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
823
1328
|
if (value === undefined)
|
|
824
1329
|
delete experimentBody[key];
|
|
825
1330
|
}
|
|
1331
|
+
await checkSubjectWorkspace(client, options.subjectKind, options.subjectId, workspaceId);
|
|
826
1332
|
let run;
|
|
827
1333
|
let cases = [];
|
|
828
1334
|
if (options.resumeExperimentId !== undefined) {
|
|
@@ -894,6 +1400,14 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
894
1400
|
snapshot ??
|
|
895
1401
|
(await datasetCases(resources, String(field(run.dataset, "id"))));
|
|
896
1402
|
}
|
|
1403
|
+
if (evaluator.dataset !== undefined && cases.length === 0) {
|
|
1404
|
+
// A named dataset that yields nothing is a mistake upstream, not an eval
|
|
1405
|
+
// with no work in it. Finishing here would write a `completed`
|
|
1406
|
+
// experiment carrying no results and no scores, which reads downstream
|
|
1407
|
+
// as a run that happened and found nothing.
|
|
1408
|
+
throw new Error(`dataset ${JSON.stringify(evaluator.dataset)} resolved to ` +
|
|
1409
|
+
`${String(field(run.dataset, "id"))} and it has no records, so there is nothing to evaluate`);
|
|
1410
|
+
}
|
|
897
1411
|
const caseIds = new Set(cases.map((item, index) => item.id ?? `case-${index + 1}`));
|
|
898
1412
|
if (caseIds.size !== cases.length)
|
|
899
1413
|
throw new Error("Eval case IDs must be unique");
|
|
@@ -1098,6 +1612,21 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
1098
1612
|
}
|
|
1099
1613
|
await verifyTraceEvidence(resources, run.id, expectations, traceVerificationTimeoutMs);
|
|
1100
1614
|
}
|
|
1615
|
+
// Without this the traces are readable by experiment but nothing on the
|
|
1616
|
+
// subject leads to them. Bind before the terminal update so a failed
|
|
1617
|
+
// create leaves a resumable experiment instead of a completed one with no
|
|
1618
|
+
// route from its subject to the traces.
|
|
1619
|
+
let session;
|
|
1620
|
+
try {
|
|
1621
|
+
session = await recordExperimentSession(client, run);
|
|
1622
|
+
}
|
|
1623
|
+
catch (error) {
|
|
1624
|
+
throw new Error(`experiment ${run.id} has every result persisted, but recording ` +
|
|
1625
|
+
`the session that binds its traces to subject ` +
|
|
1626
|
+
`${String(field(run.experiment, "subjectId"))} failed: ${safeError(error)}. ` +
|
|
1627
|
+
`The traces are readable with experimentTraces(client, "${run.id}"); the ` +
|
|
1628
|
+
`experiment remains running and can be resumed safely.`, { cause: error });
|
|
1629
|
+
}
|
|
1101
1630
|
const local = localSummary(persistedResults, Date.now() - started);
|
|
1102
1631
|
const experiment = await resources.experiments.update(run.id, {
|
|
1103
1632
|
experimentStatus: "completed",
|
|
@@ -1113,6 +1642,8 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
1113
1642
|
dataset: run.dataset,
|
|
1114
1643
|
summary,
|
|
1115
1644
|
results: options.returnResults === false ? [] : results,
|
|
1645
|
+
sessionId: run.sessionId,
|
|
1646
|
+
...(session === undefined ? {} : { session }),
|
|
1116
1647
|
};
|
|
1117
1648
|
}
|
|
1118
1649
|
catch (error) {
|
|
@@ -1126,6 +1657,191 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
1126
1657
|
}
|
|
1127
1658
|
// Leave interrupted infrastructure work running: terminal experiments
|
|
1128
1659
|
// cannot accept more results. Resume reconciles Registry case IDs.
|
|
1129
|
-
throw error;
|
|
1660
|
+
throw nameOpenExperiment(error, run.id);
|
|
1130
1661
|
}
|
|
1131
1662
|
}
|
|
1663
|
+
/**
|
|
1664
|
+
* A span's score column, from the typed projection or the raw attribute.
|
|
1665
|
+
*
|
|
1666
|
+
* The gateway promotes `atlan.score.*` onto typed columns, but only when it was
|
|
1667
|
+
* asked for the projection that carries them. Falling back to the attribute
|
|
1668
|
+
* keeps this working on spans read any other way.
|
|
1669
|
+
*/
|
|
1670
|
+
function promoted(span, name, attribute) {
|
|
1671
|
+
const value = field(span, name);
|
|
1672
|
+
return value === undefined || value === null
|
|
1673
|
+
? spanAttributes(span)[`atlan.score.${attribute}`]
|
|
1674
|
+
: value;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* True only for a span that actually carries a score verdict.
|
|
1678
|
+
*
|
|
1679
|
+
* Span type is not the test. `startAsCurrentSpan({asType: "score"})` produces a
|
|
1680
|
+
* `score`-typed wrapper around the real verdict, and the gateway answers for
|
|
1681
|
+
* that wrapper with `scoreName: ""`, `scoreValue: 0`, `scorerId: ""`,
|
|
1682
|
+
* `scorerVersion: 0` rather than omitting the columns. Read it as evidence and
|
|
1683
|
+
* an unscored wrapper becomes "scored 0 by nobody". Only a non-empty
|
|
1684
|
+
* `scoreName` marks a span a scorer actually wrote.
|
|
1685
|
+
*/
|
|
1686
|
+
function isScoreVerdict(span) {
|
|
1687
|
+
const name = promoted(span, "scoreName", "score_name");
|
|
1688
|
+
return typeof name === "string" && name.trim().length > 0;
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Run the documented seven-point evidence gate over a finished experiment.
|
|
1692
|
+
*
|
|
1693
|
+
* Reads the experiment, its result rows, its trace list and the spans of up to
|
|
1694
|
+
* `maxTraces` case traces, and reports whether the evidence chain actually
|
|
1695
|
+
* holds. It only reads; nothing is written.
|
|
1696
|
+
*
|
|
1697
|
+
* Gate 7, the content and masking policy, cannot be decided from the data and
|
|
1698
|
+
* is reported as a manual check rather than silently passed.
|
|
1699
|
+
*/
|
|
1700
|
+
async function verifyExperiment(client, experimentId, options = {}) {
|
|
1701
|
+
if (experimentId.length === 0 || experimentId !== experimentId.trim()) {
|
|
1702
|
+
throw new Error("experimentId must be a non-empty ID with no outer whitespace");
|
|
1703
|
+
}
|
|
1704
|
+
const resources = client;
|
|
1705
|
+
const maxTraces = options.maxTraces === undefined ? 3 : options.maxTraces;
|
|
1706
|
+
const experiment = await resources.experiments.get(experimentId);
|
|
1707
|
+
const results = await experimentResults(resources, experimentId);
|
|
1708
|
+
const checks = [];
|
|
1709
|
+
const record = (gate, name, ok, detail = "") => {
|
|
1710
|
+
checks.push({ gate, name, ok, detail });
|
|
1711
|
+
};
|
|
1712
|
+
// Gate 1 -------------------------------------------------------------------
|
|
1713
|
+
const status = field(experiment, "experimentStatus");
|
|
1714
|
+
record(1, "the experiment reached a terminal status", status === "completed" || status === "failed", `experimentStatus=${JSON.stringify(status)}; 'running' after a finished run ` +
|
|
1715
|
+
`is a lifecycle failure, not a slow write`);
|
|
1716
|
+
// Gate 2 -------------------------------------------------------------------
|
|
1717
|
+
record(2, "at least one result row was written", results.length > 0, `${results.length} rows`);
|
|
1718
|
+
if (options.expectedCaseCount !== undefined) {
|
|
1719
|
+
record(2, "the result count equals the case count", results.length === options.expectedCaseCount, `${results.length} rows for ${options.expectedCaseCount} cases`);
|
|
1720
|
+
}
|
|
1721
|
+
const caseIds = results.map((row) => field(row, "caseId") ?? field(row, "name"));
|
|
1722
|
+
const duplicates = [
|
|
1723
|
+
...new Set(caseIds.filter((key) => caseIds.filter((other) => other === key).length > 1)),
|
|
1724
|
+
];
|
|
1725
|
+
record(2, "no duplicate result rows", duplicates.length === 0, duplicates.length === 0 ? "" : `repeated case ids: ${duplicates.join(", ")}`);
|
|
1726
|
+
// Gate 3 -------------------------------------------------------------------
|
|
1727
|
+
const rawTraceIds = results.map((row) => field(row, "traceId"));
|
|
1728
|
+
const malformed = rawTraceIds.filter((value) => typeof value !== "string" || !TRACE_ID.test(value));
|
|
1729
|
+
record(3, "every result carries a W3C trace id", malformed.length === 0, malformed.length === 0 ? "" : `${malformed.length} malformed trace ids`);
|
|
1730
|
+
const zeroed = rawTraceIds.filter((value) => typeof value === "string" && /^0+$/.test(value));
|
|
1731
|
+
record(3, "no result carries the all-zero trace id", zeroed.length === 0, zeroed.length === 0
|
|
1732
|
+
? ""
|
|
1733
|
+
: `${zeroed.length} rows read as 'a trace that exists and is empty' rather ` +
|
|
1734
|
+
`than 'this case was not traced'`);
|
|
1735
|
+
record(3, "trace ids are distinct per case", new Set(rawTraceIds).size === rawTraceIds.length, `${rawTraceIds.length - new Set(rawTraceIds).size} rows share a trace with another row`);
|
|
1736
|
+
// Gate 4 -------------------------------------------------------------------
|
|
1737
|
+
let listed = new Set();
|
|
1738
|
+
let traceListReadable = true;
|
|
1739
|
+
try {
|
|
1740
|
+
listed = await listedTraceIds(resources, experimentId, experimentTraceWindow(experiment));
|
|
1741
|
+
}
|
|
1742
|
+
catch (error) {
|
|
1743
|
+
traceListReadable = false;
|
|
1744
|
+
record(4, "the experiment trace filter is readable", false, String(error));
|
|
1745
|
+
}
|
|
1746
|
+
if (traceListReadable) {
|
|
1747
|
+
// An empty listing is a real answer here, not a missing one: it means every
|
|
1748
|
+
// result row points at a trace the experiment does not own.
|
|
1749
|
+
const missing = rawTraceIds.filter((value) => typeof value === "string" && !listed.has(value));
|
|
1750
|
+
record(4, "every result trace id appears in the experiment trace list", missing.length === 0, missing.length === 0
|
|
1751
|
+
? `${listed.size} traces listed`
|
|
1752
|
+
: `${missing.length} results point at a trace the experiment filter does not return`);
|
|
1753
|
+
}
|
|
1754
|
+
// Gates 5 and 6 ------------------------------------------------------------
|
|
1755
|
+
const wellFormed = rawTraceIds.filter((value) => typeof value === "string" && TRACE_ID.test(value));
|
|
1756
|
+
const sampled = maxTraces === null ? wellFormed : wellFormed.slice(0, maxTraces);
|
|
1757
|
+
const verified = [];
|
|
1758
|
+
for (const traceId of sampled) {
|
|
1759
|
+
const spans = await traceSpans(resources, experimentId, traceId);
|
|
1760
|
+
verified.push(traceId);
|
|
1761
|
+
const label = `trace ${traceId}`;
|
|
1762
|
+
if (spans.length === 0) {
|
|
1763
|
+
record(5, `${label} has spans`, false, "0 spans returned");
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
const verdicts = spans.filter(isScoreVerdict);
|
|
1767
|
+
const typedScore = spans.filter((span) => field(span, "spanType") === "score" ||
|
|
1768
|
+
spanAttributes(span)["atlan.span.type"] === "score");
|
|
1769
|
+
record(5, `${label} carries a score verdict`, verdicts.length > 0, verdicts.length > 0
|
|
1770
|
+
? `${verdicts.length} of ${spans.length} spans carry a verdict`
|
|
1771
|
+
: typedScore.length > 0
|
|
1772
|
+
? `${typedScore.length} spans are typed \`score\` but none carries a ` +
|
|
1773
|
+
`scoreName, so none of them is evidence`
|
|
1774
|
+
: "no scorer wrote a span on this trace");
|
|
1775
|
+
const hollow = typedScore.filter((span) => !verdicts.includes(span));
|
|
1776
|
+
record(5, `${label} has no hollow \`score\` spans`, hollow.length === 0, hollow.length === 0
|
|
1777
|
+
? ""
|
|
1778
|
+
: `${hollow.length} spans are typed \`score\` with an empty scoreName; the ` +
|
|
1779
|
+
`gateway answers for those with scoreValue 0, which reads as a real ` +
|
|
1780
|
+
`zero to anything that does not filter them out`);
|
|
1781
|
+
const unpinned = verdicts.filter((span) => {
|
|
1782
|
+
const scorerId = promoted(span, "scorerId", "scorer_id");
|
|
1783
|
+
return typeof scorerId !== "string" || scorerId.length === 0;
|
|
1784
|
+
});
|
|
1785
|
+
record(5, `${label}: every verdict cites a scorer id`, unpinned.length === 0, unpinned.length === 0
|
|
1786
|
+
? ""
|
|
1787
|
+
: `${unpinned.length} verdict spans carry an empty scorerId; a score ` +
|
|
1788
|
+
`without an identity is a number, not evidence`);
|
|
1789
|
+
const unversioned = verdicts.filter((span) => {
|
|
1790
|
+
const version = promoted(span, "scorerVersion", "scorer_version");
|
|
1791
|
+
return typeof version !== "number" || version < 1;
|
|
1792
|
+
});
|
|
1793
|
+
record(5, `${label}: every verdict cites a scorer version`, unversioned.length === 0, unversioned.length === 0
|
|
1794
|
+
? ""
|
|
1795
|
+
: `${unversioned.length} verdict spans carry no usable scorerVersion`);
|
|
1796
|
+
const byId = new Set();
|
|
1797
|
+
for (const span of spans)
|
|
1798
|
+
byId.add(field(span, "spanId"));
|
|
1799
|
+
const roots = spans.filter((span) => {
|
|
1800
|
+
const parent = field(span, "parentSpanId");
|
|
1801
|
+
return parent === undefined || parent === null || parent === "";
|
|
1802
|
+
});
|
|
1803
|
+
record(6, `${label} has exactly one root span`, roots.length === 1, `${roots.length} spans have no parent`);
|
|
1804
|
+
const orphans = spans.filter((span) => {
|
|
1805
|
+
const parent = field(span, "parentSpanId");
|
|
1806
|
+
return typeof parent === "string" && parent.length > 0 && !byId.has(parent);
|
|
1807
|
+
});
|
|
1808
|
+
record(6, `${label} has no orphaned spans`, orphans.length === 0, orphans.length === 0
|
|
1809
|
+
? ""
|
|
1810
|
+
: `${orphans.length} spans name a parent that is not in this trace`);
|
|
1811
|
+
record(6, `${label} is nested, not flat`, spans.length > 1 && roots.length === 1, `${spans.length} spans, ${roots.length} roots`);
|
|
1812
|
+
}
|
|
1813
|
+
// Gate 7 -------------------------------------------------------------------
|
|
1814
|
+
record(7, "inputs and outputs follow the approved content policy", undefined, "re-run with traceContent=false and confirm the span payloads are gone; " +
|
|
1815
|
+
"no read-back can decide this for you");
|
|
1816
|
+
const failures = checks.filter((check) => check.ok === false);
|
|
1817
|
+
const manual = checks.filter((check) => check.ok === undefined);
|
|
1818
|
+
return {
|
|
1819
|
+
experimentId,
|
|
1820
|
+
experiment,
|
|
1821
|
+
checks,
|
|
1822
|
+
results,
|
|
1823
|
+
traceIds: rawTraceIds.filter((value) => typeof value === "string"),
|
|
1824
|
+
verifiedTraceIds: verified,
|
|
1825
|
+
failures,
|
|
1826
|
+
manual,
|
|
1827
|
+
ok: failures.length === 0,
|
|
1828
|
+
raiseForStatus() {
|
|
1829
|
+
if (failures.length === 0)
|
|
1830
|
+
return;
|
|
1831
|
+
throw new Error(`experiment ${experimentId} failed ${failures.length} of ${checks.length} ` +
|
|
1832
|
+
`evidence checks: ` +
|
|
1833
|
+
failures.map((check) => `${check.name} (${check.detail})`).join("; "));
|
|
1834
|
+
},
|
|
1835
|
+
toString() {
|
|
1836
|
+
const passed = checks.filter((check) => check.ok === true).length;
|
|
1837
|
+
const header = `experiment ${experimentId}: ${passed} passed, ${failures.length} failed, ` +
|
|
1838
|
+
`${manual.length} manual`;
|
|
1839
|
+
const lines = checks.map((check) => {
|
|
1840
|
+
const mark = check.ok === true ? "PASS" : check.ok === false ? "FAIL" : "MANUAL";
|
|
1841
|
+
const first = ` [${mark}] gate ${check.gate}: ${check.name}`;
|
|
1842
|
+
return check.detail ? `${first}\n ${check.detail}` : first;
|
|
1843
|
+
});
|
|
1844
|
+
return [header, ...lines].join("\n");
|
|
1845
|
+
},
|
|
1846
|
+
};
|
|
1847
|
+
}
|