@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,569 @@
1
+ import { createHash } from "node:crypto";
2
+ import { gzipSync } from "node:zlib";
3
+
4
+ import {
5
+ CALIBRATION_TREE_SCHEMA_VERSION,
6
+ CALIBRATION_TREE_TARGET_DETAIL_MAX_RAW_BYTES,
7
+ CALIBRATION_TREE_TARGET_SUMMARY_MAX_RAW_BYTES,
8
+ calibrationTreeFilterIndexFromDraft,
9
+ calibrationTreePartPath,
10
+ calibrationTreeTargetDetailsFromDraft,
11
+ calibrationTreeTargetSummariesFromDraft,
12
+ calibrationTreeTiersFromDraft,
13
+ compileCalibrationTreeBundleDraft,
14
+ parseCalibrationTreeIndex,
15
+ parseCalibrationTreePart,
16
+ serializeCalibrationTreePart,
17
+ type CalibrationTreeArtifactPart,
18
+ type CalibrationTreeFilterIndexArtifact,
19
+ type CalibrationTreeIndexArtifact,
20
+ type CalibrationTreePart,
21
+ type CalibrationTreePartDescriptor,
22
+ type CalibrationTreeTargetDetailsArtifact,
23
+ type CalibrationTreeTargetDetailsDescriptor,
24
+ type CalibrationTreeTargetSummaryArtifact,
25
+ type CalibrationTreeTargetSummaryDescriptor,
26
+ type CalibrationTreeTierArtifact,
27
+ type CalibrationTreeTierDescriptor,
28
+ type CompileCalibrationTreeInput,
29
+ type CompiledCalibrationLevel,
30
+ } from "./calibration-tree-artifact";
31
+ import {
32
+ fitBandForTarget,
33
+ geographyLabeller,
34
+ type CalibrationTreeTarget,
35
+ } from "./calibration-tree";
36
+
37
+ export interface CalibrationTreeBundleFile {
38
+ part: CalibrationTreePart;
39
+ path: string;
40
+ artifact: CalibrationTreeArtifactPart;
41
+ serialized: string;
42
+ compressed: Uint8Array;
43
+ sha256: string;
44
+ rawBytes: number;
45
+ gzipBytes: number;
46
+ }
47
+
48
+ export interface CalibrationTreeBundle {
49
+ index: CalibrationTreeIndexArtifact;
50
+ files: CalibrationTreeBundleFile[];
51
+ }
52
+
53
+ export function calibrationTreeBuildArtifactId(
54
+ input: CompileCalibrationTreeInput,
55
+ ): string {
56
+ const sourceHashes = Object.fromEntries(
57
+ Object.entries(input.sourceArtifacts)
58
+ .sort(([left], [right]) => left.localeCompare(right))
59
+ .map(([name, artifact]) => [
60
+ name,
61
+ artifact == null
62
+ ? null
63
+ : {
64
+ sha256: artifact.sha256,
65
+ ...(artifact.hfRepo ? { hfRepo: artifact.hfRepo } : {}),
66
+ ...(artifact.hfCommitSha
67
+ ? { hfCommitSha: artifact.hfCommitSha }
68
+ : {}),
69
+ },
70
+ ]),
71
+ );
72
+ return sha256(JSON.stringify({
73
+ schemaVersion: CALIBRATION_TREE_SCHEMA_VERSION,
74
+ country: input.country,
75
+ kind: input.buildKind ?? "release",
76
+ sourceId: input.sourceId ?? input.releaseId,
77
+ sourceHashes,
78
+ }));
79
+ }
80
+
81
+ function sha256(value: string): string {
82
+ return createHash("sha256").update(value, "utf8").digest("hex");
83
+ }
84
+
85
+ function describePart(artifact: CalibrationTreeArtifactPart): CalibrationTreeBundleFile {
86
+ const serialized = serializeCalibrationTreePart(artifact);
87
+ const compressed = gzipSync(serialized, { level: 9 });
88
+ return {
89
+ part: artifact.part,
90
+ path: calibrationTreePartPath(
91
+ artifact.country,
92
+ artifact.buildArtifactId,
93
+ artifact.part,
94
+ ),
95
+ artifact,
96
+ serialized,
97
+ compressed,
98
+ sha256: sha256(serialized),
99
+ rawBytes: Buffer.byteLength(serialized, "utf8"),
100
+ gzipBytes: compressed.byteLength,
101
+ };
102
+ }
103
+
104
+ function descriptor(file: CalibrationTreeBundleFile): CalibrationTreePartDescriptor {
105
+ if (file.part === "index") throw new Error("The bundle index cannot describe itself.");
106
+ return {
107
+ part: file.part,
108
+ path: file.path,
109
+ sha256: file.sha256,
110
+ rawBytes: file.rawBytes,
111
+ gzipBytes: file.gzipBytes,
112
+ };
113
+ }
114
+
115
+ export function buildCalibrationTreeBundle(
116
+ input: CompileCalibrationTreeInput,
117
+ ): CalibrationTreeBundle {
118
+ const draft = compileCalibrationTreeBundleDraft({
119
+ ...input,
120
+ buildArtifactId:
121
+ input.buildArtifactId ?? calibrationTreeBuildArtifactId(input),
122
+ });
123
+ const targetDetails = input.buildKind === "comparison"
124
+ ? { artifacts: [] }
125
+ : calibrationTreeTargetDetailsFromDraft(draft);
126
+ const targetDetailsFiles = targetDetails.artifacts.map(describePart);
127
+ const targetSummaries = calibrationTreeTargetSummariesFromDraft(draft);
128
+ const targetSummaryFiles = targetSummaries.artifacts.map(describePart);
129
+ const filterIndexFile = describePart(calibrationTreeFilterIndexFromDraft(draft));
130
+ const tierFiles = calibrationTreeTiersFromDraft(draft).map(describePart);
131
+ const rootLevels = Object.fromEntries(
132
+ Object.entries(draft.levels).filter(
133
+ ([levelId]) => draft.levelDepths[levelId] === 0,
134
+ ),
135
+ );
136
+ const tierDescriptors = tierFiles.map((file, index): CalibrationTreeTierDescriptor => ({
137
+ ...descriptor(file),
138
+ part: file.part as `tier-${number}`,
139
+ depth: index + 1,
140
+ levelIds: Object.keys(
141
+ (file.artifact as CalibrationTreeTierArtifact).levels,
142
+ ).sort(),
143
+ }));
144
+ const index: CalibrationTreeIndexArtifact = {
145
+ schemaVersion: CALIBRATION_TREE_SCHEMA_VERSION,
146
+ country: draft.country,
147
+ buildArtifactId: draft.build.buildArtifactId,
148
+ part: "index",
149
+ build: draft.build,
150
+ targetComparison: draft.comparison,
151
+ comparison: draft.comparisonResult,
152
+ calibrationProvenance: draft.calibrationProvenance,
153
+ lossAttributionAvailable: draft.lossAttributionAvailable,
154
+ filterOptions: draft.filterOptions,
155
+ targetCount: draft.targets.length,
156
+ maxDepth: tierFiles.length,
157
+ roots: draft.roots,
158
+ levels: rootLevels,
159
+ parts: {
160
+ tiers: tierDescriptors,
161
+ filterIndex: {
162
+ ...descriptor(filterIndexFile),
163
+ part: "filter-index",
164
+ },
165
+ targetSummaries: targetSummaryFiles.map((file, shardIndex) => ({
166
+ ...descriptor(file),
167
+ part: file.part as CalibrationTreeTargetSummaryDescriptor["part"],
168
+ startTargetOrdinal:
169
+ targetSummaries.artifacts[shardIndex].startTargetOrdinal,
170
+ endTargetOrdinalExclusive:
171
+ targetSummaries.artifacts[shardIndex].endTargetOrdinalExclusive,
172
+ })),
173
+ targetDetailStrategy:
174
+ input.buildKind === "comparison" ? "source-targets" : "shards",
175
+ targetDetails: targetDetailsFiles.map((file, shardIndex) => ({
176
+ ...descriptor(file),
177
+ part: file.part as CalibrationTreeTargetDetailsDescriptor["part"],
178
+ startTargetOrdinal: targetDetails.artifacts[shardIndex].startTargetOrdinal,
179
+ endTargetOrdinalExclusive:
180
+ targetDetails.artifacts[shardIndex].endTargetOrdinalExclusive,
181
+ })),
182
+ },
183
+ };
184
+ const indexFile = describePart(index);
185
+ const bundle = {
186
+ index,
187
+ files: [
188
+ filterIndexFile,
189
+ ...targetSummaryFiles,
190
+ ...targetDetailsFiles,
191
+ ...tierFiles,
192
+ indexFile,
193
+ ],
194
+ };
195
+ validateCalibrationTreeBundle(bundle);
196
+ return bundle;
197
+ }
198
+
199
+ function sameIndices(left: number[], right: number[]): boolean {
200
+ return left.length === right.length &&
201
+ left.every((value, index) => value === right[index]);
202
+ }
203
+
204
+ type FilterPostingKey = keyof CalibrationTreeFilterIndexArtifact["postings"];
205
+
206
+ function expectedFacet(
207
+ row: CalibrationTreeTarget,
208
+ key: Exclude<FilterPostingKey, "geographies">,
209
+ ): string | null {
210
+ if (key === "geographyLevels") return String(row.level ?? "").trim() || "national";
211
+ if (key === "fitBands") return fitBandForTarget(row);
212
+ if (key === "comparisonFits") return row.comparison_fit ?? null;
213
+ if (row.calibration_status === "included") return "included";
214
+ if (row.calibration_status === "skipped" || row.calibration_status === "not_materialized") {
215
+ return "skipped";
216
+ }
217
+ return null;
218
+ }
219
+
220
+ // The target ordinals of each facet value, with rows in target order.
221
+ function rowPostings(
222
+ rows: CalibrationTreeTarget[],
223
+ facetValue: (row: CalibrationTreeTarget) => string | null,
224
+ ): Map<string | null, number[]> {
225
+ const postings = new Map<string | null, number[]>();
226
+ rows.forEach((row, targetOrdinal) => {
227
+ const value = facetValue(row);
228
+ const targetOrdinals = postings.get(value) ?? [];
229
+ targetOrdinals.push(targetOrdinal);
230
+ postings.set(value, targetOrdinals);
231
+ });
232
+ return postings;
233
+ }
234
+
235
+ function samePostings(
236
+ actual: Map<string | null, number[]>,
237
+ expected: Map<string | null, number[]>,
238
+ ): boolean {
239
+ return actual.size === expected.size &&
240
+ [...expected].every(([value, targetOrdinals]) =>
241
+ sameIndices(actual.get(value) ?? [], targetOrdinals),
242
+ );
243
+ }
244
+
245
+ // The Geography filter offers and selects the labels of the tree's geography
246
+ // nodes, so each geography posting must hold exactly the targets of the
247
+ // geography root's node with that label. The compact target rows cannot give
248
+ // those postings: they carry each row's own spelling and no geography ID, and
249
+ // a node groups every spelling of one geography ID (geographyLabeller in
250
+ // calibration-tree.ts). Trees published before nodes were grouped by
251
+ // geography ID grouped both their nodes and their postings by each row's own
252
+ // spelling, so they meet this condition too.
253
+ //
254
+ // The node and the posting come from the same labeller, so they are also
255
+ // checked against the target rows (geographyPostingsFollowRows).
256
+ function geographyNodePostings(
257
+ bundle: CalibrationTreeBundle,
258
+ ): Map<string | null, number[]> {
259
+ const root = bundle.index.levels[bundle.index.roots.geography];
260
+ if (!root) throw new Error("Calibration tree geography root is missing.");
261
+ const postings = new Map<string | null, number[]>();
262
+ for (const treeNode of root.groups.flatMap((group) => group.nodes)) {
263
+ if (
264
+ treeNode.selection.kind !== "geography" ||
265
+ postings.has(treeNode.selection.value)
266
+ ) {
267
+ throw new Error("Calibration tree geography root must list each geography once.");
268
+ }
269
+ postings.set(treeNode.selection.value, treeNode.targetOrdinals);
270
+ }
271
+ return postings;
272
+ }
273
+
274
+ // Each geography posting must hold the targets of one label worked out from
275
+ // the target rows. A tree built with nodes grouped by geography ID has one
276
+ // posting per label that geographyLabeller gives its target detail rows,
277
+ // which carry the geography IDs; a comparison tree has no detail rows, and
278
+ // its compact rows carry no geography ID, so each row keeps its own
279
+ // spelling. A tree published before nodes were grouped by geography ID has
280
+ // one posting per row's own spelling. Where every geography ID is spelled
281
+ // one way, both rules give the same postings.
282
+ function geographyPostingsFollowRows(
283
+ actual: Map<string | null, number[]>,
284
+ summaryRows: CalibrationTreeTarget[],
285
+ detailRows: CalibrationTreeTarget[],
286
+ ): boolean {
287
+ const labelledRows = detailRows.length ? detailRows : summaryRows;
288
+ return (
289
+ samePostings(actual, rowPostings(labelledRows, geographyLabeller(labelledRows))) ||
290
+ samePostings(
291
+ actual,
292
+ rowPostings(
293
+ summaryRows,
294
+ (row) => String(row.geography ?? "").trim() || "United States",
295
+ ),
296
+ )
297
+ );
298
+ }
299
+
300
+ function validatePostingCoverage(
301
+ bundle: CalibrationTreeBundle,
302
+ filterIndexFile: CalibrationTreeBundleFile,
303
+ targetSummaryFiles: CalibrationTreeBundleFile[],
304
+ targetDetailsFiles: CalibrationTreeBundleFile[],
305
+ ): void {
306
+ const filterIndex = filterIndexFile.artifact;
307
+ const targetSummaries = targetSummaryFiles.map((file) => file.artifact);
308
+ const targetDetails = targetDetailsFiles.map((file) => file.artifact);
309
+ if (
310
+ filterIndex.part !== "filter-index" ||
311
+ targetSummaries.some((artifact) => !artifact.part.startsWith("target-summary-")) ||
312
+ targetDetails.some((artifact) => !artifact.part.startsWith("target-details-"))
313
+ ) {
314
+ throw new Error("Calibration tree target files have incorrect part identities.");
315
+ }
316
+ const detailRows = targetDetails.flatMap(
317
+ (artifact) => (artifact as CalibrationTreeTargetDetailsArtifact).targets,
318
+ );
319
+ const summaryTargets = targetSummaries.flatMap(
320
+ (artifact) => (artifact as CalibrationTreeTargetSummaryArtifact).targets,
321
+ );
322
+ const summaryRows = summaryTargets.map((target) => target.comparison.row);
323
+ if (
324
+ filterIndex.targetCount !== bundle.index.targetCount ||
325
+ summaryTargets.length !== bundle.index.targetCount ||
326
+ (bundle.index.parts.targetDetailStrategy === "shards" &&
327
+ detailRows.length !== bundle.index.targetCount) ||
328
+ (bundle.index.parts.targetDetailStrategy === "source-targets" &&
329
+ detailRows.length !== 0)
330
+ ) {
331
+ throw new Error("Calibration tree target files do not match the declared target count.");
332
+ }
333
+ const ids = new Set(summaryTargets.map((target) => target.id));
334
+ if (ids.size !== summaryTargets.length) {
335
+ throw new Error("Calibration tree target summaries repeat a target id.");
336
+ }
337
+ if (
338
+ bundle.index.parts.targetDetailStrategy === "source-targets" &&
339
+ summaryTargets.some((target) => target.detailSource == null)
340
+ ) {
341
+ throw new Error("Comparison target summaries must identify their source targets.");
342
+ }
343
+ for (const key of [
344
+ "geographyLevels",
345
+ "geographies",
346
+ "fitBands",
347
+ "comparisonFits",
348
+ "calibrationStatuses",
349
+ ] as const) {
350
+ const actual = new Map<string | null, number[]>(
351
+ (filterIndex as CalibrationTreeFilterIndexArtifact).postings[key].map(
352
+ (posting) => [posting.value, posting.targetOrdinals],
353
+ ),
354
+ );
355
+ const expected = key === "geographies"
356
+ ? geographyNodePostings(bundle)
357
+ : rowPostings(summaryRows, (row) => expectedFacet(row, key));
358
+ if (actual.size !== expected.size) {
359
+ throw new Error(`Calibration tree ${key} postings do not cover every facet value.`);
360
+ }
361
+ for (const [value, targetOrdinals] of expected) {
362
+ if (!sameIndices(actual.get(value) ?? [], targetOrdinals)) {
363
+ throw new Error(`Calibration tree ${key} posting ${String(value)} is incorrect.`);
364
+ }
365
+ }
366
+ if (
367
+ key === "geographies" &&
368
+ !geographyPostingsFollowRows(actual, summaryRows, detailRows)
369
+ ) {
370
+ throw new Error("Calibration tree geographies postings do not follow the target rows.");
371
+ }
372
+ }
373
+ }
374
+
375
+ function allLevels(bundle: CalibrationTreeBundle): Record<string, CompiledCalibrationLevel> {
376
+ return Object.assign(
377
+ {},
378
+ bundle.index.levels,
379
+ ...bundle.files.flatMap((file) =>
380
+ file.artifact.part.startsWith("tier-")
381
+ ? [(file.artifact as CalibrationTreeTierArtifact).levels]
382
+ : [],
383
+ ),
384
+ );
385
+ }
386
+
387
+ function validateGraph(bundle: CalibrationTreeBundle): void {
388
+ const levels = allLevels(bundle);
389
+ const expectedTargetOrdinals = Array.from(
390
+ { length: bundle.index.targetCount },
391
+ (_, targetOrdinal) => targetOrdinal,
392
+ );
393
+ for (const rootName of ["program", "geography"] as const) {
394
+ const root = levels[bundle.index.roots[rootName]];
395
+ if (!root || !sameIndices(root.targetOrdinals, expectedTargetOrdinals)) {
396
+ throw new Error(`Calibration tree ${rootName} root does not cover every target.`);
397
+ }
398
+ }
399
+
400
+ const depths = new Map<string, number>();
401
+ const queue = [
402
+ { levelId: bundle.index.roots.program, depth: 0 },
403
+ { levelId: bundle.index.roots.geography, depth: 0 },
404
+ ];
405
+ while (queue.length) {
406
+ const { levelId, depth } = queue.shift()!;
407
+ const previousDepth = depths.get(levelId);
408
+ if (previousDepth != null) {
409
+ if (previousDepth !== depth) {
410
+ throw new Error(`Calibration tree level ${levelId} appears at multiple depths.`);
411
+ }
412
+ continue;
413
+ }
414
+ const level = levels[levelId];
415
+ if (!level) throw new Error(`Calibration tree level ${levelId} is missing.`);
416
+ depths.set(levelId, depth);
417
+ for (const node of level.groups.flatMap((group) => group.nodes)) {
418
+ if (node.kind === "target") continue;
419
+ const child = levels[node.nextLevelId];
420
+ if (!child) throw new Error(`Calibration tree level ${node.nextLevelId} is missing.`);
421
+ if (!sameIndices(node.targetOrdinals, child.targetOrdinals)) {
422
+ throw new Error(`Calibration tree branch ${node.id} has inconsistent membership.`);
423
+ }
424
+ queue.push({ levelId: node.nextLevelId, depth: depth + 1 });
425
+ }
426
+ }
427
+ if (depths.size !== Object.keys(levels).length) {
428
+ throw new Error("Calibration tree contains an unreachable level.");
429
+ }
430
+
431
+ const expectedDepths = new Map<string, number>();
432
+ Object.keys(bundle.index.levels).forEach((levelId) => expectedDepths.set(levelId, 0));
433
+ for (const descriptor of bundle.index.parts.tiers) {
434
+ descriptor.levelIds.forEach((levelId) => expectedDepths.set(levelId, descriptor.depth));
435
+ }
436
+ if (expectedDepths.size !== Object.keys(levels).length) {
437
+ throw new Error("Calibration tree tier descriptors omit or duplicate levels.");
438
+ }
439
+ for (const [levelId, depth] of depths) {
440
+ if (expectedDepths.get(levelId) !== depth) {
441
+ throw new Error(`Calibration tree level ${levelId} is stored in the wrong tier.`);
442
+ }
443
+ }
444
+ }
445
+
446
+ export function validateCalibrationTreeBundle(bundle: CalibrationTreeBundle): void {
447
+ parseCalibrationTreeIndex(bundle.index);
448
+ const expectedParts: CalibrationTreePart[] = [
449
+ "filter-index",
450
+ ...bundle.index.parts.targetSummaries.map((descriptor) => descriptor.part),
451
+ ...bundle.index.parts.targetDetails.map((descriptor) => descriptor.part),
452
+ ...bundle.index.parts.tiers.map((tier) => tier.part),
453
+ "index",
454
+ ];
455
+ if (
456
+ bundle.files.length !== expectedParts.length ||
457
+ bundle.files.some((file, index) => file.part !== expectedParts[index])
458
+ ) {
459
+ throw new Error("Calibration tree bundle has missing, additional, or misordered files.");
460
+ }
461
+ const seenPaths = new Set<string>();
462
+ for (const file of bundle.files) {
463
+ if (seenPaths.has(file.path)) {
464
+ throw new Error(`Calibration tree bundle repeats ${file.path}.`);
465
+ }
466
+ seenPaths.add(file.path);
467
+ const expectedPath = calibrationTreePartPath(
468
+ bundle.index.country,
469
+ bundle.index.buildArtifactId,
470
+ file.part,
471
+ );
472
+ if (file.path !== expectedPath) {
473
+ throw new Error(`Calibration tree part ${file.part} has the wrong path.`);
474
+ }
475
+ if (serializeCalibrationTreePart(file.artifact) !== file.serialized) {
476
+ throw new Error(`Calibration tree part ${file.part} artifact and bytes differ.`);
477
+ }
478
+ const parsed = parseCalibrationTreePart(JSON.parse(file.serialized), file.part);
479
+ if (
480
+ parsed.country !== bundle.index.country ||
481
+ parsed.buildArtifactId !== bundle.index.buildArtifactId
482
+ ) {
483
+ throw new Error(`Calibration tree part ${file.part} has inconsistent identity.`);
484
+ }
485
+ if (
486
+ sha256(file.serialized) !== file.sha256 ||
487
+ Buffer.byteLength(file.serialized, "utf8") !== file.rawBytes ||
488
+ gzipSync(file.serialized, { level: 9 }).byteLength !== file.gzipBytes ||
489
+ !Buffer.from(file.compressed).equals(gzipSync(file.serialized, { level: 9 }))
490
+ ) {
491
+ throw new Error(`Calibration tree part ${file.part} has incorrect content metadata.`);
492
+ }
493
+ if (
494
+ file.part.startsWith("target-summary-") &&
495
+ file.rawBytes > CALIBRATION_TREE_TARGET_SUMMARY_MAX_RAW_BYTES
496
+ ) {
497
+ throw new Error(`Calibration tree part ${file.part} exceeds the target-summary limit.`);
498
+ }
499
+ if (
500
+ file.part.startsWith("target-details-") &&
501
+ file.rawBytes > CALIBRATION_TREE_TARGET_DETAIL_MAX_RAW_BYTES
502
+ ) {
503
+ throw new Error(`Calibration tree part ${file.part} exceeds the target-detail limit.`);
504
+ }
505
+ }
506
+ const fileByPart = new Map(bundle.files.map((file) => [file.part, file]));
507
+ if (serializeCalibrationTreePart(bundle.index) !== fileByPart.get("index")?.serialized) {
508
+ throw new Error("Calibration tree bundle index does not match index.json.gz.");
509
+ }
510
+ for (const partDescriptor of [
511
+ ...bundle.index.parts.tiers,
512
+ bundle.index.parts.filterIndex,
513
+ ...bundle.index.parts.targetSummaries,
514
+ ...bundle.index.parts.targetDetails,
515
+ ]) {
516
+ const file = fileByPart.get(partDescriptor.part);
517
+ if (
518
+ !file ||
519
+ file.path !== partDescriptor.path ||
520
+ file.sha256 !== partDescriptor.sha256 ||
521
+ file.rawBytes !== partDescriptor.rawBytes ||
522
+ file.gzipBytes !== partDescriptor.gzipBytes
523
+ ) {
524
+ throw new Error(`Calibration tree descriptor ${partDescriptor.part} is incorrect.`);
525
+ }
526
+ if (
527
+ partDescriptor.part.startsWith("target-summary-") ||
528
+ partDescriptor.part.startsWith("target-details-")
529
+ ) {
530
+ const artifact = file.artifact as
531
+ | CalibrationTreeTargetSummaryArtifact
532
+ | CalibrationTreeTargetDetailsArtifact;
533
+ const rangeDescriptor = partDescriptor as
534
+ | CalibrationTreeTargetSummaryDescriptor
535
+ | CalibrationTreeTargetDetailsDescriptor;
536
+ if (
537
+ artifact.startTargetOrdinal !== rangeDescriptor.startTargetOrdinal ||
538
+ artifact.endTargetOrdinalExclusive !== rangeDescriptor.endTargetOrdinalExclusive
539
+ ) {
540
+ throw new Error(
541
+ `Calibration tree descriptor ${partDescriptor.part} has an inconsistent target range.`,
542
+ );
543
+ }
544
+ }
545
+ }
546
+ const filterIndexFile = fileByPart.get("filter-index")!;
547
+ const targetSummaryFiles = bundle.index.parts.targetSummaries.map(
548
+ (descriptor) => fileByPart.get(descriptor.part)!,
549
+ );
550
+ const targetDetailsFiles = bundle.index.parts.targetDetails.map(
551
+ (descriptor) => fileByPart.get(descriptor.part)!,
552
+ );
553
+ validatePostingCoverage(
554
+ bundle,
555
+ filterIndexFile,
556
+ targetSummaryFiles,
557
+ targetDetailsFiles,
558
+ );
559
+ validateGraph(bundle);
560
+ }
561
+
562
+ export function calibrationTreeBundleFile(
563
+ bundle: CalibrationTreeBundle,
564
+ part: CalibrationTreePart,
565
+ ): CalibrationTreeBundleFile {
566
+ const file = bundle.files.find((candidate) => candidate.part === part);
567
+ if (!file) throw new Error(`Calibration tree bundle is missing ${part}.`);
568
+ return file;
569
+ }