@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,42 @@
|
|
|
1
|
+
export type ChronicleMetadata = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
const CURRENT_PREFIX = "chronicle_";
|
|
4
|
+
const LEGACY_PREFIX = "ledger_";
|
|
5
|
+
|
|
6
|
+
function asMetadata(value: unknown): ChronicleMetadata {
|
|
7
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
? (value as ChronicleMetadata)
|
|
9
|
+
: {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasValue(value: unknown): boolean {
|
|
13
|
+
return typeof value === "string" ? value.trim().length > 0 : value != null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonicalize artifact metadata after Ledger's rename to Chronicle.
|
|
18
|
+
*
|
|
19
|
+
* Published releases can contain either namespace. Preserve the original
|
|
20
|
+
* fields for diagnostics, while exposing every legacy `ledger_*` field under
|
|
21
|
+
* its canonical `chronicle_*` name. A populated Chronicle value always wins
|
|
22
|
+
* when both forms are present.
|
|
23
|
+
*
|
|
24
|
+
* This intentionally aliases arbitrary suffixes rather than maintaining a
|
|
25
|
+
* field allowlist, so newly published metadata remains backward-compatible.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeChronicleMetadata(
|
|
28
|
+
value: unknown,
|
|
29
|
+
): ChronicleMetadata {
|
|
30
|
+
const metadata = asMetadata(value);
|
|
31
|
+
const normalized = { ...metadata };
|
|
32
|
+
|
|
33
|
+
for (const [key, legacyValue] of Object.entries(metadata)) {
|
|
34
|
+
if (!key.startsWith(LEGACY_PREFIX) || !hasValue(legacyValue)) continue;
|
|
35
|
+
const canonicalKey = `${CURRENT_PREFIX}${key.slice(LEGACY_PREFIX.length)}`;
|
|
36
|
+
if (!hasValue(normalized[canonicalKey])) {
|
|
37
|
+
normalized[canonicalKey] = legacyValue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
package/src/countries.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Country registry for the Microcosm dashboard: the one place a country is
|
|
2
|
+
// registered. Adding a country is one entry here; everything else (selectors,
|
|
3
|
+
// navigation, repository resolution, national geography, link behaviour) reads
|
|
4
|
+
// the registration or the release artifact's typed `country` block.
|
|
5
|
+
//
|
|
6
|
+
// Isomorphic: imported by client components and API routes alike, so it must
|
|
7
|
+
// not import server-only modules or read `process.env`. Repository resolvers
|
|
8
|
+
// accept an explicit environment object from server-side callers.
|
|
9
|
+
|
|
10
|
+
export const COUNTRY_CAPABILITIES = [
|
|
11
|
+
"calibration",
|
|
12
|
+
"targets",
|
|
13
|
+
"compare",
|
|
14
|
+
"cross_dataset",
|
|
15
|
+
"staging",
|
|
16
|
+
"model_coverage",
|
|
17
|
+
"pipeline",
|
|
18
|
+
"variables",
|
|
19
|
+
"external_checks",
|
|
20
|
+
] as const;
|
|
21
|
+
export type CountryCapability = (typeof COUNTRY_CAPABILITIES)[number];
|
|
22
|
+
|
|
23
|
+
export type RepositoryVisibility = "public" | "private";
|
|
24
|
+
|
|
25
|
+
export type RepositoryEnvironment = Readonly<
|
|
26
|
+
Record<string, string | undefined>
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
export interface ResolvedRepository {
|
|
30
|
+
repo: string;
|
|
31
|
+
revision: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CountryRegistration {
|
|
35
|
+
// Default HF dataset repository and revision (the server may override both
|
|
36
|
+
// through the deployment variables named in repo_env / revision_env).
|
|
37
|
+
repo: string;
|
|
38
|
+
revision: string;
|
|
39
|
+
repo_env?: string;
|
|
40
|
+
revision_env?: string;
|
|
41
|
+
// The repository-root pointer naming the country's current release:
|
|
42
|
+
// `latest.json` unless the country promotes its default line through a
|
|
43
|
+
// per-line pointer (`latest-<line>.json`, microcosm#823) whose payload also
|
|
44
|
+
// names the immutable tag the release is read at (`cut_tag`). Read only
|
|
45
|
+
// while the dataset repository has no `releases.json`; a repository with
|
|
46
|
+
// one is read from it and its build folders alone, whatever the
|
|
47
|
+
// registration says (release-record.ts).
|
|
48
|
+
pointer_path?: string;
|
|
49
|
+
// Country display name ("United States").
|
|
50
|
+
label: string;
|
|
51
|
+
// Sidebar dataset line ("Microcosm US").
|
|
52
|
+
dataset_label: string;
|
|
53
|
+
// National geography label and id (null when the id is unknown).
|
|
54
|
+
geography: string;
|
|
55
|
+
geography_id: string | null;
|
|
56
|
+
visibility: RepositoryVisibility;
|
|
57
|
+
capabilities: readonly CountryCapability[];
|
|
58
|
+
// Optional staging telemetry repository. A staging-capable country must
|
|
59
|
+
// declare this instead of inheriting another country's repository.
|
|
60
|
+
staging?: {
|
|
61
|
+
repo: string;
|
|
62
|
+
revision: string;
|
|
63
|
+
repo_env?: string;
|
|
64
|
+
revision_env?: string;
|
|
65
|
+
token_env?: string;
|
|
66
|
+
// Staging pipelines, by the `pipeline.id` their run manifests record,
|
|
67
|
+
// whose candidates are local-area builds. The Staging page compares those
|
|
68
|
+
// candidates with the newest published local-area release instead of the
|
|
69
|
+
// current (national) release.
|
|
70
|
+
local_area_pipelines?: readonly string[];
|
|
71
|
+
};
|
|
72
|
+
// Other jurisdiction codes a cross-dataset bundle may use for this country.
|
|
73
|
+
jurisdiction_aliases?: readonly string[];
|
|
74
|
+
// Conformance-only registration: a valid country that is never listed in
|
|
75
|
+
// selectors or alert allowlists.
|
|
76
|
+
fixture?: true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ALL_CAPABILITIES: readonly CountryCapability[] = COUNTRY_CAPABILITIES;
|
|
80
|
+
|
|
81
|
+
const CALIBRATION_CAPABILITIES: readonly CountryCapability[] = [
|
|
82
|
+
"calibration",
|
|
83
|
+
"targets",
|
|
84
|
+
"compare",
|
|
85
|
+
"cross_dataset",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// Deprecated upstream identifiers: Microcosm's published HF repositories and
|
|
89
|
+
// deployment variables still use the former Populace names.
|
|
90
|
+
export const COUNTRY_REGISTRY = {
|
|
91
|
+
us: {
|
|
92
|
+
repo: "policyengine/populace-us",
|
|
93
|
+
revision: "main",
|
|
94
|
+
repo_env: "POPULACE_HF_REPO",
|
|
95
|
+
label: "United States",
|
|
96
|
+
dataset_label: "Microcosm US",
|
|
97
|
+
geography: "United States",
|
|
98
|
+
geography_id: "0100000US",
|
|
99
|
+
visibility: "public",
|
|
100
|
+
capabilities: ALL_CAPABILITIES,
|
|
101
|
+
staging: {
|
|
102
|
+
// Deprecated upstream identifier: the US staging publisher still uses
|
|
103
|
+
// the former Populace repository and deployment-variable names.
|
|
104
|
+
repo: "policyengine/populace-us-staging",
|
|
105
|
+
revision: "main",
|
|
106
|
+
repo_env: "POPULACE_STAGING_HF_REPO",
|
|
107
|
+
revision_env: "POPULACE_STAGING_HF_REVISION",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
uk: {
|
|
111
|
+
repo: "policyengine/populace-uk-private",
|
|
112
|
+
revision: "main",
|
|
113
|
+
repo_env: "POPULACE_UK_HF_REPO",
|
|
114
|
+
revision_env: "POPULACE_UK_HF_REVISION",
|
|
115
|
+
label: "United Kingdom",
|
|
116
|
+
dataset_label: "Microcosm UK",
|
|
117
|
+
geography: "United Kingdom",
|
|
118
|
+
geography_id: null,
|
|
119
|
+
visibility: "private",
|
|
120
|
+
capabilities: [...CALIBRATION_CAPABILITIES, "staging"],
|
|
121
|
+
// microcosm#823: the UK default line is the 2024-25 national dataset,
|
|
122
|
+
// promoted through its own pointer; `latest.json` stays on the June 2023
|
|
123
|
+
// release and is never moved. Unread once the repository has
|
|
124
|
+
// releases.json: Microcosm's switch writes it and deletes the pointer
|
|
125
|
+
// files in one commit.
|
|
126
|
+
pointer_path: "latest-national.json",
|
|
127
|
+
staging: {
|
|
128
|
+
repo: "policyengine/populace-uk-staging",
|
|
129
|
+
revision: "main",
|
|
130
|
+
repo_env: "POPULACE_UK_STAGING_HF_REPO",
|
|
131
|
+
revision_env: "POPULACE_UK_STAGING_HF_REVISION",
|
|
132
|
+
token_env: "POPULACE_UK_STAGING_HF_TOKEN",
|
|
133
|
+
// Microcosm's rowwise local-area candidates (published as the dense
|
|
134
|
+
// release). National attempts run the uk-frs-calibration pipeline.
|
|
135
|
+
local_area_pipelines: ["uk-local-candidate"],
|
|
136
|
+
},
|
|
137
|
+
jurisdiction_aliases: ["GB"],
|
|
138
|
+
},
|
|
139
|
+
be: {
|
|
140
|
+
repo: "policyengine/populace-be-private",
|
|
141
|
+
revision: "main",
|
|
142
|
+
repo_env: "POPULACE_BE_HF_REPO",
|
|
143
|
+
revision_env: "POPULACE_BE_HF_REVISION",
|
|
144
|
+
label: "Belgium",
|
|
145
|
+
dataset_label: "Microcosm Belgium",
|
|
146
|
+
geography: "Belgium",
|
|
147
|
+
geography_id: null,
|
|
148
|
+
visibility: "private",
|
|
149
|
+
capabilities: CALIBRATION_CAPABILITIES,
|
|
150
|
+
},
|
|
151
|
+
// Synthetic repository used only by the third-country conformance fixture.
|
|
152
|
+
zz: {
|
|
153
|
+
repo: "policyengine/microcosm-zz-fixture",
|
|
154
|
+
revision: "main",
|
|
155
|
+
label: "Zedland",
|
|
156
|
+
dataset_label: "Microcosm ZZ",
|
|
157
|
+
geography: "Zedland",
|
|
158
|
+
geography_id: null,
|
|
159
|
+
visibility: "private",
|
|
160
|
+
capabilities: ["calibration", "targets", "compare"],
|
|
161
|
+
fixture: true,
|
|
162
|
+
},
|
|
163
|
+
// Synthetic non-US staging registration. It verifies that staging artifact
|
|
164
|
+
// resolution is country-scoped without exposing an unfinished country in
|
|
165
|
+
// selectors or treating its fixture repository as a published dataset.
|
|
166
|
+
am: {
|
|
167
|
+
repo: "policyengine/microcosm-am-fixture",
|
|
168
|
+
revision: "main",
|
|
169
|
+
label: "Armenia",
|
|
170
|
+
dataset_label: "Microcosm Armenia",
|
|
171
|
+
geography: "Armenia",
|
|
172
|
+
geography_id: null,
|
|
173
|
+
visibility: "private",
|
|
174
|
+
capabilities: ["calibration", "targets", "compare", "staging"],
|
|
175
|
+
staging: {
|
|
176
|
+
repo: "policyengine/microcosm-am-staging-fixture",
|
|
177
|
+
revision: "main",
|
|
178
|
+
},
|
|
179
|
+
fixture: true,
|
|
180
|
+
},
|
|
181
|
+
} satisfies Record<string, CountryRegistration>;
|
|
182
|
+
|
|
183
|
+
export type MicrocosmCountry = keyof typeof COUNTRY_REGISTRY;
|
|
184
|
+
|
|
185
|
+
export const DEFAULT_COUNTRY: MicrocosmCountry = "us";
|
|
186
|
+
|
|
187
|
+
const COUNTRY_CODES = Object.keys(COUNTRY_REGISTRY) as MicrocosmCountry[];
|
|
188
|
+
|
|
189
|
+
export function isCountry(value: string | null | undefined): value is MicrocosmCountry {
|
|
190
|
+
return value != null && Object.hasOwn(COUNTRY_REGISTRY, value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function countryRegistration(country: MicrocosmCountry): CountryRegistration {
|
|
194
|
+
return COUNTRY_REGISTRY[country];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A registered country the dashboard offers: every registration except the
|
|
198
|
+
// conformance fixtures.
|
|
199
|
+
export function isSelectableCountry(
|
|
200
|
+
value: string | null | undefined,
|
|
201
|
+
): value is MicrocosmCountry {
|
|
202
|
+
return isCountry(value) && !countryRegistration(value).fixture;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// What a request's `country` parameter names. Absent (null or "") selects the
|
|
206
|
+
// registry default. A present value must equal a selectable registration key
|
|
207
|
+
// exactly; anything else (an unregistered code such as "nz", another spelling
|
|
208
|
+
// such as "US", or a conformance fixture) is not configured. It never resolves
|
|
209
|
+
// to a different country, so one country's release is never served under
|
|
210
|
+
// another country's code.
|
|
211
|
+
export type CountryParam =
|
|
212
|
+
| { configured: true; country: MicrocosmCountry; explicit: boolean }
|
|
213
|
+
| { configured: false; requested: string };
|
|
214
|
+
|
|
215
|
+
export function countryParam(value: string | null | undefined): CountryParam {
|
|
216
|
+
if (value == null || value === "") {
|
|
217
|
+
return { configured: true, country: DEFAULT_COUNTRY, explicit: false };
|
|
218
|
+
}
|
|
219
|
+
return isSelectableCountry(value)
|
|
220
|
+
? { configured: true, country: value, explicit: true }
|
|
221
|
+
: { configured: false, requested: value };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// "unknown_country": the dashboard has no such country. "no_published_release":
|
|
225
|
+
// the country is registered but the dashboard manifest names no release for it
|
|
226
|
+
// yet (a newly registered country before its first publication).
|
|
227
|
+
export type CountryNotConfiguredReason = "unknown_country" | "no_published_release";
|
|
228
|
+
|
|
229
|
+
export const COUNTRY_NOT_CONFIGURED = "country_not_configured";
|
|
230
|
+
|
|
231
|
+
export interface CountryNotConfiguredBody {
|
|
232
|
+
error: typeof COUNTRY_NOT_CONFIGURED;
|
|
233
|
+
requested_country: string;
|
|
234
|
+
reason: CountryNotConfiguredReason;
|
|
235
|
+
detail: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class CountryNotConfiguredError extends Error {
|
|
239
|
+
readonly requested: string;
|
|
240
|
+
readonly reason: CountryNotConfiguredReason;
|
|
241
|
+
|
|
242
|
+
constructor(requested: string, reason?: CountryNotConfiguredReason) {
|
|
243
|
+
const resolvedReason =
|
|
244
|
+
reason ?? (isSelectableCountry(requested) ? "no_published_release" : "unknown_country");
|
|
245
|
+
super(countryNotConfiguredDetail(requested, resolvedReason));
|
|
246
|
+
this.name = "CountryNotConfiguredError";
|
|
247
|
+
this.requested = requested;
|
|
248
|
+
this.reason = resolvedReason;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
body(): CountryNotConfiguredBody {
|
|
252
|
+
return {
|
|
253
|
+
error: COUNTRY_NOT_CONFIGURED,
|
|
254
|
+
requested_country: this.requested,
|
|
255
|
+
reason: this.reason,
|
|
256
|
+
detail: this.message,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function countryNotConfiguredDetail(
|
|
262
|
+
requested: string,
|
|
263
|
+
reason: CountryNotConfiguredReason,
|
|
264
|
+
): string {
|
|
265
|
+
if (reason === "no_published_release" && isCountry(requested)) {
|
|
266
|
+
return `No Microcosm calibration release has been published for ${countryRegistration(requested).label} yet.`;
|
|
267
|
+
}
|
|
268
|
+
return `No Microcosm calibration release is configured for country "${requested}".`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function isCountryNotConfiguredBody(value: unknown): value is CountryNotConfiguredBody {
|
|
272
|
+
return (
|
|
273
|
+
value != null &&
|
|
274
|
+
typeof value === "object" &&
|
|
275
|
+
(value as { error?: unknown }).error === COUNTRY_NOT_CONFIGURED &&
|
|
276
|
+
typeof (value as { requested_country?: unknown }).requested_country === "string"
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const DEFAULT_POINTER_PATH = "latest.json";
|
|
281
|
+
|
|
282
|
+
// The repository-root pointer a country's current release is resolved through.
|
|
283
|
+
export function countryPointerPath(country: MicrocosmCountry): string {
|
|
284
|
+
return countryRegistration(country).pointer_path ?? DEFAULT_POINTER_PATH;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Countries offered in selectors, in registry order; fixtures are excluded.
|
|
288
|
+
export function selectableCountries(): MicrocosmCountry[] {
|
|
289
|
+
return COUNTRY_CODES.filter(isSelectableCountry);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function countryCapabilities(country: MicrocosmCountry): readonly CountryCapability[] {
|
|
293
|
+
return COUNTRY_REGISTRY[country].capabilities;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Staging pipeline ids whose candidates are local-area builds; empty for
|
|
297
|
+
// countries that register none.
|
|
298
|
+
export function stagingLocalAreaPipelines(country: MicrocosmCountry): readonly string[] {
|
|
299
|
+
return countryRegistration(country).staging?.local_area_pipelines ?? [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function hasCapability(country: MicrocosmCountry, capability: CountryCapability): boolean {
|
|
303
|
+
return countryCapabilities(country).includes(capability);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function configuredValue(
|
|
307
|
+
environment: RepositoryEnvironment,
|
|
308
|
+
variableName: string | undefined,
|
|
309
|
+
fallback: string,
|
|
310
|
+
): string {
|
|
311
|
+
return (variableName ? environment[variableName] : undefined) ?? fallback;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function resolveRegisteredRepository(
|
|
315
|
+
country: MicrocosmCountry,
|
|
316
|
+
environment: RepositoryEnvironment,
|
|
317
|
+
): ResolvedRepository {
|
|
318
|
+
const registration = countryRegistration(country);
|
|
319
|
+
return {
|
|
320
|
+
repo: configuredValue(
|
|
321
|
+
environment,
|
|
322
|
+
registration.repo_env,
|
|
323
|
+
registration.repo,
|
|
324
|
+
),
|
|
325
|
+
revision: configuredValue(
|
|
326
|
+
environment,
|
|
327
|
+
registration.revision_env,
|
|
328
|
+
registration.revision,
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function resolveRegisteredStagingRepository(
|
|
334
|
+
country: MicrocosmCountry,
|
|
335
|
+
environment: RepositoryEnvironment,
|
|
336
|
+
): ResolvedRepository | null {
|
|
337
|
+
const registration = countryRegistration(country);
|
|
338
|
+
if (!hasCapability(country, "staging") || !registration.staging) return null;
|
|
339
|
+
return {
|
|
340
|
+
repo: configuredValue(
|
|
341
|
+
environment,
|
|
342
|
+
registration.staging.repo_env,
|
|
343
|
+
registration.staging.repo,
|
|
344
|
+
),
|
|
345
|
+
revision: configuredValue(
|
|
346
|
+
environment,
|
|
347
|
+
registration.staging.revision_env,
|
|
348
|
+
registration.staging.revision,
|
|
349
|
+
),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function isCountryCapability(value: unknown): value is CountryCapability {
|
|
354
|
+
return typeof value === "string" && (COUNTRY_CAPABILITIES as readonly string[]).includes(value);
|
|
355
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A target's geography is identified by its level and code, but the dashboard
|
|
2
|
+
// shows, filters and groups targets by the geography's label. Microcosm's UK
|
|
3
|
+
// local-area builds calibrate parliamentary constituencies and local
|
|
4
|
+
// authorities side by side, and many of them share a name (Ashfield,
|
|
5
|
+
// Colchester, Midlothian, ...). A label used at more than one geography level
|
|
6
|
+
// therefore names its level as well, "Ashfield (constituency)" and "Ashfield
|
|
7
|
+
// (local authority)", so the explorer's tiles and filters, the targets page
|
|
8
|
+
// and the comparison maps keep the two areas apart. A label that only one
|
|
9
|
+
// level uses is left exactly as published, and so is the country's own
|
|
10
|
+
// national geography label: a target with no resolvable geography falls back
|
|
11
|
+
// to it at the national level, and it still means the same, whole country as
|
|
12
|
+
// a target that names it at the country level.
|
|
13
|
+
|
|
14
|
+
interface GeographyLabelledRow {
|
|
15
|
+
geography?: unknown;
|
|
16
|
+
level?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function trimmedText(value: unknown): string {
|
|
20
|
+
return typeof value === "string" ? value.trim() : "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function levelPhrase(level: string): string {
|
|
24
|
+
return level.replace(/[_-]+/g, " ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the rows with every geography label that more than one level uses,
|
|
29
|
+
* other than the national geography label, qualified by its level. Returns
|
|
30
|
+
* the same array when no label is shared.
|
|
31
|
+
*/
|
|
32
|
+
export function levelQualifiedGeographyLabels<Row extends GeographyLabelledRow>(
|
|
33
|
+
rows: Row[],
|
|
34
|
+
nationalGeography: string,
|
|
35
|
+
): Row[] {
|
|
36
|
+
const nationalLabel = nationalGeography.trim();
|
|
37
|
+
const levelsByLabel = new Map<string, Set<string>>();
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const label = trimmedText(row.geography);
|
|
40
|
+
const level = trimmedText(row.level);
|
|
41
|
+
if (!label || !level || label === nationalLabel) continue;
|
|
42
|
+
const levels = levelsByLabel.get(label) ?? new Set<string>();
|
|
43
|
+
levels.add(level);
|
|
44
|
+
levelsByLabel.set(label, levels);
|
|
45
|
+
}
|
|
46
|
+
const sharedLabels = new Set(
|
|
47
|
+
[...levelsByLabel]
|
|
48
|
+
.filter(([, levels]) => levels.size > 1)
|
|
49
|
+
.map(([label]) => label),
|
|
50
|
+
);
|
|
51
|
+
if (sharedLabels.size === 0) return rows;
|
|
52
|
+
return rows.map((row) => {
|
|
53
|
+
const label = trimmedText(row.geography);
|
|
54
|
+
const level = trimmedText(row.level);
|
|
55
|
+
return sharedLabels.has(label) && level
|
|
56
|
+
? { ...row, geography: `${label} (${levelPhrase(level)})` }
|
|
57
|
+
: row;
|
|
58
|
+
});
|
|
59
|
+
}
|