diff --git a/docs/calibration-map-hierarchy.md b/docs/calibration-map-hierarchy.md index e9d5c170..648bd150 100644 --- a/docs/calibration-map-hierarchy.md +++ b/docs/calibration-map-hierarchy.md @@ -57,7 +57,10 @@ target label when its selected fact can describe the resulting target exactly. Microcosm supplies an explicitly declared, reviewed target label when a target combines facts, transforms their value, or assigns a different target period. Missing or conflicting Chronicle-owned labels stop target compilation. The -dashboard does not construct labels for schema 8. +dashboard shows these labels as published, with one exception: a geography +label that more than one geography level uses in the same build is qualified by +its level, as described in +[Geography labels shared by several levels](#geography-labels-shared-by-several-levels). ## Dashboard parsing @@ -98,6 +101,40 @@ Schema 2–7 behavior remains isolated in its historical reader and tree adapter Humanization and country-specific parsing in those adapters must not be added to the schema 8 path. +## Geography labels shared by several levels + +The map's geography breakdown, its Geography filter, `/microcosm/targets` and +the Staging comparison maps group and select targets by geography label. In one +build, geographies at different levels can share a label. UK local-area builds +calibrate parliamentary constituencies and local authorities side by side, and +many of them have the same name, such as Ashfield, Colchester and Midlothian. +Without a distinguishing label, the two areas would be shown and selected as +one geography. + +`buildCalibration` therefore applies this rule to the target rows of each +diagnostics file it reads, whatever the diagnostics schema: + +- A label that rows at more than one geography level use gets its level in + parentheses, with underscores and hyphens in the level shown as spaces: + "Ashfield (constituency)" and "Ashfield (local authority)". +- A label that rows at only one level use is left exactly as published. +- The country's national geography label is never qualified. It is the release + manifest's `country.geography_label`, or else the registration's + `geography`, such as "United Kingdom". A target whose geography cannot be + resolved falls back to that label at the national level, and it still means + the whole country. +- A row with no level keeps its label. +- Labels and levels are compared after surrounding whitespace is removed. + +The rule is `levelQualifiedGeographyLabels` in +`@policyengine/calibration-tree/geography-labels`. Every view reads its rows +from `buildCalibration`, so they all show the same labels. Matching targets +between two builds uses target names and identities, not geography labels, so +comparisons pair the same targets as before. + +Published calibration trees keep the labels they were built with; see +[Filtering indices](calibration-tree-artifacts.md#filtering-indices). + ## Required verification For a producer change, verify these representations in order: diff --git a/docs/calibration-tree-artifacts.md b/docs/calibration-tree-artifacts.md index 93da5396..49ac11eb 100644 --- a/docs/calibration-tree-artifacts.md +++ b/docs/calibration-tree-artifacts.md @@ -191,9 +191,11 @@ staging repository main-branch update ``` Active or failed staging runs remain on the live 30-second API path. Historical -staging runs with no calibration diagnostics are reported as ineligible. A -declared diagnostics file that is missing or has the wrong digest fails the -workflow. +staging runs with no calibration diagnostics are reported as ineligible. A run +whose build cannot be published or repaired, for example because a declared +diagnostics file is missing or has the wrong digest, is reported as `failed` +with the error. The remaining runs are still reconciled, and the workflow then +fails. A staged dataset's diagnostics are the `calibration_diagnostics.json` its receipt records or, failing that, the one `.diagnostics.json` it @@ -258,6 +260,16 @@ Hierarchy levels, groups, and nodes store sorted target ordinals. comparison fit, and calibration status. Target-summary shards store the compact metric inputs used to recalculate visible aggregates after filtering. +Geography values are the labels `buildCalibration` produces, so a label that +more than one geography level uses in a build carries its level, such as +"Ashfield (constituency)" and "Ashfield (local authority)" (see +[Geography labels shared by several levels](calibration-map-hierarchy.md#geography-labels-shared-by-several-levels)). +A tree keeps the labels it was published with. Its build ID derives from its +inputs, not from the builder's code, so a tree published before that rule keeps +one merged label for both areas, and a repair of it is refused because the +rebuilt index differs from the recorded digest. For a staging build, the refusal +is reported as that run's `failed` outcome. + The browser unions selected values within one category, intersects categories, filters hierarchy memberships, and recalculates visible metrics from compact per-target inputs. Target detail shards are not required for navigation, @@ -389,12 +401,20 @@ Remove `--dry-run` to publish. For staging, an automated invocation also passes repository state. The GitHub workflow exposes the same `dry_run` option for an authenticated verification using Actions secrets. -Each outcome is `complete`, `published`, `repaired`, or `ineligible`. +Each outcome is `complete`, `published`, `repaired`, or `ineligible`, and +staging outcomes can also be `failed`. `complete` means no bundle construction or source-diagnostics download occurred. `repaired` means the publisher deterministically rebuilt an existing identity and restored only missing files. Any reconstructed index must match the digest already recorded in the manifest. +Release reconciliation stops at the first release it cannot publish or repair. +Staging reconciliation records that run as `failed`, with the error as its +`reason`, and continues with the remaining runs; after printing the report the +command exits with an error naming every failed run. A refused staging repair +therefore no longer leaves the runs after it unpublished on every later +invocation. + `CALIBRATION_TREE_MAX_RAW_BYTES` defaults to 100,000,000 bytes per file, in addition to the fixed summary- and detail-shard limits. `CALIBRATION_TREE_MAX_GZIP_BYTES` can add a compressed-size limit. Any violation diff --git a/frontend/packages/calibration-tree/package.json b/frontend/packages/calibration-tree/package.json index 76d709f2..2b68a6cf 100644 --- a/frontend/packages/calibration-tree/package.json +++ b/frontend/packages/calibration-tree/package.json @@ -17,6 +17,7 @@ "./calibration-tree-manifest": "./src/calibration-tree-manifest.ts", "./chronicle-metadata": "./src/chronicle-metadata.ts", "./countries": "./src/countries.ts", + "./geography-labels": "./src/geography-labels.ts", "./hierarchy-target-reader": "./src/hierarchy-target-reader.ts", "./historical-weight-summary": "./src/historical-weight-summary.ts", "./latest-artifact": "./src/latest-artifact.ts", diff --git a/frontend/packages/calibration-tree/src/geography-labels.ts b/frontend/packages/calibration-tree/src/geography-labels.ts new file mode 100644 index 00000000..73f4ff64 --- /dev/null +++ b/frontend/packages/calibration-tree/src/geography-labels.ts @@ -0,0 +1,59 @@ +// A target's geography is identified by its level and code, but the dashboard +// shows, filters and groups targets by the geography's label. Microcosm's UK +// local-area builds calibrate parliamentary constituencies and local +// authorities side by side, and many of them share a name (Ashfield, +// Colchester, Midlothian, ...). A label used at more than one geography level +// therefore names its level as well, "Ashfield (constituency)" and "Ashfield +// (local authority)", so the explorer's tiles and filters, the targets page +// and the comparison maps keep the two areas apart. A label that only one +// level uses is left exactly as published, and so is the country's own +// national geography label: a target with no resolvable geography falls back +// to it at the national level, and it still means the same, whole country as +// a target that names it at the country level. + +interface GeographyLabelledRow { + geography?: unknown; + level?: unknown; +} + +function trimmedText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function levelPhrase(level: string): string { + return level.replace(/[_-]+/g, " "); +} + +/** + * Returns the rows with every geography label that more than one level uses, + * other than the national geography label, qualified by its level. Returns + * the same array when no label is shared. + */ +export function levelQualifiedGeographyLabels( + rows: Row[], + nationalGeography: string, +): Row[] { + const nationalLabel = nationalGeography.trim(); + const levelsByLabel = new Map>(); + for (const row of rows) { + const label = trimmedText(row.geography); + const level = trimmedText(row.level); + if (!label || !level || label === nationalLabel) continue; + const levels = levelsByLabel.get(label) ?? new Set(); + levels.add(level); + levelsByLabel.set(label, levels); + } + const sharedLabels = new Set( + [...levelsByLabel] + .filter(([, levels]) => levels.size > 1) + .map(([label]) => label), + ); + if (sharedLabels.size === 0) return rows; + return rows.map((row) => { + const label = trimmedText(row.geography); + const level = trimmedText(row.level); + return sharedLabels.has(label) && level + ? { ...row, geography: `${label} (${levelPhrase(level)})` } + : row; + }); +} diff --git a/frontend/packages/calibration-tree/src/latest-artifact.ts b/frontend/packages/calibration-tree/src/latest-artifact.ts index f20fed9b..7479717d 100644 --- a/frontend/packages/calibration-tree/src/latest-artifact.ts +++ b/frontend/packages/calibration-tree/src/latest-artifact.ts @@ -56,6 +56,7 @@ import { resolveHfCommitPublishedAt, } from "./calibration-release-locator"; import type { CalibrationTreeManifest } from "./calibration-tree-manifest"; +import { levelQualifiedGeographyLabels } from "./geography-labels"; import { parseReleaseCutTag } from "./release-cut"; // The registry is the registration point; these re-exports keep the server @@ -2063,7 +2064,9 @@ export function buildCalibration( const presentation = releasePresentation(releaseManifest); const publisherLabels = releasePublisherLabels(releaseManifest); const dimensionDefinitions = diagnosticsDimensions(diag); - const enrichedRows = addEstimateScopeWarnings( + // Same-named areas at different geography levels (a UK constituency and + // local authority, say) keep separate labels in every view. + const enrichedRows = levelQualifiedGeographyLabels(addEstimateScopeWarnings( targets.map((row) => enrichTargetRow( row, @@ -2075,7 +2078,7 @@ export function buildCalibration( targetRepresentation, ), ), - ); + ), artifactCountry.geography_label); const role = releaseRole(releaseManifest); const normalizedAttribution = normalizeTargetLossAttribution({ diagnostics: diag, diff --git a/frontend/packages/calibration-tree/src/publish-calibration-tree-golden.test.ts b/frontend/packages/calibration-tree/src/publish-calibration-tree-golden.test.ts index 35ef7d7c..b1e87ab3 100644 --- a/frontend/packages/calibration-tree/src/publish-calibration-tree-golden.test.ts +++ b/frontend/packages/calibration-tree/src/publish-calibration-tree-golden.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import recordedGoldens from "./fixtures/calibration-tree-golden.json"; import { @@ -13,13 +13,17 @@ import { } from "@policyengine/calibration-tree/countries"; import { goldenReleaseInputs, + ukStagingRepositoryFiles, withRegisteredRepositories, type CalibrationTreeGoldenRecord, type GoldenReleaseInput, } from "./test-support/calibration-tree-golden-cases"; import { reconcileReleaseBuilds, + reconcileStagingBuilds, + runPublisher, type CalibrationManifestStore, + type ReconciliationReport, } from "@policyengine/calibration-tree/publish-calibration-tree"; // A repair rebuilds a published release whose index went missing and refuses @@ -208,3 +212,124 @@ test("a repair refuses a rebuilt index that differs from the recorded digest", a reconcileReleaseBuilds(release.country, "unused-blob-token", true, storeMissingIndex(release, entry)) )).rejects.toThrow("Rebuilt calibration index differs from immutable manifest entry"); }); + +// Two finalized UK staging runs. The newer one, which reconciliation reaches +// first, already has a manifest entry whose index has gone missing, and the +// digest it records is not what the builder now writes for that run: a +// staging tree published before a builder change, such as the September UK +// local-area trees before same-named areas at different geography levels +// got separate labels. +const REFUSED_STAGING_RUN = { + runId: "uk-calibration-refused-20261004T000000Z", + updatedAt: "2026-10-04T00:00:00+00:00", +}; +const UNPUBLISHED_STAGING_RUN = { + runId: "uk-calibration-unpublished-20261003T000000Z", + updatedAt: "2026-10-03T00:00:00+00:00", +}; +const REFUSED_BUILD_ID = "a".repeat(64); + +function stagingRepositoryWithRefusedRepair(): { + revisionSha: string; + store: CalibrationManifestStore; +} { + const { revisionSha, files } = ukStagingRepositoryFiles([ + REFUSED_STAGING_RUN, + UNPUBLISHED_STAGING_RUN, + ]); + globalThis.fetch = (async (input: string | URL | Request) => { + const body = files.get(String(input)); + return body == null ? new Response(null, { status: 404 }) : new Response(body); + }) as typeof fetch; + const staleEntry: CalibrationTreeManifestEntry = { + buildArtifactId: REFUSED_BUILD_ID, + kind: "staging", + sourceId: REFUSED_STAGING_RUN.runId, + label: `${REFUSED_STAGING_RUN.runId}-candidate`, + releaseId: null, + stagingRunId: REFUSED_STAGING_RUN.runId, + hfRepo: COUNTRY_REGISTRY.uk.staging.repo, + hfCommitSha: revisionSha, + treeSchemaVersion: 6, + indexSha256: "0".repeat(64), + indexBytes: 10, + createdAt: "2026-10-04T00:00:00.000Z", + updatedAt: "2026-10-04T00:00:00.000Z", + }; + const store: CalibrationManifestStore = { + readManifest: async () => withCalibrationTreeManifestEntry( + emptyCalibrationTreeManifest(), + "uk", + staleEntry, + ), + listBlobs: async () => new Map(), + audit: async () => ({ + complete: false, + repairable: true, + reasons: ["index is missing"], + index: null, + }), + updateManifest: async () => { + throw new Error("A dry run must not write the manifest."); + }, + }; + return { revisionSha, store }; +} + +test( + "a refused staging repair is recorded as that run's failure, and the runs after it are still published", + async () => { + const { revisionSha, store } = stagingRepositoryWithRefusedRepair(); + + const report = await withRegisteredRepositories(() => + reconcileStagingBuilds("uk", revisionSha, "unused-blob-token", true, store) + ); + + expect(report.outcomes).toEqual([ + { + sourceId: REFUSED_STAGING_RUN.runId, + status: "failed", + buildArtifactId: REFUSED_BUILD_ID, + reason: `Rebuilt staging index differs from immutable manifest entry ${REFUSED_BUILD_ID}.`, + }, + { + sourceId: UNPUBLISHED_STAGING_RUN.runId, + status: "published", + buildArtifactId: expect.stringMatching(/^[0-9a-f]{64}$/), + reason: "Dry run; no Blob writes performed.", + }, + ]); + }, + BUILD_TIMEOUT_MS, +); + +test( + "the publisher prints every staging outcome, then exits with an error naming the failed run", + async () => { + const { revisionSha, store } = stagingRepositoryWithRefusedRepair(); + const originalBlobToken = process.env.BLOB_READ_WRITE_TOKEN; + process.env.BLOB_READ_WRITE_TOKEN = "unused-blob-token"; + const printed = spyOn(console, "log").mockImplementation(() => {}); + try { + await expect(withRegisteredRepositories(() => + runPublisher( + { country: "uk", mode: "reconcile-staging", hfCommitSha: revisionSha, dryRun: true }, + { store }, + ) + )).rejects.toThrow( + `1 of 2 staging sources failed to reconcile: ${REFUSED_STAGING_RUN.runId}.`, + ); + expect(printed).toHaveBeenCalledTimes(1); + const report = JSON.parse(String(printed.mock.calls[0][0])) as ReconciliationReport; + expect(report.outcomes.map((outcome) => [outcome.sourceId, outcome.status])).toEqual([ + [REFUSED_STAGING_RUN.runId, "failed"], + [UNPUBLISHED_STAGING_RUN.runId, "published"], + ]); + } finally { + printed.mockRestore(); + if (originalBlobToken == null) delete process.env.BLOB_READ_WRITE_TOKEN; + else process.env.BLOB_READ_WRITE_TOKEN = originalBlobToken; + } + }, + BUILD_TIMEOUT_MS, +); diff --git a/frontend/packages/calibration-tree/src/publish-calibration-tree.ts b/frontend/packages/calibration-tree/src/publish-calibration-tree.ts index cf782ac2..6170f7bf 100644 --- a/frontend/packages/calibration-tree/src/publish-calibration-tree.ts +++ b/frontend/packages/calibration-tree/src/publish-calibration-tree.ts @@ -88,7 +88,8 @@ export class CalibrationTreeArtifactSizeError extends Error {} export interface ReconciliationOutcome { sourceId: string; - status: "complete" | "published" | "repaired" | "ineligible"; + // `failed`: staging only; `reason` gives the error. + status: "complete" | "published" | "repaired" | "ineligible" | "failed"; buildArtifactId: string | null; reason: string | null; } @@ -945,7 +946,7 @@ async function publishStagingSource(options: { source: ResolvedStagingSource; repository: { repo: string }; revisionSha: string; - blobToken: string; + uploadBundle: NonNullable; existing: CalibrationTreeManifestEntry | null; existingIndex: Awaited>["index"]; dryRun: boolean; @@ -956,7 +957,7 @@ async function publishStagingSource(options: { source, repository, revisionSha, - blobToken, + uploadBundle, existing, existingIndex, dryRun, @@ -1014,7 +1015,7 @@ async function publishStagingSource(options: { created: false, }, } - : await uploadCalibrationTreeBundle({ bundle, token: blobToken }); + : await uploadBundle(bundle); const entry: CalibrationTreeManifestEntry = existing ?? { buildArtifactId: bundle.index.buildArtifactId, kind: "staging", @@ -1033,11 +1034,12 @@ async function publishStagingSource(options: { return { bundle, stored, entry }; } -async function reconcileStagingBuilds( +export async function reconcileStagingBuilds( country: MicrocosmCountry, expectedSha: string | undefined, blobToken: string, dryRun: boolean, + store: CalibrationManifestStore = blobCalibrationManifestStore(country, blobToken), ): Promise { const repository = stagingRepository(country); if (!repository) { @@ -1060,11 +1062,10 @@ async function reconcileStagingBuilds( const finalized = runs.runs.filter((run) => FINAL_STAGING_STATUSES.has(run.status ?? ""), ); - let { manifest } = await readCalibrationTreeManifest({ - token: blobToken, - consistent: true, - }); - let blobs = await listCalibrationTreeBlobs({ country, token: blobToken }); + const uploadBundle = store.uploadBundle ?? + ((bundle: CalibrationTreeBundle) => uploadCalibrationTreeBundle({ bundle, token: blobToken })); + let manifest = await store.readManifest(); + let blobs = await store.listBlobs(); const outcomes: ReconciliationOutcome[] = []; for (const run of finalized) { @@ -1074,85 +1075,84 @@ async function reconcileStagingBuilds( "staging", run.run_id, ); - const audit = existing - ? await auditCalibrationTreeBuild({ - country, - entry: existing, - blobs, - token: blobToken, - }) - : null; - if (existing && audit?.complete) { - outcomes.push({ - sourceId: run.run_id, - status: "complete", - buildArtifactId: existing.buildArtifactId, - reason: null, - }); - continue; - } - if (audit && !audit.repairable) { - throw new Error( - `Staging build ${run.run_id} is corrupt and cannot be repaired automatically: ` + - audit.reasons.join("; "), - ); - } - const source = await resolveStagingCalibrationSource( - run.run_id, - 0, - country, - existing?.hfCommitSha ?? revisionSha, - ); - if (!source) { - if (existing) { + // A run whose build cannot be published or repaired is reported with + // the reason, and the runs after it are still reconciled: stopping here + // would leave every later run unpublished on every later invocation. + // `runPublisher` exits with an error once the report is printed. + let buildArtifactId = existing?.buildArtifactId ?? null; + try { + const audit = existing ? await store.audit(existing, blobs) : null; + if (existing && audit?.complete) { + outcomes.push({ + sourceId: run.run_id, + status: "complete", + buildArtifactId: existing.buildArtifactId, + reason: null, + }); + continue; + } + if (audit && !audit.repairable) { throw new Error( - `Incomplete staging build ${run.run_id} no longer has calibration diagnostics.`, + `Staging build ${run.run_id} is corrupt and cannot be repaired automatically: ` + + audit.reasons.join("; "), ); } - outcomes.push({ - sourceId: run.run_id, - status: "ineligible", - buildArtifactId: null, - reason: "Finalized staging run has no calibration diagnostics.", - }); - continue; - } - const published = await publishStagingSource({ - country, - runId: run.run_id, - source, - repository, - revisionSha: existing?.hfCommitSha ?? revisionSha, - blobToken, - existing, - existingIndex: audit?.index ?? null, - dryRun, - }); - if (!dryRun) { - manifest = await updateCalibrationTreeManifest({ + const source = await resolveStagingCalibrationSource( + run.run_id, + 0, country, - entry: published.entry, - token: blobToken, - }); - blobs = await listCalibrationTreeBlobs({ country, token: blobToken }); - const verified = await auditCalibrationTreeBuild({ + existing?.hfCommitSha ?? revisionSha, + ); + if (!source) { + if (existing) { + throw new Error( + `Incomplete staging build ${run.run_id} no longer has calibration diagnostics.`, + ); + } + outcomes.push({ + sourceId: run.run_id, + status: "ineligible", + buildArtifactId: null, + reason: "Finalized staging run has no calibration diagnostics.", + }); + continue; + } + const published = await publishStagingSource({ country, - entry: published.entry, - blobs, - token: blobToken, + runId: run.run_id, + source, + repository, + revisionSha: existing?.hfCommitSha ?? revisionSha, + uploadBundle, + existing, + existingIndex: audit?.index ?? null, + dryRun, }); - if (!verified.complete) { - throw new Error( - `Staging build ${run.run_id} remains incomplete: ${verified.reasons.join("; ")}`, - ); + buildArtifactId = published.entry.buildArtifactId; + if (!dryRun) { + manifest = await store.updateManifest(published.entry); + blobs = await store.listBlobs(); + const verified = await store.audit(published.entry, blobs); + if (!verified.complete) { + throw new Error( + `Staging build ${run.run_id} remains incomplete: ${verified.reasons.join("; ")}`, + ); + } } + outcomes.push({ + sourceId: run.run_id, + status: existing ? "repaired" : "published", + buildArtifactId: published.entry.buildArtifactId, + reason: dryRun ? "Dry run; no Blob writes performed." : null, + }); + } catch (error) { + outcomes.push({ + sourceId: run.run_id, + status: "failed", + buildArtifactId, + reason: error instanceof Error ? error.message : String(error), + }); } - outcomes.push({ - sourceId: run.run_id, - status: existing ? "repaired" : "published", - buildArtifactId: published.entry.buildArtifactId, - reason: dryRun ? "Dry run; no Blob writes performed." : null, - }); } return { country, sourceKind: "staging", dryRun, outcomes }; } @@ -1350,15 +1350,36 @@ async function requestedReleaseSelection( }; } -export async function runPublisher(options: PublisherOptions): Promise { +/** The error a report's failed outcomes end the publisher with, if any. */ +export function reconciliationFailureMessage(report: ReconciliationReport): string | null { + const failed = report.outcomes.filter((outcome) => outcome.status === "failed"); + if (failed.length === 0) return null; + return ( + `${failed.length} of ${report.outcomes.length} ${report.sourceKind} sources failed to ` + + `reconcile: ${failed.map((outcome) => outcome.sourceId).join(", ")}. ` + + "The printed report gives each reason." + ); +} + +export interface PublisherDependencies { + /** The manifest and Blob store the reconciliation and migration modes use. */ + store?: CalibrationManifestStore; +} + +export async function runPublisher( + options: PublisherOptions, + dependencies: PublisherDependencies = {}, +): Promise { const blobToken = process.env.BLOB_READ_WRITE_TOKEN?.trim(); if (!blobToken) throw new Error("BLOB_READ_WRITE_TOKEN is required."); + const store = dependencies.store ?? blobCalibrationManifestStore(options.country, blobToken); if (options.mode === "migrate-release-timestamps") { const report = await migrateReleaseTimestamps( options.country, blobToken, options.dryRun === true, + store, ); console.log(JSON.stringify(report)); return; @@ -1373,8 +1394,11 @@ export async function runPublisher(options: PublisherOptions): Promise { options.hfCommitSha, blobToken, options.dryRun === true, + store, ); console.log(JSON.stringify(report)); + const failure = reconciliationFailureMessage(report); + if (failure) throw new Error(failure); return; } @@ -1386,8 +1410,11 @@ export async function runPublisher(options: PublisherOptions): Promise { options.country, blobToken, options.dryRun === true, + store, ); console.log(JSON.stringify(report)); + const failure = reconciliationFailureMessage(report); + if (failure) throw new Error(failure); return; } diff --git a/frontend/packages/calibration-tree/src/shared-geography-labels.test.ts b/frontend/packages/calibration-tree/src/shared-geography-labels.test.ts new file mode 100644 index 00000000..e0f41321 --- /dev/null +++ b/frontend/packages/calibration-tree/src/shared-geography-labels.test.ts @@ -0,0 +1,166 @@ +import { expect, test } from "bun:test"; + +import { createExplorerState } from "./calibration-explorer"; +import { buildCalibrationTree, type CalibrationTreeTarget } from "./calibration-tree"; +import { + buildCalibration, + latestMicrocosmTargetDiagnosticsPage, + type Calibration, +} from "./latest-artifact"; + +// Microcosm's UK local-area builds calibrate parliamentary constituencies and +// local authorities side by side, and 115 names belong to one of each. In the +// 19 Sep local candidate (21,523 targets), "Ashfield" carries 18 constituency +// targets and 26 local-authority targets. The geography's identity is its +// level and GSS code; its label alone does not tell the two areas apart. +interface UkArea { + id: string; + label: string; + level: string; +} + +const ASHFIELD_CONSTITUENCY: UkArea = { id: "E14001063", label: "Ashfield", level: "constituency" }; +const ASHFIELD_LOCAL_AUTHORITY: UkArea = { id: "E07000170", label: "Ashfield", level: "local_authority" }; +const ABERDEEN_NORTH: UkArea = { id: "S14000105", label: "Aberdeen North", level: "constituency" }; +const ABERDEEN_CITY: UkArea = { id: "S12000033", label: "Aberdeen City", level: "local_authority" }; +const EAST_MIDLANDS: UkArea = { id: "E12000004", label: "East Midlands", level: "region" }; +const ENGLAND: UkArea = { id: "E92000001", label: "England", level: "country" }; +const UNITED_KINGDOM: UkArea = { id: "K02000001", label: "United Kingdom", level: "country" }; + +const LOCAL_AREA_RELEASE = "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe"; +const NATIONAL_RELEASE = "microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71"; + +function populationTarget(area: UkArea, targetValue: number, finalEstimate: number) { + const targetId = `ons.population.${area.id}`; + return { + name: `${targetId}@2025`, + target_name: targetId, + source: "ONS mid-year population estimates", + target: targetValue, + initial_estimate: targetValue * 1.1, + final_estimate: finalEstimate, + hierarchy: { + provider: { id: "ons", label: "Office for National Statistics" }, + category: { id: "ons.population", label: "Population", provider_id: "ons" }, + geography: { id: area.id, label: area.label, level: area.level }, + dimensions: [], + target: { id: targetId, label: `Population, ${area.label}` }, + }, + }; +} + +function ukCalibration(releaseId: string, areas: UkArea[]) { + const diagnostics = { + schema_version: 8, + release_id: releaseId, + targets: areas.map((area, index) => + populationTarget(area, 100_000 * (index + 1), 100_000 * (index + 1) * (1 + 0.01 * index)) + ), + }; + return buildCalibration( + diagnostics, + releaseId, + null, + {}, + { country: { code: "uk" } }, + {}, + "uk", + ); +} + +const LOCAL_AREAS = [ + ASHFIELD_CONSTITUENCY, + ASHFIELD_LOCAL_AUTHORITY, + ABERDEEN_NORTH, + ABERDEEN_CITY, + EAST_MIDLANDS, + ENGLAND, +]; + +function geographyById(rows: Calibration["rows"]): Record { + return Object.fromEntries(rows.map((row) => [row.geography_id, row.geography])); +} + +test("UK areas that share a name across geography levels carry their level in the label", () => { + const calibration = ukCalibration(LOCAL_AREA_RELEASE, LOCAL_AREAS); + + expect(geographyById(calibration.rows)).toEqual({ + [ASHFIELD_CONSTITUENCY.id]: "Ashfield (constituency)", + [ASHFIELD_LOCAL_AUTHORITY.id]: "Ashfield (local authority)", + [ABERDEEN_NORTH.id]: "Aberdeen North", + [ABERDEEN_CITY.id]: "Aberdeen City", + [EAST_MIDLANDS.id]: "East Midlands", + [ENGLAND.id]: "England", + }); + // The level itself is unchanged, so the level filter still works. + expect( + calibration.rows.find((row) => row.geography_id === ASHFIELD_LOCAL_AUTHORITY.id)?.level, + ).toBe("local_authority"); +}); + +test("the targets page lists and filters same-named UK areas separately", () => { + const calibration = ukCalibration(LOCAL_AREA_RELEASE, LOCAL_AREAS); + const page = latestMicrocosmTargetDiagnosticsPage( + `http://dashboard.test/api/microcosm/target-diagnostics?country=uk&geography=${ + encodeURIComponent("Ashfield (local authority)") + }`, + calibration, + ); + + expect(page.geographies).toEqual([ + "Aberdeen City", + "Aberdeen North", + "Ashfield (constituency)", + "Ashfield (local authority)", + "East Midlands", + "England", + ]); + expect(page.levels).toEqual(["constituency", "country", "local_authority", "region"]); + expect(page.filtered_total).toBe(1); + expect(page.targets.map((row) => row.name)).toEqual([ + `ons.population.${ASHFIELD_LOCAL_AUTHORITY.id}@2025`, + ]); +}); + +test("the explorer map gives same-named UK areas their own geography tiles and filter options", () => { + const calibration = ukCalibration(LOCAL_AREA_RELEASE, LOCAL_AREAS); + const rows = calibration.rows as CalibrationTreeTarget[]; + const geographyState = { ...createExplorerState(), breakdown: "geography" as const }; + + const tree = buildCalibrationTree(rows, geographyState, LOCAL_AREA_RELEASE); + const tiles = tree.groups.flatMap((group) => group.nodes); + expect(tiles.map((tile) => [tile.label, tile.metrics.nTargets]).sort()).toEqual([ + ["Aberdeen City", 1], + ["Aberdeen North", 1], + ["Ashfield (constituency)", 1], + ["Ashfield (local authority)", 1], + ["East Midlands", 1], + ["England", 1], + ]); + expect(tree.filterOptions.geographies).toContain("Ashfield (constituency)"); + expect(tree.filterOptions.geographies).toContain("Ashfield (local authority)"); + expect(tree.filterOptions.geographies).not.toContain("Ashfield"); + + const filtered = buildCalibrationTree( + rows, + { + ...geographyState, + filters: { ...geographyState.filters, geographies: ["Ashfield (constituency)"] }, + }, + LOCAL_AREA_RELEASE, + ); + expect(filtered.filteredMetrics.nTargets).toBe(1); + expect(filtered.groups.flatMap((group) => group.nodes).map((tile) => tile.label)).toEqual([ + "Ashfield (constituency)", + ]); +}); + +test("UK geography labels no other level uses are left as published", () => { + const calibration = ukCalibration(NATIONAL_RELEASE, [UNITED_KINGDOM, ENGLAND, EAST_MIDLANDS]); + + expect(geographyById(calibration.rows)).toEqual({ + [UNITED_KINGDOM.id]: "United Kingdom", + [ENGLAND.id]: "England", + [EAST_MIDLANDS.id]: "East Midlands", + }); +}); diff --git a/frontend/packages/calibration-tree/src/test-support/calibration-tree-golden-cases.ts b/frontend/packages/calibration-tree/src/test-support/calibration-tree-golden-cases.ts index a60c2ea8..7c8b8db9 100644 --- a/frontend/packages/calibration-tree/src/test-support/calibration-tree-golden-cases.ts +++ b/frontend/packages/calibration-tree/src/test-support/calibration-tree-golden-cases.ts @@ -699,10 +699,12 @@ function version2RunManifest( } // A version 2 run that uploaded its diagnostics with its telemetry. -function ukVersion2StagingFiles(): Map { - const runId = UK_STAGING_RUN_ID; +function ukVersion2StagingFiles( + runId: string = UK_STAGING_RUN_ID, + updatedAt = "2026-10-02T23:15:56+00:00", +): Map { const diagnosticsText = JSON.stringify(ukHierarchyDiagnostics(ukStagingTargets())); - const manifest = version2RunManifest(runId, "2026-10-02T23:15:56+00:00", [{ + const manifest = version2RunManifest(runId, updatedAt, [{ logical_name: "calibration_diagnostics", artifact_kind: "aggregate_diagnostics", contract_relative_path: "artifacts/calibration_diagnostics.json", @@ -717,6 +719,29 @@ function ukVersion2StagingFiles(): Map { ]); } +// The UK staging repository at the recorded revision as the publisher's +// staging inventory reads it: its runs tree listing and, for each run, the +// recorded UK version 2 run's manifest and diagnostics under that run's id. +export function ukStagingRepositoryFiles( + runs: ReadonlyArray<{ runId: string; updatedAt: string }>, +): { revisionSha: string; files: Map } { + const files = new Map(); + const listing: JsonObject[] = []; + for (const { runId, updatedAt } of runs) { + for (const [url, body] of ukVersion2StagingFiles(runId, updatedAt)) files.set(url, body); + listing.push( + { type: "file", path: `runs/${runId}/run_manifest.json` }, + { type: "file", path: `runs/${runId}/artifacts/calibration_diagnostics.json` }, + ); + } + const repository = stagingRepository("uk")!.repo; + files.set( + `https://huggingface.co/api/datasets/${repository}/tree/${UK_STAGING_REVISION}/runs?recursive=true`, + JSON.stringify(listing), + ); + return { revisionSha: UK_STAGING_REVISION, files }; +} + // A version 2 run whose diagnostics travel in its staged dataset bundle in // the release repository, located by the staged-dataset receipt. function ukStagedBundleStagingFiles(): Map {