From a44983470e5ac383fa6480fb6b76ed4da2ac850d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:49:22 +0400 Subject: [PATCH 1/3] Keep same-named UK areas at different geography levels apart The dashboard groups, filters and lists calibration targets by the geography's label. Microcosm's UK local-area builds calibrate parliamentary constituencies and local authorities side by side, and 115 labels belong to one of each. In the published tree of the 19 Sep local candidate, the explorer's geography map therefore shows one "Ashfield" tile holding 18 constituency and 26 local-authority targets, and its Geography filter, the targets page and the comparison maps select both areas at once. The hierarchy validator already keys a geography by its level and code; only the label merged them. A label that more than one level uses now carries its level, for example "Ashfield (constituency)" and "Ashfield (local authority)". Labels used at one level stay as published, and so does the country's own national label, because a target with no resolvable geography falls back to it at the national level and still means the whole country. Of the 45 trees published today, only the three UK local candidates have such labels, so US, Belgian and UK national outputs are unchanged; the golden record still reproduces byte for byte. Co-Authored-By: Claude Opus 5.5 --- .../packages/calibration-tree/package.json | 1 + .../calibration-tree/src/geography-labels.ts | 59 +++++++ .../calibration-tree/src/latest-artifact.ts | 7 +- .../src/shared-geography-labels.test.ts | 166 ++++++++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 frontend/packages/calibration-tree/src/geography-labels.ts create mode 100644 frontend/packages/calibration-tree/src/shared-geography-labels.test.ts 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/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", + }); +}); From dd21291ac02d7788cfe502fb165aca0161be7fd2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:51:16 +0400 Subject: [PATCH 2/3] Record a staging build that cannot be reconciled and keep going Staging reconciliation threw at the first finalized run it could not publish or repair, such as a repair whose rebuilt index differs from the digest the manifest records. Every run after it then stayed unpublished on every later invocation. The September UK local-area trees are staging builds, and the level-qualified geography labels change what the builder writes for them, so repairing one would stop UK staging publication. Each run's error is now recorded as that run's "failed" outcome, with the error as its reason, and the remaining runs are still reconciled. After printing the report, the publisher exits with an error naming every failed run, so the workflow still fails. Errors before the per-run loop (an unavailable repository, incompatible runs) still stop reconciliation. reconcileStagingBuilds now takes the manifest store that release reconciliation already takes, and runPublisher accepts that store, so both can be tested without Vercel Blob. Co-Authored-By: Claude Opus 5.5 --- docs/calibration-tree-artifacts.md | 18 +- .../publish-calibration-tree-golden.test.ts | 127 +++++++++++- .../src/publish-calibration-tree.ts | 191 ++++++++++-------- .../calibration-tree-golden-cases.ts | 31 ++- 4 files changed, 277 insertions(+), 90 deletions(-) diff --git a/docs/calibration-tree-artifacts.md b/docs/calibration-tree-artifacts.md index 93da5396..ef173d7f 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 @@ -389,12 +391,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/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/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 { From 780d6d48713e50e64a487ef42ae78382e5fd3640 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:52:09 +0400 Subject: [PATCH 3/3] Document level-qualified geography labels The calibration map hierarchy document still said the dashboard does not construct labels for schema 8. buildCalibration now qualifies a geography label that more than one level uses in a build, so the document describes that rule and its exceptions, and the tree artifact document notes that published trees keep the labels they were built with. Co-Authored-By: Claude Opus 5.5 --- docs/calibration-map-hierarchy.md | 39 +++++++++++++++++++++++++++++- docs/calibration-tree-artifacts.md | 10 ++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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 ef173d7f..49ac11eb 100644 --- a/docs/calibration-tree-artifacts.md +++ b/docs/calibration-tree-artifacts.md @@ -260,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,