@policyengine/calibration-tree 0.0.0-stage → 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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/package.json +73 -4
  4. package/src/calibration-build-comparison.ts +77 -0
  5. package/src/calibration-comparison-blob.ts +367 -0
  6. package/src/calibration-comparison-bundle.ts +149 -0
  7. package/src/calibration-explorer.ts +202 -0
  8. package/src/calibration-release-locator.ts +353 -0
  9. package/src/calibration-tree-artifact.ts +1974 -0
  10. package/src/calibration-tree-blob.ts +403 -0
  11. package/src/calibration-tree-bundle.ts +569 -0
  12. package/src/calibration-tree-manifest.ts +349 -0
  13. package/src/calibration-tree-publication.ts +399 -0
  14. package/src/calibration-tree-worker.ts +637 -0
  15. package/src/calibration-tree.ts +1284 -0
  16. package/src/chronicle-metadata.ts +42 -0
  17. package/src/countries.ts +355 -0
  18. package/src/geography-labels.ts +59 -0
  19. package/src/hierarchy-catalog-reader.ts +297 -0
  20. package/src/hierarchy-target-reader.ts +243 -0
  21. package/src/historical-weight-summary.ts +53 -0
  22. package/src/latest-artifact.ts +3767 -0
  23. package/src/legacy-target-reader.ts +337 -0
  24. package/src/program-label.ts +64 -0
  25. package/src/publish-calibration-tree.ts +1984 -0
  26. package/src/reforms.ts +224 -0
  27. package/src/release-cut.ts +32 -0
  28. package/src/release-record.ts +277 -0
  29. package/src/source-label.ts +5 -0
  30. package/src/source-labels.ts +71 -0
  31. package/src/staging-artifact.ts +1411 -0
  32. package/src/staging-contract.ts +916 -0
  33. package/src/structured-dimension-reader.ts +137 -0
  34. package/src/structured-target-reader.ts +76 -0
  35. package/src/target-change.ts +421 -0
  36. package/src/target-loss-attribution-manifest.ts +436 -0
  37. package/src/target-loss-attribution.ts +1012 -0
  38. package/src/target-representation.ts +69 -0
  39. package/src/target-surface-matcher.ts +266 -0
  40. package/types/next-fetch-cache.d.ts +8 -0
@@ -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
+ }