From 209cf5f37319912ce3ea0f7c01db64ce41271087 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:35:13 +0400 Subject: [PATCH 1/3] Support UK and structured geography diagnostics --- docs/spec-driven-countries.md | 13 +- .../lib/microcosm/latest-artifact.test.ts | 222 ++++++++++++++++++ frontend/lib/microcosm/latest-artifact.ts | 21 +- .../lib/microcosm/legacy-target-reader.ts | 73 ++++-- .../microcosm/structured-dimension-reader.ts | 27 ++- .../lib/microcosm/structured-target-reader.ts | 6 + 6 files changed, 333 insertions(+), 29 deletions(-) diff --git a/docs/spec-driven-countries.md b/docs/spec-driven-countries.md index 3d97c25..dbc4d9b 100644 --- a/docs/spec-driven-countries.md +++ b/docs/spec-driven-countries.md @@ -209,16 +209,19 @@ their IDs and raw values are humanized. When an age-band value lacks an artifact label, range values such as `0_17` and `65_plus` become `0–17` and `65+`. -A geography-role dimension sets `row.geography` and uses its declared `level` -or `"region"`. Other dimensions become `target_dimensions` with `key`, +A geography-role dimension sets `row.geography`, `row.geography_id`, +`row.geography_dimension_id`, and `row.geography_rank`, and uses its declared +`level` or `"region"`. A target may contain at most one populated +geography-role dimension. Other dimensions become `target_dimensions` with `key`, `label`, `value`, `source_key`, `raw_value`, and an optional zero-based `rank`. The published dimension ID, not its display label, determines `key`. Simple lowercase IDs retain keys such as `bd_age_band`; IDs containing other characters receive a lossless query-safe encoding. Consequently, two distinct dimensions may share a display label without merging into one facet. -Facet values use rank order only when every displayed value has a rank; -otherwise the legacy facet sorter remains in force. Structured rows are also -excluded from whole-population estimate-scope inference. +Facet values, including geography values, use rank order only when every +displayed value has a rank; otherwise the legacy facet sorter remains in force. +Structured rows are also excluded from whole-population estimate-scope +inference. ### Target representation classification diff --git a/frontend/lib/microcosm/latest-artifact.test.ts b/frontend/lib/microcosm/latest-artifact.test.ts index 6b82cd2..7b11fdd 100644 --- a/frontend/lib/microcosm/latest-artifact.test.ts +++ b/frontend/lib/microcosm/latest-artifact.test.ts @@ -304,6 +304,228 @@ test("structured dimensions shape rows and honor artifact value order", () => { ]); }); +test("legacy UK geography metadata resolves GSS identifiers across target-name formats", () => { + const legacyTarget = ( + name: string, + geographyId: string, + geographyName?: string, + ) => ({ + name: `${name}@2025`, + target_name: name, + period: 2025, + entity: "household", + measure: { kind: "column", name }, + filter: null, + source: "UK source citation", + metadata: { + ledger_geography_level: "country", + ledger_geography_id: geographyId, + ...(geographyName ? { ledger_geography_name: geographyName } : {}), + }, + registry: { family: "uk_source" }, + target: 100, + initial_estimate: 90, + final_estimate: 100, + }); + const cal = buildCalibration( + { + schema_version: 6, + targets: [ + legacyTarget("obr.receipts.income_tax", "K02000001"), + legacyTarget("dwp/uc/payment_band", "K03000001"), + legacyTarget("ons.population.england", "E92000001"), + legacyTarget("scotgov.council_tax.band_a", "S92000003"), + legacyTarget("ons.population.published", "K02000001", "Published area"), + legacyTarget("ons.population.named_uk", "K02000001", "United Kingdom"), + legacyTarget("ons.population.unknown", "X00000000"), + ], + }, + "uk-legacy-geography", + null, + {}, + {}, + {}, + "uk", + ); + + expect(cal.rows.map((row) => [row.geography, row.level])).toEqual([ + ["United Kingdom", "country"], + ["Great Britain", "country"], + ["England", "country"], + ["Scotland", "country"], + ["Published area", "country"], + ["United Kingdom", "country"], + ["United Kingdom", "national"], + ]); +}); + +test("legacy US geography normalization remains unchanged", () => { + const cal = buildCalibration( + { + schema_version: 6, + targets: [ + { + name: "census.population.total@2025", + target_name: "census.population.total", + source: "Census citation", + metadata: { + ledger_geography_level: "country", + ledger_geography_id: "0100000US", + ledger_geography_name: "United States", + }, + target: 100, + initial_estimate: 90, + final_estimate: 100, + }, + ], + }, + "us-legacy-geography", + ); + + expect(cal.rows[0]).toMatchObject({ + geography: "United States", + level: "national", + }); +}); + +test("structured UK geography preserves producer identity and ordering", () => { + const geographyValues = { + K02000001: "United Kingdom", + K03000001: "Great Britain", + E92000001: "England", + S92000003: "Scotland", + }; + const order = ["K02000001", "K03000001", "E92000001", "S92000003"]; + const targets = order.map((geographyId) => ({ + name: `ons.population.${geographyId}@2025`, + target_name: `ons.population.${geographyId}`, + source: { id: "ons", citation: "ONS population table" }, + variable: { id: "population", measure: "count" }, + dimensions: { geography_country: geographyId }, + target: 100, + initial_estimate: 90, + final_estimate: 100, + })); + const cal = buildCalibration( + { + schema_version: 7, + dimensions: { + geography_country: { + label: "Geography", + role: "geography", + level: "country", + values: geographyValues, + order, + }, + }, + targets, + }, + "uk-structured-geography", + null, + {}, + {}, + {}, + "uk", + ); + const page = latestMicrocosmTargetDiagnosticsPage( + "http://x/api/microcosm/target-diagnostics?variable=ons%20%2F%20population%20%C2%B7%20count", + cal, + ); + + expect(cal.rows[3]).toMatchObject({ + geography: "Scotland", + geography_id: "S92000003", + geography_dimension_id: "geography_country", + geography_rank: 3, + level: "country", + }); + expect(page.dimensions[0]).toEqual({ + key: "geography", + label: "Geography", + values: ["United Kingdom", "Great Britain", "England", "Scotland"], + }); +}); + +test("structured geography falls back to deterministic sorting when order is incomplete", () => { + const cal = buildCalibration( + { + schema_version: 7, + dimensions: { + geography_country: { + label: "Geography", + role: "geography", + level: "country", + values: { K02000001: "United Kingdom", E92000001: "England" }, + order: ["K02000001"], + }, + }, + targets: ["K02000001", "E92000001"].map((geographyId) => ({ + name: `ons.population.${geographyId}@2025`, + source: { id: "ons" }, + variable: { id: "population" }, + dimensions: { geography_country: geographyId }, + target: 100, + initial_estimate: 90, + final_estimate: 100, + })), + }, + "uk-structured-geography-incomplete-order", + null, + {}, + {}, + {}, + "uk", + ); + const page = latestMicrocosmTargetDiagnosticsPage( + "http://x/api/microcosm/target-diagnostics?variable=ons%20%2F%20population", + cal, + ); + + expect(page.dimensions[0]?.values).toEqual(["England", "United Kingdom"]); +}); + +test("structured targets reject multiple populated geography dimensions", () => { + expect(() => + buildCalibration( + { + schema_version: 7, + dimensions: { + geography_country: { + label: "Country", + role: "geography", + level: "country", + }, + geography_region: { + label: "Region", + role: "geography", + level: "region", + }, + }, + targets: [ + { + name: "ons.population@2025", + source: { id: "ons" }, + variable: { id: "population" }, + dimensions: { + geography_country: "E92000001", + geography_region: "north_east", + }, + target: 100, + initial_estimate: 90, + final_estimate: 100, + }, + ], + }, + "uk-ambiguous-structured-geography", + null, + {}, + {}, + {}, + "uk", + ) + ).toThrow("at most one populated geography-role dimension"); +}); + test("structured facet ordering falls back when any displayed value lacks a rank", () => { const target = (suffix: string, category: string) => ({ name: `fixture.population.${suffix}@2026`, diff --git a/frontend/lib/microcosm/latest-artifact.ts b/frontend/lib/microcosm/latest-artifact.ts index 9133bd0..b716fa6 100644 --- a/frontend/lib/microcosm/latest-artifact.ts +++ b/frontend/lib/microcosm/latest-artifact.ts @@ -849,8 +849,23 @@ function computeDimensions(rows: TargetRow[]): TargetDimension[] { if (values.length <= 1) continue; const label = candidate.label ?? classifyDimension(values); const ranks = new Map(); - let everyValueRanked = candidate.key !== "geography"; + let everyValueRanked = true; for (const value of values) { + if (candidate.key === "geography") { + const matchingRows = rows.filter((row) => row.geography === value); + if ( + !matchingRows.length || + matchingRows.some((row) => typeof row.geography_rank !== "number") + ) { + everyValueRanked = false; + break; + } + ranks.set( + value, + Math.min(...matchingRows.map((row) => row.geography_rank as number)), + ); + continue; + } const matchingDimensions = rows.flatMap((row) => ((row.target_dimensions as TargetBreakdownDimension[] | undefined) ?? []) .filter((dimension) => @@ -1096,6 +1111,10 @@ function enrichTargetRow( ? null : stateFromGeoId(stringValue(metadata.ledger_geography_id)) ?? deriveState(baseName), geography, + geography_id: + structuredIdentity?.geographyId ?? stringValue(metadata.ledger_geography_id), + geography_dimension_id: structuredIdentity?.geographyDimensionId ?? null, + geography_rank: structuredIdentity?.geographyRank ?? null, level, source: parsed.source, source_label: diff --git a/frontend/lib/microcosm/legacy-target-reader.ts b/frontend/lib/microcosm/legacy-target-reader.ts index 8d32441..c7eb9b2 100644 --- a/frontend/lib/microcosm/legacy-target-reader.ts +++ b/frontend/lib/microcosm/legacy-target-reader.ts @@ -33,6 +33,16 @@ const STATE_ABBRS = new Set([ "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", "US", ]); +// Compatibility labels for the country-level GSS identifiers in the current +// UK schema-6 artifact. Future artifacts should publish geography labels in +// structured dimension definitions or `ledger_geography_name`. +const LEGACY_GSS_GEOGRAPHIES: Readonly> = { + K02000001: "United Kingdom", + K03000001: "Great Britain", + E92000001: "England", + S92000003: "Scotland", +}; + const MEASURES = new Set(["total", "count", "mean", "filers", "nonfilers"]); const LEGACY_UNDERSCORE_PUBLISHERS = new Set([ "statbel", @@ -70,6 +80,43 @@ export function districtFromGeoId(value: string | null): string | null { return state ? `${state}-${match[2]}` : null; } +function geographyFromMetadata( + metadata: JsonObject, + nationalGeography: string, +): Pick | null { + const publishedName = stringValue(metadata.ledger_geography_name)?.trim() ?? null; + const geographyId = stringValue(metadata.ledger_geography_id); + const geographyLevel = stringValue(metadata.ledger_geography_level); + const gssGeography = geographyId ? LEGACY_GSS_GEOGRAPHIES[geographyId] : null; + + if (publishedName) { + return { + geography: publishedName, + level: + geographyLevel === "country" && + publishedName === nationalGeography && + !gssGeography + ? "national" + : geographyLevel ?? "", + }; + } + if (gssGeography) { + return { geography: gssGeography, level: geographyLevel ?? "country" }; + } + if (geographyLevel === "congressional_district") { + const geography = districtFromGeoId(geographyId); + return geography ? { geography, level: geographyLevel } : null; + } + if (geographyLevel === "state") { + const geography = stateFromGeoId(geographyId) ?? stringValue(metadata.state); + return geography ? { geography, level: geographyLevel } : null; + } + if (geographyLevel === "country") { + return { geography: nationalGeography, level: "national" }; + } + return null; +} + function variableFromMeasure(value: string | null): string | null { return value ? value.replace(/_(amount|returns|claims|count|total|collections|projected_amount)$/, "") @@ -162,29 +209,13 @@ function legacyVariable( function parseDottedTarget( name: string, row: JsonObject, - nationalGeography: string, + _nationalGeography: string, ): ParsedLegacyTarget | null { if (!name.includes(".")) return null; const metadata = asObject(row.metadata); const registry = asObject(row.registry); const parts = name.split("."); const source = stringValue(registry.family) ?? parts[0] ?? ""; - const geoLevel = stringValue(metadata.ledger_geography_level); - const geoId = stringValue(metadata.ledger_geography_id); - const geography = - geoLevel === "country" - ? nationalGeography - : geoLevel === "congressional_district" - ? districtFromGeoId(geoId) ?? "" - : stateFromGeoId(geoId) ?? stringValue(metadata.state) ?? ""; - const level = - geoLevel === "country" - ? "national" - : geoLevel === "state" - ? "state" - : geoLevel === "congressional_district" - ? "congressional_district" - : ""; const measureId = stringValue(metadata.source_measure_id) ?? parts.at(-1) ?? ""; const variable = readableToken(stringValue(metadata.variable)) ?? @@ -202,7 +233,7 @@ function parseDottedTarget( .filter((value): value is string => Boolean(value && value !== variable)) .join(" · "); - return { geography, level, source, variable, breakdown }; + return { geography: "", level: "", source, variable, breakdown }; } function parseSlashTarget(name: string, nationalGeography: string): ParsedLegacyTarget { @@ -276,10 +307,12 @@ export function readLegacyTarget( parseDottedTarget(name, row, nationalGeography) ?? parseSlashTarget(name, nationalGeography); const publisher = chroniclePublisherFromMetadata(metadata); + const metadataGeography = geographyFromMetadata(metadata, nationalGeography); return { ...parsed, - geography: decomposition?.geography ?? parsed.geography, - level: decomposition?.level ?? parsed.level, + geography: + decomposition?.geography ?? metadataGeography?.geography ?? parsed.geography, + level: decomposition?.level ?? metadataGeography?.level ?? parsed.level, source: publisher ?? parsed.source, variable: legacyVariable(name, row, decomposition) ?? parsed.variable, breakdown: decomposition diff --git a/frontend/lib/microcosm/structured-dimension-reader.ts b/frontend/lib/microcosm/structured-dimension-reader.ts index 6ca60d8..3a6ea3a 100644 --- a/frontend/lib/microcosm/structured-dimension-reader.ts +++ b/frontend/lib/microcosm/structured-dimension-reader.ts @@ -19,6 +19,9 @@ export interface StructuredTargetDimension { export interface StructuredDimensions { geography: string | null; + geographyId: string | null; + geographyDimensionId: string | null; + geographyRank: number | null; level: string | null; dimensions: StructuredTargetDimension[]; } @@ -84,6 +87,9 @@ export function readStructuredDimensions( definitions: Record, ): StructuredDimensions { let geography: string | null = null; + let geographyId: string | null = null; + let geographyDimensionId: string | null = null; + let geographyRank: number | null = null; let level: string | null = null; const dimensions: StructuredTargetDimension[] = []; @@ -98,8 +104,16 @@ export function readStructuredDimensions( (definition?.values ? Object.keys(definition.values) : undefined); const rank = rankOrder?.indexOf(rawValue) ?? -1; if (definition?.role === "geography") { - geography ??= value; - level ??= definition.level ?? "region"; + if (geography != null) { + throw new Error( + "Structured target dimensions must contain at most one populated geography-role dimension.", + ); + } + geography = value; + geographyId = rawValue; + geographyDimensionId = id; + geographyRank = rank >= 0 ? rank : null; + level = definition.level ?? "region"; continue; } dimensions.push({ @@ -112,5 +126,12 @@ export function readStructuredDimensions( }); } - return { geography, level, dimensions }; + return { + geography, + geographyId, + geographyDimensionId, + geographyRank, + level, + dimensions, + }; } diff --git a/frontend/lib/microcosm/structured-target-reader.ts b/frontend/lib/microcosm/structured-target-reader.ts index 3213d78..8dabd30 100644 --- a/frontend/lib/microcosm/structured-target-reader.ts +++ b/frontend/lib/microcosm/structured-target-reader.ts @@ -15,6 +15,9 @@ export interface StructuredTargetIdentity { variableLabel: string | null; measure: string | null; geography: string; + geographyId: string | null; + geographyDimensionId: string | null; + geographyRank: number | null; level: string; dimensions: StructuredTargetDimension[]; breakdown: string; @@ -54,6 +57,9 @@ export function readStructuredTarget( variableLabel: stringValue(variable.label), measure: stringValue(variable.measure), geography, + geographyId: structured.geographyId, + geographyDimensionId: structured.geographyDimensionId, + geographyRank: structured.geographyRank, level, dimensions, breakdown: dimensions.map((dimension) => dimension.value).join(" · "), From 1167fecbb61b05cefc525e8317ffc2c0f474eb44 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:21:04 +0400 Subject: [PATCH 2/3] Test structured category measurement grouping --- .../lib/microcosm/latest-artifact.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/frontend/lib/microcosm/latest-artifact.test.ts b/frontend/lib/microcosm/latest-artifact.test.ts index 7b11fdd..ff59e6d 100644 --- a/frontend/lib/microcosm/latest-artifact.test.ts +++ b/frontend/lib/microcosm/latest-artifact.test.ts @@ -2271,6 +2271,73 @@ test("fully structured targets ignore conflicting legacy identity fields", () => }); }); +test("structured categories group count and total rows by variable identity", () => { + const target = (name: string, measure: "count" | "total") => ({ + name: `${name}@2025`, + target_name: name, + source: { id: "hmrc", citation: "HMRC SPI" }, + variable: { id: "spi_employment_income", measure }, + dimensions: { + geography_country: "K02000001", + total_income_lower_bound: "100000", + }, + metadata: {}, + target: 100, + initial_estimate: 90, + final_estimate: 100, + }); + const cal = buildCalibration( + { + schema_version: 7, + dimensions: { + geography_country: { + label: "Country", + role: "geography", + level: "country", + values: { K02000001: "United Kingdom" }, + order: ["K02000001"], + }, + total_income_lower_bound: { + label: "Total Income Lower Bound", + values: { "100000": "100000" }, + order: ["100000"], + }, + }, + targets: [ + target("hmrc/employment_income_amount_band_100000", "total"), + target("hmrc/employment_income_count_band_100000", "count"), + ], + }, + "uk-schema-7-categories", + null, + {}, + {}, + {}, + "uk", + ); + const tree = buildCalibrationTree(cal.rows, { + breakdown: "program", + path: { dimensions: [] }, + filters: { + geographyLevels: [], + geographies: [], + fitBands: [], + calibrationStatuses: [], + }, + }); + + expect(cal.target_schema.target_representation).toBe("structured"); + expect(cal.rows.map((row) => row.measure)).toEqual(["total", "count"]); + expect(cal.rows.every((row) => row.dimension_adapter === "structured")).toBe(true); + expect(tree.groups).toHaveLength(1); + expect(tree.groups[0].nodes).toHaveLength(1); + expect(tree.groups[0].nodes[0]).toMatchObject({ + id: "spi_employment_income", + label: "Spi employment income", + metrics: { nTargets: 2 }, + }); +}); + test("structured dimension ids remain independent when display labels repeat", () => { const structuredTargets = [ ["north", "east"], From 798883d156bb405cfe5e94bd10c4bfb8cd174b8d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:13:52 +0400 Subject: [PATCH 3/3] Preserve structured category labels --- .../microcosm/calibration-explorer-map.tsx | 2 +- .../calibration-explorer-view.test.ts | 56 ++++++++++++++++++- .../microcosm/calibration-explorer-view.ts | 11 ++-- .../microcosm/staging-target-change-map.tsx | 2 +- .../microcosm/calibration-prefetch.test.ts | 1 + .../lib/microcosm/calibration-tree.test.ts | 25 +++++++++ frontend/lib/microcosm/calibration-tree.ts | 42 +++++++++++++- 7 files changed, 129 insertions(+), 10 deletions(-) diff --git a/frontend/components/microcosm/calibration-explorer-map.tsx b/frontend/components/microcosm/calibration-explorer-map.tsx index 1a55bfa..6143c74 100644 --- a/frontend/components/microcosm/calibration-explorer-map.tsx +++ b/frontend/components/microcosm/calibration-explorer-map.tsx @@ -632,7 +632,7 @@ export function CalibrationExplorerMap({ const upLabel = expandedView ? `Up to all ${data.currentLevel.label.toLowerCase()}` : explorerUpLabel(state); - const breadcrumbs = explorerBreadcrumbs(state); + const breadcrumbs = explorerBreadcrumbs(state, data.pathLabels); const selectedTarget = data.groups .flatMap((group) => group.nodes) .find((item) => item.kind === "target" && item.id === state.path.target)?.target; diff --git a/frontend/components/microcosm/calibration-explorer-view.test.ts b/frontend/components/microcosm/calibration-explorer-view.test.ts index 05d3ef3..f8eeb64 100644 --- a/frontend/components/microcosm/calibration-explorer-view.test.ts +++ b/frontend/components/microcosm/calibration-explorer-view.test.ts @@ -50,18 +50,18 @@ describe("calibration explorer presentation model", () => { ); }); - test("resolves program labels at the final presentation boundary", () => { + test("uses the program label resolved by the tree", () => { expect( explorerNodeLabel({ id: "taxable interest income", - label: "taxable interest income", + label: "Taxable interest income", kind: "program", }), ).toBe("Taxable interest income"); expect( explorerNodeLabel({ id: "refundable ctc", - label: "refundable ctc", + label: "Refundable CTC", kind: "program", }), ).toBe("Refundable CTC"); @@ -246,6 +246,56 @@ describe("calibration explorer presentation model", () => { }); }); + test("prefers artifact source and category labels in breadcrumbs", () => { + expect( + explorerBreadcrumbs( + state({ + source: "obr", + program: "efo_receipts", + geography: "United Kingdom", + dimensions: [], + }), + { + source: "Office for Budget Responsibility", + program: "EFO receipts", + }, + ), + ).toEqual([ + { label: "All targets", path: { dimensions: [] } }, + { + label: "Office for Budget Responsibility", + path: { dimensions: [] }, + }, + { + label: "EFO receipts", + path: { + source: "obr", + program: "efo_receipts", + dimensions: [], + }, + }, + { + label: "United Kingdom", + path: { + source: "obr", + program: "efo_receipts", + geography: "United Kingdom", + dimensions: [], + }, + }, + ]); + }); + + test("preserves an artifact category label on a program tile", () => { + expect( + explorerNodeLabel({ + id: "efo_receipts", + kind: "program", + label: "EFO receipts", + }), + ).toBe("EFO receipts"); + }); + test("distinguishes an empty filtered result from an invalid hierarchy scope", () => { expect(explorerEmptyMessage(state({ dimensions: [] }, true))).toContain( "filters", diff --git a/frontend/components/microcosm/calibration-explorer-view.ts b/frontend/components/microcosm/calibration-explorer-view.ts index 7d41ee7..621e9fd 100644 --- a/frontend/components/microcosm/calibration-explorer-view.ts +++ b/frontend/components/microcosm/calibration-explorer-view.ts @@ -53,7 +53,10 @@ export interface ExplorerBreadcrumb { path: ExplorerPath; } -export function explorerBreadcrumbs(state: ExplorerState): ExplorerBreadcrumb[] { +export function explorerBreadcrumbs( + state: ExplorerState, + pathLabels: { source?: string; program?: string } = {}, +): ExplorerBreadcrumb[] { const crumbs: ExplorerBreadcrumb[] = [ { label: "All targets", path: { dimensions: [] } }, ]; @@ -65,14 +68,14 @@ export function explorerBreadcrumbs(state: ExplorerState): ExplorerBreadcrumb[] } if (state.path.source && state.path.program) { crumbs.push({ - label: sourceLabel(state.path.source), + label: pathLabels.source ?? sourceLabel(state.path.source), path: state.breakdown === "geography" && state.path.geography ? { geography: state.path.geography, dimensions: [] } : { dimensions: [] }, }); crumbs.push({ - label: programLabel(state.path.program), + label: pathLabels.program ?? programLabel(state.path.program), path: { source: state.path.source, program: state.path.program, @@ -183,7 +186,7 @@ export function explorerMapHeight(pageIntroHeight: number): string { export function explorerNodeLabel( node: Pick, ): string { - if (node.kind === "program") return programLabel(node.id); + if (node.kind === "program") return node.label || programLabel(node.id); if (node.kind === "dimension_value") return canonicalLabel(node.label); return node.label; } diff --git a/frontend/components/microcosm/staging-target-change-map.tsx b/frontend/components/microcosm/staging-target-change-map.tsx index a6e8bee..b9eaef9 100644 --- a/frontend/components/microcosm/staging-target-change-map.tsx +++ b/frontend/components/microcosm/staging-target-change-map.tsx @@ -442,7 +442,7 @@ export function StagingTargetChangeMap({ } const directions = layoutDirections(data, size.width, size.height, expanded); - const breadcrumbs = explorerBreadcrumbs(state); + const breadcrumbs = explorerBreadcrumbs(state, data.pathLabels); const upLabel = expanded ? `Up to ${data.currentLevel.label.toLowerCase()}` : explorerUpLabel(state); return ( diff --git a/frontend/lib/microcosm/calibration-prefetch.test.ts b/frontend/lib/microcosm/calibration-prefetch.test.ts index bc2f34a..5a15233 100644 --- a/frontend/lib/microcosm/calibration-prefetch.test.ts +++ b/frontend/lib/microcosm/calibration-prefetch.test.ts @@ -28,6 +28,7 @@ function response( return { lossAttributionAvailable: false, path: state.path, + pathLabels: {}, currentLevel: { kind: "overview", label: "Test" }, groups: [{ id: "test", label: "Test", nodes, metrics: EMPTY_METRICS }], dimensionOrder: [], diff --git a/frontend/lib/microcosm/calibration-tree.test.ts b/frontend/lib/microcosm/calibration-tree.test.ts index e2e26d9..378b15e 100644 --- a/frontend/lib/microcosm/calibration-tree.test.ts +++ b/frontend/lib/microcosm/calibration-tree.test.ts @@ -234,6 +234,31 @@ describe("source, geography, and declared-dimension hierarchy", () => { expect(tree.groups[0].label).toBe("Nova Statistics Agency"); }); + test("retains artifact source and category labels throughout the selected path", () => { + const tree = buildCalibrationTree( + [ + target("obr-income-tax", { + source: "obr", + source_label: "Office for Budget Responsibility", + variable: "efo_receipts", + variable_label: "EFO receipts", + geography: "United Kingdom", + }), + ], + state({ + source: "obr", + program: "efo_receipts", + dimensions: [], + }), + ); + + expect(tree.pathLabels).toEqual({ + source: "Office for Budget Responsibility", + program: "EFO receipts", + }); + expect(tree.groups[0].label).toBe("EFO receipts"); + }); + test("renders geography first, then programs grouped by their source", () => { const overview = buildCalibrationTree( rows, diff --git a/frontend/lib/microcosm/calibration-tree.ts b/frontend/lib/microcosm/calibration-tree.ts index cc64c83..2ede3bd 100644 --- a/frontend/lib/microcosm/calibration-tree.ts +++ b/frontend/lib/microcosm/calibration-tree.ts @@ -85,6 +85,10 @@ export interface CalibrationTreeResponse { releaseId?: string; lossAttributionAvailable: boolean; path: ExplorerState["path"]; + pathLabels: { + source?: string; + program?: string; + }; currentLevel: | { kind: "overview"; label: string } | { kind: "geography"; label: string } @@ -484,6 +488,36 @@ function programGroups(rows: CalibrationTreeTarget[]): CalibrationTreeGroup[] { ); } +function pathLabels( + rows: CalibrationTreeTarget[], + path: ExplorerState["path"], +): CalibrationTreeResponse["pathLabels"] { + if (!path.source) return {}; + const sourceRows = rows.filter( + (row) => String(row.source ?? "other") === path.source, + ); + const artifactSourceLabel = sourceRows + .map((row) => row.source_label?.trim()) + .find((label): label is string => Boolean(label)); + const labels: CalibrationTreeResponse["pathLabels"] = { + source: artifactSourceLabel ?? sourceLabel(path.source), + }; + if (!path.program) return labels; + const programRows = sourceRows.filter((row) => programId(row) === path.program); + const artifactProgramLabels = [ + ...new Set( + programRows + .map((row) => row.variable_label?.trim()) + .filter((label): label is string => Boolean(label)), + ), + ]; + labels.program = programLabel( + path.program, + artifactProgramLabels.length === 1 ? artifactProgramLabels[0] : null, + ); + return labels; +} + function geographyNodes(rows: CalibrationTreeTarget[]): CalibrationTreeNode[] { const byGeography = groupRows(rows, geographyId); return sortNodes( @@ -508,6 +542,7 @@ export function buildCalibrationTree( ): CalibrationTreeResponse { const chartRows = allRows.map(normalizeChartCalibrationStatus); const { path } = state; + const selectedPathLabels = pathLabels(chartRows, path); const options = filterOptions(chartRows); const filteredRows = applyExplorerFilters(chartRows, state.filters); @@ -517,6 +552,7 @@ export function buildCalibrationTree( releaseId, lossAttributionAvailable, path, + pathLabels: selectedPathLabels, currentLevel: { kind: "geography", label: "Geography" }, groups: [{ id: "geography", @@ -542,6 +578,7 @@ export function buildCalibrationTree( releaseId, lossAttributionAvailable, path, + pathLabels: selectedPathLabels, currentLevel: { kind: "overview", label: "Programs" }, groups: programGroups(filteredGeographyRows), dimensionOrder: [], @@ -555,6 +592,7 @@ export function buildCalibrationTree( releaseId, lossAttributionAvailable, path, + pathLabels: selectedPathLabels, currentLevel: { kind: "overview", label: "Programs" }, groups: programGroups(filteredRows), dimensionOrder: [], @@ -575,10 +613,11 @@ export function buildCalibrationTree( releaseId, lossAttributionAvailable, path, + pathLabels: selectedPathLabels, currentLevel: { kind: "geography", label: "Geography" }, groups: [{ id: path.program, - label: path.program, + label: selectedPathLabels.program ?? programLabel(path.program), nodes, metrics: calibrationTreeMetrics(filteredProgramRows), }], @@ -677,6 +716,7 @@ export function buildCalibrationTree( releaseId, lossAttributionAvailable, path, + pathLabels: selectedPathLabels, currentLevel, groups, dimensionOrder,