diff --git a/docs/calibration-map-hierarchy.md b/docs/calibration-map-hierarchy.md index 75284a1f..16e0bbbe 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,24 @@ 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; +- 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. + ## 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..23ecd173 --- /dev/null +++ b/frontend/packages/calibration-tree/src/hierarchy-catalog-reader.ts @@ -0,0 +1,297 @@ +// 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, 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; + +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 }; +} + +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(); + } + 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.", + ); + } + refuseUndefinedFields( + dimensionReference, + DIMENSION_REFERENCE_FIELDS, + `Schema 9 target ${name} hierarchy.dimensions[${rank}]`, + ); + 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}.`, + ); + } + + // 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 }, + 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.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/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/publish-calibration-tree.test.ts b/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts index 47c17e23..8b92c6b5 100644 --- a/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts +++ b/frontend/packages/calibration-tree/src/publish-calibration-tree.test.ts @@ -19,6 +19,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; @@ -897,6 +898,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("a dry run reports the same latest-release failure as a run that writes when the release the pointer file names fails", async () => { const refuseNationalDiagnostics = (directory: string) => directory === NATIONAL_LINE ? REFUSED_SCHEMA_8_DIAGNOSTICS : SCHEMA_8_DIAGNOSTICS; 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..65247290 --- /dev/null +++ b/frontend/packages/calibration-tree/src/schema-9-diagnostics.test.ts @@ -0,0 +1,252 @@ +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 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"; + })).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/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.", ); } 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() ?? [])], + })), + }, + }; +}