engineering-behavior-observatory 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +151 -0
- package/contracts/codex-app-server-0.150.1/manifest.json +25 -0
- package/contracts/codex-app-server-0.150.1/schema/InitializeParams.json +84 -0
- package/contracts/codex-app-server-0.150.1/schema/JSONRPCMessage.json +137 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/ThreadReadParams.json +17 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/ThreadTokenUsageUpdatedNotification.json +82 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/TurnInterruptParams.json +17 -0
- package/contracts/codex-app-server-0.150.1/types/AskForApproval.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/ClientNotification.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/SandboxMode.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/ThreadReadParams.ts +9 -0
- package/contracts/codex-app-server-0.150.1/types/TokenUsageBreakdown.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/TurnInterruptParams.ts +5 -0
- package/contracts/codex-app-server-0.153.4/manifest.json +27 -0
- package/contracts/codex-app-server-0.153.4/schema/JSONRPCMessage.json +137 -0
- package/contracts/codex-app-server-0.153.4/schema/v1/InitializeParams.json +84 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadReadParams.json +17 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadStartParams.json +515 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadTokenUsageUpdatedNotification.json +82 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/TurnInterruptParams.json +17 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/TurnStartParams.json +921 -0
- package/contracts/codex-app-server-0.153.4/types/AskForApproval.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/ClientNotification.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/SandboxMode.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/ThreadReadParams.ts +12 -0
- package/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.ts +5 -0
- package/contracts/openhands-agent-server-v1.44.1.json +65 -0
- package/contracts/openhands-agent-server-v1.46.0.json +46 -0
- package/dist/contracts/codex-app-server-0.153.4/types/AskForApproval.d.ts +9 -0
- package/dist/contracts/codex-app-server-0.153.4/types/AskForApproval.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ClientNotification.d.ts +3 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ClientNotification.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/SandboxMode.d.ts +1 -0
- package/dist/contracts/codex-app-server-0.153.4/types/SandboxMode.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ThreadReadParams.d.ts +10 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ThreadReadParams.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.d.ts +8 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.d.ts +4 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.js +2 -0
- package/dist/src/agent-sdk-normalizer.d.ts +22 -0
- package/dist/src/agent-sdk-normalizer.js +995 -0
- package/dist/src/agent-sdk-run.d.ts +31 -0
- package/dist/src/agent-sdk-run.js +242 -0
- package/dist/src/agent-sdk-runner.d.ts +96 -0
- package/dist/src/agent-sdk-runner.js +407 -0
- package/dist/src/agent-sdk.d.ts +294 -0
- package/dist/src/agent-sdk.js +644 -0
- package/dist/src/aggregation.d.ts +183 -0
- package/dist/src/aggregation.js +513 -0
- package/dist/src/artifacts.d.ts +36 -0
- package/dist/src/artifacts.js +1372 -0
- package/dist/src/atlas-grafana.d.ts +377 -0
- package/dist/src/atlas-grafana.js +47 -0
- package/dist/src/atlas-html.d.ts +2 -0
- package/dist/src/atlas-html.js +34 -0
- package/dist/src/atlas.d.ts +130 -0
- package/dist/src/atlas.js +235 -0
- package/dist/src/behavior-assertions.d.ts +95 -0
- package/dist/src/behavior-assertions.js +154 -0
- package/dist/src/cli.d.ts +5 -0
- package/dist/src/cli.js +957 -0
- package/dist/src/codex-judge.d.ts +44 -0
- package/dist/src/codex-judge.js +214 -0
- package/dist/src/codex-run.d.ts +114 -0
- package/dist/src/codex-run.js +519 -0
- package/dist/src/codex.d.ts +187 -0
- package/dist/src/codex.js +1028 -0
- package/dist/src/contracts.d.ts +155 -0
- package/dist/src/contracts.js +866 -0
- package/dist/src/corpus.d.ts +67 -0
- package/dist/src/corpus.js +540 -0
- package/dist/src/cursor-sdk-runner.d.ts +81 -0
- package/dist/src/cursor-sdk-runner.js +362 -0
- package/dist/src/cursor-sdk.d.ts +81 -0
- package/dist/src/cursor-sdk.js +1078 -0
- package/dist/src/deepseek-adapter.d.ts +152 -0
- package/dist/src/deepseek-adapter.js +777 -0
- package/dist/src/exports.d.ts +69 -0
- package/dist/src/exports.js +966 -0
- package/dist/src/human-calibration.d.ts +167 -0
- package/dist/src/human-calibration.js +618 -0
- package/dist/src/index.d.ts +71 -0
- package/dist/src/index.js +38 -0
- package/dist/src/lifecycle.d.ts +196 -0
- package/dist/src/lifecycle.js +1889 -0
- package/dist/src/normalization-integrity.d.ts +128 -0
- package/dist/src/normalization-integrity.js +429 -0
- package/dist/src/openhands-run.d.ts +36 -0
- package/dist/src/openhands-run.js +415 -0
- package/dist/src/openhands.d.ts +130 -0
- package/dist/src/openhands.js +859 -0
- package/dist/src/pi.d.ts +203 -0
- package/dist/src/pi.js +1345 -0
- package/dist/src/process-protocol.d.ts +245 -0
- package/dist/src/process-protocol.js +1261 -0
- package/dist/src/retained-evidence.d.ts +12 -0
- package/dist/src/retained-evidence.js +201 -0
- package/dist/src/run-bundles.d.ts +193 -0
- package/dist/src/run-bundles.js +993 -0
- package/dist/src/scheduler.d.ts +109 -0
- package/dist/src/scheduler.js +1080 -0
- package/dist/src/semantic-judge.d.ts +194 -0
- package/dist/src/semantic-judge.js +875 -0
- package/dist/src/structural-observations.d.ts +124 -0
- package/dist/src/structural-observations.js +671 -0
- package/dist/src/task-packets.d.ts +115 -0
- package/dist/src/task-packets.js +683 -0
- package/dist/src/uniform-events.d.ts +120 -0
- package/dist/src/uniform-events.js +158 -0
- package/dist/src/verifiers.d.ts +116 -0
- package/dist/src/verifiers.js +819 -0
- package/dist/src/workspaces.d.ts +56 -0
- package/dist/src/workspaces.js +1314 -0
- package/docs/README.md +60 -0
- package/docs/development/README.md +71 -0
- package/docs/development/documentation-sync.md +58 -0
- package/docs/development/extension-contracts.md +307 -0
- package/docs/evaluation/README.md +48 -0
- package/docs/evaluation/aggregation.md +112 -0
- package/docs/evaluation/behavior-assertions.md +45 -0
- package/docs/evaluation/human-calibration.md +170 -0
- package/docs/evaluation/normalization-integrity.md +80 -0
- package/docs/evaluation/semantic-judge.md +188 -0
- package/docs/evaluation/structural-observations.md +84 -0
- package/docs/evaluation/uniform-events.md +131 -0
- package/docs/guides/atlas.md +144 -0
- package/docs/guides/evidence-and-sharing.md +100 -0
- package/docs/guides/operator-guide.md +424 -0
- package/docs/guides/quickstart.md +152 -0
- package/docs/guides/telemetry.md +66 -0
- package/docs/harnesses/README.md +31 -0
- package/docs/harnesses/claude-agent-sdk.md +61 -0
- package/docs/harnesses/codex-harness.md +174 -0
- package/docs/harnesses/cursor-sdk.md +149 -0
- package/docs/harnesses/deepseek-harness.md +134 -0
- package/docs/harnesses/openhands-agent-server.md +153 -0
- package/docs/harnesses/pi-sdk.md +66 -0
- package/docs/reference/README.md +19 -0
- package/docs/reference/agent-sdk-operational-runner.md +118 -0
- package/docs/reference/cli.md +114 -0
- package/docs/reference/contracts.md +222 -0
- package/docs/reference/run-bundle-contract.md +354 -0
- package/docs/reference/run-lifecycle.md +54 -0
- package/examples/cursor-sdk/README.md +11 -0
- package/examples/cursor-sdk/capture-profile.json +1 -0
- package/examples/cursor-sdk/harness.json +1 -0
- package/examples/cursor-sdk/model.json +1 -0
- package/examples/cursor-sdk/native-limits.json +1 -0
- package/examples/cursor-sdk/native-tool-policy.json +1 -0
- package/ontology/behavior-categories.v1.json +46 -0
- package/package.json +66 -0
- package/release/0.1.0/KNOWN_LIMITATIONS.md +35 -0
- package/release/0.1.0/README.md +46 -0
- package/release/0.1.0/reproducibility.json +61 -0
- package/release/0.2.0/KNOWN_LIMITATIONS.md +40 -0
- package/release/0.2.0/README.md +42 -0
- package/release/0.2.0/reproducibility.json +73 -0
- package/release/0.2.1/KNOWN_LIMITATIONS.md +18 -0
- package/release/0.2.1/README.md +63 -0
- package/release/0.2.1/reproducibility.json +73 -0
- package/release/README.md +13 -0
- package/schemas/aggregation.v1.json +293 -0
- package/schemas/behavior-assertions.v1.json +220 -0
- package/schemas/deepseek-runtime-composition.v1.schema.json +122 -0
- package/schemas/experiment.v1.schema.json +188 -0
- package/schemas/human-calibration.v1.json +336 -0
- package/schemas/normalization-integrity.v1.json +302 -0
- package/schemas/run-bundles/v1.json +1040 -0
- package/schemas/run-queue.v1.schema.json +206 -0
- package/schemas/semantic-judge.v1.json +380 -0
- package/schemas/structural-observations.v1.json +131 -0
- package/schemas/task-packet-freeze.v1.schema.json +106 -0
- package/schemas/task-packet.v1.schema.json +234 -0
- package/schemas/uniform-events/v1.json +289 -0
- package/scripts/atlas-grafana.sh +18 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { assertNoDuplicateJsonKeys, canonicalizeMetadata, digestMetadata, validateArtifact, verifyDigest, writeMetadataAtomicallyIfAbsentSync, } from "./artifacts.js";
|
|
5
|
+
import { assertAdmittedTaskPackets, assertDeclaredOrder, assertResolvedExperimentConfigurationDigests, declaredMatrixCells, isSafeArtifactRelativePath, MAX_CONFIGURATION_BYTES, resolveBundleArtifact, resolveBundleArtifactDigest, } from "./contracts.js";
|
|
6
|
+
import { assertTaskPacketFreezeRecord, assertFreezeLocatorPathWithinLimits, defaultFreezeLocator, formatErrors, statusTaskPacket, } from "./task-packets.js";
|
|
7
|
+
export const MAX_RUN_QUEUE_ENTRIES = 100_000;
|
|
8
|
+
export const MAX_RUN_QUEUE_BYTES = 128 * 1024 * 1024;
|
|
9
|
+
export function expandMatrixCells(experiment) {
|
|
10
|
+
assertMatrixSize(experiment);
|
|
11
|
+
const order = matrixOrder(experiment);
|
|
12
|
+
return [...declaredMatrixCells(order, experiment.trialCount)];
|
|
13
|
+
}
|
|
14
|
+
export function compileRunQueue(experiment, options = {}) {
|
|
15
|
+
const experimentErrors = validateArtifact("experiment.json", experiment);
|
|
16
|
+
if (experimentErrors.length > 0)
|
|
17
|
+
throw new Error(formatErrors(experimentErrors));
|
|
18
|
+
assertMatrixSize(experiment);
|
|
19
|
+
const ordering = normalizeOrdering(experiment.ordering);
|
|
20
|
+
const experimentDigest = digestMetadata(experiment);
|
|
21
|
+
const resolvedDigests = resolveConfigurationDigests(experiment, options);
|
|
22
|
+
assertResolvedExperimentConfigurationDigests(experiment, resolvedDigests);
|
|
23
|
+
const permutationAlgorithm = resolvePermutationAlgorithm(experiment, options);
|
|
24
|
+
const persistedOrdering = ordering.strategy === "seeded-shuffle"
|
|
25
|
+
? { ...ordering, permutationAlgorithm }
|
|
26
|
+
: ordering;
|
|
27
|
+
const matrix = { ...matrixOrder(experiment), trialCount: experiment.trialCount };
|
|
28
|
+
const schedulingDigest = digestScheduling(experimentDigest, experiment.captureProfile, experiment.coordinatorBudget, matrix, experiment.ordering.seed, persistedOrdering);
|
|
29
|
+
const tasks = resolveFrozenTasks(experiment, options);
|
|
30
|
+
if (options.bundleRoot === undefined) {
|
|
31
|
+
if (options.resolvedPackets === undefined) {
|
|
32
|
+
throw new Error("Resolved admitted task packets are required when no bundle root is supplied.");
|
|
33
|
+
}
|
|
34
|
+
assertAdmittedTaskPackets(experiment.taskSet, options.resolvedPackets);
|
|
35
|
+
assertAdmittedFreezeRecords(experiment, options, tasks);
|
|
36
|
+
}
|
|
37
|
+
else if (options.resolvedPackets !== undefined) {
|
|
38
|
+
assertAdmittedTaskPackets(experiment.taskSet, options.resolvedPackets);
|
|
39
|
+
}
|
|
40
|
+
const cells = orderCells(expandMatrixCells(experiment), experiment.ordering, permutationAlgorithm);
|
|
41
|
+
const entries = cells.map((cell) => {
|
|
42
|
+
const task = tasks.get(cell.taskId);
|
|
43
|
+
const modelCondition = experiment.modelSet[cell.modelId];
|
|
44
|
+
const harnessCondition = experiment.harnessSet[cell.harnessId];
|
|
45
|
+
const model = { id: cell.modelId, configurationRef: modelCondition.configurationRef };
|
|
46
|
+
const harness = { id: cell.harnessId, ...harnessCondition };
|
|
47
|
+
const configuration = {
|
|
48
|
+
model: model.configurationRef,
|
|
49
|
+
harness: harness.configurationRef,
|
|
50
|
+
nativeLimits: harness.nativeLimitsRef,
|
|
51
|
+
nativeToolPolicy: harness.nativeToolPolicyRef,
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
...cell,
|
|
55
|
+
runId: runId(experiment.id, experimentDigest, schedulingDigest, task, model, harness, cell.trialIndex),
|
|
56
|
+
task,
|
|
57
|
+
model,
|
|
58
|
+
harness,
|
|
59
|
+
configuration,
|
|
60
|
+
trial: { index: cell.trialIndex },
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const queue = {
|
|
64
|
+
schemaVersion: "ebo.run-queue/v1",
|
|
65
|
+
experimentId: experiment.id,
|
|
66
|
+
experimentDigest,
|
|
67
|
+
schedulingDigest,
|
|
68
|
+
captureProfile: experiment.captureProfile,
|
|
69
|
+
coordinatorBudget: experiment.coordinatorBudget,
|
|
70
|
+
matrix,
|
|
71
|
+
seed: experiment.ordering.seed,
|
|
72
|
+
ordering: persistedOrdering,
|
|
73
|
+
entries,
|
|
74
|
+
};
|
|
75
|
+
assertValidRunQueue(queue, experiment, "run-queue.json", options);
|
|
76
|
+
return queue;
|
|
77
|
+
}
|
|
78
|
+
export function writeRunQueue(path, queue) {
|
|
79
|
+
assertValidRunQueue(queue);
|
|
80
|
+
assertRunQueueByteLimit(queue);
|
|
81
|
+
const absolutePath = resolve(path);
|
|
82
|
+
const relativePath = basename(absolutePath);
|
|
83
|
+
if (!isSafeArtifactRelativePath(relativePath)) {
|
|
84
|
+
throw new Error(`Run queue path "${relativePath}" is unsafe.`);
|
|
85
|
+
}
|
|
86
|
+
const root = dirname(absolutePath);
|
|
87
|
+
mkdirSync(root, { recursive: true });
|
|
88
|
+
const expected = digestMetadata(queue);
|
|
89
|
+
const published = writeMetadataAtomicallyIfAbsentSync(root, relativePath, queue).digest;
|
|
90
|
+
if (!sameDigest(expected, published)) {
|
|
91
|
+
throw new Error(`Run queue path "${path}" already contains a different queue.`);
|
|
92
|
+
}
|
|
93
|
+
return published;
|
|
94
|
+
}
|
|
95
|
+
function assertRunQueueByteLimit(queue) {
|
|
96
|
+
const emptyEntries = { ...queue, entries: [] };
|
|
97
|
+
let bytes = Buffer.byteLength(canonicalizeMetadata(emptyEntries));
|
|
98
|
+
for (const entry of queue.entries) {
|
|
99
|
+
bytes += Buffer.byteLength(canonicalizeMetadata(entry));
|
|
100
|
+
if (bytes > MAX_RUN_QUEUE_BYTES) {
|
|
101
|
+
throw new Error(`Run queue exceeds the local byte limit of ${MAX_RUN_QUEUE_BYTES}.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (queue.entries.length > 1)
|
|
105
|
+
bytes += queue.entries.length - 1;
|
|
106
|
+
if (bytes > MAX_RUN_QUEUE_BYTES) {
|
|
107
|
+
throw new Error(`Run queue exceeds the local byte limit of ${MAX_RUN_QUEUE_BYTES}.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function readRunQueue(path, experiment, options = {}) {
|
|
111
|
+
const bytes = readBoundedFile(path, "Run queue");
|
|
112
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
113
|
+
assertNoDuplicateJsonKeys(text);
|
|
114
|
+
const queue = JSON.parse(text);
|
|
115
|
+
assertValidRunQueue(queue, experiment, path, options);
|
|
116
|
+
return queue;
|
|
117
|
+
}
|
|
118
|
+
export function readBoundedFile(path, label = "Artifact", afterOpen, maxBytes = MAX_RUN_QUEUE_BYTES) {
|
|
119
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
120
|
+
throw new Error(`${label} byte limit must be a nonnegative safe integer.`);
|
|
121
|
+
let descriptor;
|
|
122
|
+
try {
|
|
123
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
124
|
+
const opened = fstatSync(descriptor);
|
|
125
|
+
if (!opened.isFile())
|
|
126
|
+
throw new Error(`${label} path "${path}" is not a regular file.`);
|
|
127
|
+
const namedBefore = lstatSync(path);
|
|
128
|
+
if (!namedBefore.isFile() || !sameFileIdentity(namedBefore, opened)) {
|
|
129
|
+
throw new Error(`${label} file "${path}" changed while being opened.`);
|
|
130
|
+
}
|
|
131
|
+
if (!Number.isSafeInteger(opened.size) || opened.size > maxBytes) {
|
|
132
|
+
throw new Error(`${label} file exceeds the local byte limit of ${maxBytes}.`);
|
|
133
|
+
}
|
|
134
|
+
const openedTimes = fstatSync(descriptor, { bigint: true });
|
|
135
|
+
afterOpen?.();
|
|
136
|
+
const bytes = Buffer.alloc(opened.size);
|
|
137
|
+
for (let offset = 0; offset < opened.size;) {
|
|
138
|
+
const read = readSync(descriptor, bytes, offset, opened.size - offset, offset);
|
|
139
|
+
if (read === 0)
|
|
140
|
+
throw new Error(`${label} file "${path}" was truncated while being read.`);
|
|
141
|
+
offset += read;
|
|
142
|
+
}
|
|
143
|
+
const trailing = Buffer.allocUnsafe(1);
|
|
144
|
+
if (readSync(descriptor, trailing, 0, 1, opened.size) !== 0) {
|
|
145
|
+
throw new Error(`${label} file "${path}" grew while being read.`);
|
|
146
|
+
}
|
|
147
|
+
const completed = fstatSync(descriptor);
|
|
148
|
+
const completedTimes = fstatSync(descriptor, { bigint: true });
|
|
149
|
+
let namedAfter;
|
|
150
|
+
try {
|
|
151
|
+
namedAfter = lstatSync(path);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error(`${label} file "${path}" changed while being read.`);
|
|
155
|
+
}
|
|
156
|
+
if (!completed.isFile() || completed.dev !== opened.dev || completed.ino !== opened.ino
|
|
157
|
+
|| completed.size !== opened.size || !namedAfter.isFile() || !sameFileIdentity(namedAfter, completed)
|
|
158
|
+
|| completedTimes.mtimeNs !== openedTimes.mtimeNs || completedTimes.ctimeNs !== openedTimes.ctimeNs) {
|
|
159
|
+
throw new Error(`${label} file "${path}" changed while being read.`);
|
|
160
|
+
}
|
|
161
|
+
return bytes;
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
if (descriptor !== undefined)
|
|
165
|
+
closeSync(descriptor);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function validateRunQueue(queue, experiment, artifact = "run-queue.json", options = {}) {
|
|
169
|
+
let validatedPermutationAlgorithm;
|
|
170
|
+
let expectedMatrix;
|
|
171
|
+
if (experiment !== undefined) {
|
|
172
|
+
const experimentErrors = validateArtifact("experiment.json", experiment);
|
|
173
|
+
if (experimentErrors.length > 0)
|
|
174
|
+
return experimentErrors;
|
|
175
|
+
try {
|
|
176
|
+
assertMatrixSize(experiment);
|
|
177
|
+
expectedMatrix = { ...matrixOrder(experiment), trialCount: experiment.trialCount };
|
|
178
|
+
if (options.bundleRoot !== undefined || options.resolvedDigests !== undefined) {
|
|
179
|
+
assertResolvedExperimentConfigurationDigests(experiment, resolveConfigurationDigests(experiment, options));
|
|
180
|
+
}
|
|
181
|
+
if (isPermutationOrdering(experiment.ordering) && canResolvePermutationAlgorithm(options)) {
|
|
182
|
+
validatedPermutationAlgorithm = resolvePermutationAlgorithm(experiment, options);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return [queueError(artifact, "/experiment", error instanceof Error ? error.message : "Experiment cannot be scheduled.")];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const errors = validateArtifact(artifact, queue);
|
|
190
|
+
if (errors.length > 0)
|
|
191
|
+
return errors;
|
|
192
|
+
if (!isRecord(queue))
|
|
193
|
+
return [queueError(artifact, "/", "Run queue must be an object.")];
|
|
194
|
+
if (queue.schemaVersion !== "ebo.run-queue/v1") {
|
|
195
|
+
return [queueError(artifact, "/schemaVersion", "Expected an ebo.run-queue/v1 artifact.")];
|
|
196
|
+
}
|
|
197
|
+
const runQueue = queue;
|
|
198
|
+
const semanticErrors = [];
|
|
199
|
+
semanticErrors.push(...validateQueueLocatorBindings(runQueue, artifact));
|
|
200
|
+
if (!sameDigest(runQueue.schedulingDigest, digestScheduling(runQueue.experimentDigest, runQueue.captureProfile, runQueue.coordinatorBudget, runQueue.matrix, runQueue.seed, runQueue.ordering))) {
|
|
201
|
+
semanticErrors.push(queueError(artifact, "/schedulingDigest", "Scheduling digest does not match the persisted queue policy."));
|
|
202
|
+
}
|
|
203
|
+
if (experiment === undefined && options.bundleRoot !== undefined) {
|
|
204
|
+
semanticErrors.push(...validateQueueBundleRoot(runQueue, options.bundleRoot, artifact));
|
|
205
|
+
}
|
|
206
|
+
const runIds = new Set();
|
|
207
|
+
const cells = new Set();
|
|
208
|
+
const taskIdentities = new Map();
|
|
209
|
+
const modelIdentities = new Map();
|
|
210
|
+
const harnessIdentities = new Map();
|
|
211
|
+
const taskCompositions = new Map();
|
|
212
|
+
const modelCompositions = new Map();
|
|
213
|
+
const harnessCompositions = new Map();
|
|
214
|
+
const locatorDigests = new Map();
|
|
215
|
+
checkLocatorDigest(artifact, "/captureProfile", runQueue.captureProfile, locatorDigests, semanticErrors);
|
|
216
|
+
if (runQueue.ordering.permutationAlgorithmRef !== undefined) {
|
|
217
|
+
checkLocatorDigest(artifact, "/ordering/permutationAlgorithmRef", runQueue.ordering.permutationAlgorithmRef, locatorDigests, semanticErrors);
|
|
218
|
+
}
|
|
219
|
+
let expectedTasks;
|
|
220
|
+
if (experiment !== undefined) {
|
|
221
|
+
if (options.resolvedPackets !== undefined) {
|
|
222
|
+
try {
|
|
223
|
+
assertAdmittedTaskPackets(experiment.taskSet, options.resolvedPackets);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
semanticErrors.push(queueError(artifact, "/entries/task", error instanceof Error ? error.message : "Resolved task packets are not admitted."));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (hasFrozenTaskInputs(options)) {
|
|
230
|
+
try {
|
|
231
|
+
if (options.bundleRoot === undefined && options.resolvedPackets === undefined) {
|
|
232
|
+
throw new Error("Resolved admitted task packets are required for API-supplied freeze identities.");
|
|
233
|
+
}
|
|
234
|
+
expectedTasks = resolveFrozenTasks(experiment, provenanceOptions(runQueue, options));
|
|
235
|
+
if (options.bundleRoot === undefined) {
|
|
236
|
+
assertAdmittedFreezeRecords(experiment, options, expectedTasks);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
semanticErrors.push(queueError(artifact, "/entries/task", error instanceof Error ? error.message : "Frozen task records could not be resolved."));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const [index, entry] of runQueue.entries.entries()) {
|
|
245
|
+
const field = `/entries/${index}`;
|
|
246
|
+
if (runIds.has(entry.runId)) {
|
|
247
|
+
semanticErrors.push(queueError(artifact, `${field}/runId`, `Duplicate run ID "${entry.runId}".`));
|
|
248
|
+
}
|
|
249
|
+
runIds.add(entry.runId);
|
|
250
|
+
const cellKey = `${entry.taskId}\u0000${entry.modelId}\u0000${entry.harnessId}\u0000${entry.trialIndex}`;
|
|
251
|
+
if (cells.has(cellKey)) {
|
|
252
|
+
semanticErrors.push(queueError(artifact, field, "Duplicate matrix cell."));
|
|
253
|
+
}
|
|
254
|
+
cells.add(cellKey);
|
|
255
|
+
if (entry.task.id !== entry.taskId || entry.model.id !== entry.modelId || entry.harness.id !== entry.harnessId) {
|
|
256
|
+
semanticErrors.push(queueError(artifact, field, "Entry identities do not match their dimension IDs."));
|
|
257
|
+
}
|
|
258
|
+
if (entry.trial.index !== entry.trialIndex) {
|
|
259
|
+
semanticErrors.push(queueError(artifact, `${field}/trial`, "Trial identity does not match trialIndex."));
|
|
260
|
+
}
|
|
261
|
+
if (!sameReference(entry.configuration.model, entry.model.configurationRef)
|
|
262
|
+
|| !sameReference(entry.configuration.harness, entry.harness.configurationRef)
|
|
263
|
+
|| !sameReference(entry.configuration.nativeLimits, entry.harness.nativeLimitsRef)
|
|
264
|
+
|| !sameReference(entry.configuration.nativeToolPolicy, entry.harness.nativeToolPolicyRef)) {
|
|
265
|
+
semanticErrors.push(queueError(artifact, `${field}/configuration`, "Configuration identity does not match its model or harness."));
|
|
266
|
+
}
|
|
267
|
+
checkConditionIdentity(artifact, field, "task", entry.taskId, entry.task, taskIdentities, semanticErrors);
|
|
268
|
+
checkConditionIdentity(artifact, field, "model", entry.modelId, entry.model, modelIdentities, semanticErrors);
|
|
269
|
+
checkConditionIdentity(artifact, field, "harness", entry.harnessId, entry.harness, harnessIdentities, semanticErrors);
|
|
270
|
+
checkCompositionIdentity(artifact, field, "task", entry.taskId, digestIdentity(entry.task.packetRef.digest), taskCompositions, semanticErrors);
|
|
271
|
+
checkCompositionIdentity(artifact, field, "model", entry.modelId, digestIdentity(entry.model.configurationRef.digest), modelCompositions, semanticErrors);
|
|
272
|
+
checkCompositionIdentity(artifact, field, "harness", entry.harnessId, [entry.harness.configurationRef, entry.harness.nativeLimitsRef, entry.harness.nativeToolPolicyRef]
|
|
273
|
+
.map((reference) => digestIdentity(reference.digest)).join("|"), harnessCompositions, semanticErrors);
|
|
274
|
+
for (const [label, reference] of [
|
|
275
|
+
["task", entry.task.packetRef],
|
|
276
|
+
["model", entry.model.configurationRef],
|
|
277
|
+
["harness", entry.harness.configurationRef],
|
|
278
|
+
["harness limits", entry.harness.nativeLimitsRef],
|
|
279
|
+
["harness tools", entry.harness.nativeToolPolicyRef],
|
|
280
|
+
]) {
|
|
281
|
+
checkLocatorDigest(artifact, `${field}/${label}`, reference, locatorDigests, semanticErrors);
|
|
282
|
+
}
|
|
283
|
+
if (experiment !== undefined) {
|
|
284
|
+
const taskCondition = experiment.taskSet[entry.taskId];
|
|
285
|
+
const modelCondition = experiment.modelSet[entry.modelId];
|
|
286
|
+
const harnessCondition = experiment.harnessSet[entry.harnessId];
|
|
287
|
+
if (taskCondition === undefined || !sameReference(entry.task.packetRef, taskCondition.packetRef)) {
|
|
288
|
+
semanticErrors.push(queueError(artifact, `${field}/task`, "Task packet reference does not match the experiment."));
|
|
289
|
+
}
|
|
290
|
+
if (modelCondition === undefined || !sameReference(entry.model.configurationRef, modelCondition.configurationRef)) {
|
|
291
|
+
semanticErrors.push(queueError(artifact, `${field}/model`, "Model configuration reference does not match the experiment."));
|
|
292
|
+
}
|
|
293
|
+
if (harnessCondition === undefined
|
|
294
|
+
|| !sameReference(entry.harness.configurationRef, harnessCondition.configurationRef)
|
|
295
|
+
|| !sameReference(entry.harness.nativeLimitsRef, harnessCondition.nativeLimitsRef)
|
|
296
|
+
|| !sameReference(entry.harness.nativeToolPolicyRef, harnessCondition.nativeToolPolicyRef)) {
|
|
297
|
+
semanticErrors.push(queueError(artifact, `${field}/harness`, "Harness configuration references do not match the experiment."));
|
|
298
|
+
}
|
|
299
|
+
const expectedTask = expectedTasks?.get(entry.taskId);
|
|
300
|
+
if (expectedTask !== undefined && !sameFrozenTask(entry.task, expectedTask)) {
|
|
301
|
+
semanticErrors.push(queueError(artifact, `${field}/task`, "Frozen task identity does not match its referenced freeze record."));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (entry.runId !== runId(runQueue.experimentId, runQueue.experimentDigest, runQueue.schedulingDigest, entry.task, entry.model, entry.harness, entry.trialIndex)) {
|
|
305
|
+
semanticErrors.push(queueError(artifact, `${field}/runId`, "Run ID does not match its frozen cell identities."));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (experiment === undefined) {
|
|
309
|
+
try {
|
|
310
|
+
assertMatrixSize(runQueue.matrix);
|
|
311
|
+
const expectedCells = [...declaredMatrixCells(runQueue.matrix, runQueue.matrix.trialCount)];
|
|
312
|
+
const expectedKeys = new Set(expectedCells.map(cellKeyOf));
|
|
313
|
+
const expectedOrder = orderCells(expectedCells, queueMatrixOrdering(runQueue), runQueue.ordering.permutationAlgorithm ?? "fisher-yates-v1").map(cellKeyOf);
|
|
314
|
+
const actualOrder = runQueue.entries.map(cellKeyOf);
|
|
315
|
+
if (expectedKeys.size !== cells.size || [...expectedKeys].some((key) => !cells.has(key))) {
|
|
316
|
+
semanticErrors.push(queueError(artifact, "/entries", "Run queue cells do not match its persisted matrix."));
|
|
317
|
+
}
|
|
318
|
+
if (expectedOrder.length !== actualOrder.length
|
|
319
|
+
|| expectedOrder.some((key, index) => key !== actualOrder[index])) {
|
|
320
|
+
semanticErrors.push(queueError(artifact, "/entries", "Run queue order does not match its persisted ordering policy."));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
semanticErrors.push(queueError(artifact, "/matrix", error instanceof Error ? error.message : "Persisted matrix is too large."));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (experiment !== undefined) {
|
|
328
|
+
if (runQueue.experimentId !== experiment.id) {
|
|
329
|
+
semanticErrors.push(queueError(artifact, "/experimentId", "Run queue experiment ID does not match the experiment."));
|
|
330
|
+
}
|
|
331
|
+
if (!sameDigest(runQueue.experimentDigest, digestMetadata(experiment))) {
|
|
332
|
+
semanticErrors.push(queueError(artifact, "/experimentDigest", "Run queue experiment digest does not match the experiment."));
|
|
333
|
+
}
|
|
334
|
+
if (!sameReference(runQueue.captureProfile, experiment.captureProfile)) {
|
|
335
|
+
semanticErrors.push(queueError(artifact, "/captureProfile", "Run queue capture profile does not match the experiment."));
|
|
336
|
+
}
|
|
337
|
+
if (runQueue.coordinatorBudget.maxWallClockMs !== experiment.coordinatorBudget.maxWallClockMs) {
|
|
338
|
+
semanticErrors.push(queueError(artifact, "/coordinatorBudget", "Run queue coordinator budget does not match the experiment."));
|
|
339
|
+
}
|
|
340
|
+
if (expectedMatrix !== undefined && !sameMatrix(runQueue.matrix, expectedMatrix)) {
|
|
341
|
+
semanticErrors.push(queueError(artifact, "/matrix", "Run queue matrix metadata does not match the experiment."));
|
|
342
|
+
}
|
|
343
|
+
const expectedOrdering = normalizeOrdering(experiment.ordering);
|
|
344
|
+
if (runQueue.seed !== experiment.ordering.seed || runQueue.ordering.strategy !== expectedOrdering.strategy
|
|
345
|
+
|| runQueue.ordering.balanceBy !== expectedOrdering.balanceBy
|
|
346
|
+
|| !sameOptionalReference(runQueue.ordering.permutationAlgorithmRef, expectedOrdering.permutationAlgorithmRef)
|
|
347
|
+
|| (validatedPermutationAlgorithm !== undefined
|
|
348
|
+
&& runQueue.ordering.permutationAlgorithm !== validatedPermutationAlgorithm)
|
|
349
|
+
|| (expectedOrdering.strategy === "seeded-shuffle" && runQueue.ordering.permutationAlgorithm === undefined)) {
|
|
350
|
+
semanticErrors.push(queueError(artifact, "/ordering", "Run queue ordering does not match the experiment."));
|
|
351
|
+
}
|
|
352
|
+
if (expectedMatrix !== undefined) {
|
|
353
|
+
const expectedCells = [...declaredMatrixCells(expectedMatrix, experiment.trialCount)];
|
|
354
|
+
const expectedKeys = new Set(expectedCells.map(cellKeyOf));
|
|
355
|
+
if (expectedKeys.size !== cells.size || [...expectedKeys].some((key) => !cells.has(key))) {
|
|
356
|
+
semanticErrors.push(queueError(artifact, "/entries", "Run queue cells do not match the experiment matrix."));
|
|
357
|
+
}
|
|
358
|
+
const expectedOrder = orderCells(expectedCells, experiment.ordering, validatedPermutationAlgorithm ?? runQueue.ordering.permutationAlgorithm ?? "fisher-yates-v1").map(cellKeyOf);
|
|
359
|
+
const actualOrder = runQueue.entries.map(cellKeyOf);
|
|
360
|
+
if (expectedOrder.length !== actualOrder.length
|
|
361
|
+
|| expectedOrder.some((key, index) => key !== actualOrder[index])) {
|
|
362
|
+
semanticErrors.push(queueError(artifact, "/entries", "Run queue order does not match the experiment ordering policy."));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return semanticErrors;
|
|
367
|
+
}
|
|
368
|
+
export function assertValidRunQueue(queue, experiment, artifact = "run-queue.json", options = {}) {
|
|
369
|
+
const errors = validateRunQueue(queue, experiment, artifact, options);
|
|
370
|
+
if (errors.length > 0)
|
|
371
|
+
throw new Error(formatErrors(errors));
|
|
372
|
+
}
|
|
373
|
+
export function inspectRunQueue(queue) {
|
|
374
|
+
assertValidRunQueue(queue);
|
|
375
|
+
return {
|
|
376
|
+
experimentId: queue.experimentId,
|
|
377
|
+
seed: queue.seed,
|
|
378
|
+
strategy: queue.ordering.strategy,
|
|
379
|
+
entryCount: queue.entries.length,
|
|
380
|
+
firstRunId: queue.entries[0]?.runId ?? null,
|
|
381
|
+
lastRunId: queue.entries.at(-1)?.runId ?? null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
export class LocalRunQueue {
|
|
385
|
+
position = 0;
|
|
386
|
+
snapshot;
|
|
387
|
+
constructor(queue) {
|
|
388
|
+
assertValidRunQueue(queue);
|
|
389
|
+
this.snapshot = structuredClone(queue);
|
|
390
|
+
}
|
|
391
|
+
get queue() {
|
|
392
|
+
return structuredClone(this.snapshot);
|
|
393
|
+
}
|
|
394
|
+
get remaining() {
|
|
395
|
+
return this.snapshot.entries.length - this.position;
|
|
396
|
+
}
|
|
397
|
+
next() {
|
|
398
|
+
if (this.position >= this.snapshot.entries.length)
|
|
399
|
+
return undefined;
|
|
400
|
+
return structuredClone(this.snapshot.entries[this.position++]);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function assertMatrixSize(experiment) {
|
|
404
|
+
let cellCount = 1;
|
|
405
|
+
const dimensions = "taskSet" in experiment
|
|
406
|
+
? [
|
|
407
|
+
Object.keys(experiment.taskSet).length,
|
|
408
|
+
Object.keys(experiment.modelSet).length,
|
|
409
|
+
Object.keys(experiment.harnessSet).length,
|
|
410
|
+
experiment.trialCount,
|
|
411
|
+
]
|
|
412
|
+
: [experiment.taskIds.length, experiment.modelIds.length, experiment.harnessIds.length, experiment.trialCount];
|
|
413
|
+
for (const dimension of dimensions) {
|
|
414
|
+
if (cellCount > Math.floor(MAX_RUN_QUEUE_ENTRIES / dimension)) {
|
|
415
|
+
// ponytail: fixed local queue cap; stream to disk if larger matrices become required.
|
|
416
|
+
throw new Error(`Run matrix exceeds the local queue limit of ${MAX_RUN_QUEUE_ENTRIES} entries.`);
|
|
417
|
+
}
|
|
418
|
+
cellCount *= dimension;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function matrixOrder(experiment) {
|
|
422
|
+
const declared = "declaredOrder" in experiment.ordering ? experiment.ordering.declaredOrder : undefined;
|
|
423
|
+
if (declared !== undefined) {
|
|
424
|
+
assertDeclaredOrder(experiment, declared);
|
|
425
|
+
return declared;
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
taskIds: Object.keys(experiment.taskSet).sort(),
|
|
429
|
+
modelIds: Object.keys(experiment.modelSet).sort(),
|
|
430
|
+
harnessIds: Object.keys(experiment.harnessSet).sort(),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function normalizeOrdering(ordering) {
|
|
434
|
+
switch (ordering.strategy) {
|
|
435
|
+
case "declared":
|
|
436
|
+
case "sequential":
|
|
437
|
+
return { strategy: "sequential" };
|
|
438
|
+
case "permuted":
|
|
439
|
+
case "seeded-shuffle":
|
|
440
|
+
return {
|
|
441
|
+
strategy: "seeded-shuffle",
|
|
442
|
+
...(ordering.permutationAlgorithmRef === undefined ? {} : { permutationAlgorithmRef: ordering.permutationAlgorithmRef }),
|
|
443
|
+
};
|
|
444
|
+
case "balanced":
|
|
445
|
+
case "balanced-interleaved":
|
|
446
|
+
case "interleaved":
|
|
447
|
+
return { strategy: "balanced", ...(ordering.balanceBy === undefined ? {} : { balanceBy: ordering.balanceBy }) };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function digestScheduling(experimentDigest, captureProfile, coordinatorBudget, matrix, seed, ordering) {
|
|
451
|
+
return digestMetadata({ experimentDigest, captureProfile, coordinatorBudget, matrix, seed, ordering });
|
|
452
|
+
}
|
|
453
|
+
function isPermutationOrdering(ordering) {
|
|
454
|
+
return ordering.strategy === "permuted" || ordering.strategy === "seeded-shuffle";
|
|
455
|
+
}
|
|
456
|
+
function queueMatrixOrdering(queue) {
|
|
457
|
+
switch (queue.ordering.strategy) {
|
|
458
|
+
case "sequential":
|
|
459
|
+
return { seed: queue.seed, strategy: "sequential", declaredOrder: queue.matrix };
|
|
460
|
+
case "seeded-shuffle":
|
|
461
|
+
return {
|
|
462
|
+
seed: queue.seed,
|
|
463
|
+
strategy: "seeded-shuffle",
|
|
464
|
+
declaredOrder: queue.matrix,
|
|
465
|
+
...(queue.ordering.permutationAlgorithmRef === undefined
|
|
466
|
+
? {}
|
|
467
|
+
: { permutationAlgorithmRef: queue.ordering.permutationAlgorithmRef }),
|
|
468
|
+
};
|
|
469
|
+
case "balanced":
|
|
470
|
+
return {
|
|
471
|
+
seed: queue.seed,
|
|
472
|
+
strategy: "balanced",
|
|
473
|
+
declaredOrder: queue.matrix,
|
|
474
|
+
...(queue.ordering.balanceBy === undefined ? {} : { balanceBy: queue.ordering.balanceBy }),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function resolveConfigurationDigests(experiment, options) {
|
|
479
|
+
if (options.bundleRoot === undefined) {
|
|
480
|
+
if (options.resolvedDigests === undefined) {
|
|
481
|
+
throw new Error("Configuration references must be resolved before compiling a run queue.");
|
|
482
|
+
}
|
|
483
|
+
return options.resolvedDigests;
|
|
484
|
+
}
|
|
485
|
+
const references = experimentReferences(experiment);
|
|
486
|
+
const resolved = {};
|
|
487
|
+
for (const reference of references) {
|
|
488
|
+
resolved[reference.locator] = resolveBundleArtifactDigest(options.bundleRoot, reference);
|
|
489
|
+
}
|
|
490
|
+
return resolved;
|
|
491
|
+
}
|
|
492
|
+
function resolvePermutationAlgorithm(experiment, options) {
|
|
493
|
+
const reference = "permutationAlgorithmRef" in experiment.ordering
|
|
494
|
+
? experiment.ordering.permutationAlgorithmRef
|
|
495
|
+
: undefined;
|
|
496
|
+
if (reference === undefined)
|
|
497
|
+
return "fisher-yates-v1";
|
|
498
|
+
return resolvePermutationDefinition(reference, options);
|
|
499
|
+
}
|
|
500
|
+
function resolvePermutationDefinition(reference, options) {
|
|
501
|
+
let definition;
|
|
502
|
+
if (options.bundleRoot !== undefined) {
|
|
503
|
+
const bytes = resolveBundleArtifact(options.bundleRoot, reference, MAX_CONFIGURATION_BYTES);
|
|
504
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
505
|
+
assertNoDuplicateJsonKeys(text);
|
|
506
|
+
definition = JSON.parse(text);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
definition = options.permutationAlgorithm;
|
|
510
|
+
if (definition === undefined)
|
|
511
|
+
definition = options.permutationAlgorithms?.[reference.locator];
|
|
512
|
+
if (!(definition instanceof Uint8Array)) {
|
|
513
|
+
throw new Error(`Permutation algorithm "${reference.locator}" must be supplied as verified bytes.`);
|
|
514
|
+
}
|
|
515
|
+
if (definition.byteLength > MAX_CONFIGURATION_BYTES) {
|
|
516
|
+
throw new Error(`Permutation algorithm "${reference.locator}" exceeds maximum bytes.`);
|
|
517
|
+
}
|
|
518
|
+
const bytes = Buffer.from(definition);
|
|
519
|
+
if (!verifyDigest(bytes, reference.digest)) {
|
|
520
|
+
throw new Error(`Permutation algorithm "${reference.locator}" digest does not match its reference.`);
|
|
521
|
+
}
|
|
522
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
523
|
+
assertNoDuplicateJsonKeys(text);
|
|
524
|
+
definition = JSON.parse(text);
|
|
525
|
+
}
|
|
526
|
+
if (definition === undefined) {
|
|
527
|
+
throw new Error(`Permutation algorithm "${reference.locator}" content must be supplied before scheduling.`);
|
|
528
|
+
}
|
|
529
|
+
const name = typeof definition === "string"
|
|
530
|
+
? definition
|
|
531
|
+
: isRecord(definition)
|
|
532
|
+
? definition.algorithm ?? definition.name ?? definition.id ?? definition.kind
|
|
533
|
+
: undefined;
|
|
534
|
+
if (name === "fisher-yates-v1")
|
|
535
|
+
return "fisher-yates-v1";
|
|
536
|
+
throw new Error(`Unsupported permutation algorithm "${String(name)}".`);
|
|
537
|
+
}
|
|
538
|
+
function canResolvePermutationAlgorithm(options) {
|
|
539
|
+
return options.bundleRoot !== undefined
|
|
540
|
+
|| options.permutationAlgorithm !== undefined
|
|
541
|
+
|| options.permutationAlgorithms !== undefined;
|
|
542
|
+
}
|
|
543
|
+
function validateQueueBundleRoot(queue, bundleRoot, artifact) {
|
|
544
|
+
const errors = [];
|
|
545
|
+
const references = new Map();
|
|
546
|
+
const addReference = (reference) => references.set(`${reference.locator}\u0000${digestIdentity(reference.digest)}`, reference);
|
|
547
|
+
addReference(queue.captureProfile);
|
|
548
|
+
for (const entry of queue.entries) {
|
|
549
|
+
addReference(entry.model.configurationRef);
|
|
550
|
+
addReference(entry.harness.configurationRef);
|
|
551
|
+
addReference(entry.harness.nativeLimitsRef);
|
|
552
|
+
addReference(entry.harness.nativeToolPolicyRef);
|
|
553
|
+
}
|
|
554
|
+
if (queue.ordering.permutationAlgorithmRef !== undefined)
|
|
555
|
+
addReference(queue.ordering.permutationAlgorithmRef);
|
|
556
|
+
for (const [index, reference] of [...references.values()].entries()) {
|
|
557
|
+
try {
|
|
558
|
+
resolveBundleArtifactDigest(bundleRoot, reference);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
errors.push(queueError(artifact, `/references/${index}`, error instanceof Error ? error.message : "Reference could not be resolved."));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const observed = new Map();
|
|
565
|
+
for (const [index, entry] of queue.entries.entries()) {
|
|
566
|
+
const key = `${entry.task.packetRef.locator}\u0000${entry.task.freezeLocator}`;
|
|
567
|
+
let resolved = observed.get(key);
|
|
568
|
+
if (resolved === undefined) {
|
|
569
|
+
try {
|
|
570
|
+
const status = statusTaskPacket(bundleRoot, entry.task.packetRef.locator, entry.task.freezeLocator);
|
|
571
|
+
if (status.status !== "frozen" || status.packetId === null || status.packetDigest === null || status.aggregateDigest === null) {
|
|
572
|
+
const details = status.errors.length > 0 ? ` ${formatErrors(status.errors)}` : "";
|
|
573
|
+
resolved = new Error(`Task packet is not frozen.${details}`);
|
|
574
|
+
}
|
|
575
|
+
else if (!sameDigest(status.packetDigest, entry.task.packetRef.digest)) {
|
|
576
|
+
resolved = new Error("Task packet digest does not match its queue reference.");
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
resolved = {
|
|
580
|
+
id: entry.task.id,
|
|
581
|
+
packetId: status.packetId,
|
|
582
|
+
packetRef: entry.task.packetRef,
|
|
583
|
+
freezeLocator: status.freezeLocator,
|
|
584
|
+
aggregateDigest: status.aggregateDigest,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
resolved = error instanceof Error ? error : new Error("Task packet freeze could not be resolved.");
|
|
590
|
+
}
|
|
591
|
+
observed.set(key, resolved);
|
|
592
|
+
}
|
|
593
|
+
if (resolved instanceof Error || !sameFrozenTask(entry.task, resolved)) {
|
|
594
|
+
errors.push(queueError(artifact, `/entries/${index}/task`, resolved instanceof Error
|
|
595
|
+
? resolved.message
|
|
596
|
+
: "Frozen task identity does not match its referenced freeze record."));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (queue.ordering.strategy === "seeded-shuffle" && queue.ordering.permutationAlgorithmRef !== undefined) {
|
|
600
|
+
try {
|
|
601
|
+
const algorithm = resolvePermutationDefinition(queue.ordering.permutationAlgorithmRef, { bundleRoot });
|
|
602
|
+
if (queue.ordering.permutationAlgorithm !== algorithm) {
|
|
603
|
+
errors.push(queueError(artifact, "/ordering", "Persisted permutation algorithm does not match its referenced artifact."));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
errors.push(queueError(artifact, "/ordering/permutationAlgorithmRef", error instanceof Error ? error.message : "Permutation algorithm could not be resolved."));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return errors;
|
|
611
|
+
}
|
|
612
|
+
function experimentReferences(experiment) {
|
|
613
|
+
return [
|
|
614
|
+
...Object.values(experiment.modelSet).map((condition) => condition.configurationRef),
|
|
615
|
+
...Object.values(experiment.harnessSet).flatMap((condition) => [
|
|
616
|
+
condition.configurationRef,
|
|
617
|
+
condition.nativeLimitsRef,
|
|
618
|
+
condition.nativeToolPolicyRef,
|
|
619
|
+
]),
|
|
620
|
+
experiment.captureProfile,
|
|
621
|
+
...("permutationAlgorithmRef" in experiment.ordering && experiment.ordering.permutationAlgorithmRef !== undefined
|
|
622
|
+
? [experiment.ordering.permutationAlgorithmRef]
|
|
623
|
+
: []),
|
|
624
|
+
];
|
|
625
|
+
}
|
|
626
|
+
function resolveFrozenTasks(experiment, options) {
|
|
627
|
+
const explicit = options.frozenTasks ?? options.taskFreezeRecords ?? options.taskPackets;
|
|
628
|
+
const supplied = explicit ?? Object.fromEntries(Object.entries(options.resolvedPackets ?? {}).flatMap(([taskId, packet]) => (packet.freezeRecord === undefined ? [] : [[taskId, packet.freezeRecord]])));
|
|
629
|
+
const tasks = new Map();
|
|
630
|
+
const packetDigests = new Set();
|
|
631
|
+
for (const [taskId, condition] of Object.entries(experiment.taskSet)) {
|
|
632
|
+
const suppliedInput = supplied?.[taskId] ?? supplied?.[condition.packetRef.locator];
|
|
633
|
+
const identity = options.bundleRoot !== undefined
|
|
634
|
+
? frozenTaskFromBundle(options.bundleRoot, taskId, condition.packetRef.locator, condition.packetRef, options.freezeLocators?.[taskId] ?? freezeLocatorOf(suppliedInput))
|
|
635
|
+
: frozenTaskFromInput(taskId, condition.packetRef, suppliedInput, options.freezeLocators?.[taskId]);
|
|
636
|
+
if (packetDigests.has(digestIdentity(identity.packetRef.digest))) {
|
|
637
|
+
throw new Error(`Task packet "${taskId}" duplicates a packet digest.`);
|
|
638
|
+
}
|
|
639
|
+
packetDigests.add(digestIdentity(identity.packetRef.digest));
|
|
640
|
+
tasks.set(taskId, identity);
|
|
641
|
+
}
|
|
642
|
+
assertDistinctFreezeLocatorBindings(experiment, tasks);
|
|
643
|
+
return tasks;
|
|
644
|
+
}
|
|
645
|
+
function assertDistinctFreezeLocatorBindings(experiment, tasks) {
|
|
646
|
+
const artifactBindings = [
|
|
647
|
+
...experimentReferences(experiment),
|
|
648
|
+
...Object.values(experiment.taskSet).map((condition) => condition.packetRef),
|
|
649
|
+
].map((reference) => ({ kind: "artifact", path: reference.locator, field: reference.locator }));
|
|
650
|
+
const freezeBindings = [...tasks.values()].map((task) => ({
|
|
651
|
+
kind: "freeze",
|
|
652
|
+
taskId: task.id,
|
|
653
|
+
path: task.freezeLocator,
|
|
654
|
+
field: task.freezeLocator,
|
|
655
|
+
packetLocator: task.packetRef.locator,
|
|
656
|
+
}));
|
|
657
|
+
const collision = findLocatorCollisions([...artifactBindings, ...freezeBindings])[0];
|
|
658
|
+
if (collision !== undefined)
|
|
659
|
+
throw new Error(locatorCollisionMessage(collision.left, collision.right));
|
|
660
|
+
}
|
|
661
|
+
function validateQueueLocatorBindings(queue, artifact) {
|
|
662
|
+
const errors = [];
|
|
663
|
+
const artifacts = new Map();
|
|
664
|
+
const addArtifact = (path, field) => {
|
|
665
|
+
if (!artifacts.has(path))
|
|
666
|
+
artifacts.set(path, { kind: "artifact", path, field });
|
|
667
|
+
};
|
|
668
|
+
addArtifact(queue.captureProfile.locator, "/captureProfile");
|
|
669
|
+
if (queue.ordering.permutationAlgorithmRef !== undefined) {
|
|
670
|
+
addArtifact(queue.ordering.permutationAlgorithmRef.locator, "/ordering/permutationAlgorithmRef");
|
|
671
|
+
}
|
|
672
|
+
for (const [index, entry] of queue.entries.entries()) {
|
|
673
|
+
addArtifact(entry.task.packetRef.locator, `/entries/${index}/task`);
|
|
674
|
+
addArtifact(entry.model.configurationRef.locator, `/entries/${index}/model`);
|
|
675
|
+
addArtifact(entry.harness.configurationRef.locator, `/entries/${index}/harness`);
|
|
676
|
+
addArtifact(entry.harness.nativeLimitsRef.locator, `/entries/${index}/harness/nativeLimits`);
|
|
677
|
+
addArtifact(entry.harness.nativeToolPolicyRef.locator, `/entries/${index}/harness/nativeToolPolicy`);
|
|
678
|
+
}
|
|
679
|
+
const freezePaths = new Map();
|
|
680
|
+
for (const [index, entry] of queue.entries.entries()) {
|
|
681
|
+
const key = `${entry.taskId}\u0000${entry.task.freezeLocator}`;
|
|
682
|
+
if (!freezePaths.has(key)) {
|
|
683
|
+
freezePaths.set(key, {
|
|
684
|
+
kind: "freeze",
|
|
685
|
+
taskId: entry.taskId,
|
|
686
|
+
path: entry.task.freezeLocator,
|
|
687
|
+
field: `/entries/${index}/task/freezeLocator`,
|
|
688
|
+
packetLocator: entry.task.packetRef.locator,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
for (const freeze of freezePaths.values()) {
|
|
693
|
+
try {
|
|
694
|
+
assertFreezeLocatorPathWithinLimits(freeze.path, freeze.packetLocator);
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
errors.push(queueError(artifact, freeze.field, error instanceof Error ? error.message : "Freeze locator exceeds safe path limits."));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const freezeBindings = [...freezePaths.values()];
|
|
701
|
+
errors.push(...findLocatorCollisions([...artifacts.values(), ...freezeBindings]).map(({ left, right }) => {
|
|
702
|
+
const field = left.kind === "freeze" ? left.field : right.kind === "freeze" ? right.field : right.field;
|
|
703
|
+
return queueError(artifact, field, locatorCollisionMessage(left, right));
|
|
704
|
+
}));
|
|
705
|
+
return errors;
|
|
706
|
+
}
|
|
707
|
+
function findLocatorCollisions(bindings) {
|
|
708
|
+
const exact = new Map();
|
|
709
|
+
const descendantPrefixes = new Map();
|
|
710
|
+
const collisions = [];
|
|
711
|
+
const seen = new Set();
|
|
712
|
+
const addCollision = (left, right) => {
|
|
713
|
+
const key = [left, right]
|
|
714
|
+
.map((binding) => `${binding.kind}\u0000${binding.taskId ?? ""}\u0000${binding.path}`)
|
|
715
|
+
.sort()
|
|
716
|
+
.join("\u0001");
|
|
717
|
+
if (seen.has(key))
|
|
718
|
+
return;
|
|
719
|
+
seen.add(key);
|
|
720
|
+
collisions.push({ left, right });
|
|
721
|
+
};
|
|
722
|
+
for (const binding of bindings) {
|
|
723
|
+
const key = binding.path.toLowerCase();
|
|
724
|
+
const existing = exact.get(key);
|
|
725
|
+
if (existing !== undefined) {
|
|
726
|
+
if (existing.path !== binding.path || !allowExactLocatorDuplicate(existing, binding)) {
|
|
727
|
+
addCollision(existing, binding);
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const descendant = descendantPrefixes.get(key);
|
|
732
|
+
if (descendant !== undefined)
|
|
733
|
+
addCollision(binding, descendant);
|
|
734
|
+
for (let boundary = key.lastIndexOf("/"); boundary > 0; boundary = key.lastIndexOf("/", boundary - 1)) {
|
|
735
|
+
const prefix = key.slice(0, boundary);
|
|
736
|
+
const ancestor = exact.get(prefix);
|
|
737
|
+
if (ancestor !== undefined)
|
|
738
|
+
addCollision(ancestor, binding);
|
|
739
|
+
if (!descendantPrefixes.has(prefix))
|
|
740
|
+
descendantPrefixes.set(prefix, binding);
|
|
741
|
+
}
|
|
742
|
+
exact.set(key, binding);
|
|
743
|
+
}
|
|
744
|
+
return collisions;
|
|
745
|
+
}
|
|
746
|
+
function allowExactLocatorDuplicate(left, right) {
|
|
747
|
+
return (left.kind === "artifact" && right.kind === "artifact")
|
|
748
|
+
|| (left.kind === "freeze" && right.kind === "freeze" && left.taskId === right.taskId);
|
|
749
|
+
}
|
|
750
|
+
function locatorCollisionMessage(left, right) {
|
|
751
|
+
if (left.kind === "freeze" || right.kind === "freeze") {
|
|
752
|
+
const freeze = left.kind === "freeze" ? left : right;
|
|
753
|
+
const other = left.kind === "freeze" ? right : left;
|
|
754
|
+
if (other.kind === "freeze") {
|
|
755
|
+
return `Freeze locator "${freeze.path}" aliases task "${other.taskId}" freeze locator "${other.path}".`;
|
|
756
|
+
}
|
|
757
|
+
return `Freeze locator "${freeze.path}" aliases persisted artifact path "${other.path}".`;
|
|
758
|
+
}
|
|
759
|
+
if (left.path.toLowerCase() === right.path.toLowerCase() && left.path !== right.path) {
|
|
760
|
+
return `Persisted artifact path "${right.path}" case-aliases "${left.path}".`;
|
|
761
|
+
}
|
|
762
|
+
return `Persisted artifact path "${right.path}" aliases "${left.path}".`;
|
|
763
|
+
}
|
|
764
|
+
function assertAdmittedFreezeRecords(experiment, options, tasks) {
|
|
765
|
+
const resolvedPackets = options.resolvedPackets;
|
|
766
|
+
if (resolvedPackets === undefined) {
|
|
767
|
+
throw new Error("Resolved admitted task packets are required before scheduling.");
|
|
768
|
+
}
|
|
769
|
+
const supplied = options.frozenTasks ?? options.taskFreezeRecords ?? options.taskPackets;
|
|
770
|
+
for (const [taskId, condition] of Object.entries(experiment.taskSet)) {
|
|
771
|
+
const packet = resolvedPackets[taskId];
|
|
772
|
+
const record = packet?.freezeRecord;
|
|
773
|
+
if (record === undefined)
|
|
774
|
+
throw new Error(`Task packet "${taskId}" has no admitted freeze record.`);
|
|
775
|
+
assertTaskPacketFreezeRecord(record);
|
|
776
|
+
if (record.packetLocator !== condition.packetRef.locator
|
|
777
|
+
|| !sameDigest(record.packetDigest, condition.packetRef.digest)) {
|
|
778
|
+
throw new Error(`Task packet "${taskId}" freeze record does not match its experiment reference.`);
|
|
779
|
+
}
|
|
780
|
+
if (packet === undefined
|
|
781
|
+
|| packet.packetId !== record.packetId
|
|
782
|
+
|| packet.assessmentMode !== (record.assessmentMode ?? "verified")
|
|
783
|
+
|| !sameDigest(packet.digest, record.packetDigest)
|
|
784
|
+
|| packet.preAdmissionDigest === null
|
|
785
|
+
|| !sameDigest(packet.preAdmissionDigest, record.preAdmissionDigest)) {
|
|
786
|
+
throw new Error(`Task packet "${taskId}" freeze record is not bound to its admitted packet evidence.`);
|
|
787
|
+
}
|
|
788
|
+
assertFreezeComponents(taskId, packet, record);
|
|
789
|
+
const suppliedRecord = freezeRecordOf(supplied?.[taskId] ?? supplied?.[condition.packetRef.locator]);
|
|
790
|
+
if (suppliedRecord !== undefined && !sameFreezeRecord(record, suppliedRecord)) {
|
|
791
|
+
throw new Error(`Task packet "${taskId}" supplied freeze record does not match its admitted packet evidence.`);
|
|
792
|
+
}
|
|
793
|
+
const expected = tasks.get(taskId);
|
|
794
|
+
if (expected === undefined)
|
|
795
|
+
throw new Error(`Task packet "${taskId}" did not resolve.`);
|
|
796
|
+
const identity = {
|
|
797
|
+
id: taskId,
|
|
798
|
+
packetId: record.packetId,
|
|
799
|
+
packetRef: { locator: record.packetLocator, digest: record.packetDigest },
|
|
800
|
+
freezeLocator: expected.freezeLocator,
|
|
801
|
+
aggregateDigest: record.aggregateDigest,
|
|
802
|
+
};
|
|
803
|
+
if (!sameFrozenTask(expected, identity)) {
|
|
804
|
+
throw new Error(`Task packet "${taskId}" freeze record does not match its admitted identity.`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function assertFreezeComponents(taskId, packet, record) {
|
|
809
|
+
if (packet.promptDigest === undefined || !sameDigest(record.components.prompt, packet.promptDigest)) {
|
|
810
|
+
throw new Error(`Task packet "${taskId}" freeze prompt component is not bound to its admitted packet evidence.`);
|
|
811
|
+
}
|
|
812
|
+
if (!sameOptionalDigest(record.components.fixture, packet.fixtureDigest)) {
|
|
813
|
+
throw new Error(`Task packet "${taskId}" freeze fixture component is not bound to its admitted packet evidence.`);
|
|
814
|
+
}
|
|
815
|
+
if (!sameOptionalDigest(record.components.verifier, packet.resolvedVerifierDigest)) {
|
|
816
|
+
throw new Error(`Task packet "${taskId}" freeze verifier component is not bound to its admitted packet evidence.`);
|
|
817
|
+
}
|
|
818
|
+
if (!sameOptionalDigest(record.components.reviewRecord, packet.resolvedReviewRecordDigest)) {
|
|
819
|
+
throw new Error(`Task packet "${taskId}" freeze review component is not bound to its admitted packet evidence.`);
|
|
820
|
+
}
|
|
821
|
+
if (!sameFreezeComponent(record.components.reference, packet.referenceSolution.declaration, packet.referenceSolution.resolvedDigest)) {
|
|
822
|
+
throw new Error(`Task packet "${taskId}" freeze reference component is not bound to its admitted packet evidence.`);
|
|
823
|
+
}
|
|
824
|
+
if (!sameFreezeComponent(record.components.controlledPerturbation, packet.controlledPerturbation.declaration, packet.controlledPerturbation.resolvedDigest)) {
|
|
825
|
+
throw new Error(`Task packet "${taskId}" freeze perturbation component is not bound to its admitted packet evidence.`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
function sameFreezeComponent(actual, declaration, resolvedDigest) {
|
|
829
|
+
if (declaration.status === "referenced") {
|
|
830
|
+
return actual.status === "referenced"
|
|
831
|
+
&& actual.digest !== undefined
|
|
832
|
+
&& actual.digest !== null
|
|
833
|
+
&& resolvedDigest !== null
|
|
834
|
+
&& sameDigest(actual.digest, resolvedDigest)
|
|
835
|
+
&& declaration.digest !== undefined
|
|
836
|
+
&& sameDigest(actual.digest, declaration.digest);
|
|
837
|
+
}
|
|
838
|
+
return actual.status === declaration.status && resolvedDigest === null;
|
|
839
|
+
}
|
|
840
|
+
function provenanceOptions(queue, options) {
|
|
841
|
+
const hasExplicitFreezeInputs = hasExplicitFrozenTaskInputs(options);
|
|
842
|
+
let resolved = options;
|
|
843
|
+
if (options.bundleRoot !== undefined && !hasExplicitFreezeInputs) {
|
|
844
|
+
const frozenTasks = Object.fromEntries(queue.entries.map((entry) => [entry.taskId, {
|
|
845
|
+
status: "frozen",
|
|
846
|
+
packetId: entry.task.packetId,
|
|
847
|
+
packetLocator: entry.task.packetRef.locator,
|
|
848
|
+
packetDigest: entry.task.packetRef.digest,
|
|
849
|
+
freezeLocator: entry.task.freezeLocator,
|
|
850
|
+
aggregateDigest: entry.task.aggregateDigest,
|
|
851
|
+
}]));
|
|
852
|
+
resolved = { ...resolved, frozenTasks };
|
|
853
|
+
}
|
|
854
|
+
const persistedLocators = Object.fromEntries(queue.entries.map((entry) => [entry.taskId, entry.task.freezeLocator]));
|
|
855
|
+
resolved = {
|
|
856
|
+
...resolved,
|
|
857
|
+
freezeLocators: { ...persistedLocators, ...options.freezeLocators },
|
|
858
|
+
};
|
|
859
|
+
return resolved;
|
|
860
|
+
}
|
|
861
|
+
function frozenTaskFromBundle(bundleRoot, taskId, packetLocator, packetRef, freezeLocator) {
|
|
862
|
+
if (freezeLocator !== undefined && freezeLocator.toLowerCase() === packetLocator.toLowerCase()) {
|
|
863
|
+
throw new Error(`Task packet "${taskId}" freeze locator must differ from its packet locator.`);
|
|
864
|
+
}
|
|
865
|
+
if (freezeLocator !== undefined)
|
|
866
|
+
assertFreezeLocatorPathWithinLimits(freezeLocator, packetLocator);
|
|
867
|
+
const status = statusTaskPacket(bundleRoot, packetLocator, freezeLocator ?? defaultFreezeLocator(packetLocator));
|
|
868
|
+
if (status.status !== "frozen" || status.packetId === null || status.packetDigest === null || status.aggregateDigest === null) {
|
|
869
|
+
const details = status.errors.length > 0 ? ` ${formatErrors(status.errors)}` : "";
|
|
870
|
+
throw new Error(`Task packet "${taskId}" is not frozen.${details}`);
|
|
871
|
+
}
|
|
872
|
+
if (!sameDigest(status.packetDigest, packetRef.digest)) {
|
|
873
|
+
throw new Error(`Task packet "${taskId}" freeze digest does not match its experiment reference.`);
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
id: taskId,
|
|
877
|
+
packetId: status.packetId,
|
|
878
|
+
packetRef,
|
|
879
|
+
freezeLocator: status.freezeLocator,
|
|
880
|
+
aggregateDigest: status.aggregateDigest,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function freezeLocatorOf(input) {
|
|
884
|
+
return isRecord(input) && "freezeLocator" in input && typeof input.freezeLocator === "string"
|
|
885
|
+
? input.freezeLocator
|
|
886
|
+
: undefined;
|
|
887
|
+
}
|
|
888
|
+
function freezeRecordOf(input) {
|
|
889
|
+
return isRecord(input) && "schemaVersion" in input && input.schemaVersion === "ebo.task-packet-freeze/v1"
|
|
890
|
+
? input
|
|
891
|
+
: undefined;
|
|
892
|
+
}
|
|
893
|
+
function frozenTaskFromInput(taskId, packetRef, input, freezeLocatorOverride) {
|
|
894
|
+
if (input === undefined) {
|
|
895
|
+
throw new Error(`Task packet "${taskId}" has no frozen record.`);
|
|
896
|
+
}
|
|
897
|
+
if (!("schemaVersion" in input) || input.schemaVersion !== "ebo.task-packet-freeze/v1") {
|
|
898
|
+
throw new Error(`Task packet "${taskId}" requires a complete freeze record when no bundle root is supplied.`);
|
|
899
|
+
}
|
|
900
|
+
assertTaskPacketFreezeRecord(input);
|
|
901
|
+
if ("status" in input && input.status !== undefined && input.status !== "frozen") {
|
|
902
|
+
throw new Error(`Task packet "${taskId}" is ${input.status}, not frozen.`);
|
|
903
|
+
}
|
|
904
|
+
const record = input;
|
|
905
|
+
const packetLocator = record.packetLocator ?? record.packetRef?.locator ?? packetRef.locator;
|
|
906
|
+
const packetDigest = record.packetDigest ?? record.packetRef?.digest;
|
|
907
|
+
const packetId = record.packetId;
|
|
908
|
+
const freezeLocator = freezeLocatorOverride ?? record.freezeLocator
|
|
909
|
+
?? (typeof packetLocator === "string" ? defaultFreezeLocator(packetLocator) : undefined);
|
|
910
|
+
const aggregateDigest = record.aggregateDigest;
|
|
911
|
+
if (typeof packetLocator !== "string" || packetDigest === undefined || typeof packetId !== "string"
|
|
912
|
+
|| typeof freezeLocator !== "string" || aggregateDigest === undefined) {
|
|
913
|
+
throw new Error(`Task packet "${taskId}" has an incomplete frozen record.`);
|
|
914
|
+
}
|
|
915
|
+
if (packetLocator !== packetRef.locator || !sameDigest(packetDigest, packetRef.digest)) {
|
|
916
|
+
throw new Error(`Task packet "${taskId}" freeze record does not match its experiment reference.`);
|
|
917
|
+
}
|
|
918
|
+
if (!isSafeArtifactRelativePath(freezeLocator)) {
|
|
919
|
+
throw new Error(`Task packet "${taskId}" freeze locator is unsafe.`);
|
|
920
|
+
}
|
|
921
|
+
assertFreezeLocatorPathWithinLimits(freezeLocator, packetLocator);
|
|
922
|
+
if (freezeLocator.toLowerCase() === packetLocator.toLowerCase()) {
|
|
923
|
+
throw new Error(`Task packet "${taskId}" freeze locator must differ from its packet locator.`);
|
|
924
|
+
}
|
|
925
|
+
return { id: taskId, packetId, packetRef, freezeLocator, aggregateDigest };
|
|
926
|
+
}
|
|
927
|
+
function orderCells(cells, ordering, permutationAlgorithm = "fisher-yates-v1") {
|
|
928
|
+
switch (ordering.strategy) {
|
|
929
|
+
case "declared":
|
|
930
|
+
case "sequential":
|
|
931
|
+
return cells;
|
|
932
|
+
case "permuted":
|
|
933
|
+
case "seeded-shuffle":
|
|
934
|
+
return shuffle(cells, ordering.seed, permutationAlgorithm);
|
|
935
|
+
case "balanced":
|
|
936
|
+
case "balanced-interleaved":
|
|
937
|
+
case "interleaved":
|
|
938
|
+
return interleave(cells, ordering.balanceBy ?? "model");
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function shuffle(values, seed, algorithm) {
|
|
942
|
+
if (algorithm !== "fisher-yates-v1")
|
|
943
|
+
throw new Error(`Unsupported permutation algorithm "${algorithm}".`);
|
|
944
|
+
const shuffled = [...values];
|
|
945
|
+
let state = createHash("sha256").update(seed).digest().readUInt32BE(0) || 0x9e3779b9;
|
|
946
|
+
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
947
|
+
state ^= state << 13;
|
|
948
|
+
state ^= state >>> 17;
|
|
949
|
+
state ^= state << 5;
|
|
950
|
+
state >>>= 0;
|
|
951
|
+
const swap = state % (index + 1);
|
|
952
|
+
[shuffled[index], shuffled[swap]] = [shuffled[swap], shuffled[index]];
|
|
953
|
+
}
|
|
954
|
+
return shuffled;
|
|
955
|
+
}
|
|
956
|
+
function interleave(cells, dimension) {
|
|
957
|
+
const groups = new Map();
|
|
958
|
+
for (const cell of cells) {
|
|
959
|
+
const id = dimension === "task" ? cell.taskId : dimension === "model" ? cell.modelId : cell.harnessId;
|
|
960
|
+
const group = groups.get(id);
|
|
961
|
+
if (group === undefined)
|
|
962
|
+
groups.set(id, [cell]);
|
|
963
|
+
else
|
|
964
|
+
group.push(cell);
|
|
965
|
+
}
|
|
966
|
+
const keys = [...groups.keys()];
|
|
967
|
+
const positions = new Map(keys.map((key) => [key, 0]));
|
|
968
|
+
const ordered = [];
|
|
969
|
+
let remaining = cells.length;
|
|
970
|
+
while (remaining > 0) {
|
|
971
|
+
for (const key of keys) {
|
|
972
|
+
const group = groups.get(key);
|
|
973
|
+
const index = positions.get(key);
|
|
974
|
+
if (index >= group.length)
|
|
975
|
+
continue;
|
|
976
|
+
ordered.push(group[index]);
|
|
977
|
+
positions.set(key, index + 1);
|
|
978
|
+
remaining -= 1;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return ordered;
|
|
982
|
+
}
|
|
983
|
+
function runId(experimentId, experimentDigest, schedulingDigest, task, model, harness, trialIndex) {
|
|
984
|
+
return `run-${digestMetadata({
|
|
985
|
+
experimentId,
|
|
986
|
+
experimentDigest,
|
|
987
|
+
schedulingDigest,
|
|
988
|
+
task,
|
|
989
|
+
model,
|
|
990
|
+
harness,
|
|
991
|
+
trialIndex,
|
|
992
|
+
}).value}`;
|
|
993
|
+
}
|
|
994
|
+
function queueError(artifact, field, message) {
|
|
995
|
+
return { artifact, schemaVersion: "ebo.run-queue/v1", field, message };
|
|
996
|
+
}
|
|
997
|
+
function checkConditionIdentity(artifact, field, label, id, identity, identities, errors) {
|
|
998
|
+
const signature = digestMetadata(identity).value;
|
|
999
|
+
const previous = identities.get(id);
|
|
1000
|
+
if (previous === undefined)
|
|
1001
|
+
identities.set(id, signature);
|
|
1002
|
+
else if (previous !== signature) {
|
|
1003
|
+
errors.push(queueError(artifact, `${field}/${label}`, `Conflicting ${label} identity for condition ID "${id}".`));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function checkCompositionIdentity(artifact, field, label, id, signature, compositions, errors) {
|
|
1007
|
+
const previous = compositions.get(signature);
|
|
1008
|
+
if (previous === undefined)
|
|
1009
|
+
compositions.set(signature, id);
|
|
1010
|
+
else if (previous !== id) {
|
|
1011
|
+
errors.push(queueError(artifact, `${field}/${label}`, `Duplicate ${label} composition for condition IDs "${previous}" and "${id}".`));
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
function checkLocatorDigest(artifact, field, reference, locators, errors) {
|
|
1015
|
+
const locator = reference.locator.toLowerCase();
|
|
1016
|
+
const signature = digestIdentity(reference.digest);
|
|
1017
|
+
const previous = locators.get(locator);
|
|
1018
|
+
if (previous === undefined)
|
|
1019
|
+
locators.set(locator, signature);
|
|
1020
|
+
else if (previous !== signature) {
|
|
1021
|
+
errors.push(queueError(artifact, field, `Locator "${reference.locator}" is bound to conflicting digests.`));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function cellKeyOf(cell) {
|
|
1025
|
+
return `${cell.taskId}\u0000${cell.modelId}\u0000${cell.harnessId}\u0000${cell.trialIndex}`;
|
|
1026
|
+
}
|
|
1027
|
+
function sameReference(left, right) {
|
|
1028
|
+
return left.locator === right.locator && sameDigest(left.digest, right.digest);
|
|
1029
|
+
}
|
|
1030
|
+
function sameMatrix(left, right) {
|
|
1031
|
+
return left.trialCount === right.trialCount
|
|
1032
|
+
&& sameIdList(left.taskIds, right.taskIds)
|
|
1033
|
+
&& sameIdList(left.modelIds, right.modelIds)
|
|
1034
|
+
&& sameIdList(left.harnessIds, right.harnessIds);
|
|
1035
|
+
}
|
|
1036
|
+
function sameIdList(left, right) {
|
|
1037
|
+
return left.length === right.length && left.every((id, index) => id === right[index]);
|
|
1038
|
+
}
|
|
1039
|
+
function sameFrozenTask(left, right) {
|
|
1040
|
+
return left.id === right.id
|
|
1041
|
+
&& left.packetId === right.packetId
|
|
1042
|
+
&& sameReference(left.packetRef, right.packetRef)
|
|
1043
|
+
&& left.freezeLocator === right.freezeLocator
|
|
1044
|
+
&& sameDigest(left.aggregateDigest, right.aggregateDigest);
|
|
1045
|
+
}
|
|
1046
|
+
function sameFreezeRecord(left, right) {
|
|
1047
|
+
return sameDigest(digestMetadata(left), digestMetadata(right));
|
|
1048
|
+
}
|
|
1049
|
+
function sameOptionalReference(left, right) {
|
|
1050
|
+
return left === undefined ? right === undefined : right !== undefined && sameReference(left, right);
|
|
1051
|
+
}
|
|
1052
|
+
function sameDigest(left, right) {
|
|
1053
|
+
return left.algorithm === right.algorithm && left.value === right.value;
|
|
1054
|
+
}
|
|
1055
|
+
function sameOptionalDigest(left, right) {
|
|
1056
|
+
return left === null
|
|
1057
|
+
? right === null
|
|
1058
|
+
: right !== null && right !== undefined && sameDigest(left, right);
|
|
1059
|
+
}
|
|
1060
|
+
function sameFileIdentity(left, right) {
|
|
1061
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1062
|
+
}
|
|
1063
|
+
function digestIdentity(digest) {
|
|
1064
|
+
return `${digest.algorithm}:${digest.value}`;
|
|
1065
|
+
}
|
|
1066
|
+
function hasFrozenTaskInputs(options) {
|
|
1067
|
+
return options.bundleRoot !== undefined
|
|
1068
|
+
|| options.frozenTasks !== undefined
|
|
1069
|
+
|| options.taskFreezeRecords !== undefined
|
|
1070
|
+
|| options.taskPackets !== undefined
|
|
1071
|
+
|| options.resolvedPackets !== undefined;
|
|
1072
|
+
}
|
|
1073
|
+
function hasExplicitFrozenTaskInputs(options) {
|
|
1074
|
+
return options.frozenTasks !== undefined
|
|
1075
|
+
|| options.taskFreezeRecords !== undefined
|
|
1076
|
+
|| options.taskPackets !== undefined;
|
|
1077
|
+
}
|
|
1078
|
+
function isRecord(value) {
|
|
1079
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1080
|
+
}
|