@policyengine/calibration-tree 0.1.0 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@policyengine/calibration-tree",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Builds, validates, reads and publishes Microcosm calibration tree artifacts (tree schema 6), with the release and staging readers they are built from.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "./calibration-tree-blob": "./src/calibration-tree-blob.ts",
31
31
  "./calibration-tree-bundle": "./src/calibration-tree-bundle.ts",
32
32
  "./calibration-tree-manifest": "./src/calibration-tree-manifest.ts",
33
+ "./calibration-tree-publication": "./src/calibration-tree-publication.ts",
33
34
  "./calibration-tree-worker": "./src/calibration-tree-worker.ts",
34
35
  "./chronicle-metadata": "./src/chronicle-metadata.ts",
35
36
  "./countries": "./src/countries.ts",
@@ -60,7 +61,8 @@
60
61
  "publish:calibration-tree": "bun run src/publish-calibration-tree.ts",
61
62
  "test": "bun test",
62
63
  "typecheck": "tsc --noEmit",
63
- "worker": "bun run src/calibration-tree-worker.ts"
64
+ "worker": "bun run src/calibration-tree-worker.ts",
65
+ "publication": "bun run src/calibration-tree-publication.ts"
64
66
  },
65
67
  "dependencies": {
66
68
  "@vercel/blob": "^2.6.1"
@@ -0,0 +1,399 @@
1
+ // Publishes a tree the calibration tree worker wrote to disk: uploads each of
2
+ // its gzip parts to the part's immutable path, then adds the build's entry to
3
+ // the manifest with a compare-and-swap. The collector runs it as its own Bun
4
+ // process, after the worker and under its one manifest lock:
5
+ //
6
+ // bun run src/calibration-tree-publication.ts PUBLICATION.json
7
+ //
8
+ // with the store's read-write token in CALIBRATION_TREE_BLOB_READ_WRITE_TOKEN
9
+ // and PUBLICATION.json holding:
10
+ //
11
+ // {
12
+ // "bundleDirectory": "<the worker's output directory>",
13
+ // "worker": <the worker's printed result>,
14
+ // "makeLatest": false
15
+ // }
16
+ //
17
+ // Each part is read from disk and checked against the worker's record of its
18
+ // raw bytes before anything is uploaded. A part already stored with the same
19
+ // bytes is kept; one stored with other bytes stops the publication. The index
20
+ // goes last. The manifest is created only if it is still absent, and an
21
+ // existing one is replaced only if its ETag is still the one read, so a
22
+ // manifest another writer changed is never overwritten. A build asked to
23
+ // become the country's latest does so only when it is at least as new
24
+ // (createdAt) as the current latest, so a retried or reprocessed older build
25
+ // never takes the latest back. On success it prints one JSON line and exits
26
+ // 0. The other exit codes:
27
+ //
28
+ // 1 the request could not be followed: no token, a malformed request, or a
29
+ // part on disk that differs from the worker's record;
30
+ // 2 the build is refused: another build is already recorded for the same
31
+ // release or staged run, or the same build with other content (another
32
+ // submission of the same files, whose index records another time or
33
+ // label), or a staging build was to become the latest release; running
34
+ // again on the same tree cannot help;
35
+ // 3 anything else, including a manifest another writer changed meanwhile
36
+ // and any error from the store; running again may succeed.
37
+ import { createHash } from "node:crypto";
38
+ import { readFileSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { gunzipSync } from "node:zlib";
41
+ import { get, put } from "@vercel/blob";
42
+
43
+ import { calibrationTreePartPath, type CalibrationTreePart } from "./calibration-tree-artifact";
44
+ import {
45
+ calibrationTreeBlobText,
46
+ getCalibrationTreeBlob,
47
+ readCalibrationTreeManifest,
48
+ type CalibrationTreeBlobClient,
49
+ } from "./calibration-tree-blob";
50
+ import {
51
+ CALIBRATION_TREE_MANIFEST_PATH,
52
+ CALIBRATION_TREE_MANIFEST_SCHEMA_VERSION,
53
+ parseCalibrationTreeManifest,
54
+ serializeCalibrationTreeManifest,
55
+ withCalibrationTreeManifestEntry,
56
+ type CalibrationTreeManifest,
57
+ type CalibrationTreeManifestEntry,
58
+ } from "./calibration-tree-manifest";
59
+ import { isCountry, type MicrocosmCountry } from "./countries";
60
+
61
+ export const CALIBRATION_TREE_PUBLICATION_EXIT_REQUEST_ERROR = 1;
62
+ export const CALIBRATION_TREE_PUBLICATION_EXIT_ENTRY_REFUSED = 2;
63
+ export const CALIBRATION_TREE_PUBLICATION_EXIT_PUBLICATION_ERROR = 3;
64
+ export const CALIBRATION_TREE_PUBLICATION_TOKEN_VARIABLE = "CALIBRATION_TREE_BLOB_READ_WRITE_TOKEN";
65
+
66
+ // As the package's own uploads write them.
67
+ const IMMUTABLE_CACHE_SECONDS = 31_536_000;
68
+ const MANIFEST_CACHE_SECONDS = 60;
69
+ const SHA256_RE = /^[0-9a-f]{64}$/;
70
+
71
+ /** The request could not be followed; nothing was published. */
72
+ export class CalibrationTreePublicationRequestError extends Error {}
73
+
74
+ /** The build is refused; nothing in the manifest changed. */
75
+ export class CalibrationTreeEntryRefusedError extends Error {}
76
+
77
+ /** Another writer changed the manifest after it was read; nothing was overwritten. */
78
+ export class CalibrationTreeManifestConflictError extends Error {}
79
+
80
+ export interface CalibrationTreePublicationPart {
81
+ part: CalibrationTreePart;
82
+ path: string;
83
+ sha256: string;
84
+ rawBytes: number;
85
+ }
86
+
87
+ export interface CalibrationTreePublicationRequest {
88
+ bundleDirectory: string;
89
+ country: MicrocosmCountry;
90
+ buildArtifactId: string;
91
+ files: CalibrationTreePublicationPart[];
92
+ manifestEntry: CalibrationTreeManifestEntry;
93
+ makeLatest: boolean;
94
+ }
95
+
96
+ export interface CalibrationTreePublicationResult {
97
+ buildArtifactId: string;
98
+ kind: CalibrationTreeManifestEntry["kind"];
99
+ uploadedParts: number;
100
+ keptParts: number;
101
+ madeLatest: boolean;
102
+ }
103
+
104
+ type JsonObject = Record<string, unknown>;
105
+
106
+ function requestObject(value: unknown, label: string): JsonObject {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
108
+ throw new CalibrationTreePublicationRequestError(`${label} must be a JSON object.`);
109
+ }
110
+ return value as JsonObject;
111
+ }
112
+
113
+ function requestString(value: JsonObject, field: string, label: string): string {
114
+ const text = value[field];
115
+ if (typeof text !== "string" || text.length === 0) {
116
+ throw new CalibrationTreePublicationRequestError(`${label} must be a non-empty string.`);
117
+ }
118
+ return text;
119
+ }
120
+
121
+ function publicationPart(value: unknown, country: MicrocosmCountry, buildArtifactId: string): CalibrationTreePublicationPart {
122
+ const file = requestObject(value, "worker.files[]");
123
+ const part = requestString(file, "part", "worker.files[].part") as CalibrationTreePart;
124
+ const path = requestString(file, "path", "worker.files[].path");
125
+ const sha256 = requestString(file, "sha256", "worker.files[].sha256");
126
+ const rawBytes = file.rawBytes;
127
+ let expectedPath: string;
128
+ try {
129
+ expectedPath = calibrationTreePartPath(country, buildArtifactId, part);
130
+ } catch {
131
+ throw new CalibrationTreePublicationRequestError(`worker.files[] names an unknown part ${part}.`);
132
+ }
133
+ // A part is only ever written to its own immutable path.
134
+ if (path !== expectedPath) {
135
+ throw new CalibrationTreePublicationRequestError(`Part ${part} must be stored at ${expectedPath}.`);
136
+ }
137
+ if (!SHA256_RE.test(sha256) || !Number.isSafeInteger(rawBytes) || (rawBytes as number) < 0) {
138
+ throw new CalibrationTreePublicationRequestError(`Part ${part} needs a SHA-256 and a raw size.`);
139
+ }
140
+ return { part, path, sha256, rawBytes: rawBytes as number };
141
+ }
142
+
143
+ /** Checks a publication request read from JSON. */
144
+ export function parseCalibrationTreePublicationRequest(value: unknown): CalibrationTreePublicationRequest {
145
+ const request = requestObject(value, "The request");
146
+ const unknown = Object.keys(request).filter((field) => !["bundleDirectory", "worker", "makeLatest"].includes(field));
147
+ if (unknown.length > 0) {
148
+ throw new CalibrationTreePublicationRequestError(`The request has unknown fields: ${unknown.join(", ")}.`);
149
+ }
150
+ if (typeof request.makeLatest !== "boolean") {
151
+ throw new CalibrationTreePublicationRequestError("makeLatest must be true or false.");
152
+ }
153
+ const bundleDirectory = requestString(request, "bundleDirectory", "bundleDirectory");
154
+ const worker = requestObject(request.worker, "worker");
155
+ const country = requestString(worker, "country", "worker.country");
156
+ if (!isCountry(country)) {
157
+ throw new CalibrationTreePublicationRequestError(`worker.country ${country} is not a registered country.`);
158
+ }
159
+ const buildArtifactId = requestString(worker, "buildArtifactId", "worker.buildArtifactId");
160
+ if (!SHA256_RE.test(buildArtifactId)) {
161
+ throw new CalibrationTreePublicationRequestError("worker.buildArtifactId must be a build ID.");
162
+ }
163
+ if (!Array.isArray(worker.files) || worker.files.length === 0) {
164
+ throw new CalibrationTreePublicationRequestError("worker.files must list the tree's parts.");
165
+ }
166
+ const files = worker.files.map((file) => publicationPart(file, country, buildArtifactId));
167
+ if (files.filter((file) => file.part === "index").length !== 1) {
168
+ throw new CalibrationTreePublicationRequestError("worker.files must hold exactly one index.");
169
+ }
170
+ const manifestEntry = requestObject(worker.manifestEntry, "worker.manifestEntry") as unknown as CalibrationTreeManifestEntry;
171
+ // The manifest's own rules, checked before anything is uploaded: a
172
+ // malformed entry is a defect in the request, not a refusal of the build.
173
+ try {
174
+ parseCalibrationTreeManifest({
175
+ schemaVersion: CALIBRATION_TREE_MANIFEST_SCHEMA_VERSION,
176
+ countries: { [country]: { latestReleaseBuildArtifactId: null, builds: [manifestEntry] } },
177
+ });
178
+ } catch (error) {
179
+ throw new CalibrationTreePublicationRequestError(
180
+ `worker.manifestEntry is not a manifest entry: ${error instanceof Error ? error.message : String(error)}`,
181
+ );
182
+ }
183
+ const index = files.find((file) => file.part === "index")!;
184
+ if (
185
+ manifestEntry.buildArtifactId !== buildArtifactId ||
186
+ manifestEntry.indexSha256 !== index.sha256 ||
187
+ manifestEntry.indexBytes !== index.rawBytes
188
+ ) {
189
+ throw new CalibrationTreePublicationRequestError("worker.manifestEntry does not describe the tree's index.");
190
+ }
191
+ return { bundleDirectory, country, buildArtifactId, files, manifestEntry, makeLatest: request.makeLatest };
192
+ }
193
+
194
+ interface PartOnDisk extends CalibrationTreePublicationPart {
195
+ compressed: Uint8Array;
196
+ serialized: string;
197
+ }
198
+
199
+ // Every part is read and checked before anything is uploaded, so a damaged
200
+ // output directory publishes nothing.
201
+ function partsOnDisk(request: CalibrationTreePublicationRequest): PartOnDisk[] {
202
+ return request.files.map((file) => {
203
+ let compressed: Uint8Array;
204
+ let raw: Uint8Array;
205
+ try {
206
+ compressed = new Uint8Array(readFileSync(join(request.bundleDirectory, file.path)));
207
+ raw = new Uint8Array(gunzipSync(compressed));
208
+ } catch (error) {
209
+ throw new CalibrationTreePublicationRequestError(
210
+ `Part ${file.part} could not be read from ${request.bundleDirectory}: ${error instanceof Error ? error.message : String(error)}`,
211
+ );
212
+ }
213
+ if (raw.byteLength !== file.rawBytes || createHash("sha256").update(raw).digest("hex") !== file.sha256) {
214
+ throw new CalibrationTreePublicationRequestError(`Part ${file.part} on disk differs from the worker's record.`);
215
+ }
216
+ return { ...file, compressed, serialized: new TextDecoder("utf-8").decode(raw) };
217
+ });
218
+ }
219
+
220
+ // As the package's uploadCalibrationTreeFile: an existing part must hold the
221
+ // same raw JSON, and an uploaded one is read back.
222
+ async function uploadPart(
223
+ part: PartOnDisk,
224
+ request: CalibrationTreePublicationRequest,
225
+ token: string,
226
+ client: CalibrationTreeBlobClient,
227
+ ): Promise<boolean> {
228
+ const stored = () =>
229
+ getCalibrationTreeBlob({
230
+ country: request.country,
231
+ buildArtifactId: request.buildArtifactId,
232
+ part: part.part,
233
+ token,
234
+ consistent: true,
235
+ client,
236
+ });
237
+ const existing = await stored();
238
+ if (existing) {
239
+ if (await calibrationTreeBlobText(existing.stream) !== part.serialized) {
240
+ // The build ID covers the files, not the time or label the index
241
+ // records, so another submission of the same files lands here, as does
242
+ // a rebuild whose output changed.
243
+ throw new CalibrationTreeEntryRefusedError(
244
+ `Immutable calibration tree part ${part.path} already exists with different content: ` +
245
+ "a tree for these files is already stored with another index.",
246
+ );
247
+ }
248
+ return false;
249
+ }
250
+ await client.put(part.path, Buffer.from(part.compressed), {
251
+ access: "private",
252
+ token,
253
+ addRandomSuffix: false,
254
+ multipart: true,
255
+ contentType: "application/gzip",
256
+ cacheControlMaxAge: IMMUTABLE_CACHE_SECONDS,
257
+ });
258
+ const uploaded = await stored();
259
+ if (!uploaded || await calibrationTreeBlobText(uploaded.stream) !== part.serialized) {
260
+ throw new Error(`Uploaded calibration tree part ${part.path} failed verification.`);
261
+ }
262
+ return true;
263
+ }
264
+
265
+ // When a build was published: createdAt, else updatedAt; an undated build is
266
+ // older than any dated one.
267
+ function publishedInstant(entry: CalibrationTreeManifestEntry): number {
268
+ const instant = Date.parse(entry.createdAt ?? entry.updatedAt ?? "");
269
+ return Number.isFinite(instant) ? instant : Number.NEGATIVE_INFINITY;
270
+ }
271
+
272
+ // Whether the build is at least as new as the country's current latest.
273
+ function isAtLeastAsNewAsLatest(
274
+ manifest: CalibrationTreeManifest,
275
+ country: MicrocosmCountry,
276
+ entry: CalibrationTreeManifestEntry,
277
+ ): boolean {
278
+ const current = manifest.countries[country];
279
+ const latest = current?.builds.find((build) => build.buildArtifactId === current.latestReleaseBuildArtifactId);
280
+ return !latest || latest.buildArtifactId === entry.buildArtifactId ||
281
+ publishedInstant(entry) >= publishedInstant(latest);
282
+ }
283
+
284
+ async function addManifestEntry(
285
+ request: CalibrationTreePublicationRequest,
286
+ token: string,
287
+ client: CalibrationTreeBlobClient,
288
+ ): Promise<boolean> {
289
+ const current = await readCalibrationTreeManifest({ token, consistent: true, client });
290
+ // An existing manifest is replaced only on its ETag; without one, the write
291
+ // would carry no condition.
292
+ if (current.etag !== null && !current.etag) {
293
+ throw new CalibrationTreeManifestConflictError(
294
+ "The stored calibration tree manifest was read without an ETag, so it cannot be replaced safely.",
295
+ );
296
+ }
297
+ const makeLatest = request.makeLatest &&
298
+ (request.manifestEntry.kind !== "release" ||
299
+ isAtLeastAsNewAsLatest(current.manifest, request.country, request.manifestEntry));
300
+ let updated;
301
+ try {
302
+ updated = withCalibrationTreeManifestEntry(current.manifest, request.country, request.manifestEntry, makeLatest);
303
+ } catch (error) {
304
+ throw new CalibrationTreeEntryRefusedError(error instanceof Error ? error.message : String(error));
305
+ }
306
+ try {
307
+ await client.put(CALIBRATION_TREE_MANIFEST_PATH, serializeCalibrationTreeManifest(updated), {
308
+ access: "private",
309
+ token,
310
+ addRandomSuffix: false,
311
+ // Create only while absent; otherwise replace only the version read.
312
+ allowOverwrite: current.etag != null,
313
+ ifMatch: current.etag ?? undefined,
314
+ contentType: "application/json; charset=utf-8",
315
+ cacheControlMaxAge: MANIFEST_CACHE_SECONDS,
316
+ });
317
+ } catch (error) {
318
+ throw new CalibrationTreeManifestConflictError(
319
+ `The calibration tree manifest was not written, because another writer changed it or the store refused the write: ${error instanceof Error ? error.message : String(error)}`,
320
+ );
321
+ }
322
+ const verified = await readCalibrationTreeManifest({ token, consistent: true, client });
323
+ const recorded = verified.manifest.countries[request.country];
324
+ const entry = recorded?.builds.find((build) => build.buildArtifactId === request.buildArtifactId);
325
+ if (
326
+ !entry ||
327
+ entry.indexSha256 !== request.manifestEntry.indexSha256 ||
328
+ (makeLatest && recorded?.latestReleaseBuildArtifactId !== request.buildArtifactId)
329
+ ) {
330
+ throw new Error("Calibration tree manifest verification failed.");
331
+ }
332
+ return makeLatest;
333
+ }
334
+
335
+ const DEFAULT_CLIENT: CalibrationTreeBlobClient = { get, put };
336
+
337
+ /** Uploads a built tree and records it in the manifest. */
338
+ export async function publishBuiltCalibrationTree(options: {
339
+ request: CalibrationTreePublicationRequest;
340
+ token: string;
341
+ client?: CalibrationTreeBlobClient;
342
+ }): Promise<CalibrationTreePublicationResult> {
343
+ const { request, token, client = DEFAULT_CLIENT } = options;
344
+ const parts = partsOnDisk(request);
345
+ const ordered = [...parts.filter((part) => part.part !== "index"), ...parts.filter((part) => part.part === "index")];
346
+ let uploadedParts = 0;
347
+ for (const part of ordered) {
348
+ if (await uploadPart(part, request, token, client)) uploadedParts += 1;
349
+ }
350
+ const madeLatest = await addManifestEntry(request, token, client);
351
+ return {
352
+ buildArtifactId: request.buildArtifactId,
353
+ kind: request.manifestEntry.kind,
354
+ uploadedParts,
355
+ keptParts: parts.length - uploadedParts,
356
+ madeLatest,
357
+ };
358
+ }
359
+
360
+ /** The exit code for an error that stopped the publication. */
361
+ export function calibrationTreePublicationExitCode(error: unknown): number {
362
+ if (error instanceof CalibrationTreePublicationRequestError) return CALIBRATION_TREE_PUBLICATION_EXIT_REQUEST_ERROR;
363
+ if (error instanceof CalibrationTreeEntryRefusedError) return CALIBRATION_TREE_PUBLICATION_EXIT_ENTRY_REFUSED;
364
+ return CALIBRATION_TREE_PUBLICATION_EXIT_PUBLICATION_ERROR;
365
+ }
366
+
367
+ /** The command line: returns the process exit code. */
368
+ export async function calibrationTreePublicationMain(
369
+ argv: string[],
370
+ environment: Record<string, string | undefined> = process.env,
371
+ ): Promise<number> {
372
+ try {
373
+ if (argv.length !== 1) {
374
+ throw new CalibrationTreePublicationRequestError("Usage: bun run src/calibration-tree-publication.ts PUBLICATION.json");
375
+ }
376
+ const token = environment[CALIBRATION_TREE_PUBLICATION_TOKEN_VARIABLE]?.trim();
377
+ if (!token) {
378
+ throw new CalibrationTreePublicationRequestError(`${CALIBRATION_TREE_PUBLICATION_TOKEN_VARIABLE} is required.`);
379
+ }
380
+ let parsed: unknown;
381
+ try {
382
+ parsed = JSON.parse(readFileSync(argv[0], "utf8"));
383
+ } catch (error) {
384
+ throw new CalibrationTreePublicationRequestError(
385
+ `The request ${argv[0]} could not be read as JSON: ${error instanceof Error ? error.message : String(error)}`,
386
+ );
387
+ }
388
+ const result = await publishBuiltCalibrationTree({ request: parseCalibrationTreePublicationRequest(parsed), token });
389
+ console.log(JSON.stringify(result));
390
+ return 0;
391
+ } catch (error) {
392
+ console.error(error instanceof Error ? error.message : String(error));
393
+ return calibrationTreePublicationExitCode(error);
394
+ }
395
+ }
396
+
397
+ if (import.meta.main) {
398
+ process.exitCode = await calibrationTreePublicationMain(Bun.argv.slice(2));
399
+ }
@@ -1,7 +1,7 @@
1
- // The calibration tree worker: builds one release's tree bundle from local
2
- // copies of the files the publisher reads from Hugging Face, and writes each
3
- // part's gzip bytes under an output directory, at the path the publisher
4
- // uploads it to. The collector runs it as its own Bun process:
1
+ // The calibration tree worker: builds one release's or staged run's tree
2
+ // bundle from local copies of a build's files, and writes each part's gzip
3
+ // bytes under an output directory, at the path the publisher uploads it to.
4
+ // The collector runs it as its own Bun process:
5
5
  //
6
6
  // bun run src/calibration-tree-worker.ts REQUEST.json OUTPUT_DIRECTORY
7
7
  //
@@ -24,12 +24,25 @@
24
24
  // }
25
25
  // }
26
26
  //
27
- // It builds the bundle exactly as the publisher's `publishRelease` does from
28
- // the same files, commit and publication time, so both write the same bytes:
29
- // the golden tests hold each to the recorded trees. Like the publisher, it
30
- // records the publication time as Date.prototype.toISOString() writes it. On
31
- // success it prints one JSON line describing the written parts and exits 0.
32
- // It writes nothing unless the whole bundle builds. The other exit codes:
27
+ // or a staged run's, built from a staged bundle's submitted files:
28
+ //
29
+ // {
30
+ // "kind": "staging",
31
+ // "country": "uk",
32
+ // "runId": "<the staged run>",
33
+ // "label": "<the candidate release, or the run id when it names none>",
34
+ // "hfRepo": ..., "hfCommitSha": ..., "createdAt": ..., "artifacts": {...}
35
+ // }
36
+ //
37
+ // A release bundle is built exactly as the publisher's `publishRelease` builds
38
+ // it from the same files, commit and publication time, so both write the same
39
+ // bytes: the golden tests hold each to the recorded trees. A staged run's is
40
+ // built as the publisher's `publishStagingSource` builds a staging tree, but
41
+ // from the files given here rather than the staging repo's run files. Like the
42
+ // publisher, it records the publication time as Date.prototype.toISOString()
43
+ // writes it. On success it prints one JSON line describing the written parts,
44
+ // with the manifest entry the publisher would record for the build, and exits
45
+ // 0. It writes nothing unless the whole bundle builds. The other exit codes:
33
46
  //
34
47
  // 1 the request could not be followed: a malformed request, a missing
35
48
  // file, an output directory that is not empty;
@@ -49,10 +62,11 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
49
62
  import { dirname, join } from "node:path";
50
63
  import { createHash } from "node:crypto";
51
64
 
52
- import type { CalibrationTreeSourceArtifact } from "./calibration-tree-artifact";
65
+ import { CALIBRATION_TREE_SCHEMA_VERSION, type CalibrationTreeSourceArtifact } from "./calibration-tree-artifact";
53
66
  import { buildCalibrationTreeBundle, type CalibrationTreeBundle } from "./calibration-tree-bundle";
67
+ import type { CalibrationTreeManifestEntry } from "./calibration-tree-manifest";
54
68
  import { isCountry, type MicrocosmCountry } from "./countries";
55
- import { buildCalibration, InvalidReleaseIdError } from "./latest-artifact";
69
+ import { assertSafeReleaseId, buildCalibration, InvalidReleaseIdError } from "./latest-artifact";
56
70
  import { CalibrationTreeArtifactSizeError, enforceConfiguredSizeLimits } from "./publish-calibration-tree";
57
71
  import { isBuildFolderId, isReleaseDirectory } from "./release-record";
58
72
  import { IncompatibleStagingDataError } from "./staging-contract";
@@ -76,6 +90,16 @@ const REQUEST_FIELDS = [
76
90
  "createdAt",
77
91
  "artifacts",
78
92
  ] as const;
93
+ const STAGING_REQUEST_FIELDS = [
94
+ "kind",
95
+ "country",
96
+ "runId",
97
+ "label",
98
+ "hfRepo",
99
+ "hfCommitSha",
100
+ "createdAt",
101
+ "artifacts",
102
+ ] as const;
79
103
  const HF_COMMIT_RE = /^[0-9a-f]{40,64}$/;
80
104
  // A date and time with seconds and an explicit offset, so the instant never
81
105
  // depends on the machine's time zone.
@@ -91,7 +115,14 @@ export interface CalibrationTreeWorkerArtifact {
91
115
  file: string;
92
116
  }
93
117
 
94
- export interface CalibrationTreeWorkerRequest {
118
+ export interface CalibrationTreeWorkerArtifacts {
119
+ calibrationDiagnostics: CalibrationTreeWorkerArtifact;
120
+ buildManifest: CalibrationTreeWorkerArtifact | null;
121
+ releaseManifest: CalibrationTreeWorkerArtifact | null;
122
+ demographics: CalibrationTreeWorkerArtifact | null;
123
+ }
124
+
125
+ export interface CalibrationTreeWorkerReleaseRequest {
95
126
  kind: "release";
96
127
  country: MicrocosmCountry;
97
128
  releaseId: string;
@@ -99,14 +130,26 @@ export interface CalibrationTreeWorkerRequest {
99
130
  hfRepo: string;
100
131
  hfCommitSha: string;
101
132
  createdAt: string | null;
102
- artifacts: {
103
- calibrationDiagnostics: CalibrationTreeWorkerArtifact;
104
- buildManifest: CalibrationTreeWorkerArtifact | null;
105
- releaseManifest: CalibrationTreeWorkerArtifact | null;
106
- demographics: CalibrationTreeWorkerArtifact | null;
107
- };
133
+ artifacts: CalibrationTreeWorkerArtifacts;
134
+ }
135
+
136
+ /** A staged run, built from its staged bundle's files. */
137
+ export interface CalibrationTreeWorkerStagingRequest {
138
+ kind: "staging";
139
+ country: MicrocosmCountry;
140
+ runId: string;
141
+ /** The candidate release the run was staged for, or the run id. */
142
+ label: string;
143
+ hfRepo: string;
144
+ hfCommitSha: string;
145
+ createdAt: string | null;
146
+ artifacts: CalibrationTreeWorkerArtifacts;
108
147
  }
109
148
 
149
+ export type CalibrationTreeWorkerRequest =
150
+ | CalibrationTreeWorkerReleaseRequest
151
+ | CalibrationTreeWorkerStagingRequest;
152
+
110
153
  export interface CalibrationTreeWorkerPart {
111
154
  part: string;
112
155
  path: string;
@@ -117,6 +160,7 @@ export interface CalibrationTreeWorkerPart {
117
160
 
118
161
  export interface CalibrationTreeWorkerResult {
119
162
  country: MicrocosmCountry;
163
+ /** The release id for a release; the label for a staged run. */
120
164
  releaseId: string;
121
165
  hfRepo: string;
122
166
  hfCommitSha: string;
@@ -124,6 +168,8 @@ export interface CalibrationTreeWorkerResult {
124
168
  buildArtifactId: string;
125
169
  index: Omit<CalibrationTreeWorkerPart, "part">;
126
170
  files: CalibrationTreeWorkerPart[];
171
+ /** The entry the publisher would add to the manifest for this build. */
172
+ manifestEntry: CalibrationTreeManifestEntry;
127
173
  }
128
174
 
129
175
  /** The request could not be followed; nothing about the files is known. */
@@ -224,13 +270,23 @@ function workerArtifact(value: unknown, name: ArtifactName): CalibrationTreeWork
224
270
  };
225
271
  }
226
272
 
273
+ function stagedRunId(value: JsonObject): string {
274
+ const runId = requestString(value, "runId");
275
+ try {
276
+ if (runId === "latest") throw new InvalidReleaseIdError("Invalid run id");
277
+ return assertSafeReleaseId(runId, "run");
278
+ } catch {
279
+ throw new CalibrationTreeWorkerRequestError("runId must be a run id: letters, digits, dots, underscores and hyphens.");
280
+ }
281
+ }
282
+
227
283
  /** Checks a request read from JSON, refusing anything the worker would guess at. */
228
284
  export function parseCalibrationTreeWorkerRequest(value: unknown): CalibrationTreeWorkerRequest {
229
285
  const request = requestObject(value, "The request");
230
- onlyFields(request, REQUEST_FIELDS, "The request");
231
- if (request.kind !== "release") {
232
- throw new CalibrationTreeWorkerRequestError('kind must be "release".');
286
+ if (request.kind !== "release" && request.kind !== "staging") {
287
+ throw new CalibrationTreeWorkerRequestError('kind must be "release" or "staging".');
233
288
  }
289
+ onlyFields(request, request.kind === "release" ? REQUEST_FIELDS : STAGING_REQUEST_FIELDS, "The request");
234
290
  const country = requestString(request, "country");
235
291
  if (!isCountry(country)) {
236
292
  throw new CalibrationTreeWorkerRequestError(`country ${country} is not a registered country.`);
@@ -244,13 +300,31 @@ export function parseCalibrationTreeWorkerRequest(value: unknown): CalibrationTr
244
300
  throw new CalibrationTreeWorkerRequestError("hfCommitSha must be a full lowercase commit SHA.");
245
301
  }
246
302
  const createdAt = publicationTime(request.createdAt);
247
- const artifacts = requestObject(request.artifacts, "artifacts");
248
- onlyFields(artifacts, ARTIFACT_NAMES, "artifacts");
303
+ const artifactFields = requestObject(request.artifacts, "artifacts");
304
+ onlyFields(artifactFields, ARTIFACT_NAMES, "artifacts");
249
305
  for (const name of ARTIFACT_NAMES) {
250
- if (!(name in artifacts)) {
306
+ if (!(name in artifactFields)) {
251
307
  throw new CalibrationTreeWorkerRequestError(`artifacts.${name} is required (null when absent).`);
252
308
  }
253
309
  }
310
+ const artifacts = {
311
+ calibrationDiagnostics: workerArtifact(artifactFields.calibrationDiagnostics, "calibrationDiagnostics")!,
312
+ buildManifest: workerArtifact(artifactFields.buildManifest, "buildManifest"),
313
+ releaseManifest: workerArtifact(artifactFields.releaseManifest, "releaseManifest"),
314
+ demographics: workerArtifact(artifactFields.demographics, "demographics"),
315
+ };
316
+ if (request.kind === "staging") {
317
+ return {
318
+ kind: "staging",
319
+ country,
320
+ runId: stagedRunId(request),
321
+ label: requestString(request, "label"),
322
+ hfRepo,
323
+ hfCommitSha,
324
+ createdAt,
325
+ artifacts,
326
+ };
327
+ }
254
328
  return {
255
329
  kind: "release",
256
330
  country,
@@ -259,12 +333,7 @@ export function parseCalibrationTreeWorkerRequest(value: unknown): CalibrationTr
259
333
  hfRepo,
260
334
  hfCommitSha,
261
335
  createdAt,
262
- artifacts: {
263
- calibrationDiagnostics: workerArtifact(artifacts.calibrationDiagnostics, "calibrationDiagnostics")!,
264
- buildManifest: workerArtifact(artifacts.buildManifest, "buildManifest"),
265
- releaseManifest: workerArtifact(artifacts.releaseManifest, "releaseManifest"),
266
- demographics: workerArtifact(artifacts.demographics, "demographics"),
267
- },
336
+ artifacts,
268
337
  };
269
338
  }
270
339
 
@@ -302,61 +371,183 @@ function readArtifact(artifact: CalibrationTreeWorkerArtifact | null): ReadArtif
302
371
  };
303
372
  }
304
373
 
305
- /** Builds the release's bundle in memory, as the publisher's publishRelease does. */
374
+ /**
375
+ * Builds the requested bundle in memory: a release as the publisher's
376
+ * publishRelease does, a staged run as its publishStagingSource does.
377
+ */
306
378
  export function buildCalibrationTreeWorkerBundle(request: CalibrationTreeWorkerRequest): CalibrationTreeBundle {
307
379
  const [diagnostics, buildManifest, releaseManifest, demographics] = ARTIFACT_NAMES.map((name) =>
308
380
  readArtifact(request.artifacts[name])
309
381
  );
310
382
  if (!diagnostics) throw new CalibrationTreeWorkerRequestError("Calibration diagnostics are missing.");
311
- const { country, releaseId: id, releaseDirectory, hfCommitSha } = request;
312
383
  try {
313
- const calibration = buildCalibration(
314
- diagnostics.json,
315
- id,
316
- null,
317
- buildManifest?.json ?? {},
318
- releaseManifest?.json ?? {},
319
- demographics?.json ?? {},
320
- country,
321
- "huggingface_immutable",
322
- diagnostics.source.sha256,
323
- hfCommitSha,
324
- );
325
- const bundle = buildCalibrationTreeBundle({
326
- country,
327
- createdAt: request.createdAt,
328
- releaseId: id,
329
- hfRepo: request.hfRepo,
330
- hfCommitSha,
331
- sourceArtifacts: {
332
- calibrationDiagnostics: diagnostics.source,
333
- buildManifest: buildManifest?.source ?? null,
334
- releaseManifest: releaseManifest?.source ?? null,
335
- demographics: demographics?.source ?? null,
336
- },
337
- rows: calibration.rows,
338
- calibrationProvenance: calibration.calibration_provenance,
339
- lossAttributionAvailable: calibration.target_loss_attribution.status !== "unavailable",
340
- comparison: {
341
- // As in publishRelease: a tag or a build folder compares under its
342
- // own id, a release folder under the release its diagnostics name.
343
- releaseId: releaseDirectory === id && !isBuildFolderId(id) ? calibration.release_id : id,
344
- calibrationProvenance: calibration.calibration_provenance,
345
- status: calibration.target_loss_attribution.status,
346
- aggregate: calibration.target_loss_attribution.aggregate,
347
- cap: calibration.target_loss_attribution.cap,
348
- basisIdentifier: calibration.target_loss_attribution.basis_identifier,
349
- targetRepresentation: calibration.target_schema.target_representation,
350
- },
384
+ const sourceArtifacts = {
385
+ calibrationDiagnostics: diagnostics.source,
386
+ buildManifest: buildManifest?.source ?? null,
387
+ releaseManifest: releaseManifest?.source ?? null,
388
+ demographics: demographics?.source ?? null,
389
+ };
390
+ if (request.kind === "staging") {
391
+ return buildStagingBundle(request, sourceArtifacts, {
392
+ diagnostics: diagnostics.json,
393
+ buildManifest: buildManifest?.json ?? {},
394
+ releaseManifest: releaseManifest?.json ?? {},
395
+ demographics: demographics?.json ?? {},
396
+ });
397
+ }
398
+ return buildReleaseBundle(request, sourceArtifacts, {
399
+ diagnostics: diagnostics.json,
400
+ buildManifest: buildManifest?.json ?? {},
401
+ releaseManifest: releaseManifest?.json ?? {},
402
+ demographics: demographics?.json ?? {},
351
403
  });
352
- for (const file of bundle.files) enforceConfiguredSizeLimits(file);
353
- return bundle;
354
404
  } catch (error) {
355
405
  if (!isCalibrationTreeContentRefusal(error)) throw error;
356
406
  throw new CalibrationTreeUnbuildableInputError((error as Error).message);
357
407
  }
358
408
  }
359
409
 
410
+ interface WorkerDocuments {
411
+ diagnostics: JsonObject;
412
+ buildManifest: JsonObject;
413
+ releaseManifest: JsonObject;
414
+ demographics: JsonObject;
415
+ }
416
+
417
+ type WorkerSourceArtifacts = Parameters<typeof buildCalibrationTreeBundle>[0]["sourceArtifacts"];
418
+
419
+ // As publishStagingSource builds a new staged run's tree: a staging build of
420
+ // the run, labelled and released under its candidate release, comparing
421
+ // under the release its diagnostics name.
422
+ function buildStagingBundle(
423
+ request: CalibrationTreeWorkerStagingRequest,
424
+ sourceArtifacts: WorkerSourceArtifacts,
425
+ documents: WorkerDocuments,
426
+ ): CalibrationTreeBundle {
427
+ const { country, runId, label, hfRepo, hfCommitSha, createdAt } = request;
428
+ const calibration = buildCalibration(
429
+ documents.diagnostics,
430
+ label,
431
+ createdAt,
432
+ documents.buildManifest,
433
+ documents.releaseManifest,
434
+ documents.demographics,
435
+ country,
436
+ "huggingface_staged_bundle",
437
+ sourceArtifacts.calibrationDiagnostics?.sha256 ?? null,
438
+ hfCommitSha,
439
+ );
440
+ const bundle = buildCalibrationTreeBundle({
441
+ country,
442
+ buildKind: "staging",
443
+ sourceId: runId,
444
+ label,
445
+ createdAt,
446
+ releaseId: label,
447
+ hfRepo,
448
+ hfCommitSha,
449
+ sourceArtifacts,
450
+ rows: calibration.rows,
451
+ calibrationProvenance: calibration.calibration_provenance,
452
+ lossAttributionAvailable: calibration.target_loss_attribution.status !== "unavailable",
453
+ comparison: {
454
+ releaseId: calibration.release_id,
455
+ calibrationProvenance: calibration.calibration_provenance,
456
+ status: calibration.target_loss_attribution.status,
457
+ aggregate: calibration.target_loss_attribution.aggregate,
458
+ cap: calibration.target_loss_attribution.cap,
459
+ basisIdentifier: calibration.target_loss_attribution.basis_identifier,
460
+ targetRepresentation: calibration.target_schema.target_representation,
461
+ },
462
+ });
463
+ for (const file of bundle.files) enforceConfiguredSizeLimits(file);
464
+ return bundle;
465
+ }
466
+
467
+ function buildReleaseBundle(
468
+ request: CalibrationTreeWorkerReleaseRequest,
469
+ sourceArtifacts: WorkerSourceArtifacts,
470
+ documents: WorkerDocuments,
471
+ ): CalibrationTreeBundle {
472
+ const { country, releaseId: id, releaseDirectory, hfCommitSha } = request;
473
+ const calibration = buildCalibration(
474
+ documents.diagnostics,
475
+ id,
476
+ null,
477
+ documents.buildManifest,
478
+ documents.releaseManifest,
479
+ documents.demographics,
480
+ country,
481
+ "huggingface_immutable",
482
+ sourceArtifacts.calibrationDiagnostics?.sha256 ?? null,
483
+ hfCommitSha,
484
+ );
485
+ const bundle = buildCalibrationTreeBundle({
486
+ country,
487
+ createdAt: request.createdAt,
488
+ releaseId: id,
489
+ hfRepo: request.hfRepo,
490
+ hfCommitSha,
491
+ sourceArtifacts,
492
+ rows: calibration.rows,
493
+ calibrationProvenance: calibration.calibration_provenance,
494
+ lossAttributionAvailable: calibration.target_loss_attribution.status !== "unavailable",
495
+ comparison: {
496
+ // As in publishRelease: a tag or a build folder compares under its
497
+ // own id, a release folder under the release its diagnostics name.
498
+ releaseId: releaseDirectory === id && !isBuildFolderId(id) ? calibration.release_id : id,
499
+ calibrationProvenance: calibration.calibration_provenance,
500
+ status: calibration.target_loss_attribution.status,
501
+ aggregate: calibration.target_loss_attribution.aggregate,
502
+ cap: calibration.target_loss_attribution.cap,
503
+ basisIdentifier: calibration.target_loss_attribution.basis_identifier,
504
+ targetRepresentation: calibration.target_schema.target_representation,
505
+ },
506
+ });
507
+ for (const file of bundle.files) enforceConfiguredSizeLimits(file);
508
+ return bundle;
509
+ }
510
+
511
+ // The entry the publisher adds to the manifest for the build: releaseManifestEntry
512
+ // for a release, publishStagingSource's new entry for a staged run.
513
+ function manifestEntry(
514
+ request: CalibrationTreeWorkerRequest,
515
+ bundle: CalibrationTreeBundle,
516
+ index: Omit<CalibrationTreeWorkerPart, "part">,
517
+ ): CalibrationTreeManifestEntry {
518
+ const build = bundle.index.build;
519
+ const shared = {
520
+ buildArtifactId: build.buildArtifactId,
521
+ hfRepo: request.hfRepo,
522
+ hfCommitSha: request.hfCommitSha,
523
+ treeSchemaVersion: CALIBRATION_TREE_SCHEMA_VERSION,
524
+ indexSha256: index.sha256,
525
+ indexBytes: index.rawBytes,
526
+ createdAt: build.createdAt,
527
+ updatedAt: build.createdAt,
528
+ };
529
+ if (request.kind === "staging") {
530
+ return {
531
+ ...shared,
532
+ kind: "staging",
533
+ sourceId: request.runId,
534
+ label: request.label,
535
+ releaseId: null,
536
+ stagingRunId: request.runId,
537
+ };
538
+ }
539
+ return {
540
+ ...shared,
541
+ kind: "release",
542
+ sourceId: build.sourceId,
543
+ label: build.label,
544
+ releaseId: request.releaseId,
545
+ // Recorded only for a tag read from its release's folder.
546
+ ...(request.releaseDirectory !== request.releaseId ? { releaseDirectory: request.releaseDirectory } : {}),
547
+ stagingRunId: null,
548
+ };
549
+ }
550
+
360
551
  function emptyOutputDirectory(outputDirectory: string): void {
361
552
  try {
362
553
  mkdirSync(outputDirectory, { recursive: true });
@@ -394,13 +585,14 @@ export function runCalibrationTreeWorker(
394
585
  const { part: _index, ...index } = files.find((file) => file.part === "index")!;
395
586
  return {
396
587
  country: request.country,
397
- releaseId: request.releaseId,
588
+ releaseId: request.kind === "staging" ? request.label : request.releaseId,
398
589
  hfRepo: request.hfRepo,
399
590
  hfCommitSha: request.hfCommitSha,
400
591
  createdAt: request.createdAt,
401
592
  buildArtifactId: bundle.index.buildArtifactId,
402
593
  index,
403
594
  files,
595
+ manifestEntry: manifestEntry(request, bundle, index),
404
596
  };
405
597
  }
406
598