@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
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Diagnostics schema 9 declares each provider, category, geography and
|
|
2
|
+
// dimension label once, in top-level `hierarchy_catalogs`, and each target
|
|
3
|
+
// row refers to them by ID; only the target's own label stays on the row.
|
|
4
|
+
// One geography ID or one dimension value ID therefore has exactly one label
|
|
5
|
+
// in the file. This module expands a schema 9 document into the schema 8 row
|
|
6
|
+
// shape, so the schema 8 reader, the tree builder and every page read both
|
|
7
|
+
// versions through the same code:
|
|
8
|
+
//
|
|
9
|
+
// hierarchy_catalogs: {
|
|
10
|
+
// providers: [{ id, label }],
|
|
11
|
+
// categories: [{ id, label, provider_id }],
|
|
12
|
+
// geographies: [{ id, label, level }],
|
|
13
|
+
// dimensions: [{ id, label, values: [{ id, label }] }],
|
|
14
|
+
// }
|
|
15
|
+
// targets[].hierarchy: {
|
|
16
|
+
// category_id, geography_id,
|
|
17
|
+
// dimensions: [{ id, value_id }], // in producer order
|
|
18
|
+
// target: { id, label },
|
|
19
|
+
// }
|
|
20
|
+
//
|
|
21
|
+
// The provider is the category's. A file that is structurally inconsistent
|
|
22
|
+
// (missing catalogs, an ID listed twice, a reference to an entry the catalogs
|
|
23
|
+
// do not list, a value looked up under a dimension that does not list it) is
|
|
24
|
+
// refused, as is a row hierarchy field schema 9 does not define (such as an
|
|
25
|
+
// inline schema 8 label). IDs are compared after trimming whitespace, as the
|
|
26
|
+
// schema 8 reader trims them.
|
|
27
|
+
|
|
28
|
+
type JsonObject = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
export const HIERARCHY_CATALOG_SCHEMA_VERSION = 9;
|
|
31
|
+
|
|
32
|
+
function isPlainObject(value: unknown): value is JsonObject {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
34
|
+
const prototype = Object.getPrototypeOf(value);
|
|
35
|
+
return prototype === Object.prototype || prototype === null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requiredObject(value: unknown, message: string): JsonObject {
|
|
39
|
+
if (!isPlainObject(value)) throw new Error(message);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requiredString(value: unknown, message: string): string {
|
|
44
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(message);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function catalogEntries(catalogs: JsonObject, name: string): JsonObject[] {
|
|
49
|
+
const entries = catalogs[name];
|
|
50
|
+
if (!Array.isArray(entries)) {
|
|
51
|
+
throw new Error(`Schema 9 hierarchy_catalogs.${name} must be an array.`);
|
|
52
|
+
}
|
|
53
|
+
return entries.map((entry, index) =>
|
|
54
|
+
requiredObject(entry, `Schema 9 hierarchy_catalogs.${name}[${index}] must be an object.`)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Index entries by trimmed ID, refusing an ID listed twice. */
|
|
59
|
+
function byId<Entry extends { id: string }>(
|
|
60
|
+
entries: Entry[],
|
|
61
|
+
duplicateMessage: (id: string) => string,
|
|
62
|
+
): Map<string, Entry> {
|
|
63
|
+
const indexed = new Map<string, Entry>();
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const key = entry.id.trim();
|
|
66
|
+
if (indexed.has(key)) throw new Error(duplicateMessage(key));
|
|
67
|
+
indexed.set(key, entry);
|
|
68
|
+
}
|
|
69
|
+
return indexed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface DimensionCatalogEntry {
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
values: Map<string, { id: string; label: string }>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HierarchyCatalogs {
|
|
79
|
+
providers: Map<string, { id: string; label: string }>;
|
|
80
|
+
categories: Map<string, { id: string; label: string; provider_id: string }>;
|
|
81
|
+
geographies: Map<string, { id: string; label: string; level: string }>;
|
|
82
|
+
dimensions: Map<string, DimensionCatalogEntry>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readCatalogs(document: JsonObject): HierarchyCatalogs {
|
|
86
|
+
const catalogs = requiredObject(
|
|
87
|
+
document.hierarchy_catalogs,
|
|
88
|
+
"Schema 9 diagnostics must have a hierarchy_catalogs object.",
|
|
89
|
+
);
|
|
90
|
+
const field = (catalog: string, index: number, name: string) =>
|
|
91
|
+
`Schema 9 hierarchy_catalogs.${catalog}[${index}].${name} must be a non-empty string.`;
|
|
92
|
+
const listedTwice = (catalog: string) => (id: string) =>
|
|
93
|
+
`Schema 9 hierarchy_catalogs.${catalog} lists ${id} more than once.`;
|
|
94
|
+
|
|
95
|
+
const providers = byId(
|
|
96
|
+
catalogEntries(catalogs, "providers").map((entry, index) => ({
|
|
97
|
+
id: requiredString(entry.id, field("providers", index, "id")),
|
|
98
|
+
label: requiredString(entry.label, field("providers", index, "label")),
|
|
99
|
+
})),
|
|
100
|
+
listedTwice("providers"),
|
|
101
|
+
);
|
|
102
|
+
const categories = byId(
|
|
103
|
+
catalogEntries(catalogs, "categories").map((entry, index) => ({
|
|
104
|
+
id: requiredString(entry.id, field("categories", index, "id")),
|
|
105
|
+
label: requiredString(entry.label, field("categories", index, "label")),
|
|
106
|
+
provider_id: requiredString(entry.provider_id, field("categories", index, "provider_id")),
|
|
107
|
+
})),
|
|
108
|
+
listedTwice("categories"),
|
|
109
|
+
);
|
|
110
|
+
for (const category of categories.values()) {
|
|
111
|
+
if (!providers.has(category.provider_id.trim())) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Schema 9 category ${category.id.trim()} refers to provider ` +
|
|
114
|
+
`${category.provider_id.trim()}, which hierarchy_catalogs.providers does not list.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const geographies = byId(
|
|
119
|
+
catalogEntries(catalogs, "geographies").map((entry, index) => ({
|
|
120
|
+
id: requiredString(entry.id, field("geographies", index, "id")),
|
|
121
|
+
label: requiredString(entry.label, field("geographies", index, "label")),
|
|
122
|
+
level: requiredString(entry.level, field("geographies", index, "level")),
|
|
123
|
+
})),
|
|
124
|
+
listedTwice("geographies"),
|
|
125
|
+
);
|
|
126
|
+
const dimensions = byId(
|
|
127
|
+
catalogEntries(catalogs, "dimensions").map((entry, index) => {
|
|
128
|
+
const id = requiredString(entry.id, field("dimensions", index, "id"));
|
|
129
|
+
if (!Array.isArray(entry.values)) {
|
|
130
|
+
throw new Error(`Schema 9 hierarchy_catalogs.dimensions[${index}].values must be an array.`);
|
|
131
|
+
}
|
|
132
|
+
const values = byId(
|
|
133
|
+
entry.values.map((value, valueIndex) => {
|
|
134
|
+
const valueField = (name: string) =>
|
|
135
|
+
`Schema 9 hierarchy_catalogs.dimensions[${index}].values[${valueIndex}].${name} ` +
|
|
136
|
+
"must be a non-empty string.";
|
|
137
|
+
const valueEntry = requiredObject(
|
|
138
|
+
value,
|
|
139
|
+
`Schema 9 hierarchy_catalogs.dimensions[${index}].values[${valueIndex}] must be an object.`,
|
|
140
|
+
);
|
|
141
|
+
return {
|
|
142
|
+
id: requiredString(valueEntry.id, valueField("id")),
|
|
143
|
+
label: requiredString(valueEntry.label, valueField("label")),
|
|
144
|
+
};
|
|
145
|
+
}),
|
|
146
|
+
(valueId) =>
|
|
147
|
+
`Schema 9 hierarchy_catalogs.dimensions entry ${id.trim()} lists value ` +
|
|
148
|
+
`${valueId} more than once.`,
|
|
149
|
+
);
|
|
150
|
+
return {
|
|
151
|
+
id,
|
|
152
|
+
label: requiredString(entry.label, field("dimensions", index, "label")),
|
|
153
|
+
values,
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
listedTwice("dimensions"),
|
|
157
|
+
);
|
|
158
|
+
return { providers, categories, geographies, dimensions };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ROW_HIERARCHY_FIELDS = new Set(["category_id", "geography_id", "dimensions", "target"]);
|
|
162
|
+
const DIMENSION_REFERENCE_FIELDS = new Set(["id", "value_id"]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a field schema 9 does not define, as Microcosm's model and JSON
|
|
166
|
+
* Schema do: an inline schema 8 label beside a catalog reference could only
|
|
167
|
+
* disagree with the catalog.
|
|
168
|
+
*/
|
|
169
|
+
function refuseUndefinedFields(
|
|
170
|
+
value: JsonObject,
|
|
171
|
+
allowed: Set<string>,
|
|
172
|
+
location: string,
|
|
173
|
+
): void {
|
|
174
|
+
for (const field of Object.keys(value)) {
|
|
175
|
+
if (!allowed.has(field)) {
|
|
176
|
+
throw new Error(`${location} has field ${field}, which schema 9 does not define.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function rowName(row: JsonObject, index: number): string {
|
|
182
|
+
for (const candidate of [row.name, row.target_name]) {
|
|
183
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
184
|
+
}
|
|
185
|
+
return `row ${index}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function expandRowHierarchy(
|
|
189
|
+
row: JsonObject,
|
|
190
|
+
index: number,
|
|
191
|
+
catalogs: HierarchyCatalogs,
|
|
192
|
+
): JsonObject {
|
|
193
|
+
const name = rowName(row, index);
|
|
194
|
+
const reference = requiredObject(row.hierarchy, `Schema 9 target ${name} hierarchy must be an object.`);
|
|
195
|
+
const referenceField = (field: string) =>
|
|
196
|
+
`Schema 9 target ${name} hierarchy.${field} must be a non-empty string.`;
|
|
197
|
+
const notListed = (kind: string, id: string, catalog: string) =>
|
|
198
|
+
`Schema 9 target ${name} refers to ${kind} ${id}, which hierarchy_catalogs.${catalog} ` +
|
|
199
|
+
"does not list.";
|
|
200
|
+
|
|
201
|
+
const categoryId = requiredString(reference.category_id, referenceField("category_id")).trim();
|
|
202
|
+
const category = catalogs.categories.get(categoryId);
|
|
203
|
+
if (!category) throw new Error(notListed("category", categoryId, "categories"));
|
|
204
|
+
// readCatalogs checked that the catalog lists every category's provider.
|
|
205
|
+
const provider = catalogs.providers.get(category.provider_id.trim())!;
|
|
206
|
+
|
|
207
|
+
const geographyId = requiredString(reference.geography_id, referenceField("geography_id")).trim();
|
|
208
|
+
const geography = catalogs.geographies.get(geographyId);
|
|
209
|
+
if (!geography) throw new Error(notListed("geography", geographyId, "geographies"));
|
|
210
|
+
|
|
211
|
+
if (!Array.isArray(reference.dimensions)) {
|
|
212
|
+
throw new Error(`Schema 9 target ${name} hierarchy.dimensions must be an array.`);
|
|
213
|
+
}
|
|
214
|
+
const seenDimensions = new Set<string>();
|
|
215
|
+
const dimensions = reference.dimensions.map((value, rank) => {
|
|
216
|
+
const dimensionReference = requiredObject(
|
|
217
|
+
value,
|
|
218
|
+
`Schema 9 target ${name} hierarchy.dimensions[${rank}] must be an object.`,
|
|
219
|
+
);
|
|
220
|
+
const dimensionId = requiredString(
|
|
221
|
+
dimensionReference.id,
|
|
222
|
+
referenceField(`dimensions[${rank}].id`),
|
|
223
|
+
).trim();
|
|
224
|
+
if (seenDimensions.has(dimensionId)) {
|
|
225
|
+
throw new Error(`Schema 9 target ${name} lists dimension ${dimensionId} more than once.`);
|
|
226
|
+
}
|
|
227
|
+
seenDimensions.add(dimensionId);
|
|
228
|
+
const dimension = catalogs.dimensions.get(dimensionId);
|
|
229
|
+
if (!dimension) throw new Error(notListed("dimension", dimensionId, "dimensions"));
|
|
230
|
+
const valueId = requiredString(
|
|
231
|
+
dimensionReference.value_id,
|
|
232
|
+
referenceField(`dimensions[${rank}].value_id`),
|
|
233
|
+
).trim();
|
|
234
|
+
const dimensionValue = dimension.values.get(valueId);
|
|
235
|
+
if (!dimensionValue) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Schema 9 target ${name} refers to value ${valueId} of dimension ${dimensionId}, ` +
|
|
238
|
+
"which that dimension does not list.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
refuseUndefinedFields(
|
|
242
|
+
dimensionReference,
|
|
243
|
+
DIMENSION_REFERENCE_FIELDS,
|
|
244
|
+
`Schema 9 target ${name} hierarchy.dimensions[${rank}]`,
|
|
245
|
+
);
|
|
246
|
+
return {
|
|
247
|
+
id: dimension.id,
|
|
248
|
+
label: dimension.label,
|
|
249
|
+
value_id: dimensionValue.id,
|
|
250
|
+
value_label: dimensionValue.label,
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const target = requiredObject(reference.target, `Schema 9 target ${name} hierarchy.target must be an object.`);
|
|
255
|
+
const targetId = requiredString(target.id, referenceField("target.id"));
|
|
256
|
+
requiredString(target.label, referenceField("target.label"));
|
|
257
|
+
const rowTargetId = typeof row.target_name === "string" ? row.target_name.trim() : "";
|
|
258
|
+
if (rowTargetId && rowTargetId !== targetId.trim()) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Schema 9 target ${name} hierarchy.target.id ${targetId.trim()} does not match ` +
|
|
261
|
+
`target_name ${rowTargetId}.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Checked after the required fields, so a schema 8 row reports its missing
|
|
266
|
+
// category_id first.
|
|
267
|
+
refuseUndefinedFields(reference, ROW_HIERARCHY_FIELDS, `Schema 9 target ${name} hierarchy`);
|
|
268
|
+
|
|
269
|
+
// The schema 8 hierarchy, with the producer's key order.
|
|
270
|
+
return {
|
|
271
|
+
provider: { id: provider.id, label: provider.label },
|
|
272
|
+
category: { id: category.id, label: category.label, provider_id: category.provider_id },
|
|
273
|
+
geography: { id: geography.id, label: geography.label, level: geography.level },
|
|
274
|
+
dimensions,
|
|
275
|
+
target: { id: target.id, label: target.label },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The document every reader below `buildCalibration` sees. A schema 9
|
|
281
|
+
* document becomes a copy whose target rows carry the schema 8 hierarchy,
|
|
282
|
+
* without `hierarchy_catalogs`; its `schema_version` stays 9. Any other
|
|
283
|
+
* document is returned unchanged, as the same object.
|
|
284
|
+
*/
|
|
285
|
+
export function expandHierarchyCatalogs(document: JsonObject): JsonObject {
|
|
286
|
+
if (document.schema_version !== HIERARCHY_CATALOG_SCHEMA_VERSION) return document;
|
|
287
|
+
const catalogs = readCatalogs(document);
|
|
288
|
+
if (!Array.isArray(document.targets)) {
|
|
289
|
+
throw new Error("Schema 9 diagnostics must have a targets array.");
|
|
290
|
+
}
|
|
291
|
+
const targets = document.targets.map((value, index) => {
|
|
292
|
+
const row = requiredObject(value, `Schema 9 target row ${index} must be an object.`);
|
|
293
|
+
return { ...row, hierarchy: expandRowHierarchy(row, index, catalogs) };
|
|
294
|
+
});
|
|
295
|
+
const { hierarchy_catalogs: _catalogs, ...rest } = document;
|
|
296
|
+
return { ...rest, targets };
|
|
297
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface HierarchyTargetDimension {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
value_id: string;
|
|
8
|
+
source_key: string;
|
|
9
|
+
raw_value: string;
|
|
10
|
+
rank: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HierarchyTargetIdentity {
|
|
14
|
+
source: string;
|
|
15
|
+
sourceLabel: string;
|
|
16
|
+
variable: string;
|
|
17
|
+
variableLabel: string;
|
|
18
|
+
geography: string;
|
|
19
|
+
geographyId: string;
|
|
20
|
+
level: string;
|
|
21
|
+
dimensions: HierarchyTargetDimension[];
|
|
22
|
+
targetId: string;
|
|
23
|
+
targetLabel: string;
|
|
24
|
+
breakdown: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPlainObject(value: unknown): value is JsonObject {
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
29
|
+
const prototype = Object.getPrototypeOf(value);
|
|
30
|
+
return prototype === Object.prototype || prototype === null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requiredObject(value: unknown, path: string): JsonObject {
|
|
34
|
+
if (!isPlainObject(value)) {
|
|
35
|
+
throw new Error(`Schema 8 target ${path} must be an object.`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function requiredString(value: unknown, path: string): string {
|
|
41
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
42
|
+
throw new Error(`Schema 8 target ${path} must be a non-empty string.`);
|
|
43
|
+
}
|
|
44
|
+
return value.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function node(value: unknown, path: string): { id: string; label: string } {
|
|
48
|
+
const object = requiredObject(value, path);
|
|
49
|
+
return {
|
|
50
|
+
id: requiredString(object.id, `${path}.id`),
|
|
51
|
+
label: requiredString(object.label, `${path}.label`),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read the complete, producer-authored hierarchy on one schema-8 row. */
|
|
56
|
+
export function readHierarchyTarget(row: JsonObject): HierarchyTargetIdentity {
|
|
57
|
+
const hierarchy = requiredObject(row.hierarchy, "hierarchy");
|
|
58
|
+
const provider = node(hierarchy.provider, "hierarchy.provider");
|
|
59
|
+
const categoryObject = requiredObject(
|
|
60
|
+
hierarchy.category,
|
|
61
|
+
"hierarchy.category",
|
|
62
|
+
);
|
|
63
|
+
const category = node(categoryObject, "hierarchy.category");
|
|
64
|
+
const categoryProviderId = requiredString(
|
|
65
|
+
categoryObject.provider_id,
|
|
66
|
+
"hierarchy.category.provider_id",
|
|
67
|
+
);
|
|
68
|
+
if (categoryProviderId !== provider.id) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Schema 8 category ${category.id} references provider ` +
|
|
71
|
+
`${categoryProviderId}, expected ${provider.id}.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const geographyObject = requiredObject(
|
|
76
|
+
hierarchy.geography,
|
|
77
|
+
"hierarchy.geography",
|
|
78
|
+
);
|
|
79
|
+
const geography = node(geographyObject, "hierarchy.geography");
|
|
80
|
+
const geographyLevel = requiredString(
|
|
81
|
+
geographyObject.level,
|
|
82
|
+
"hierarchy.geography.level",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(hierarchy.dimensions)) {
|
|
86
|
+
throw new Error("Schema 8 target hierarchy.dimensions must be an array.");
|
|
87
|
+
}
|
|
88
|
+
const dimensionIds = new Set<string>();
|
|
89
|
+
const dimensions = hierarchy.dimensions.map((value, rank) => {
|
|
90
|
+
const object = requiredObject(value, `hierarchy.dimensions[${rank}]`);
|
|
91
|
+
const id = requiredString(object.id, `hierarchy.dimensions[${rank}].id`);
|
|
92
|
+
if (dimensionIds.has(id)) {
|
|
93
|
+
throw new Error(`Schema 8 target contains duplicate dimension id ${id}.`);
|
|
94
|
+
}
|
|
95
|
+
dimensionIds.add(id);
|
|
96
|
+
const valueId = requiredString(
|
|
97
|
+
object.value_id,
|
|
98
|
+
`hierarchy.dimensions[${rank}].value_id`,
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
key: id,
|
|
102
|
+
label: requiredString(
|
|
103
|
+
object.label,
|
|
104
|
+
`hierarchy.dimensions[${rank}].label`,
|
|
105
|
+
),
|
|
106
|
+
value: requiredString(
|
|
107
|
+
object.value_label,
|
|
108
|
+
`hierarchy.dimensions[${rank}].value_label`,
|
|
109
|
+
),
|
|
110
|
+
value_id: valueId,
|
|
111
|
+
source_key: id,
|
|
112
|
+
raw_value: valueId,
|
|
113
|
+
rank,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const target = node(hierarchy.target, "hierarchy.target");
|
|
118
|
+
const rowTargetId =
|
|
119
|
+
typeof row.target_name === "string" && row.target_name.trim()
|
|
120
|
+
? row.target_name.trim()
|
|
121
|
+
: typeof row.name === "string"
|
|
122
|
+
? row.name.replace(/@[^@]+$/, "").trim()
|
|
123
|
+
: "";
|
|
124
|
+
if (rowTargetId && rowTargetId !== target.id) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Schema 8 hierarchy target id ${target.id} does not match row target ` +
|
|
127
|
+
`${rowTargetId}.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
source: provider.id,
|
|
133
|
+
sourceLabel: provider.label,
|
|
134
|
+
variable: category.id,
|
|
135
|
+
variableLabel: category.label,
|
|
136
|
+
geography: geography.label,
|
|
137
|
+
geographyId: geography.id,
|
|
138
|
+
level: geographyLevel,
|
|
139
|
+
dimensions,
|
|
140
|
+
targetId: target.id,
|
|
141
|
+
targetLabel: target.label,
|
|
142
|
+
breakdown: dimensions.map((dimension) => dimension.value).join(" · "),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type HierarchyLabelKind =
|
|
147
|
+
| "provider"
|
|
148
|
+
| "category"
|
|
149
|
+
| "geography"
|
|
150
|
+
| "dimension"
|
|
151
|
+
| "dimension value"
|
|
152
|
+
| "target";
|
|
153
|
+
|
|
154
|
+
/** One identifier a file labels more than one way. */
|
|
155
|
+
export interface HierarchyLabelVariant {
|
|
156
|
+
kind: HierarchyLabelKind;
|
|
157
|
+
id: string;
|
|
158
|
+
// Distinct spellings in file order; the first is the one the dashboard shows.
|
|
159
|
+
labels: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface HierarchyTargetValidation {
|
|
163
|
+
label_variants: HierarchyLabelVariant[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Chronicle keeps each publisher's own text for a name (chronicle#267: HMRC
|
|
167
|
+
// writes "Hartlepool UA" where ONS writes "Hartlepool", DWP writes "Ynys Môn"
|
|
168
|
+
// where HMRC writes "Ynys Mon"), and Microcosm requires one spelling only
|
|
169
|
+
// within a target's own member facts. A file that spans publishers therefore
|
|
170
|
+
// carries several spellings of one identifier legitimately, so a differing
|
|
171
|
+
// label is reported, never refused: the first spelling seen is kept and the
|
|
172
|
+
// rest are listed for the reader. Capitalisation and whitespace differences
|
|
173
|
+
// (Unicode whitespace, as JS `\s` matches) are one spelling, not a variant.
|
|
174
|
+
function labelIdentity(label: string): string {
|
|
175
|
+
return label.trim().replace(/\s+/g, " ").toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function recordLabel(labels: Map<string, string[]>, id: string, label: string): void {
|
|
179
|
+
const spellings = labels.get(id);
|
|
180
|
+
if (spellings == null) {
|
|
181
|
+
labels.set(id, [label]);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const identity = labelIdentity(label);
|
|
185
|
+
if (!spellings.some((known) => labelIdentity(known) === identity)) {
|
|
186
|
+
spellings.push(label);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function variantsOf(
|
|
191
|
+
kind: HierarchyLabelKind,
|
|
192
|
+
labels: Map<string, string[]>,
|
|
193
|
+
displayId: (key: string) => string = (key) => key,
|
|
194
|
+
): HierarchyLabelVariant[] {
|
|
195
|
+
return [...labels.entries()]
|
|
196
|
+
.filter(([, spellings]) => spellings.length > 1)
|
|
197
|
+
.map(([key, spellings]) => ({ kind, id: displayId(key), labels: [...spellings] }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read every row's hierarchy, refuse structural inconsistencies (a category
|
|
202
|
+
* under two providers), and report every identifier the file labels more
|
|
203
|
+
* than one way.
|
|
204
|
+
*/
|
|
205
|
+
export function validateHierarchyTargets(rows: JsonObject[]): HierarchyTargetValidation {
|
|
206
|
+
const providers = new Map<string, string[]>();
|
|
207
|
+
const categories = new Map<string, string[]>();
|
|
208
|
+
const categoryProviders = new Map<string, string>();
|
|
209
|
+
const geographies = new Map<string, string[]>();
|
|
210
|
+
const dimensions = new Map<string, string[]>();
|
|
211
|
+
const dimensionValues = new Map<string, string[]>();
|
|
212
|
+
const targets = new Map<string, string[]>();
|
|
213
|
+
for (const row of rows) {
|
|
214
|
+
const identity = readHierarchyTarget(row);
|
|
215
|
+
recordLabel(providers, identity.source, identity.sourceLabel);
|
|
216
|
+
recordLabel(categories, identity.variable, identity.variableLabel);
|
|
217
|
+
const categoryProvider = categoryProviders.get(identity.variable);
|
|
218
|
+
if (categoryProvider != null && categoryProvider !== identity.source) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Schema 8 category ${identity.variable} references inconsistent ` +
|
|
221
|
+
`providers: ${categoryProvider} and ${identity.source}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
categoryProviders.set(identity.variable, identity.source);
|
|
225
|
+
recordLabel(geographies, `${identity.level}\0${identity.geographyId}`, identity.geography);
|
|
226
|
+
for (const dimension of identity.dimensions) {
|
|
227
|
+
recordLabel(dimensions, dimension.key, dimension.label);
|
|
228
|
+
recordLabel(dimensionValues, `${dimension.key}\0${dimension.value_id}`, dimension.value);
|
|
229
|
+
}
|
|
230
|
+
recordLabel(targets, identity.targetId, identity.targetLabel);
|
|
231
|
+
}
|
|
232
|
+
const joined = (separator: string) => (key: string) => key.split("\0").join(separator);
|
|
233
|
+
return {
|
|
234
|
+
label_variants: [
|
|
235
|
+
...variantsOf("provider", providers),
|
|
236
|
+
...variantsOf("category", categories),
|
|
237
|
+
...variantsOf("geography", geographies, joined(" ")),
|
|
238
|
+
...variantsOf("dimension", dimensions),
|
|
239
|
+
...variantsOf("dimension value", dimensionValues, joined("=")),
|
|
240
|
+
...variantsOf("target", targets),
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface CalibrationWeightSummary {
|
|
2
|
+
weight_entity: string;
|
|
3
|
+
n_nonzero: number;
|
|
4
|
+
n_records: number;
|
|
5
|
+
effective_sample_size: number;
|
|
6
|
+
top_1pct_weight_share: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface HistoricalWeightSummaryRecord {
|
|
10
|
+
releaseId: string;
|
|
11
|
+
diagnosticsSha256: string;
|
|
12
|
+
sourceH5Sha256: string;
|
|
13
|
+
summary: CalibrationWeightSummary;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Historical releases cannot be changed in place. This registry supplies only
|
|
17
|
+
// weight summaries reconstructed from an immutable published H5 artifact, and
|
|
18
|
+
// only when the release id and diagnostics digest both match. The September
|
|
19
|
+
// local-area values were calculated from `household_weight` in the H5 whose
|
|
20
|
+
// SHA-256 is pinned below. Its calibration conserved total mass, so the
|
|
21
|
+
// canonical survivor threshold is 1e-6 times the final mean weight as well as
|
|
22
|
+
// 1e-6 times the original mean weight used by the historical solver.
|
|
23
|
+
const HISTORICAL_WEIGHT_SUMMARIES: readonly HistoricalWeightSummaryRecord[] = [
|
|
24
|
+
{
|
|
25
|
+
releaseId: "populace-us-2024-buildo-acs-local-767312d60-20260923T074941Z",
|
|
26
|
+
diagnosticsSha256:
|
|
27
|
+
"f39d10a72415eb85ac0faa231f7970bffcc408bd53ef0f05985ea8dd0ac94903",
|
|
28
|
+
sourceH5Sha256:
|
|
29
|
+
"769756c31f3ca646d12c272511744dec04c0e68870c6946dd945fbba65b6a7ec",
|
|
30
|
+
summary: {
|
|
31
|
+
weight_entity: "household",
|
|
32
|
+
n_nonzero: 1_585_847,
|
|
33
|
+
n_records: 1_588_854,
|
|
34
|
+
effective_sample_size: 13_631.348394968125,
|
|
35
|
+
top_1pct_weight_share: 0.6930830701181169,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export function historicalWeightSummaryForDiagnostics(
|
|
41
|
+
releaseId: string,
|
|
42
|
+
diagnosticsSha256: string | null,
|
|
43
|
+
): CalibrationWeightSummary | null {
|
|
44
|
+
if (diagnosticsSha256 == null) return null;
|
|
45
|
+
return (
|
|
46
|
+
HISTORICAL_WEIGHT_SUMMARIES.find(
|
|
47
|
+
(record) =>
|
|
48
|
+
record.releaseId === releaseId &&
|
|
49
|
+
record.diagnosticsSha256 === diagnosticsSha256,
|
|
50
|
+
)?.summary ?? null
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|