@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.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/package.json +73 -4
- package/src/calibration-build-comparison.ts +77 -0
- package/src/calibration-comparison-blob.ts +367 -0
- package/src/calibration-comparison-bundle.ts +149 -0
- package/src/calibration-explorer.ts +202 -0
- package/src/calibration-release-locator.ts +353 -0
- package/src/calibration-tree-artifact.ts +1974 -0
- package/src/calibration-tree-blob.ts +403 -0
- package/src/calibration-tree-bundle.ts +569 -0
- package/src/calibration-tree-manifest.ts +349 -0
- package/src/calibration-tree-publication.ts +399 -0
- package/src/calibration-tree-worker.ts +637 -0
- package/src/calibration-tree.ts +1284 -0
- package/src/chronicle-metadata.ts +42 -0
- package/src/countries.ts +355 -0
- package/src/geography-labels.ts +59 -0
- package/src/hierarchy-catalog-reader.ts +297 -0
- package/src/hierarchy-target-reader.ts +243 -0
- package/src/historical-weight-summary.ts +53 -0
- package/src/latest-artifact.ts +3767 -0
- package/src/legacy-target-reader.ts +337 -0
- package/src/program-label.ts +64 -0
- package/src/publish-calibration-tree.ts +1984 -0
- package/src/reforms.ts +224 -0
- package/src/release-cut.ts +32 -0
- package/src/release-record.ts +277 -0
- package/src/source-label.ts +5 -0
- package/src/source-labels.ts +71 -0
- package/src/staging-artifact.ts +1411 -0
- package/src/staging-contract.ts +916 -0
- package/src/structured-dimension-reader.ts +137 -0
- package/src/structured-target-reader.ts +76 -0
- package/src/target-change.ts +421 -0
- package/src/target-loss-attribution-manifest.ts +436 -0
- package/src/target-loss-attribution.ts +1012 -0
- package/src/target-representation.ts +69 -0
- package/src/target-surface-matcher.ts +266 -0
- package/types/next-fetch-cache.d.ts +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PolicyEngine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @policyengine/calibration-tree
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Builds, validates, reads and publishes Microcosm calibration tree artifacts
|
|
4
|
+
(tree schema 6), with the release and staging readers they are built from.
|
|
5
|
+
The PolicyEngine calibration dashboard reads trees with it, and the Microcosm
|
|
6
|
+
collector builds them with it.
|
|
7
|
+
|
|
8
|
+
## Using it
|
|
9
|
+
|
|
10
|
+
The package ships TypeScript source, not compiled JavaScript. Each module is
|
|
11
|
+
its own entry point, for example:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { parseCalibrationTreeIndex } from "@policyengine/calibration-tree/calibration-tree-artifact";
|
|
15
|
+
import { COUNTRY_REGISTRY } from "@policyengine/calibration-tree/countries";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Bun** runs the modules directly.
|
|
19
|
+
- **Next.js** compiles them when the package is listed in `transpilePackages`
|
|
20
|
+
in `next.config.ts`.
|
|
21
|
+
- **TypeScript** checks them with `moduleResolution` set to `bundler`. Some
|
|
22
|
+
modules use Bun's globals (`Bun.argv`, `import.meta.main`), so the checking
|
|
23
|
+
project needs `@types/bun`; `types/next-fetch-cache.d.ts` declares the
|
|
24
|
+
`next.revalidate` fetch option the readers pass, for projects without
|
|
25
|
+
Next.js.
|
|
26
|
+
|
|
27
|
+
Plain Node.js cannot load the modules, because they are TypeScript.
|
|
28
|
+
|
|
29
|
+
## The tree worker
|
|
30
|
+
|
|
31
|
+
`src/calibration-tree-worker.ts` builds one release's tree from local copies
|
|
32
|
+
of the files the publisher reads from Hugging Face, and writes each part's
|
|
33
|
+
gzip bytes under an output directory:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun run src/calibration-tree-worker.ts REQUEST.json OUTPUT_DIRECTORY
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The module's opening comment describes the request. The worker writes the
|
|
40
|
+
same bytes as the publisher (`src/publish-calibration-tree.ts`) for the same
|
|
41
|
+
files, commit and publication time; the golden tests hold both to the trees
|
|
42
|
+
recorded in `src/fixtures/calibration-tree-golden.json`. It exits 0 after
|
|
43
|
+
printing a JSON description of the written parts, 1 when the request cannot be
|
|
44
|
+
followed, 2 when the builder refuses the files, so another run on the same
|
|
45
|
+
files cannot help, and 3 for anything else: a size limit that is not a
|
|
46
|
+
positive integer, or an unexpected error such as a defect in the builder.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bun install --frozen-lockfile
|
|
52
|
+
bun test src/calibration-tree-worker.test.ts
|
|
53
|
+
bun run typecheck
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The source lives in
|
|
57
|
+
[PolicyEngine/microcosm-collector](https://github.com/PolicyEngine/microcosm-collector)
|
|
58
|
+
under `packages/calibration-tree`; its README describes how the package is
|
|
59
|
+
released.
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@policyengine/calibration-tree",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/PolicyEngine/microcosm-collector.git",
|
|
9
|
+
"directory": "packages/calibration-tree"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"files": [
|
|
16
|
+
"src/**/*.ts",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/test-support/**",
|
|
19
|
+
"!src/fixtures/**",
|
|
20
|
+
"types/**/*.d.ts"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./calibration-build-comparison": "./src/calibration-build-comparison.ts",
|
|
24
|
+
"./calibration-comparison-blob": "./src/calibration-comparison-blob.ts",
|
|
25
|
+
"./calibration-comparison-bundle": "./src/calibration-comparison-bundle.ts",
|
|
26
|
+
"./calibration-explorer": "./src/calibration-explorer.ts",
|
|
27
|
+
"./calibration-release-locator": "./src/calibration-release-locator.ts",
|
|
28
|
+
"./calibration-tree": "./src/calibration-tree.ts",
|
|
29
|
+
"./calibration-tree-artifact": "./src/calibration-tree-artifact.ts",
|
|
30
|
+
"./calibration-tree-blob": "./src/calibration-tree-blob.ts",
|
|
31
|
+
"./calibration-tree-bundle": "./src/calibration-tree-bundle.ts",
|
|
32
|
+
"./calibration-tree-manifest": "./src/calibration-tree-manifest.ts",
|
|
33
|
+
"./calibration-tree-publication": "./src/calibration-tree-publication.ts",
|
|
34
|
+
"./calibration-tree-worker": "./src/calibration-tree-worker.ts",
|
|
35
|
+
"./chronicle-metadata": "./src/chronicle-metadata.ts",
|
|
36
|
+
"./countries": "./src/countries.ts",
|
|
37
|
+
"./geography-labels": "./src/geography-labels.ts",
|
|
38
|
+
"./hierarchy-catalog-reader": "./src/hierarchy-catalog-reader.ts",
|
|
39
|
+
"./hierarchy-target-reader": "./src/hierarchy-target-reader.ts",
|
|
40
|
+
"./historical-weight-summary": "./src/historical-weight-summary.ts",
|
|
41
|
+
"./latest-artifact": "./src/latest-artifact.ts",
|
|
42
|
+
"./legacy-target-reader": "./src/legacy-target-reader.ts",
|
|
43
|
+
"./program-label": "./src/program-label.ts",
|
|
44
|
+
"./publish-calibration-tree": "./src/publish-calibration-tree.ts",
|
|
45
|
+
"./reforms": "./src/reforms.ts",
|
|
46
|
+
"./release-cut": "./src/release-cut.ts",
|
|
47
|
+
"./release-record": "./src/release-record.ts",
|
|
48
|
+
"./source-label": "./src/source-label.ts",
|
|
49
|
+
"./source-labels": "./src/source-labels.ts",
|
|
50
|
+
"./staging-artifact": "./src/staging-artifact.ts",
|
|
51
|
+
"./staging-contract": "./src/staging-contract.ts",
|
|
52
|
+
"./structured-dimension-reader": "./src/structured-dimension-reader.ts",
|
|
53
|
+
"./structured-target-reader": "./src/structured-target-reader.ts",
|
|
54
|
+
"./target-change": "./src/target-change.ts",
|
|
55
|
+
"./target-loss-attribution": "./src/target-loss-attribution.ts",
|
|
56
|
+
"./target-loss-attribution-manifest": "./src/target-loss-attribution-manifest.ts",
|
|
57
|
+
"./target-representation": "./src/target-representation.ts",
|
|
58
|
+
"./target-surface-matcher": "./src/target-surface-matcher.ts"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"publish:calibration-tree": "bun run src/publish-calibration-tree.ts",
|
|
62
|
+
"test": "bun test",
|
|
63
|
+
"typecheck": "tsc --noEmit",
|
|
64
|
+
"worker": "bun run src/calibration-tree-worker.ts",
|
|
65
|
+
"publication": "bun run src/calibration-tree-publication.ts"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@vercel/blob": "^2.6.1"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/bun": "^1.3.14",
|
|
72
|
+
"fast-check": "^4.10.2",
|
|
73
|
+
"typescript": "^5.8.0"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Calibration } from "./latest-artifact";
|
|
2
|
+
import type {
|
|
3
|
+
CalibrationTreeComparisonMetadata,
|
|
4
|
+
CalibrationTreeTargetSummary,
|
|
5
|
+
} from "./calibration-tree-artifact";
|
|
6
|
+
import type { MicrocosmCountry } from "./countries";
|
|
7
|
+
import { buildTargetChangeDataset, type TargetChangeDataset } from "./target-change";
|
|
8
|
+
|
|
9
|
+
export interface CalibrationTreeComparisonInput {
|
|
10
|
+
country: MicrocosmCountry;
|
|
11
|
+
comparison: CalibrationTreeComparisonMetadata;
|
|
12
|
+
targets: CalibrationTreeTargetSummary[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function calibrationFromTargetSummaries(
|
|
16
|
+
input: CalibrationTreeComparisonInput,
|
|
17
|
+
): Calibration {
|
|
18
|
+
const comparison = input.comparison;
|
|
19
|
+
return {
|
|
20
|
+
release_id: comparison.releaseId,
|
|
21
|
+
calibration_provenance: comparison.calibrationProvenance ?? {
|
|
22
|
+
mode: "direct",
|
|
23
|
+
dataset_release_id: comparison.releaseId,
|
|
24
|
+
calibration_source_id: comparison.releaseId,
|
|
25
|
+
diagnostics_sha256: null,
|
|
26
|
+
target_surface_sha256: null,
|
|
27
|
+
validation: { status: "not_applicable", reason: null },
|
|
28
|
+
},
|
|
29
|
+
target_schema: {
|
|
30
|
+
diagnostics_schema_version: null,
|
|
31
|
+
structured_dimensions:
|
|
32
|
+
comparison.targetRepresentation === "structured" ||
|
|
33
|
+
comparison.targetRepresentation === "hierarchy" ||
|
|
34
|
+
comparison.targetRepresentation === "mixed",
|
|
35
|
+
target_representation: comparison.targetRepresentation,
|
|
36
|
+
},
|
|
37
|
+
target_loss_attribution: {
|
|
38
|
+
status: comparison.status,
|
|
39
|
+
aggregate: comparison.aggregate,
|
|
40
|
+
historical_final_loss: null,
|
|
41
|
+
cap: comparison.cap,
|
|
42
|
+
basis_identifier: comparison.basisIdentifier,
|
|
43
|
+
basis_hash: null,
|
|
44
|
+
verification: null,
|
|
45
|
+
producer_warnings: [],
|
|
46
|
+
reason:
|
|
47
|
+
comparison.status === "unavailable"
|
|
48
|
+
? "The immutable build does not include target-loss attribution."
|
|
49
|
+
: null,
|
|
50
|
+
targets: [],
|
|
51
|
+
},
|
|
52
|
+
rows: input.targets.map((target) => target.comparison.row),
|
|
53
|
+
} as unknown as Calibration;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildTargetChangeDatasetFromSummaries(
|
|
57
|
+
current: CalibrationTreeComparisonInput,
|
|
58
|
+
candidate: CalibrationTreeComparisonInput,
|
|
59
|
+
): TargetChangeDataset {
|
|
60
|
+
if (current.country !== candidate.country) {
|
|
61
|
+
throw new Error("Calibration builds from different countries cannot be compared.");
|
|
62
|
+
}
|
|
63
|
+
return buildTargetChangeDataset(
|
|
64
|
+
calibrationFromTargetSummaries(current),
|
|
65
|
+
calibrationFromTargetSummaries(candidate),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildTargetChangeDatasetFromSummaryAndCalibration(
|
|
70
|
+
current: CalibrationTreeComparisonInput,
|
|
71
|
+
candidate: Calibration,
|
|
72
|
+
): TargetChangeDataset {
|
|
73
|
+
return buildTargetChangeDataset(
|
|
74
|
+
calibrationFromTargetSummaries(current),
|
|
75
|
+
candidate,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildCalibrationComparisonBundles,
|
|
5
|
+
calibrationComparisonBuildArtifactId,
|
|
6
|
+
calibrationComparisonPairArtifactId,
|
|
7
|
+
type ComparisonBundleSource,
|
|
8
|
+
} from "./calibration-comparison-bundle";
|
|
9
|
+
import {
|
|
10
|
+
CALIBRATION_TREE_SCHEMA_VERSION,
|
|
11
|
+
calibrationTreeTargetDetailSelection,
|
|
12
|
+
calibrationTreeTargetsFromSummaries,
|
|
13
|
+
parseCalibrationTreeIndex,
|
|
14
|
+
parseCalibrationTreeTargetDetails,
|
|
15
|
+
parseCalibrationTreeTargetSummary,
|
|
16
|
+
type CalibrationTreeComparisonTargetDetailResponse,
|
|
17
|
+
type CalibrationTreeIndexArtifact,
|
|
18
|
+
type CalibrationTreeTargetDetailsArtifact,
|
|
19
|
+
} from "./calibration-tree-artifact";
|
|
20
|
+
import {
|
|
21
|
+
calibrationTreeBlobText,
|
|
22
|
+
getCalibrationTreeBlob,
|
|
23
|
+
uploadCalibrationTreeBundle,
|
|
24
|
+
} from "./calibration-tree-blob";
|
|
25
|
+
import type { MicrocosmCountry } from "./countries";
|
|
26
|
+
import type { TargetChangeRow } from "./target-change";
|
|
27
|
+
|
|
28
|
+
interface ComparisonBuildIds {
|
|
29
|
+
pairArtifactId: string;
|
|
30
|
+
reportedBuildArtifactId: string;
|
|
31
|
+
sharedBuildArtifactId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const activeBuilds = new Map<string, Promise<ComparisonBuildIds>>();
|
|
35
|
+
const ACTIVE_BUILD_LIMIT = 8;
|
|
36
|
+
|
|
37
|
+
function ids(
|
|
38
|
+
country: MicrocosmCountry,
|
|
39
|
+
currentBuildArtifactId: string,
|
|
40
|
+
candidateBuildArtifactId: string,
|
|
41
|
+
): ComparisonBuildIds {
|
|
42
|
+
const pairArtifactId = calibrationComparisonPairArtifactId(
|
|
43
|
+
country,
|
|
44
|
+
currentBuildArtifactId,
|
|
45
|
+
candidateBuildArtifactId,
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
pairArtifactId,
|
|
49
|
+
reportedBuildArtifactId: calibrationComparisonBuildArtifactId(
|
|
50
|
+
pairArtifactId,
|
|
51
|
+
"reported",
|
|
52
|
+
),
|
|
53
|
+
sharedBuildArtifactId: calibrationComparisonBuildArtifactId(
|
|
54
|
+
pairArtifactId,
|
|
55
|
+
"shared",
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function textFromBlob(
|
|
61
|
+
result: Awaited<ReturnType<typeof getCalibrationTreeBlob>>,
|
|
62
|
+
label: string,
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
if (!result || result.statusCode !== 200 || !result.stream) {
|
|
65
|
+
throw new Error(`${label} is unavailable.`);
|
|
66
|
+
}
|
|
67
|
+
return calibrationTreeBlobText(result.stream);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sha256(value: string): string {
|
|
71
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadCalibrationComparisonSource(
|
|
75
|
+
country: MicrocosmCountry,
|
|
76
|
+
buildArtifactId: string,
|
|
77
|
+
getBlob: typeof getCalibrationTreeBlob = getCalibrationTreeBlob,
|
|
78
|
+
): Promise<ComparisonBundleSource> {
|
|
79
|
+
const indexText = await textFromBlob(
|
|
80
|
+
await getBlob({
|
|
81
|
+
country,
|
|
82
|
+
buildArtifactId,
|
|
83
|
+
part: "index",
|
|
84
|
+
consistent: true,
|
|
85
|
+
}),
|
|
86
|
+
`Calibration build ${buildArtifactId} index`,
|
|
87
|
+
);
|
|
88
|
+
const index = parseCalibrationTreeIndex(JSON.parse(indexText));
|
|
89
|
+
if (index.country !== country || index.buildArtifactId !== buildArtifactId) {
|
|
90
|
+
throw new Error(`Calibration build ${buildArtifactId} index identity is inconsistent.`);
|
|
91
|
+
}
|
|
92
|
+
if (index.build.kind === "comparison") {
|
|
93
|
+
throw new Error("A derived comparison cannot be used as a source build.");
|
|
94
|
+
}
|
|
95
|
+
const targetSummaries = await Promise.all(
|
|
96
|
+
index.parts.targetSummaries.map(async (descriptor) => {
|
|
97
|
+
const text = await textFromBlob(
|
|
98
|
+
await getBlob({
|
|
99
|
+
country,
|
|
100
|
+
buildArtifactId,
|
|
101
|
+
part: descriptor.part,
|
|
102
|
+
consistent: true,
|
|
103
|
+
}),
|
|
104
|
+
`Calibration build ${buildArtifactId} ${descriptor.part}`,
|
|
105
|
+
);
|
|
106
|
+
if (sha256(text) !== descriptor.sha256) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Calibration build ${buildArtifactId} ${descriptor.part} digest is invalid.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return parseCalibrationTreeTargetSummary(JSON.parse(text), descriptor.part);
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
index,
|
|
116
|
+
indexSha256: sha256(indexText),
|
|
117
|
+
targetSummaries: calibrationTreeTargetsFromSummaries(index, targetSummaries),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadIndex(options: {
|
|
122
|
+
country: MicrocosmCountry;
|
|
123
|
+
buildArtifactId: string;
|
|
124
|
+
expectedSha256?: string;
|
|
125
|
+
getBlob: typeof getCalibrationTreeBlob;
|
|
126
|
+
}): Promise<CalibrationTreeIndexArtifact> {
|
|
127
|
+
const text = await textFromBlob(
|
|
128
|
+
await options.getBlob({
|
|
129
|
+
country: options.country,
|
|
130
|
+
buildArtifactId: options.buildArtifactId,
|
|
131
|
+
part: "index",
|
|
132
|
+
consistent: true,
|
|
133
|
+
}),
|
|
134
|
+
`Calibration build ${options.buildArtifactId} index`,
|
|
135
|
+
);
|
|
136
|
+
if (options.expectedSha256 && sha256(text) !== options.expectedSha256) {
|
|
137
|
+
throw new Error(`Calibration build ${options.buildArtifactId} index digest is invalid.`);
|
|
138
|
+
}
|
|
139
|
+
const index = parseCalibrationTreeIndex(JSON.parse(text));
|
|
140
|
+
if (
|
|
141
|
+
index.country !== options.country ||
|
|
142
|
+
index.buildArtifactId !== options.buildArtifactId
|
|
143
|
+
) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Calibration build ${options.buildArtifactId} index identity is inconsistent.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function loadCalibrationComparisonTargetDetail(options: {
|
|
152
|
+
country: MicrocosmCountry;
|
|
153
|
+
buildArtifactId: string;
|
|
154
|
+
targetOrdinal: number;
|
|
155
|
+
getBlob?: typeof getCalibrationTreeBlob;
|
|
156
|
+
}): Promise<CalibrationTreeComparisonTargetDetailResponse> {
|
|
157
|
+
const getBlob = options.getBlob ?? getCalibrationTreeBlob;
|
|
158
|
+
const comparisonIndex = await loadIndex({
|
|
159
|
+
country: options.country,
|
|
160
|
+
buildArtifactId: options.buildArtifactId,
|
|
161
|
+
getBlob,
|
|
162
|
+
});
|
|
163
|
+
if (
|
|
164
|
+
comparisonIndex.build.kind !== "comparison" ||
|
|
165
|
+
comparisonIndex.parts.targetDetailStrategy !== "source-targets" ||
|
|
166
|
+
!comparisonIndex.comparison
|
|
167
|
+
) {
|
|
168
|
+
throw new Error("The requested build does not use source-backed comparison details.");
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
!Number.isSafeInteger(options.targetOrdinal) ||
|
|
172
|
+
options.targetOrdinal < 0 ||
|
|
173
|
+
options.targetOrdinal >= comparisonIndex.targetCount
|
|
174
|
+
) {
|
|
175
|
+
throw new Error("The requested comparison target ordinal is out of range.");
|
|
176
|
+
}
|
|
177
|
+
const summaryDescriptor = comparisonIndex.parts.targetSummaries.find(
|
|
178
|
+
(descriptor) =>
|
|
179
|
+
options.targetOrdinal >= descriptor.startTargetOrdinal &&
|
|
180
|
+
options.targetOrdinal < descriptor.endTargetOrdinalExclusive,
|
|
181
|
+
);
|
|
182
|
+
if (!summaryDescriptor) {
|
|
183
|
+
throw new Error("The requested comparison target has no summary shard.");
|
|
184
|
+
}
|
|
185
|
+
const summaryText = await textFromBlob(
|
|
186
|
+
await getBlob({
|
|
187
|
+
country: options.country,
|
|
188
|
+
buildArtifactId: options.buildArtifactId,
|
|
189
|
+
part: summaryDescriptor.part,
|
|
190
|
+
consistent: true,
|
|
191
|
+
}),
|
|
192
|
+
`Calibration comparison ${options.buildArtifactId} ${summaryDescriptor.part}`,
|
|
193
|
+
);
|
|
194
|
+
if (sha256(summaryText) !== summaryDescriptor.sha256) {
|
|
195
|
+
throw new Error("The comparison target-summary digest is invalid.");
|
|
196
|
+
}
|
|
197
|
+
const summaryArtifact = parseCalibrationTreeTargetSummary(
|
|
198
|
+
JSON.parse(summaryText),
|
|
199
|
+
summaryDescriptor.part,
|
|
200
|
+
);
|
|
201
|
+
const summary = summaryArtifact.targets[
|
|
202
|
+
options.targetOrdinal - summaryDescriptor.startTargetOrdinal
|
|
203
|
+
];
|
|
204
|
+
if (!summary?.detailSource) {
|
|
205
|
+
throw new Error("The comparison target does not identify its source targets.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sourceIndexes = new Map<string, Promise<CalibrationTreeIndexArtifact>>();
|
|
209
|
+
const sourceDetails = new Map<string, Promise<CalibrationTreeTargetDetailsArtifact>>();
|
|
210
|
+
const sourceIndex = (
|
|
211
|
+
buildArtifactId: string,
|
|
212
|
+
expectedSha256: string,
|
|
213
|
+
): Promise<CalibrationTreeIndexArtifact> => {
|
|
214
|
+
const existing = sourceIndexes.get(buildArtifactId);
|
|
215
|
+
if (existing) return existing;
|
|
216
|
+
const loading = loadIndex({
|
|
217
|
+
country: options.country,
|
|
218
|
+
buildArtifactId,
|
|
219
|
+
expectedSha256,
|
|
220
|
+
getBlob,
|
|
221
|
+
});
|
|
222
|
+
sourceIndexes.set(buildArtifactId, loading);
|
|
223
|
+
return loading;
|
|
224
|
+
};
|
|
225
|
+
const sourceDetail = async (
|
|
226
|
+
buildArtifactId: string,
|
|
227
|
+
expectedIndexSha256: string,
|
|
228
|
+
targetOrdinal: number | null,
|
|
229
|
+
) => {
|
|
230
|
+
if (targetOrdinal == null) return null;
|
|
231
|
+
const index = await sourceIndex(buildArtifactId, expectedIndexSha256);
|
|
232
|
+
if (index.build.kind === "comparison") {
|
|
233
|
+
throw new Error("Comparison target details must resolve to source builds.");
|
|
234
|
+
}
|
|
235
|
+
const selection = calibrationTreeTargetDetailSelection(index, targetOrdinal);
|
|
236
|
+
const key = `${buildArtifactId}:${selection.descriptor.part}`;
|
|
237
|
+
let loading = sourceDetails.get(key);
|
|
238
|
+
if (!loading) {
|
|
239
|
+
loading = (async () => {
|
|
240
|
+
const text = await textFromBlob(
|
|
241
|
+
await getBlob({
|
|
242
|
+
country: options.country,
|
|
243
|
+
buildArtifactId,
|
|
244
|
+
part: selection.descriptor.part,
|
|
245
|
+
consistent: true,
|
|
246
|
+
}),
|
|
247
|
+
`Calibration build ${buildArtifactId} ${selection.descriptor.part}`,
|
|
248
|
+
);
|
|
249
|
+
if (sha256(text) !== selection.descriptor.sha256) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Calibration build ${buildArtifactId} ${selection.descriptor.part} digest is invalid.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return parseCalibrationTreeTargetDetails(
|
|
255
|
+
JSON.parse(text),
|
|
256
|
+
selection.descriptor.part,
|
|
257
|
+
);
|
|
258
|
+
})();
|
|
259
|
+
sourceDetails.set(key, loading);
|
|
260
|
+
}
|
|
261
|
+
const artifact = await loading;
|
|
262
|
+
return artifact.targets[selection.offset] ?? null;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const currentSource = comparisonIndex.build.sourceArtifacts.comparisonCurrentIndex;
|
|
266
|
+
const candidateSource = comparisonIndex.build.sourceArtifacts.comparisonCandidateIndex;
|
|
267
|
+
if (!currentSource || !candidateSource) {
|
|
268
|
+
throw new Error("The comparison build does not identify both source indexes.");
|
|
269
|
+
}
|
|
270
|
+
const { currentBuildArtifactId, candidateBuildArtifactId } = comparisonIndex.comparison;
|
|
271
|
+
const [currentDetail, candidateDetail] = await Promise.all([
|
|
272
|
+
sourceDetail(
|
|
273
|
+
currentBuildArtifactId,
|
|
274
|
+
currentSource.sha256,
|
|
275
|
+
summary.detailSource.currentTargetOrdinal,
|
|
276
|
+
),
|
|
277
|
+
sourceDetail(
|
|
278
|
+
candidateBuildArtifactId,
|
|
279
|
+
candidateSource.sha256,
|
|
280
|
+
summary.detailSource.candidateTargetOrdinal,
|
|
281
|
+
),
|
|
282
|
+
]);
|
|
283
|
+
const compact = summary.comparison.row as TargetChangeRow;
|
|
284
|
+
return {
|
|
285
|
+
schemaVersion: CALIBRATION_TREE_SCHEMA_VERSION,
|
|
286
|
+
country: options.country,
|
|
287
|
+
buildArtifactId: options.buildArtifactId,
|
|
288
|
+
targetOrdinal: options.targetOrdinal,
|
|
289
|
+
targetId: summary.id,
|
|
290
|
+
target: {
|
|
291
|
+
...compact,
|
|
292
|
+
currentDetail,
|
|
293
|
+
candidateDetail,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function comparisonExists(
|
|
299
|
+
country: MicrocosmCountry,
|
|
300
|
+
buildArtifactId: string,
|
|
301
|
+
): Promise<boolean> {
|
|
302
|
+
return Boolean(await getCalibrationTreeBlob({
|
|
303
|
+
country,
|
|
304
|
+
buildArtifactId,
|
|
305
|
+
part: "index",
|
|
306
|
+
consistent: true,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function buildAndPublishComparison(options: {
|
|
311
|
+
country: MicrocosmCountry;
|
|
312
|
+
currentBuildArtifactId: string;
|
|
313
|
+
candidateBuildArtifactId: string;
|
|
314
|
+
token: string;
|
|
315
|
+
}): Promise<ComparisonBuildIds> {
|
|
316
|
+
const result = ids(
|
|
317
|
+
options.country,
|
|
318
|
+
options.currentBuildArtifactId,
|
|
319
|
+
options.candidateBuildArtifactId,
|
|
320
|
+
);
|
|
321
|
+
const [reportedExists, sharedExists] = await Promise.all([
|
|
322
|
+
comparisonExists(options.country, result.reportedBuildArtifactId),
|
|
323
|
+
comparisonExists(options.country, result.sharedBuildArtifactId),
|
|
324
|
+
]);
|
|
325
|
+
if (reportedExists && sharedExists) return result;
|
|
326
|
+
|
|
327
|
+
const [current, candidate] = await Promise.all([
|
|
328
|
+
loadCalibrationComparisonSource(options.country, options.currentBuildArtifactId),
|
|
329
|
+
loadCalibrationComparisonSource(options.country, options.candidateBuildArtifactId),
|
|
330
|
+
]);
|
|
331
|
+
const bundles = buildCalibrationComparisonBundles(current, candidate);
|
|
332
|
+
if (bundles.pairArtifactId !== result.pairArtifactId) {
|
|
333
|
+
throw new Error("Calibration comparison identity is inconsistent.");
|
|
334
|
+
}
|
|
335
|
+
await Promise.all([
|
|
336
|
+
uploadCalibrationTreeBundle({ bundle: bundles.reported, token: options.token }),
|
|
337
|
+
uploadCalibrationTreeBundle({ bundle: bundles.shared, token: options.token }),
|
|
338
|
+
]);
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function ensureCalibrationComparison(options: {
|
|
343
|
+
country: MicrocosmCountry;
|
|
344
|
+
currentBuildArtifactId: string;
|
|
345
|
+
candidateBuildArtifactId: string;
|
|
346
|
+
token: string;
|
|
347
|
+
}): Promise<ComparisonBuildIds> {
|
|
348
|
+
const key = [
|
|
349
|
+
options.country,
|
|
350
|
+
options.currentBuildArtifactId,
|
|
351
|
+
options.candidateBuildArtifactId,
|
|
352
|
+
].join(":");
|
|
353
|
+
const existing = activeBuilds.get(key);
|
|
354
|
+
if (existing) return existing;
|
|
355
|
+
const promise = buildAndPublishComparison(options);
|
|
356
|
+
activeBuilds.set(key, promise);
|
|
357
|
+
while (activeBuilds.size > ACTIVE_BUILD_LIMIT) {
|
|
358
|
+
const oldest = activeBuilds.keys().next().value;
|
|
359
|
+
if (typeof oldest !== "string") break;
|
|
360
|
+
activeBuilds.delete(oldest);
|
|
361
|
+
}
|
|
362
|
+
void promise.then(
|
|
363
|
+
() => activeBuilds.delete(key),
|
|
364
|
+
() => activeBuilds.delete(key),
|
|
365
|
+
);
|
|
366
|
+
return promise;
|
|
367
|
+
}
|