Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion docs/calibration-map-hierarchy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
28 changes: 24 additions & 4 deletions docs/calibration-tree-artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dataset>.diagnostics.json` it
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/calibration-tree/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions frontend/packages/calibration-tree/src/geography-labels.ts
Original file line number Diff line number Diff line change
@@ -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<Row extends GeographyLabelledRow>(
rows: Row[],
nationalGeography: string,
): Row[] {
const nationalLabel = nationalGeography.trim();
const levelsByLabel = new Map<string, Set<string>>();
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<string>();
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;
});
}
7 changes: 5 additions & 2 deletions frontend/packages/calibration-tree/src/latest-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -2075,7 +2078,7 @@ export function buildCalibration(
targetRepresentation,
),
),
);
), artifactCountry.geography_label);
const role = releaseRole(releaseManifest);
const normalizedAttribution = normalizeTargetLossAttribution({
diagnostics: diag,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
);
Loading
Loading