From 34c84813967bfa266a177a349101d1152838d788 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:10:07 +0400 Subject: [PATCH 1/4] Test that the dashboard reads diagnostics schema 9's hierarchy catalogs Diagnostics schema 9 declares provider, category, geography and dimension labels once, in a top-level `hierarchy_catalogs` object, and each target row refers to them by ID; the target's own label stays on the row. These tests describe how the dashboard should read it: - a schema 9 file reads into exactly the rows of the equivalent schema 8 file, with no label variants, and reports schema version 9 with the hierarchy representation; - compiled under the recorded schema 8 file's identity, it builds the recorded UK release tree and reader responses byte for byte; - release reconciliation publishes a schema 9 release whose uploaded files equal the schema 8 release's apart from the build ID and the source digest, which come from the file's bytes; - a structurally inconsistent file is refused with a message naming the problem: missing catalogs, an ID listed twice in a catalog or in one dimension's values, a category whose provider is not listed, a row that refers to an unknown category, geography, dimension or value (including a value listed only under another dimension), a row that lists a dimension twice, a row still in the schema 8 layout, and a hierarchy target that is not the row's target; - schema version 9 maps to the hierarchy reader, and 10 is refused. Test support gains `schema9Diagnostics`, which writes a schema 8 document in the schema 9 layout the way Microcosm's writer will, and `goldenReleaseRecordFromDocument`, which compiles a recorded release from another document under the recorded file's identity. Tests only. Every new test fails because the reader refuses schema 9 ("Unsupported calibration diagnostics schema_version 9"). Co-Authored-By: Claude Opus 5.5 --- .../src/latest-artifact.test.ts | 3 +- .../src/publish-calibration-tree.test.ts | 33 +++ .../src/schema-9-diagnostics.test.ts | 229 ++++++++++++++++++ .../src/target-representation.test.ts | 5 +- .../calibration-tree-golden-cases.ts | 27 ++- .../src/test-support/schema-9-diagnostics.ts | 96 ++++++++ 6 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts create mode 100644 frontend/packages/calibration-tree/src/test-support/schema-9-diagnostics.ts diff --git a/frontend/packages/calibration-tree/src/latest-artifact.test.ts b/frontend/packages/calibration-tree/src/latest-artifact.test.ts index 2839370c..022f6082 100644 --- a/frontend/packages/calibration-tree/src/latest-artifact.test.ts +++ b/frontend/packages/calibration-tree/src/latest-artifact.test.ts @@ -403,7 +403,8 @@ test("top-level schema version selects one reader and rejects unsupported versio "invalid-schema-8", ), ).toThrow("hierarchy must be an object"); - for (const schemaVersion of [undefined, 0, 9]) { + // Schema 9 is read (schema-9-diagnostics.test.ts); 10 is not. + for (const schemaVersion of [undefined, 0, 10]) { expect(() => buildCalibration( { schema_version: schemaVersion, targets: [] }, diff --git a/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts b/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts index e52a889b..067564b8 100644 --- a/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts +++ b/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts @@ -18,6 +18,7 @@ import { type CalibrationTreeManifestEntry, } from "./calibration-tree-manifest"; import type { CalibrationTreeBundle } from "./calibration-tree-bundle"; +import { schema9Diagnostics } from "./test-support/schema-9-diagnostics"; const originalFetch = globalThis.fetch; @@ -894,6 +895,38 @@ test("a dry run records a failed release, writes nothing and still reports the r } }); +test("release reconciliation publishes a schema 9 release with the tree of its schema 8 equivalent", async () => { + const schema8Store = lineStoreWithJuneLatest(); + const schema9Store = lineStoreWithJuneLatest(); + try { + globalThis.fetch = lineRepositoryFetch([]); + await reconcileReleaseBuilds("uk", "blob-token", false, schema8Store); + globalThis.fetch = lineRepositoryFetch([], () => schema9Diagnostics(SCHEMA_8_DIAGNOSTICS)); + const report = await reconcileReleaseBuilds("uk", "blob-token", false, schema9Store); + + expect(report.outcomes.filter((outcome) => outcome.status === "published") + .map((outcome) => outcome.sourceId).sort()).toEqual([DENSE_CUT, NATIONAL_CUT].sort()); + // The source digest, and so the build ID, comes from the file's bytes. + // With both replaced, every uploaded file is the same as for schema 8, + // except that the index records each part's SHA-256 and compressed size, + // which cover the build ID inside the part; those two fields are left out. + const withoutIdentity = (bundle: CalibrationTreeBundle) => + bundle.files.map((file) => { + const text = file.serialized + .replaceAll(bundle.index.buildArtifactId, "") + .replaceAll(bundle.index.build.sourceArtifacts.calibrationDiagnostics?.sha256 ?? "", ""); + if (file.part !== "index") return text; + return JSON.stringify(JSON.parse(text), (key, value) => + key === "sha256" || key === "gzipBytes" ? undefined : value + ); + }); + expect(schema9Store.uploads.map(withoutIdentity)) + .toEqual(schema8Store.uploads.map(withoutIdentity)); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("the UK latest release is the cut its line pointer names", async () => { const requested: string[] = []; globalThis.fetch = lineRepositoryFetch(requested); diff --git a/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts b/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts new file mode 100644 index 00000000..f7dbacbc --- /dev/null +++ b/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test"; + +import recordedGoldens from "./fixtures/calibration-tree-golden.json"; +import { buildCalibration } from "./latest-artifact"; +import { + goldenReleaseInputs, + goldenReleaseRecordFromDocument, + type CalibrationTreeGoldenRecord, +} from "./test-support/calibration-tree-golden-cases"; +import { schema9Diagnostics } from "./test-support/schema-9-diagnostics"; + +type JsonObject = Record; + +const recorded = recordedGoldens as CalibrationTreeGoldenRecord; +const BUILD_TIMEOUT_MS = 300_000; +const RELEASE_ID = "microcosm-uk-schema-9-test"; + +function ukSchema8(): JsonObject { + return JSON.parse(goldenReleaseInputs()["uk-release"].diagnosticsText) as JsonObject; +} + +// Two US-style rows with two dimensions each, in producer order, so the +// expansion must keep each row's dimension order and look values up under +// their own dimension: both dimensions have a value `all`. +function usSchema8(): JsonObject { + const row = ( + name: string, + band: { id: string; label: string }, + status: { id: string; label: string }, + ) => ({ + name: `${name}@2024`, + target_name: name, + source: "IRS SOI citation", + target: 100, + initial_estimate: 90, + final_estimate: 101, + hierarchy: { + provider: { id: "irs_soi", label: "IRS Statistics of Income" }, + category: { id: "irs_soi.eitc", label: "Earned income tax credit", provider_id: "irs_soi" }, + geography: { id: "0100000US", label: "United States", level: "country" }, + dimensions: [ + { + id: "us:statutes/26/62#adjusted_gross_income", + label: "Adjusted gross income", + value_id: band.id, + value_label: band.label, + }, + { id: "filing_status", label: "Filing status", value_id: status.id, value_label: status.label }, + ], + target: { id: name, label: `EITC, ${band.label}, ${status.label}` }, + }, + }); + return { + schema_version: 8, + targets: [ + row( + "irs_soi.eitc.under_1.all", + { id: "under_1", label: "No adjusted gross income" }, + { id: "all", label: "All filing statuses" }, + ), + row( + "irs_soi.eitc.all.joint", + { id: "all", label: "All returns" }, + { id: "joint", label: "Married filing jointly" }, + ), + ], + }; +} + +function calibrationOf(document: JsonObject) { + return buildCalibration(document, RELEASE_ID, null, {}, {}, {}, "uk"); +} + +function catalogs(document: JsonObject): Record { + return document.hierarchy_catalogs as Record; +} + +function rowHierarchy(document: JsonObject, index: number): JsonObject { + return (document.targets as JsonObject[])[index].hierarchy as JsonObject; +} + +function refusal(mutate: (document: JsonObject) => void): string { + const document = schema9Diagnostics(ukSchema8()); + mutate(document); + try { + calibrationOf(document); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error("The schema 9 document was accepted."); +} + +describe("schema 9 calibration diagnostics", () => { + test("a schema 9 file reads into the rows of the equivalent schema 8 file", () => { + for (const schema8 of [ukSchema8(), usSchema8()]) { + const fromSchema8 = calibrationOf(schema8); + const fromSchema9 = calibrationOf(schema9Diagnostics(schema8)); + + expect(fromSchema9.rows).toEqual(fromSchema8.rows); + expect(fromSchema9.hierarchy_label_variants).toEqual([]); + expect(fromSchema9.schema_version).toBe(9); + expect(fromSchema9.target_schema).toEqual({ + diagnostics_schema_version: 9, + structured_dimensions: false, + target_representation: "hierarchy", + }); + } + }); + + test( + "a schema 9 file builds the recorded UK release tree byte for byte", + async () => { + // Compiled under the recorded schema 8 file's identity (its source + // digest and so its build ID); every file and reader response must + // match the recorded schema 8 tree. + expect( + await goldenReleaseRecordFromDocument("uk-release", schema9Diagnostics(ukSchema8())), + ).toEqual(recorded.sources["uk-release"]); + }, + BUILD_TIMEOUT_MS, + ); + + test("a document without hierarchy catalogs is refused", () => { + expect(refusal((document) => { + delete document.hierarchy_catalogs; + })).toBe("Schema 9 diagnostics must have a hierarchy_catalogs object."); + expect(refusal((document) => { + delete catalogs(document).geographies; + })).toBe("Schema 9 hierarchy_catalogs.geographies must be an array."); + }); + + test("a catalog that lists one ID twice is refused", () => { + expect(refusal((document) => { + const geographies = catalogs(document).geographies; + geographies.push({ ...geographies[0], label: "UK" }); + })).toBe("Schema 9 hierarchy_catalogs.geographies lists K02000001 more than once."); + expect(refusal((document) => { + catalogs(document).providers.push({ id: "obr", label: "OBR" }); + })).toBe("Schema 9 hierarchy_catalogs.providers lists obr more than once."); + expect(refusal((document) => { + const categories = catalogs(document).categories; + categories.push({ ...categories[0] }); + })).toBe("Schema 9 hierarchy_catalogs.categories lists obr.efo_receipts more than once."); + expect(refusal((document) => { + const dimensions = catalogs(document).dimensions; + dimensions.push({ ...dimensions[0] }); + })).toBe( + "Schema 9 hierarchy_catalogs.dimensions lists obr.efo_receipts_line more than once.", + ); + expect(refusal((document) => { + const values = catalogs(document).dimensions[0].values as JsonObject[]; + values.push({ id: "income_tax", label: "Income tax" }); + })).toBe( + "Schema 9 hierarchy_catalogs.dimensions entry obr.efo_receipts_line lists value " + + "income_tax more than once.", + ); + }); + + test("a category whose provider the catalog does not list is refused", () => { + expect(refusal((document) => { + catalogs(document).categories[0].provider_id = "hmrc"; + })).toBe( + "Schema 9 category obr.efo_receipts refers to provider hmrc, which " + + "hierarchy_catalogs.providers does not list.", + ); + }); + + test("a row that refers to an unknown category, geography or dimension is refused", () => { + expect(refusal((document) => { + rowHierarchy(document, 0).category_id = "obr.efo_spending"; + })).toBe( + "Schema 9 target obr.income_tax.K02000001@2025 refers to category obr.efo_spending, " + + "which hierarchy_catalogs.categories does not list.", + ); + expect(refusal((document) => { + rowHierarchy(document, 0).geography_id = "W92000004"; + })).toBe( + "Schema 9 target obr.income_tax.K02000001@2025 refers to geography W92000004, " + + "which hierarchy_catalogs.geographies does not list.", + ); + expect(refusal((document) => { + (rowHierarchy(document, 0).dimensions as JsonObject[])[0].id = "obr.efo_line"; + })).toBe( + "Schema 9 target obr.income_tax.K02000001@2025 refers to dimension obr.efo_line, " + + "which hierarchy_catalogs.dimensions does not list.", + ); + }); + + test("a row that refers to a value under the wrong dimension is refused", () => { + // `under_1` is listed, but under the income-band dimension, not under + // filing status. + const document = schema9Diagnostics(usSchema8()); + (rowHierarchy(document, 0).dimensions as JsonObject[])[1].value_id = "under_1"; + expect(() => calibrationOf(document)).toThrow( + "Schema 9 target irs_soi.eitc.under_1.all@2024 refers to value under_1 of dimension " + + "filing_status, which that dimension does not list.", + ); + }); + + test("a row that lists one dimension twice is refused", () => { + const document = schema9Diagnostics(usSchema8()); + const dimensions = rowHierarchy(document, 0).dimensions as JsonObject[]; + dimensions[1] = { ...dimensions[0] }; + expect(() => calibrationOf(document)).toThrow( + "Schema 9 target irs_soi.eitc.under_1.all@2024 lists dimension " + + "us:statutes/26/62#adjusted_gross_income more than once.", + ); + }); + + test("a row in the schema 8 layout is refused under schema version 9", () => { + const schema8 = ukSchema8(); + expect(() => calibrationOf({ + ...schema9Diagnostics(schema8), + targets: schema8.targets, + })).toThrow( + "Schema 9 target obr.income_tax.K02000001@2025 hierarchy.category_id must be a " + + "non-empty string.", + ); + }); + + test("a row whose hierarchy target is another target is refused", () => { + expect(refusal((document) => { + (rowHierarchy(document, 0).target as JsonObject).id = "obr.national_insurance.K02000001"; + })).toBe( + "Schema 9 target obr.income_tax.K02000001@2025 hierarchy.target.id " + + "obr.national_insurance.K02000001 does not match target_name obr.income_tax.K02000001.", + ); + }); +}); diff --git a/frontend/packages/calibration-tree/src/target-representation.test.ts b/frontend/packages/calibration-tree/src/target-representation.test.ts index 6d3313ca..64b0effc 100644 --- a/frontend/packages/calibration-tree/src/target-representation.test.ts +++ b/frontend/packages/calibration-tree/src/target-representation.test.ts @@ -13,10 +13,13 @@ describe("calibration target representation", () => { } expect(targetRepresentationForSchema(7)).toBe("structured"); expect(targetRepresentationForSchema(8)).toBe("hierarchy"); + // Schema 9 declares hierarchy labels in catalogs; its reader expands + // them into the schema 8 rows, so it shares the hierarchy reader. + expect(targetRepresentationForSchema(9)).toBe("hierarchy"); }); test("rejects versions without a supported compatibility contract", () => { - for (const schemaVersion of [undefined, null, "8", 0, 9, 7.5]) { + for (const schemaVersion of [undefined, null, "8", 0, 10, 7.5]) { expect(() => targetRepresentationForSchema(schemaVersion)).toThrow( UnsupportedCalibrationDiagnosticsSchemaError, ); 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 7c8b8db9..afc5af61 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 @@ -514,10 +514,15 @@ export async function withHuggingFaceFiles( } } -function buildReleaseSource(input: GoldenReleaseInput): GoldenSourceBuild { +// `diagnosticsJson` replaces the parsed document while every identity input +// (the source digest, and so the build ID) stays the recorded file's. +function buildReleaseSource( + input: GoldenReleaseInput, + diagnosticsJson: JsonObject = JSON.parse(input.diagnosticsText) as JsonObject, +): GoldenSourceBuild { const prefix = `releases/${input.releaseId}`; const diagnostics = { - json: JSON.parse(input.diagnosticsText) as JsonObject, + json: diagnosticsJson, source: { path: `${prefix}/calibration_diagnostics.json`, sha256: sha256Text(input.diagnosticsText), @@ -925,6 +930,24 @@ export async function goldenSourceRecord(name: GoldenSourceCaseName): Promise { + return withRegisteredRepositories(async () => { + const { bundle } = buildReleaseSource(goldenReleaseInputs()[name], diagnosticsJson); + return { ...bundleRecord(bundle), readers: readerRecord(bundle) }; + }); +} + const COMPARISON_SOURCES: Record< GoldenComparisonCaseName, { current: GoldenSourceCaseName; candidate: GoldenSourceCaseName } diff --git a/frontend/packages/calibration-tree/src/test-support/schema-9-diagnostics.ts b/frontend/packages/calibration-tree/src/test-support/schema-9-diagnostics.ts new file mode 100644 index 00000000..21eaefd9 --- /dev/null +++ b/frontend/packages/calibration-tree/src/test-support/schema-9-diagnostics.ts @@ -0,0 +1,96 @@ +// Test support: write a schema 8 calibration diagnostics document in the +// schema 9 layout, the way Microcosm's writer does. Provider, category, +// geography and dimension labels move into `hierarchy_catalogs`, declared +// once each, and every target row refers to them by ID. The target's own +// label stays on the row. Catalog entries are listed in the order the rows +// first use them. Like the writer, this refuses a document that labels one +// identifier more than one way, since a catalog holds one label. + +type JsonObject = Record; + +interface Schema8Hierarchy { + provider: { id: string; label: string }; + category: { id: string; label: string; provider_id: string }; + geography: { id: string; label: string; level: string }; + dimensions: Array<{ id: string; label: string; value_id: string; value_label: string }>; + target: { id: string; label: string }; +} + +function recordOnce( + entries: Map, + id: string, + entry: Entry, + description: string, +): Entry { + const known = entries.get(id); + if (known === undefined) { + entries.set(id, entry); + return entry; + } + if (JSON.stringify(known) !== JSON.stringify(entry)) { + throw new Error( + `Cannot write ${description} ${id} in a catalog: rows give it ` + + `${JSON.stringify(known)} and ${JSON.stringify(entry)}.`, + ); + } + return known; +} + +export function schema9Diagnostics(schema8: JsonObject): JsonObject { + if (schema8.schema_version !== 8) { + throw new Error("Only a schema 8 document can be written as schema 9."); + } + const providers = new Map(); + const categories = new Map(); + const geographies = new Map(); + const dimensions = new Map(); + const dimensionValues = new Map>(); + const targets = (schema8.targets as JsonObject[]).map((row) => { + const hierarchy = row.hierarchy as Schema8Hierarchy; + recordOnce(providers, hierarchy.provider.id, { ...hierarchy.provider }, "provider"); + recordOnce(categories, hierarchy.category.id, { ...hierarchy.category }, "category"); + recordOnce(geographies, hierarchy.geography.id, { ...hierarchy.geography }, "geography"); + for (const dimension of hierarchy.dimensions) { + recordOnce( + dimensions, + dimension.id, + { id: dimension.id, label: dimension.label }, + "dimension", + ); + const values = dimensionValues.get(dimension.id) ?? new Map(); + dimensionValues.set(dimension.id, values); + recordOnce( + values, + dimension.value_id, + { id: dimension.value_id, label: dimension.value_label }, + `dimension ${dimension.id} value`, + ); + } + return { + ...row, + hierarchy: { + category_id: hierarchy.category.id, + geography_id: hierarchy.geography.id, + dimensions: hierarchy.dimensions.map((dimension) => ({ + id: dimension.id, + value_id: dimension.value_id, + })), + target: { ...hierarchy.target }, + }, + }; + }); + return { + ...schema8, + schema_version: 9, + targets, + hierarchy_catalogs: { + providers: [...providers.values()], + categories: [...categories.values()], + geographies: [...geographies.values()], + dimensions: [...dimensions.values()].map((dimension) => ({ + ...dimension, + values: [...(dimensionValues.get(dimension.id)?.values() ?? [])], + })), + }, + }; +} From e49f7dd548184c8e093385a7271913356b5e55e6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:15:21 +0400 Subject: [PATCH 2/4] Read diagnostics schema 9 by expanding its hierarchy catalogs Diagnostics schema 9 declares provider, category, geography and dimension labels once, in top-level `hierarchy_catalogs`, and each target row refers to them by ID, so one geography ID or one dimension value ID cannot carry two labels. The target's own label stays on the row, and every other field is the same as in schema 8. `expandHierarchyCatalogs` (new module `hierarchy-catalog-reader`, exported by the package) turns a schema 9 document into a copy whose rows carry the schema 8 hierarchy, with the producer's key order and dimension order, and drops `hierarchy_catalogs`; `schema_version` stays 9. `buildCalibration` applies it before anything reads the targets, so the schema 8 reader, the tree builder, published trees and every route read both versions through the same code. Any other version is passed through as the same object, so schema 8 reading is unchanged. A structurally inconsistent schema 9 file is refused with a message naming the row or catalog entry: missing catalogs or fields, an ID listed twice, a category whose provider is not listed, a reference to an unlisted category, geography, dimension or value (a value is looked up only under its own dimension), a dimension listed twice on one row, a row in the schema 8 layout, or a hierarchy target that is not the row's target. `targetRepresentationForSchema(9)` selects the hierarchy reader. The docs describe the schema 9 layout, the refusals and the reader table. Co-Authored-By: Claude Opus 5.5 --- docs/calibration-map-hierarchy.md | 86 +++++- docs/spec-driven-countries.md | 4 +- .../packages/calibration-tree/package.json | 1 + .../src/hierarchy-catalog-reader.ts | 267 ++++++++++++++++++ .../calibration-tree/src/latest-artifact.ts | 6 +- .../src/target-representation.ts | 6 +- 6 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts diff --git a/docs/calibration-map-hierarchy.md b/docs/calibration-map-hierarchy.md index 75284a1f..4e751792 100644 --- a/docs/calibration-map-hierarchy.md +++ b/docs/calibration-map-hierarchy.md @@ -1,7 +1,7 @@ # Calibration map hierarchy The calibration map displays one producer-authored hierarchy for every target -in Microcosm diagnostics schema 8: +in Microcosm diagnostics schemas 8 and 9: 1. provider 2. category @@ -51,6 +51,74 @@ while preserving the order in which the dashboard must traverse dimensions. } ``` +## Schema 9 catalogs + +Schema 8 copies every label onto every row, so one geography or one dimension +value can be labelled differently on different rows (for example "Maine" and +"ME", or "No adjusted gross income" and "No adjusted gross income and +deficit"). Schema 9 declares provider, category, geography and dimension labels +once, in a top-level `hierarchy_catalogs` object, and each row refers to them by +ID. A schema 9 file therefore cannot give one ID two labels. Every other +top-level field and every other row field is the same as in schema 8, and the +target's own label stays on the row. + +```json +{ + "schema_version": 9, + "hierarchy_catalogs": { + "providers": [ + {"id": "obr", "label": "Office for Budget Responsibility"} + ], + "categories": [ + { + "id": "obr.efo_receipts", + "label": "Economic and fiscal outlook receipts", + "provider_id": "obr" + } + ], + "geographies": [ + {"id": "K02000001", "label": "United Kingdom", "level": "country"} + ], + "dimensions": [ + { + "id": "obr.efo_line", + "label": "Economic and fiscal outlook line", + "values": [ + {"id": "income_tax", "label": "Income tax (gross of tax credits)"} + ] + } + ] + }, + "targets": [ + { + "name": "obr.income_tax@2025", + "target_name": "obr.income_tax", + "hierarchy": { + "category_id": "obr.efo_receipts", + "geography_id": "K02000001", + "dimensions": [{"id": "obr.efo_line", "value_id": "income_tax"}], + "target": {"id": "obr.income_tax", "label": "Income tax receipts"} + } + } + ] +} +``` + +- A row names its category only; the provider is the category's. +- A row's `dimensions` keep producer order. Each `value_id` is looked up among + its own dimension's `values`, so two dimensions can both have a value `all`. +- Catalogs are arrays, so a repeated ID stays visible to a reader. +- A geography ID has one level as well as one label. + +The dashboard expands each schema 9 row into the schema 8 row above +(`expandHierarchyCatalogs` in `@policyengine/calibration-tree`) before any +other code reads it, so the schema 8 reader, the tree builder, published trees +and every page treat both versions the same way. A schema 9 file builds the same +tree files as the equivalent schema 8 file without label variants, apart from +the build ID and the recorded source digest, which come from the file's bytes. +The Calibration summary reports `diagnostics_schema_version` 9 and the +`hierarchy` representation. + Microcosm owns the provider/category relationship. Chronicle supplies geography, categorical dimensions, categorical value labels, and the preferred target label when its selected fact can describe the resulting target exactly. @@ -72,6 +140,7 @@ file. Target row structure is not used to choose a reader. | 2–6 | Legacy name/filter reader | | 7 | Structured source/variable/dimensions reader | | 8 | Normalized hierarchy reader | +| 9 | Normalized hierarchy reader, after expanding `hierarchy_catalogs` | An absent version, version 1, and unsupported future versions produce an explicit compatibility error. A schema 7 file containing a legacy-shaped row @@ -81,6 +150,21 @@ The schema 8 reader requires every identifier and label, verifies that the category references the row's provider, rejects duplicate dimension IDs, and verifies that `hierarchy.target.id` agrees with the diagnostic target ID. +Before that, a schema 9 file is refused, with a message naming the row or +catalog entry, when: + +- `hierarchy_catalogs` or one of its four arrays is missing, or an entry lacks + an ID or label (or a category its `provider_id`, a geography its `level`); +- a catalog lists one ID twice, or one dimension lists one value ID twice; +- a category's `provider_id` is not in `providers`; +- a row refers to a category, geography or dimension the catalogs do not list, + or to a value its own dimension does not list (including a value listed only + under another dimension); +- a row lists one dimension twice, or is still in the schema 8 layout; or +- a row's `hierarchy.target.id` differs from its `target_name`. + +IDs are compared after trimming whitespace, as the schema 8 reader trims them. + ## Tree construction The map first groups targets by provider and category, then by geography. It diff --git a/docs/spec-driven-countries.md b/docs/spec-driven-countries.md index 40a64175..58417280 100644 --- a/docs/spec-driven-countries.md +++ b/docs/spec-driven-countries.md @@ -234,7 +234,9 @@ inference. The top-level diagnostics schema selects exactly one target reader. Schemas 2–6 use the legacy reader, schema 7 uses the structured reader described above, and -schema 8 uses the normalized hierarchy reader. Missing versions, version 1, and +schemas 8 and 9 use the normalized hierarchy reader (schema 9 after its +hierarchy catalogs are expanded into schema 8 rows; see +[Calibration map hierarchy](calibration-map-hierarchy.md#schema-9-catalogs)). Missing versions, version 1, and unsupported future versions are rejected explicitly. A row that does not match its file's declared representation is rejected; the dashboard does not choose a different reader from that row's shape. diff --git a/frontend/packages/calibration-tree/package.json b/frontend/packages/calibration-tree/package.json index 2b68a6cf..7177affb 100644 --- a/frontend/packages/calibration-tree/package.json +++ b/frontend/packages/calibration-tree/package.json @@ -18,6 +18,7 @@ "./chronicle-metadata": "./src/chronicle-metadata.ts", "./countries": "./src/countries.ts", "./geography-labels": "./src/geography-labels.ts", + "./hierarchy-catalog-reader": "./src/hierarchy-catalog-reader.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/hierarchy-catalog-reader.ts b/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts new file mode 100644 index 00000000..68319dbf --- /dev/null +++ b/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts @@ -0,0 +1,267 @@ +// Diagnostics schema 9 declares each provider, category, geography and +// dimension label once, in top-level `hierarchy_catalogs`, and each target +// row refers to them by ID; only the target's own label stays on the row. +// One geography ID or one dimension value ID therefore has exactly one label +// in the file. This module expands a schema 9 document into the schema 8 row +// shape, so the schema 8 reader, the tree builder and every page read both +// versions through the same code: +// +// hierarchy_catalogs: { +// providers: [{ id, label }], +// categories: [{ id, label, provider_id }], +// geographies: [{ id, label, level }], +// dimensions: [{ id, label, values: [{ id, label }] }], +// } +// targets[].hierarchy: { +// category_id, geography_id, +// dimensions: [{ id, value_id }], // in producer order +// target: { id, label }, +// } +// +// The provider is the category's. A file that is structurally inconsistent +// (missing catalogs, an ID listed twice, a reference to an entry the catalogs +// do not list, a value looked up under a dimension that does not list it) is +// refused. IDs are compared after trimming whitespace, as the schema 8 reader +// trims them. + +type JsonObject = Record; + +export const HIERARCHY_CATALOG_SCHEMA_VERSION = 9; + +function isPlainObject(value: unknown): value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function requiredObject(value: unknown, message: string): JsonObject { + if (!isPlainObject(value)) throw new Error(message); + return value; +} + +function requiredString(value: unknown, message: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(message); + return value; +} + +function catalogEntries(catalogs: JsonObject, name: string): JsonObject[] { + const entries = catalogs[name]; + if (!Array.isArray(entries)) { + throw new Error(`Schema 9 hierarchy_catalogs.${name} must be an array.`); + } + return entries.map((entry, index) => + requiredObject(entry, `Schema 9 hierarchy_catalogs.${name}[${index}] must be an object.`) + ); +} + +/** Index entries by trimmed ID, refusing an ID listed twice. */ +function byId( + entries: Entry[], + duplicateMessage: (id: string) => string, +): Map { + const indexed = new Map(); + for (const entry of entries) { + const key = entry.id.trim(); + if (indexed.has(key)) throw new Error(duplicateMessage(key)); + indexed.set(key, entry); + } + return indexed; +} + +interface DimensionCatalogEntry { + id: string; + label: string; + values: Map; +} + +interface HierarchyCatalogs { + providers: Map; + categories: Map; + geographies: Map; + dimensions: Map; +} + +function readCatalogs(document: JsonObject): HierarchyCatalogs { + const catalogs = requiredObject( + document.hierarchy_catalogs, + "Schema 9 diagnostics must have a hierarchy_catalogs object.", + ); + const field = (catalog: string, index: number, name: string) => + `Schema 9 hierarchy_catalogs.${catalog}[${index}].${name} must be a non-empty string.`; + const listedTwice = (catalog: string) => (id: string) => + `Schema 9 hierarchy_catalogs.${catalog} lists ${id} more than once.`; + + const providers = byId( + catalogEntries(catalogs, "providers").map((entry, index) => ({ + id: requiredString(entry.id, field("providers", index, "id")), + label: requiredString(entry.label, field("providers", index, "label")), + })), + listedTwice("providers"), + ); + const categories = byId( + catalogEntries(catalogs, "categories").map((entry, index) => ({ + id: requiredString(entry.id, field("categories", index, "id")), + label: requiredString(entry.label, field("categories", index, "label")), + provider_id: requiredString(entry.provider_id, field("categories", index, "provider_id")), + })), + listedTwice("categories"), + ); + for (const category of categories.values()) { + if (!providers.has(category.provider_id.trim())) { + throw new Error( + `Schema 9 category ${category.id.trim()} refers to provider ` + + `${category.provider_id.trim()}, which hierarchy_catalogs.providers does not list.`, + ); + } + } + const geographies = byId( + catalogEntries(catalogs, "geographies").map((entry, index) => ({ + id: requiredString(entry.id, field("geographies", index, "id")), + label: requiredString(entry.label, field("geographies", index, "label")), + level: requiredString(entry.level, field("geographies", index, "level")), + })), + listedTwice("geographies"), + ); + const dimensions = byId( + catalogEntries(catalogs, "dimensions").map((entry, index) => { + const id = requiredString(entry.id, field("dimensions", index, "id")); + if (!Array.isArray(entry.values)) { + throw new Error(`Schema 9 hierarchy_catalogs.dimensions[${index}].values must be an array.`); + } + const values = byId( + entry.values.map((value, valueIndex) => { + const valueField = (name: string) => + `Schema 9 hierarchy_catalogs.dimensions[${index}].values[${valueIndex}].${name} ` + + "must be a non-empty string."; + const valueEntry = requiredObject( + value, + `Schema 9 hierarchy_catalogs.dimensions[${index}].values[${valueIndex}] must be an object.`, + ); + return { + id: requiredString(valueEntry.id, valueField("id")), + label: requiredString(valueEntry.label, valueField("label")), + }; + }), + (valueId) => + `Schema 9 hierarchy_catalogs.dimensions entry ${id.trim()} lists value ` + + `${valueId} more than once.`, + ); + return { + id, + label: requiredString(entry.label, field("dimensions", index, "label")), + values, + }; + }), + listedTwice("dimensions"), + ); + return { providers, categories, geographies, dimensions }; +} + +function rowName(row: JsonObject, index: number): string { + for (const candidate of [row.name, row.target_name]) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + return `row ${index}`; +} + +function expandRowHierarchy( + row: JsonObject, + index: number, + catalogs: HierarchyCatalogs, +): JsonObject { + const name = rowName(row, index); + const reference = requiredObject(row.hierarchy, `Schema 9 target ${name} hierarchy must be an object.`); + const referenceField = (field: string) => + `Schema 9 target ${name} hierarchy.${field} must be a non-empty string.`; + const notListed = (kind: string, id: string, catalog: string) => + `Schema 9 target ${name} refers to ${kind} ${id}, which hierarchy_catalogs.${catalog} ` + + "does not list."; + + const categoryId = requiredString(reference.category_id, referenceField("category_id")).trim(); + const category = catalogs.categories.get(categoryId); + if (!category) throw new Error(notListed("category", categoryId, "categories")); + // readCatalogs checked that the catalog lists every category's provider. + const provider = catalogs.providers.get(category.provider_id.trim())!; + + const geographyId = requiredString(reference.geography_id, referenceField("geography_id")).trim(); + const geography = catalogs.geographies.get(geographyId); + if (!geography) throw new Error(notListed("geography", geographyId, "geographies")); + + if (!Array.isArray(reference.dimensions)) { + throw new Error(`Schema 9 target ${name} hierarchy.dimensions must be an array.`); + } + const seenDimensions = new Set(); + const dimensions = reference.dimensions.map((value, rank) => { + const dimensionReference = requiredObject( + value, + `Schema 9 target ${name} hierarchy.dimensions[${rank}] must be an object.`, + ); + const dimensionId = requiredString( + dimensionReference.id, + referenceField(`dimensions[${rank}].id`), + ).trim(); + if (seenDimensions.has(dimensionId)) { + throw new Error(`Schema 9 target ${name} lists dimension ${dimensionId} more than once.`); + } + seenDimensions.add(dimensionId); + const dimension = catalogs.dimensions.get(dimensionId); + if (!dimension) throw new Error(notListed("dimension", dimensionId, "dimensions")); + const valueId = requiredString( + dimensionReference.value_id, + referenceField(`dimensions[${rank}].value_id`), + ).trim(); + const dimensionValue = dimension.values.get(valueId); + if (!dimensionValue) { + throw new Error( + `Schema 9 target ${name} refers to value ${valueId} of dimension ${dimensionId}, ` + + "which that dimension does not list.", + ); + } + return { + id: dimension.id, + label: dimension.label, + value_id: dimensionValue.id, + value_label: dimensionValue.label, + }; + }); + + const target = requiredObject(reference.target, `Schema 9 target ${name} hierarchy.target must be an object.`); + const targetId = requiredString(target.id, referenceField("target.id")); + requiredString(target.label, referenceField("target.label")); + const rowTargetId = typeof row.target_name === "string" ? row.target_name.trim() : ""; + if (rowTargetId && rowTargetId !== targetId.trim()) { + throw new Error( + `Schema 9 target ${name} hierarchy.target.id ${targetId.trim()} does not match ` + + `target_name ${rowTargetId}.`, + ); + } + + // The schema 8 hierarchy, with the producer's key order. + return { + provider: { id: provider.id, label: provider.label }, + category: { id: category.id, label: category.label, provider_id: category.provider_id }, + geography: { id: geography.id, label: geography.label, level: geography.level }, + dimensions, + target: { id: target.id, label: target.label }, + }; +} + +/** + * The document every reader below `buildCalibration` sees. A schema 9 + * document becomes a copy whose target rows carry the schema 8 hierarchy, + * without `hierarchy_catalogs`; its `schema_version` stays 9. Any other + * document is returned unchanged, as the same object. + */ +export function expandHierarchyCatalogs(document: JsonObject): JsonObject { + if (document.schema_version !== HIERARCHY_CATALOG_SCHEMA_VERSION) return document; + const catalogs = readCatalogs(document); + if (!Array.isArray(document.targets)) { + throw new Error("Schema 9 diagnostics must have a targets array."); + } + const targets = document.targets.map((value, index) => { + const row = requiredObject(value, `Schema 9 target row ${index} must be an object.`); + return { ...row, hierarchy: expandRowHierarchy(row, index, catalogs) }; + }); + const { hierarchy_catalogs: _catalogs, ...rest } = document; + return { ...rest, targets }; +} diff --git a/frontend/packages/calibration-tree/src/latest-artifact.ts b/frontend/packages/calibration-tree/src/latest-artifact.ts index 7479717d..49a5b93e 100644 --- a/frontend/packages/calibration-tree/src/latest-artifact.ts +++ b/frontend/packages/calibration-tree/src/latest-artifact.ts @@ -16,6 +16,7 @@ import { type HierarchyLabelVariant, validateHierarchyTargets, } from "./hierarchy-target-reader"; +import { expandHierarchyCatalogs } from "./hierarchy-catalog-reader"; import { historicalWeightSummaryForDiagnostics } from "./historical-weight-summary"; import { chroniclePublisherFromMetadata, @@ -2029,7 +2030,7 @@ export function releaseCountry( } export function buildCalibration( - diag: JsonObject, + diagnostics: JsonObject, releaseId: string, updatedAt: string | null = null, buildManifest: JsonObject = {}, @@ -2040,6 +2041,9 @@ export function buildCalibration( diagnosticsSha256: string | null = null, hfCommitSha: string | null = null, ): Calibration { + // A schema 9 file's rows carry catalog references; every reader below + // sees them as schema 8 rows. Other versions pass through unchanged. + const diag = expandHierarchyCatalogs(diagnostics); const targetRepresentation = targetRepresentationForDiagnosticsArtifact( diag.schema_version, { releaseId, sha256: diagnosticsSha256 }, diff --git a/frontend/packages/calibration-tree/src/target-representation.ts b/frontend/packages/calibration-tree/src/target-representation.ts index 444fbde1..112fa010 100644 --- a/frontend/packages/calibration-tree/src/target-representation.ts +++ b/frontend/packages/calibration-tree/src/target-representation.ts @@ -43,10 +43,12 @@ export function targetRepresentationForSchema( } if (schemaVersion >= 1 && schemaVersion <= 6) return "legacy"; if (schemaVersion === 7) return "structured"; - if (schemaVersion === 8) return "hierarchy"; + // Schema 9 declares the schema 8 hierarchy's labels in catalogs; + // `expandHierarchyCatalogs` turns its rows back into schema 8 rows. + if (schemaVersion === 8 || schemaVersion === 9) return "hierarchy"; throw new UnsupportedCalibrationDiagnosticsSchemaError( `Unsupported calibration diagnostics schema_version ${schemaVersion}; ` + - "this dashboard reads versions 1 through 8.", + "this dashboard reads versions 1 through 9.", ); } From d6dde33a8f6a3b2f27a30fa29b68703221b7e950 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:17:12 +0400 Subject: [PATCH 3/4] Test that a schema 9 row carrying schema 8 labels is refused A schema 9 row's hierarchy holds only catalog references and the target label. Microcosm's schema 9 model and its JSON Schema forbid any other field, but the dashboard reader ignores extra fields, so a row that also carries an inline geography or a dimension value_label (which can only disagree with the catalog) is read using the catalog's label without any error. This test fails now: both documents are accepted. Co-Authored-By: Claude Opus 5.5 --- .../src/schema-9-diagnostics.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts b/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts index f7dbacbc..65247290 100644 --- a/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts +++ b/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts @@ -218,6 +218,29 @@ describe("schema 9 calibration diagnostics", () => { ); }); + test("a row that also carries schema 8 labels is refused", () => { + // Microcosm's schema 9 model and JSON Schema forbid undeclared fields, so + // an inline label beside a catalog reference can only disagree with the + // catalog; the reader must not silently prefer one of them. + expect(refusal((document) => { + rowHierarchy(document, 0).geography = { + id: "K02000001", + label: "UK", + level: "country", + }; + })).toBe( + "Schema 9 target obr.income_tax.K02000001@2025 hierarchy has field geography, " + + "which schema 9 does not define.", + ); + expect(refusal((document) => { + const dimensions = rowHierarchy(document, 0).dimensions as JsonObject[]; + dimensions[0].value_label = "Income tax receipts"; + })).toBe( + "Schema 9 target obr.income_tax.K02000001@2025 hierarchy.dimensions[0] has field " + + "value_label, which schema 9 does not define.", + ); + }); + test("a row whose hierarchy target is another target is refused", () => { expect(refusal((document) => { (rowHierarchy(document, 0).target as JsonObject).id = "obr.national_insurance.K02000001"; From 9e49b755160e65ffbeeb18425e5263cd01e64da8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:18:06 +0400 Subject: [PATCH 4/4] Refuse a schema 9 row hierarchy field that schema 9 does not define The schema 9 reader now refuses a row hierarchy field other than category_id, geography_id, dimensions and target, and a dimension reference field other than id and value_id, as Microcosm's schema 9 model and JSON Schema do. An inline schema 8 label beside a catalog reference could only disagree with the catalog. The check runs after the required fields, so a row still in the schema 8 layout keeps reporting its missing category_id. The refusal list in docs/calibration-map-hierarchy.md names the new case. Co-Authored-By: Claude Opus 5.5 --- docs/calibration-map-hierarchy.md | 5 ++- .../src/hierarchy-catalog-reader.ts | 34 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/calibration-map-hierarchy.md b/docs/calibration-map-hierarchy.md index 4e751792..16e0bbbe 100644 --- a/docs/calibration-map-hierarchy.md +++ b/docs/calibration-map-hierarchy.md @@ -160,7 +160,10 @@ catalog entry, when: - a row refers to a category, geography or dimension the catalogs do not list, or to a value its own dimension does not list (including a value listed only under another dimension); -- a row lists one dimension twice, or is still in the schema 8 layout; or +- a row lists one dimension twice, or is still in the schema 8 layout; +- a row's `hierarchy`, or one of its `dimensions` references, has a field + schema 9 does not define (such as an inline schema 8 `geography` or + `value_label`); or - a row's `hierarchy.target.id` differs from its `target_name`. IDs are compared after trimming whitespace, as the schema 8 reader trims them. diff --git a/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts b/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts index 68319dbf..23ecd173 100644 --- a/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts +++ b/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts @@ -21,8 +21,9 @@ // The provider is the category's. A file that is structurally inconsistent // (missing catalogs, an ID listed twice, a reference to an entry the catalogs // do not list, a value looked up under a dimension that does not list it) is -// refused. IDs are compared after trimming whitespace, as the schema 8 reader -// trims them. +// refused, as is a row hierarchy field schema 9 does not define (such as an +// inline schema 8 label). IDs are compared after trimming whitespace, as the +// schema 8 reader trims them. type JsonObject = Record; @@ -157,6 +158,26 @@ function readCatalogs(document: JsonObject): HierarchyCatalogs { return { providers, categories, geographies, dimensions }; } +const ROW_HIERARCHY_FIELDS = new Set(["category_id", "geography_id", "dimensions", "target"]); +const DIMENSION_REFERENCE_FIELDS = new Set(["id", "value_id"]); + +/** + * Refuse a field schema 9 does not define, as Microcosm's model and JSON + * Schema do: an inline schema 8 label beside a catalog reference could only + * disagree with the catalog. + */ +function refuseUndefinedFields( + value: JsonObject, + allowed: Set, + location: string, +): void { + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new Error(`${location} has field ${field}, which schema 9 does not define.`); + } + } +} + function rowName(row: JsonObject, index: number): string { for (const candidate of [row.name, row.target_name]) { if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); @@ -217,6 +238,11 @@ function expandRowHierarchy( "which that dimension does not list.", ); } + refuseUndefinedFields( + dimensionReference, + DIMENSION_REFERENCE_FIELDS, + `Schema 9 target ${name} hierarchy.dimensions[${rank}]`, + ); return { id: dimension.id, label: dimension.label, @@ -236,6 +262,10 @@ function expandRowHierarchy( ); } + // Checked after the required fields, so a schema 8 row reports its missing + // category_id first. + refuseUndefinedFields(reference, ROW_HIERARCHY_FIELDS, `Schema 9 target ${name} hierarchy`); + // The schema 8 hierarchy, with the producer's key order. return { provider: { id: provider.id, label: provider.label },