@atlanai/sdk 0.2.2 → 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.
Files changed (148) hide show
  1. package/README.md +6 -2
  2. package/dist/client.d.ts +20 -3
  3. package/dist/client.js +130 -9
  4. package/dist/errors.d.ts +10 -0
  5. package/dist/errors.js +34 -1
  6. package/dist/evals.d.ts +135 -8
  7. package/dist/evals.js +1004 -73
  8. package/dist/index.d.ts +3 -2
  9. package/dist/index.js +7 -1
  10. package/dist/manifest.json +84 -11
  11. package/dist/raw/agent/apis/AgentsApi.d.ts +29 -0
  12. package/dist/raw/agent/apis/AgentsApi.js +62 -1
  13. package/dist/raw/agent/apis/SessionsApi.d.ts +2 -2
  14. package/dist/raw/agent/apis/SessionsApi.js +2 -2
  15. package/dist/raw/agent/models/AgentAgentArtifactView.d.ts +48 -0
  16. package/dist/raw/agent/models/AgentAgentArtifactView.js +13 -0
  17. package/dist/raw/agent/models/AgentAgentEnvironmentArtifactView.d.ts +12 -0
  18. package/dist/raw/agent/models/AgentAgentEnvironmentArtifactView.js +2 -0
  19. package/dist/raw/agent/models/AgentAgentEnvironmentMeta.d.ts +12 -0
  20. package/dist/raw/agent/models/AgentAgentEnvironmentMeta.js +2 -0
  21. package/dist/raw/agent/models/AgentAgentMeta.d.ts +48 -0
  22. package/dist/raw/agent/models/AgentAgentMeta.js +13 -0
  23. package/dist/raw/agent/models/AgentAgentProviderArtifactView.d.ts +55 -2
  24. package/dist/raw/agent/models/AgentAgentProviderArtifactView.js +8 -0
  25. package/dist/raw/agent/models/AgentAgentProviderCreate.d.ts +24 -3
  26. package/dist/raw/agent/models/AgentAgentProviderCreate.js +4 -0
  27. package/dist/raw/agent/models/AgentAgentProviderMeta.d.ts +56 -13
  28. package/dist/raw/agent/models/AgentAgentProviderMeta.js +8 -0
  29. package/dist/raw/agent/models/AgentAgentProviderPatch.d.ts +13 -0
  30. package/dist/raw/agent/models/AgentAgentProviderPatch.js +4 -0
  31. package/dist/raw/agent/models/AgentAgentProviderType.d.ts +19 -9
  32. package/dist/raw/agent/models/AgentAgentProviderType.js +18 -8
  33. package/dist/raw/agent/models/AgentListAgentFrameworks401Response.d.ts +1 -0
  34. package/dist/raw/agent/models/AgentListAgentFrameworks401Response.js +1 -0
  35. package/dist/raw/agent/models/AgentPageSessionEventView.d.ts +3 -3
  36. package/dist/raw/agent/models/AgentPageSessionEventView.js +3 -3
  37. package/dist/raw/agent/models/AgentRemoteSessionEvent.d.ts +38 -0
  38. package/dist/raw/agent/models/AgentRemoteSessionEvent.js +58 -0
  39. package/dist/raw/agent/models/AgentSessionArtifactView.d.ts +25 -0
  40. package/dist/raw/agent/models/AgentSessionArtifactView.js +2 -0
  41. package/dist/raw/agent/models/AgentSessionEventView.d.ts +30 -0
  42. package/dist/raw/agent/models/AgentSessionEventView.js +57 -0
  43. package/dist/raw/agent/models/AgentSessionMeta.d.ts +25 -0
  44. package/dist/raw/agent/models/AgentSessionMeta.js +2 -0
  45. package/dist/raw/agent/models/AgentSessionStreamFrame.d.ts +8 -6
  46. package/dist/raw/agent/models/AgentSessionStreamFrame.js +7 -0
  47. package/dist/raw/agent/models/AgentSyncState.d.ts +27 -0
  48. package/dist/raw/agent/models/AgentSyncState.js +55 -0
  49. package/dist/raw/agent/models/TriggerBody.d.ts +52 -0
  50. package/dist/raw/agent/models/TriggerBody.js +58 -0
  51. package/dist/raw/agent/models/index.d.ts +4 -1
  52. package/dist/raw/agent/models/index.js +4 -1
  53. package/dist/raw/eval/models/EvalListDatasets401Response.d.ts +1 -0
  54. package/dist/raw/eval/models/EvalListDatasets401Response.js +1 -0
  55. package/dist/raw/file/models/FileList401Response.d.ts +1 -0
  56. package/dist/raw/file/models/FileList401Response.js +1 -0
  57. package/dist/raw/mcp/apis/ServersApi.d.ts +2 -2
  58. package/dist/raw/mcp/apis/ServersApi.js +2 -2
  59. package/dist/raw/mcp/models/McpConnectionState.d.ts +2 -1
  60. package/dist/raw/mcp/models/McpConnectionState.js +2 -1
  61. package/dist/raw/mcp/models/McpListServers401Response.d.ts +1 -0
  62. package/dist/raw/mcp/models/McpListServers401Response.js +1 -0
  63. package/dist/raw/mcp/models/McpServerTestResponse.d.ts +13 -2
  64. package/dist/raw/model/models/ModelListProviders401Response.d.ts +1 -0
  65. package/dist/raw/model/models/ModelListProviders401Response.js +1 -0
  66. package/dist/raw/registry/apis/WorkspacesApi.d.ts +28 -0
  67. package/dist/raw/registry/apis/WorkspacesApi.js +61 -1
  68. package/dist/raw/registry/models/CreateWorkspaceBody.d.ts +9 -0
  69. package/dist/raw/registry/models/CreateWorkspaceBody.js +2 -0
  70. package/dist/raw/registry/models/RegistryAggregate401Response.d.ts +1 -0
  71. package/dist/raw/registry/models/RegistryAggregate401Response.js +1 -0
  72. package/dist/raw/registry/models/RegistryWorkspaceView.d.ts +9 -0
  73. package/dist/raw/registry/models/RegistryWorkspaceView.js +4 -0
  74. package/dist/raw/registry/models/SearchRequest.d.ts +1 -1
  75. package/dist/raw/registry/models/SetVisibilityBody.d.ts +33 -0
  76. package/dist/raw/registry/models/SetVisibilityBody.js +50 -0
  77. package/dist/raw/registry/models/index.d.ts +1 -0
  78. package/dist/raw/registry/models/index.js +1 -0
  79. package/dist/raw/secret/apis/OauthApi.d.ts +143 -0
  80. package/dist/raw/secret/apis/OauthApi.js +335 -0
  81. package/dist/raw/secret/apis/index.d.ts +1 -0
  82. package/dist/raw/secret/apis/index.js +19 -0
  83. package/dist/raw/secret/index.d.ts +1 -0
  84. package/dist/raw/secret/index.js +1 -0
  85. package/dist/raw/secret/models/GatewayOauthCompleteRequest.d.ts +32 -0
  86. package/dist/raw/secret/models/GatewayOauthCompleteRequest.js +50 -0
  87. package/dist/raw/secret/models/GatewayOauthCompletion.d.ts +46 -0
  88. package/dist/raw/secret/models/GatewayOauthCompletion.js +55 -0
  89. package/dist/raw/secret/models/GatewayOauthConnection.d.ts +83 -0
  90. package/dist/raw/secret/models/GatewayOauthConnection.js +83 -0
  91. package/dist/raw/secret/models/GatewayOauthOwner.d.ts +31 -0
  92. package/dist/raw/secret/models/GatewayOauthOwner.js +59 -0
  93. package/dist/raw/secret/models/GatewayOauthProviderView.d.ts +69 -0
  94. package/dist/raw/secret/models/GatewayOauthProviderView.js +69 -0
  95. package/dist/raw/secret/models/GatewayOauthStartRequest.d.ts +52 -0
  96. package/dist/raw/secret/models/GatewayOauthStartRequest.js +59 -0
  97. package/dist/raw/secret/models/GatewayOauthStarted.d.ts +39 -0
  98. package/dist/raw/secret/models/GatewayOauthStarted.js +54 -0
  99. package/dist/raw/secret/models/SecretCompleteOauth400Response.d.ts +70 -0
  100. package/dist/raw/secret/models/SecretCompleteOauth400Response.js +80 -0
  101. package/dist/raw/secret/models/SecretCompleteOauth413Response.d.ts +64 -0
  102. package/dist/raw/secret/models/SecretCompleteOauth413Response.js +74 -0
  103. package/dist/raw/secret/models/SecretCompleteOauth415Response.d.ts +64 -0
  104. package/dist/raw/secret/models/SecretCompleteOauth415Response.js +74 -0
  105. package/dist/raw/secret/models/SecretCompleteOauth422Response.d.ts +63 -0
  106. package/dist/raw/secret/models/SecretCompleteOauth422Response.js +75 -0
  107. package/dist/raw/secret/models/SecretDisconnectOauth400Response.d.ts +67 -0
  108. package/dist/raw/secret/models/SecretDisconnectOauth400Response.js +77 -0
  109. package/dist/raw/secret/models/SecretOauthCallback400Response.d.ts +68 -0
  110. package/dist/raw/secret/models/SecretOauthCallback400Response.js +78 -0
  111. package/dist/raw/secret/models/SecretOauthCallback401Response.d.ts +80 -0
  112. package/dist/raw/secret/models/SecretOauthCallback401Response.js +90 -0
  113. package/dist/raw/secret/models/SecretOauthCallback403Response.d.ts +66 -0
  114. package/dist/raw/secret/models/SecretOauthCallback403Response.js +76 -0
  115. package/dist/raw/secret/models/SecretOauthCallback404Response.d.ts +65 -0
  116. package/dist/raw/secret/models/SecretOauthCallback404Response.js +75 -0
  117. package/dist/raw/secret/models/SecretOauthCallback500Response.d.ts +64 -0
  118. package/dist/raw/secret/models/SecretOauthCallback500Response.js +74 -0
  119. package/dist/raw/secret/models/SecretOauthCallback503Response.d.ts +66 -0
  120. package/dist/raw/secret/models/SecretOauthCallback503Response.js +76 -0
  121. package/dist/raw/secret/models/SecretOauthCallback504Response.d.ts +63 -0
  122. package/dist/raw/secret/models/SecretOauthCallback504Response.js +75 -0
  123. package/dist/raw/secret/models/index.d.ts +19 -0
  124. package/dist/raw/secret/models/index.js +19 -0
  125. package/dist/raw/skill/models/SkillInsightSchemas401Response.d.ts +1 -0
  126. package/dist/raw/skill/models/SkillInsightSchemas401Response.js +1 -0
  127. package/dist/resource-types.d.ts +670 -0
  128. package/dist/resource-types.js +2 -0
  129. package/dist/resources.d.ts +10 -0
  130. package/dist/resources.js +54 -4
  131. package/dist/resources.json +86 -0
  132. package/dist/tracing/client.d.ts +8 -1
  133. package/dist/tracing/client.js +43 -16
  134. package/dist/tracing/config.d.ts +3 -1
  135. package/dist/tracing/config.js +7 -2
  136. package/dist/tracing/exporter.d.ts +19 -1
  137. package/dist/tracing/exporter.js +56 -14
  138. package/dist/tracing/propagation.d.ts +2 -0
  139. package/dist/tracing/propagation.js +16 -0
  140. package/dist/tracing/semconv.d.ts +4 -0
  141. package/dist/tracing/semconv.js +14 -2
  142. package/dist/tracing/spans.d.ts +2 -0
  143. package/dist/tracing/spans.js +12 -1
  144. package/dist/tracing/version.d.ts +1 -1
  145. package/dist/tracing/version.js +1 -1
  146. package/package.json +1 -1
  147. package/dist/raw/agent/models/Box.d.ts +0 -342
  148. package/dist/raw/agent/models/Box.js +0 -167
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}$/;
@@ -62,6 +69,9 @@ function manifestItem(item) {
62
69
  if (item.version.toLowerCase() === "latest") {
63
70
  throw new Error("context item version must be pinned; 'latest' is not a version");
64
71
  }
72
+ if (item.artifactKind !== undefined && !CONTEXT_KIND.test(item.artifactKind)) {
73
+ throw new Error("context item artifactKind must be a lowercase identifier");
74
+ }
65
75
  if (item.artifactId !== undefined) {
66
76
  validText(item.artifactId, "context item artifactId", 200);
67
77
  }
@@ -69,11 +79,18 @@ function manifestItem(item) {
69
79
  (!Number.isInteger(item.versionOrdinal) || item.versionOrdinal < 1)) {
70
80
  throw new Error("context item versionOrdinal must be an integer of at least 1");
71
81
  }
82
+ if ((item.artifactId === undefined) !== (item.versionOrdinal === undefined)) {
83
+ throw new Error("context item Registry pins require artifactId and versionOrdinal");
84
+ }
85
+ if (item.artifactKind !== undefined && item.artifactId === undefined) {
86
+ throw new Error("context item artifactKind requires a Registry artifact pin");
87
+ }
72
88
  return {
73
89
  kind: item.kind,
74
90
  name: item.name,
75
91
  digest: item.digest,
76
92
  version: item.version,
93
+ ...(item.artifactKind === undefined ? {} : { artifact_kind: item.artifactKind }),
77
94
  ...(item.artifactId === undefined ? {} : { artifact_id: item.artifactId }),
78
95
  ...(item.versionOrdinal === undefined ? {} : { version_ordinal: item.versionOrdinal }),
79
96
  };
@@ -97,6 +114,8 @@ async function createContextManifest(input) {
97
114
  const digest = "sha256:" + [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
98
115
  return Object.freeze({ ...body, items: Object.freeze(items), digest });
99
116
  }
117
+ class DatasetNotFoundError extends Error {
118
+ }
100
119
  /** Resolve one dataset by artifact ID or exact name, never fuzzy matching. */
101
120
  async function resolveDataset(client, idOrExactName, workspaceId) {
102
121
  if (!idOrExactName || idOrExactName.trim() !== idOrExactName) {
@@ -131,17 +150,263 @@ async function resolveDataset(client, idOrExactName, workspaceId) {
131
150
  }
132
151
  if (matches.length === 0) {
133
152
  const scope = workspace === undefined ? "" : ` in workspace '${workspace}'`;
134
- throw new Error(`no dataset named '${idOrExactName}'${scope}`);
153
+ throw new DatasetNotFoundError(`no dataset named '${idOrExactName}'${scope}`);
135
154
  }
136
155
  if (matches.length > 1) {
137
156
  throw new Error(`multiple datasets are named '${idOrExactName}'; pass the dataset ID`);
138
157
  }
139
158
  return matches[0];
140
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
+ }
141
399
  class EvalRun {
142
400
  experiment;
143
401
  dataset;
144
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
+ */
145
410
  constructor(experiment, dataset, contextManifest) {
146
411
  this.experiment = experiment;
147
412
  this.dataset = dataset;
@@ -157,10 +422,20 @@ class EvalRun {
157
422
  get experimentId() {
158
423
  return this.id;
159
424
  }
160
- /** Options for `propagateAttributes` from `@atlanai/sdk/tracing`. */
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
+ */
161
435
  get traceOptions() {
162
436
  return {
163
437
  experimentId: this.id,
438
+ sessionId: this.sessionId,
164
439
  ...(this.contextManifest === undefined
165
440
  ? {}
166
441
  : { metadata: { context_manifest_digest: this.contextManifest.digest } }),
@@ -235,9 +510,127 @@ async function startExperiment(client, dataset, body, options = {}) {
235
510
  }
236
511
  return run;
237
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
+ }
238
631
  // ---------------------------------------------------------------------------
239
632
  // Declarative evaluator
240
- const DEFAULT_GATEWAY_ORIGIN = "https://agentgateway.atlan.engineering";
633
+ const DEFAULT_GATEWAY_ORIGIN = "https://api.atlan.com";
241
634
  function tracingModule() {
242
635
  // The root client is CommonJS while tracing has its own NodeNext build. A
243
636
  // runtime require keeps the two compiler targets independent and resolves to
@@ -311,6 +704,20 @@ async function collectData(data) {
311
704
  }
312
705
  return cases;
313
706
  }
707
+ async function casesDigest(cases) {
708
+ const normalized = cases.map((item, index) => ({
709
+ id: item.id ?? `case-${index + 1}`,
710
+ input: item.input,
711
+ expected: item.expected ?? null,
712
+ metadata: item.metadata ?? {},
713
+ tags: item.tags ?? [],
714
+ }));
715
+ if (new Set(normalized.map((item) => item.id)).size !== cases.length) {
716
+ throw new Error("Eval case IDs must be unique");
717
+ }
718
+ const hash = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(normalized)));
719
+ return "sha256:" + [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
720
+ }
314
721
  async function datasetCases(resources, datasetId) {
315
722
  const records = [];
316
723
  let offset = 0;
@@ -394,11 +801,6 @@ async function experimentResults(resources, experimentId) {
394
801
  }
395
802
  return results;
396
803
  }
397
- async function existingCaseIds(resources, experimentId) {
398
- return new Set((await experimentResults(resources, experimentId))
399
- .map((item) => field(item, "caseId"))
400
- .filter((caseId) => typeof caseId === "string" && caseId.length > 0));
401
- }
402
804
  async function findScorer(resources, name, workspaceId) {
403
805
  const matches = [];
404
806
  let offset = 0;
@@ -434,6 +836,39 @@ function scorerIdentity(value, workspaceId) {
434
836
  }
435
837
  return { id, version };
436
838
  }
839
+ async function scorerDefinition(definition) {
840
+ const outputs = definition.outputs ?? { [definition.name]: { type: "number", minimum: 0, maximum: 1 } };
841
+ if (definition.spec !== undefined)
842
+ return { spec: definition.spec, outputs };
843
+ if (!globalThis.crypto?.subtle)
844
+ throw new Error("Web Crypto is required to identify a scorer");
845
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(definition.scorer)));
846
+ const codeDigest = "sha256:" +
847
+ [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
848
+ return {
849
+ outputs,
850
+ spec: {
851
+ executor: "external",
852
+ sdk: "@atlanai/sdk",
853
+ entrypoint: definition.scorer.name || "anonymous",
854
+ code_digest: codeDigest,
855
+ },
856
+ };
857
+ }
858
+ async function reconcileScorer(resources, existing, workspaceId, definition) {
859
+ const identity = scorerIdentity(existing, workspaceId);
860
+ const scorerKind = definition.scorerKind ?? "code";
861
+ const scope = definition.scope ?? "result";
862
+ if (field(existing, "scorerKind") !== scorerKind || field(existing, "scope") !== scope) {
863
+ throw new Error(`scorer '${definition.name}' reuses a Registry name with a different identity`);
864
+ }
865
+ const desired = await scorerDefinition(definition);
866
+ if (canonicalJson(field(existing, "spec")) === canonicalJson(desired.spec) &&
867
+ canonicalJson(field(existing, "outputs")) === canonicalJson(desired.outputs)) {
868
+ return identity;
869
+ }
870
+ return scorerIdentity(await resources.scorers.update(identity.id, desired), workspaceId);
871
+ }
437
872
  async function resolveScorer(resources, workspaceId, evalName, definition) {
438
873
  if (!definition.name || /[\r\n]/.test(definition.name)) {
439
874
  throw new Error("each scorer needs a non-empty single-line name");
@@ -448,7 +883,7 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
448
883
  if (!Number.isInteger(definition.scorerVersion) || definition.scorerVersion < 1) {
449
884
  throw new Error(`scorer '${definition.name}' has an invalid scorerVersion`);
450
885
  }
451
- return { id: identity.id, version: definition.scorerVersion };
886
+ return scorerIdentity(await resources.scorers.versions.get(identity.id, definition.scorerVersion), workspaceId);
452
887
  }
453
888
  return identity;
454
889
  }
@@ -457,8 +892,10 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
457
892
  }
458
893
  const artifactName = definition.registryName ?? `${slug(evalName, "eval")}-${slug(definition.name, "score")}`;
459
894
  const existing = await findScorer(resources, artifactName, workspaceId);
460
- if (existing !== undefined)
461
- return scorerIdentity(existing, workspaceId);
895
+ if (existing !== undefined) {
896
+ return reconcileScorer(resources, existing, workspaceId, definition);
897
+ }
898
+ const desired = await scorerDefinition(definition);
462
899
  try {
463
900
  return scorerIdentity(await resources.scorers.create({
464
901
  workspaceId,
@@ -468,12 +905,8 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
468
905
  sourceId: `atlan-eval:${slug(evalName, "eval")}:${slug(definition.name, "score")}`,
469
906
  scorerKind: definition.scorerKind ?? "code",
470
907
  scope: definition.scope ?? "result",
471
- outputs: definition.outputs ?? { [definition.name]: { type: "number", minimum: 0, maximum: 1 } },
472
- spec: definition.spec ?? {
473
- executor: "external",
474
- sdk: "@atlanai/sdk",
475
- entrypoint: definition.scorer.name || "anonymous",
476
- },
908
+ outputs: desired.outputs,
909
+ spec: desired.spec,
477
910
  }), workspaceId);
478
911
  }
479
912
  catch (error) {
@@ -482,7 +915,7 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
482
915
  const raced = await findScorer(resources, artifactName, workspaceId);
483
916
  if (raced === undefined)
484
917
  throw error;
485
- return scorerIdentity(raced, workspaceId);
918
+ return reconcileScorer(resources, raced, workspaceId, definition);
486
919
  }
487
920
  }
488
921
  function normalizedScores(value, defaultName) {
@@ -546,12 +979,63 @@ function localSummary(results, durationMs) {
546
979
  },
547
980
  };
548
981
  }
549
- async function listedTraceIds(resources, experimentId) {
982
+ async function traceSpans(resources, experimentId, traceId) {
983
+ const spans = [];
984
+ const seenCursors = new Set();
985
+ let cursor;
986
+ while (true) {
987
+ const page = await resources.experiments.traces.listSpans({
988
+ experimentId,
989
+ traceId,
990
+ limit: 500,
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",
996
+ ...(cursor === undefined ? {} : { cursor }),
997
+ });
998
+ const items = field(page, "items");
999
+ if (Array.isArray(items))
1000
+ spans.push(...items);
1001
+ const nextCursor = field(field(page, "page"), "nextCursor");
1002
+ if (typeof nextCursor !== "string" || nextCursor.length === 0)
1003
+ return spans;
1004
+ if (seenCursors.has(nextCursor)) {
1005
+ throw new Error("experiment trace span pagination repeated a cursor");
1006
+ }
1007
+ seenCursors.add(nextCursor);
1008
+ cursor = nextCursor;
1009
+ }
1010
+ }
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) {
550
1027
  const traceIds = new Set();
551
1028
  const seenCursors = new Set();
552
1029
  let cursor;
1030
+ const windowEnd = Math.floor(Date.now() / 1_000);
1031
+ const bounds = window ?? {
1032
+ startTimeUnixSeconds: windowEnd - 30 * 24 * 60 * 60,
1033
+ endTimeUnixSeconds: windowEnd,
1034
+ };
553
1035
  while (true) {
554
- const page = await resources.experiments.traces.list(experimentId, {
1036
+ const page = await resources.experiments.traces.list({
1037
+ experimentId,
1038
+ ...bounds,
555
1039
  limit: 500,
556
1040
  ...(cursor === undefined ? {} : { cursor }),
557
1041
  });
@@ -573,14 +1057,106 @@ async function listedTraceIds(resources, experimentId) {
573
1057
  cursor = nextCursor;
574
1058
  }
575
1059
  }
576
- async function verifyTraceJoins(resources, experimentId, expected, timeoutMs) {
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
+ */
1082
+ function spanAttributes(span) {
1083
+ const attributes = field(span, "attributes");
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;
1096
+ }
1097
+ function missingTraceEvidence(spans, expected) {
1098
+ const missing = [];
1099
+ const byId = new Map();
1100
+ for (const span of spans) {
1101
+ const spanId = field(span, "spanId");
1102
+ if (typeof spanId === "string")
1103
+ byId.set(spanId, span);
1104
+ }
1105
+ const root = expected.rootSpanId === undefined
1106
+ ? spans.find((span) => spanAttributes(span)["atlan.eval.case_id"] === expected.caseId &&
1107
+ spanAttributes(span)["atlan.eval.evidence_complete"] === true)
1108
+ : byId.get(expected.rootSpanId);
1109
+ if (root === undefined) {
1110
+ missing.push("case root");
1111
+ }
1112
+ else {
1113
+ const attributes = spanAttributes(root);
1114
+ if (attributes["atlan.eval.evidence_complete"] !== true) {
1115
+ missing.push("producer completion marker");
1116
+ }
1117
+ if (attributes["atlan.eval.case_id"] !== expected.caseId)
1118
+ missing.push("case identity");
1119
+ if (expected.datasetRecordId !== undefined &&
1120
+ attributes["atlan.eval.dataset_record_id"] !== expected.datasetRecordId) {
1121
+ missing.push("dataset record identity");
1122
+ }
1123
+ }
1124
+ const absentIds = [...expected.requiredSpanIds].filter((spanId) => !byId.has(spanId));
1125
+ if (absentIds.length > 0)
1126
+ missing.push(`${absentIds.length} SDK-owned spans`);
1127
+ if (spans.some((span) => spanAttributes(span)["atlan.eval.evidence_truncated"] === true)) {
1128
+ missing.push("untruncated payloads");
1129
+ }
1130
+ for (const [scoreName, scorer] of expected.scores) {
1131
+ const present = spans.some((span) => field(span, "scoreName") === scoreName &&
1132
+ field(span, "scorerId") === scorer.id &&
1133
+ field(span, "scorerVersion") === scorer.version);
1134
+ if (!present)
1135
+ missing.push(`score '${scoreName}'`);
1136
+ }
1137
+ return missing;
1138
+ }
1139
+ async function verifyTraceEvidence(resources, experimentId, expected, timeoutMs) {
577
1140
  const deadline = Date.now() + timeoutMs;
578
- let missing = expected;
1141
+ let missing = new Map();
579
1142
  let lastError;
580
1143
  while (true) {
581
1144
  try {
582
- const observed = await listedTraceIds(resources, experimentId);
583
- missing = new Set([...expected].filter((traceId) => !observed.has(traceId)));
1145
+ missing = new Map();
1146
+ if (typeof resources.experiments.traces.listSpans === "function") {
1147
+ for (const [traceId, expectation] of expected) {
1148
+ const gaps = missingTraceEvidence(await traceSpans(resources, experimentId, traceId), expectation);
1149
+ if (gaps.length > 0)
1150
+ missing.set(traceId, gaps);
1151
+ }
1152
+ }
1153
+ else {
1154
+ const observed = await listedTraceIds(resources, experimentId);
1155
+ for (const traceId of expected.keys()) {
1156
+ if (!observed.has(traceId))
1157
+ missing.set(traceId, ["trace read"]);
1158
+ }
1159
+ }
584
1160
  lastError = undefined;
585
1161
  }
586
1162
  catch (error) {
@@ -588,15 +1164,16 @@ async function verifyTraceJoins(resources, experimentId, expected, timeoutMs) {
588
1164
  ![404, 429, 500, 502, 503, 504].includes(error.status)) {
589
1165
  throw error;
590
1166
  }
591
- missing = expected;
1167
+ missing = new Map([...expected.keys()].map((traceId) => [traceId, ["trace read"]]));
592
1168
  lastError = error;
593
1169
  }
594
1170
  if (missing.size === 0)
595
1171
  return;
596
1172
  const remaining = deadline - Date.now();
597
1173
  if (remaining <= 0) {
598
- const failure = new Error(`Registry did not expose ${missing.size} of ${expected.size} case traces ` +
599
- "before the verification deadline");
1174
+ const failure = new Error(`Registry did not expose complete trace evidence for ${missing.size} of ` +
1175
+ `${expected.size} case traces before the verification deadline: ` +
1176
+ [...missing].map(([traceId, gaps]) => `${traceId} (${gaps.join(", ")})`).join("; "));
600
1177
  if (lastError !== undefined)
601
1178
  failure.cause = lastError;
602
1179
  throw failure;
@@ -632,6 +1209,11 @@ function defaultConnections(name, options) {
632
1209
  if (sampleRate !== undefined && sampleRate !== 1) {
633
1210
  throw new Error("Eval tracing requires sampleRate=1 so no case trace is sampled out");
634
1211
  }
1212
+ const traceContent = traceLogger.client.config
1213
+ ?.traceContent;
1214
+ if (traceContent === false) {
1215
+ throw new Error("Eval tracing requires traceContent=true so case evidence is readable");
1216
+ }
635
1217
  return { client, logger: traceLogger, workspaceId, tracing };
636
1218
  }
637
1219
  /**
@@ -642,6 +1224,73 @@ function defaultConnections(name, options) {
642
1224
  * carry the root trace ID, traces flush before summary, and the experiment is
643
1225
  * explicitly finalized.
644
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
+ }
645
1294
  async function Eval(name, evaluator, options = {}) {
646
1295
  if (!name || /[\r\n]/.test(name))
647
1296
  throw new Error("Eval name must be non-empty and single-line");
@@ -679,6 +1328,7 @@ async function Eval(name, evaluator, options = {}) {
679
1328
  if (value === undefined)
680
1329
  delete experimentBody[key];
681
1330
  }
1331
+ await checkSubjectWorkspace(client, options.subjectKind, options.subjectId, workspaceId);
682
1332
  let run;
683
1333
  let cases = [];
684
1334
  if (options.resumeExperimentId !== undefined) {
@@ -689,11 +1339,14 @@ async function Eval(name, evaluator, options = {}) {
689
1339
  if (field(experiment, "experimentStatus") !== "running") {
690
1340
  throw new Error("resumeExperimentId must name a running experiment");
691
1341
  }
692
- if (options.contextManifest !== undefined &&
693
- field(field(experiment, "config"), "context_manifest_digest") !==
694
- options.contextManifest.digest) {
1342
+ const storedConfig = field(experiment, "config") ?? {};
1343
+ if (field(storedConfig, "context_manifest_digest") !== options.contextManifest?.digest) {
695
1344
  throw new Error("resume experiment uses a different context manifest");
696
1345
  }
1346
+ const storedUserConfig = Object.fromEntries(Object.entries(storedConfig).filter(([key]) => !["runner", "registry_dataset", "context_manifest", "context_manifest_digest", "eval_cases_digest"].includes(key)));
1347
+ if (canonicalJson(storedUserConfig) !== canonicalJson(options.config ?? {})) {
1348
+ throw new Error("resume experiment uses a different config");
1349
+ }
697
1350
  let resolvedDataset;
698
1351
  if (evaluator.dataset !== undefined) {
699
1352
  resolvedDataset = await resolveDataset(client, evaluator.dataset, workspaceId);
@@ -706,6 +1359,9 @@ async function Eval(name, evaluator, options = {}) {
706
1359
  }
707
1360
  else {
708
1361
  cases = await collectData(evaluator.data);
1362
+ if (field(storedConfig, "eval_cases_digest") !== await casesDigest(cases)) {
1363
+ throw new Error("resume inline data differs or has no saved digest; start a new experiment");
1364
+ }
709
1365
  }
710
1366
  run = new EvalRun(experiment, resolvedDataset, options.contextManifest);
711
1367
  }
@@ -717,6 +1373,7 @@ async function Eval(name, evaluator, options = {}) {
717
1373
  else {
718
1374
  cases = await collectData(evaluator.data);
719
1375
  const config = { ...experimentBody.config };
1376
+ config.eval_cases_digest = await casesDigest(cases);
720
1377
  if (options.contextManifest !== undefined) {
721
1378
  config.context_manifest = options.contextManifest;
722
1379
  config.context_manifest_digest = options.contextManifest.digest;
@@ -729,23 +1386,42 @@ async function Eval(name, evaluator, options = {}) {
729
1386
  }
730
1387
  }
731
1388
  const scorerCache = new Map();
1389
+ const traceEvidence = new Map();
732
1390
  const started = Date.now();
733
1391
  const results = [];
734
1392
  try {
735
1393
  options.onStart?.({ experimentId: run.id, experiment: run.experiment });
736
1394
  if (evaluator.dataset !== undefined) {
1395
+ const snapshot = snapshotCases(run.experiment);
1396
+ if (options.resumeExperimentId !== undefined && snapshot === undefined) {
1397
+ throw new Error("resume requires the experiment's immutable dataset snapshot");
1398
+ }
737
1399
  cases =
738
- snapshotCases(run.experiment) ??
1400
+ snapshot ??
739
1401
  (await datasetCases(resources, String(field(run.dataset, "id"))));
740
1402
  }
741
- const completedCaseIds = options.resumeExperimentId === undefined
742
- ? new Set()
743
- : await existingCaseIds(resources, run.id);
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
+ }
1411
+ const caseIds = new Set(cases.map((item, index) => item.id ?? `case-${index + 1}`));
1412
+ if (caseIds.size !== cases.length)
1413
+ throw new Error("Eval case IDs must be unique");
1414
+ const persisted = options.resumeExperimentId === undefined ? [] : await experimentResults(resources, run.id);
1415
+ const completedCaseIds = new Set(persisted.map((item) => field(item, "caseId")));
1416
+ if (completedCaseIds.size !== persisted.length || [...completedCaseIds].some((id) => typeof id !== "string" || !caseIds.has(id))) {
1417
+ throw new Error("resume experiment contains unexpected or duplicate case IDs");
1418
+ }
744
1419
  for (const definition of evaluator.scores) {
745
1420
  scorerCache.set(definition.name, await resolveScorer(resources, workspaceId, name, definition));
746
1421
  }
747
1422
  for (const [index, item] of cases.entries()) {
748
1423
  const caseId = item.id ?? `case-${index + 1}`;
1424
+ const datasetRecordId = evaluator.dataset === undefined ? undefined : item.id;
749
1425
  if (completedCaseIds.has(caseId))
750
1426
  continue;
751
1427
  const caseStarted = Date.now();
@@ -753,8 +1429,15 @@ async function Eval(name, evaluator, options = {}) {
753
1429
  const failures = [];
754
1430
  let output;
755
1431
  let traceId = "";
1432
+ let rootSpanId;
1433
+ const requiredSpanIds = new Set();
1434
+ const scoreEvidence = new Map();
756
1435
  const metadata = (item.metadata ?? {});
757
- await tracing.propagateAttributes(run.traceOptions, () => traceLogger.client.startAsCurrentSpan(`eval.case.${index + 1}`, {
1436
+ await tracing.propagateAttributes({
1437
+ ...run.traceOptions,
1438
+ caseId,
1439
+ ...(datasetRecordId === undefined ? {} : { datasetRecordId }),
1440
+ }, () => traceLogger.client.startAsCurrentSpan(`eval.case.${index + 1}`, {
758
1441
  asType: "task",
759
1442
  fields: {
760
1443
  input: item.input,
@@ -763,8 +1446,13 @@ async function Eval(name, evaluator, options = {}) {
763
1446
  },
764
1447
  }, async (rootSpan) => {
765
1448
  traceId = rootSpan.traceId;
1449
+ rootSpanId = rootSpan.spanId;
1450
+ if (rootSpan.spanId !== undefined)
1451
+ requiredSpanIds.add(rootSpan.spanId);
766
1452
  try {
767
1453
  output = await traceLogger.client.startAsCurrentSpan("eval.task", { asType: "task", fields: { input: item.input } }, async (taskSpan) => {
1454
+ if (taskSpan.spanId !== undefined)
1455
+ requiredSpanIds.add(taskSpan.spanId);
768
1456
  const value = await evaluator.task(item.input, {
769
1457
  expected: item.expected,
770
1458
  metadata,
@@ -779,6 +1467,7 @@ async function Eval(name, evaluator, options = {}) {
779
1467
  catch (error) {
780
1468
  failures.push(safeError(error));
781
1469
  rootSpan.recordFailure(error);
1470
+ rootSpan.markEvidenceComplete?.();
782
1471
  return;
783
1472
  }
784
1473
  for (const definition of evaluator.scores) {
@@ -793,6 +1482,8 @@ async function Eval(name, evaluator, options = {}) {
793
1482
  },
794
1483
  },
795
1484
  }, async (scoreSpan) => {
1485
+ if (scoreSpan.spanId !== undefined)
1486
+ requiredSpanIds.add(scoreSpan.spanId);
796
1487
  const value = await definition.scorer({ ...item, output: output });
797
1488
  scoreSpan.update({ output: value });
798
1489
  return value;
@@ -813,6 +1504,7 @@ async function Eval(name, evaluator, options = {}) {
813
1504
  }
814
1505
  const numeric = numericScore(scored.score);
815
1506
  scores[scoreName] = numeric;
1507
+ scoreEvidence.set(scoreName, registered);
816
1508
  rootSpan.score(scoreName, numeric, {
817
1509
  scorerId: registered.id,
818
1510
  scorerVersion: registered.version,
@@ -828,6 +1520,7 @@ async function Eval(name, evaluator, options = {}) {
828
1520
  }
829
1521
  }
830
1522
  rootSpan.update({ output, metadata });
1523
+ rootSpan.markEvidenceComplete?.();
831
1524
  }));
832
1525
  if (!TRACE_ID.test(traceId) || /^0+$/.test(traceId)) {
833
1526
  throw new Error(`case ${index + 1} did not produce a trace ID`);
@@ -841,41 +1534,99 @@ async function Eval(name, evaluator, options = {}) {
841
1534
  traceId,
842
1535
  durationMs: Date.now() - caseStarted,
843
1536
  });
844
- }
845
- // Score aggregation reads spans, so make the async exporter observable first.
846
- await traceLogger.flush();
847
- if (options.verifyTraces !== false && results.length > 0) {
848
- await verifyTraceJoins(resources, run.id, new Set(results.map((result) => result.traceId)), traceVerificationTimeoutMs);
849
- }
850
- const resultBodies = results.map((result, index) => ({
851
- workspaceId,
852
- name: `${slug(run.id, "experiment")}-case-${index + 1}`,
853
- caseId: result.caseId,
854
- datasetRecordId: result.id || undefined,
855
- input: registryObject(result.input),
856
- expected: registryObject(result.expected),
857
- output: registryObject(result.output),
858
- error: result.error,
859
- scores: result.scores,
860
- traceId: result.traceId,
861
- durationMs: result.durationMs,
862
- sourceType: "local",
863
- }));
864
- for (let offset = 0; offset < resultBodies.length; offset += 100) {
1537
+ traceEvidence.set(traceId, {
1538
+ caseId,
1539
+ ...(datasetRecordId === undefined ? {} : { datasetRecordId }),
1540
+ ...(rootSpanId === undefined ? {} : { rootSpanId }),
1541
+ requiredSpanIds,
1542
+ scores: scoreEvidence,
1543
+ });
1544
+ // Persist each completed case before starting another costly task.
1545
+ // Keep the source index so a resumed subset cannot reuse earlier names.
1546
+ const result = results[results.length - 1];
1547
+ await traceLogger.flush();
1548
+ if (options.verifyTraces !== false) {
1549
+ await verifyTraceEvidence(resources, run.id, new Map([[result.traceId, traceEvidence.get(result.traceId)]]), traceVerificationTimeoutMs);
1550
+ }
1551
+ const resultBody = {
1552
+ workspaceId,
1553
+ name: `${slug(run.id, "experiment")}-case-${index + 1}`,
1554
+ caseId: result.caseId,
1555
+ datasetRecordId,
1556
+ input: registryObject(result.input),
1557
+ expected: registryObject(result.expected),
1558
+ output: registryObject(result.output),
1559
+ error: result.error,
1560
+ scores: result.scores,
1561
+ traceId: result.traceId,
1562
+ durationMs: result.durationMs,
1563
+ sourceType: "local",
1564
+ };
865
1565
  const response = await resources.experiments.results.createBulk(run.id, {
866
- items: resultBodies.slice(offset, offset + 100),
1566
+ items: [resultBody],
867
1567
  });
868
1568
  const slots = field(response, "items");
869
- if (Array.isArray(slots)) {
870
- const failed = slots.filter((slot) => {
871
- const status = field(slot, "statusCode");
872
- return typeof status === "number" && status >= 400;
873
- });
874
- if (failed.length > 0)
875
- throw new Error(`${failed.length} Eval results were rejected`);
1569
+ const status = Array.isArray(slots) && slots.length === 1 ? field(slots[0], "statusCode") : undefined;
1570
+ if (typeof status !== "number" || status < 200 || status >= 300) {
1571
+ throw new Error("1 Eval results were rejected or not acknowledged");
876
1572
  }
877
1573
  }
878
1574
  const persistedResults = await experimentResults(resources, run.id);
1575
+ const persistedCaseIds = new Set(persistedResults.map((item) => field(item, "caseId")));
1576
+ if (persistedResults.length !== caseIds.size || persistedCaseIds.size !== caseIds.size || [...caseIds].some((id) => !persistedCaseIds.has(id))) {
1577
+ throw new Error("Registry result coverage does not match the evaluation cases");
1578
+ }
1579
+ if (options.verifyTraces !== false && persistedResults.length > 0) {
1580
+ const traceIds = new Set();
1581
+ for (const item of persistedResults) {
1582
+ const traceId = field(item, "traceId");
1583
+ if (typeof traceId !== "string" || !TRACE_ID.test(traceId) || /^0+$/.test(traceId)) {
1584
+ throw new Error("Registry result is missing a valid trace ID");
1585
+ }
1586
+ traceIds.add(traceId);
1587
+ }
1588
+ const expectations = new Map();
1589
+ for (const item of persistedResults) {
1590
+ const traceId = field(item, "traceId");
1591
+ const current = traceEvidence.get(traceId);
1592
+ if (current !== undefined) {
1593
+ expectations.set(traceId, current);
1594
+ continue;
1595
+ }
1596
+ const persistedScores = field(item, "scores");
1597
+ const scorePins = new Map();
1598
+ if (isRecord(persistedScores)) {
1599
+ for (const [scoreName, value] of Object.entries(persistedScores)) {
1600
+ const scorer = scorerCache.get(scoreName);
1601
+ if (value !== null && scorer !== undefined)
1602
+ scorePins.set(scoreName, scorer);
1603
+ }
1604
+ }
1605
+ const datasetRecordId = field(item, "datasetRecordId");
1606
+ expectations.set(traceId, {
1607
+ caseId: String(field(item, "caseId")),
1608
+ ...(typeof datasetRecordId === "string" ? { datasetRecordId } : {}),
1609
+ requiredSpanIds: new Set(),
1610
+ scores: scorePins,
1611
+ });
1612
+ }
1613
+ await verifyTraceEvidence(resources, run.id, expectations, traceVerificationTimeoutMs);
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
+ }
879
1630
  const local = localSummary(persistedResults, Date.now() - started);
880
1631
  const experiment = await resources.experiments.update(run.id, {
881
1632
  experimentStatus: "completed",
@@ -891,6 +1642,8 @@ async function Eval(name, evaluator, options = {}) {
891
1642
  dataset: run.dataset,
892
1643
  summary,
893
1644
  results: options.returnResults === false ? [] : results,
1645
+ sessionId: run.sessionId,
1646
+ ...(session === undefined ? {} : { session }),
894
1647
  };
895
1648
  }
896
1649
  catch (error) {
@@ -902,15 +1655,193 @@ async function Eval(name, evaluator, options = {}) {
902
1655
  catch {
903
1656
  // Preserve the original runner/upload failure.
904
1657
  }
905
- try {
906
- await resources.experiments.update(run.id, {
907
- experimentStatus: "failed",
908
- summary: localSummary(results, Date.now() - started),
909
- });
910
- }
911
- catch {
912
- // Preserve the original runner/upload failure.
1658
+ // Leave interrupted infrastructure work running: terminal experiments
1659
+ // cannot accept more results. Resume reconciles Registry case IDs.
1660
+ throw nameOpenExperiment(error, run.id);
1661
+ }
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;
913
1765
  }
914
- throw error;
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`);
915
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
+ };
916
1847
  }