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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 54 additions & 30 deletions docs/spec-driven-countries.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,37 +212,57 @@ artifact label, range values such as `0_17` and `65_plus` become `0–17` and
A geography-role dimension sets `row.geography` and uses its declared `level`
or `"region"`. 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.

Adapter selection is per row and never by country:
### Target representation classification

1. A plain-object `targets[].dimensions` selects `structured`, even when the
dictionary is absent or the row object is empty.
2. Otherwise, a filter matched by the shared filter-decomposition specs selects
`legacy_filter`.
3. Every other row selects `legacy_name`, preserving dotted/slash name parsing
and Chronicle metadata dimensions.
The dashboard classifies each target row by structure before normalizing it,
then summarizes the complete `targets` array. Diagnostics schema versions do
not identify the target representation: published schema 5 and schema 6 files
can both contain legacy string fields. The structural classification is:

Each target response records this choice as `dimension_adapter`. Calibration
summary and target-diagnostics responses include:
1. `structured` when every row has a plain-object `source` with a non-empty
`id`, a plain-object `variable` with a non-empty `id`, and a plain-object
`dimensions` field. Use `{}` when a target has no dimensions.
2. `legacy` when no row has object-valued `source`, `variable`, or `dimensions`
fields.
3. `mixed` when complete structured rows and complete legacy rows occur together.
4. `unknown` when there are no target rows.

Every target row must independently satisfy either the structured or legacy
shape. Partially structured rows are invalid. In a mixed file, each complete
structured row uses the structured reader and each complete legacy row uses the
legacy reader; the file-level `mixed` value is descriptive and does not select a
third normalization strategy.

Calibration summary and target-diagnostics responses report the classification:

```json
{
"target_schema": {
"diagnostics_schema_version": 7,
"structured_dimensions": true
"structured_dimensions": true,
"target_representation": "structured"
}
}
```

`structured_dimensions` reports whether the diagnostics published a plain
dimension dictionary; it does not choose every row's adapter.
dimension dictionary. `target_representation` summarizes the collection. The
existing per-row `dimension_adapter` response field remains for compatibility
and describes only whether that row's dimensions came from a structured object,
a known legacy filter, or legacy name and metadata parsing.

### Structured source and variable identifiers

Targets accept their legacy strings or the following objects:
Fully structured targets use the following objects together. The `dimensions`
object is required and may be empty:

```json
{
Expand All @@ -258,34 +278,38 @@ Targets accept their legacy strings or the following objects:
"id": "population",
"label": "Resident population",
"measure": "count"
}
},
"dimensions": {}
}
]
}
```

Publisher-key precedence is Chronicle metadata, then `source.id`, then legacy
name grammar. `source_citation` is the legacy source string or
`source.citation`; `source_url` is the structured URL or `null`. The publisher
label precedence is the manifest map, `source.label`, then the shared
humanizer.

Variable-ID precedence is `variable.id`, `metadata.variable`, then the existing
artifact/name fallbacks. A structured ID remains an identifier; its display
name is separately returned as `variable_label`. Measure precedence is
`variable.measure`, then the existing first-dimension and Chronicle metadata
logic. Legacy string sources and variables retain their existing values,
families, citations, facets, and grouping. The new response fields are
additive: `source_label`, `source_url`, `variable_label`, and
`dimension_adapter`.
For a `structured` file, navigation identity comes only from `source`,
`variable`, and `dimensions`. Legacy names, filters, registry families, and
Chronicle metadata cannot change its source, statistic, geography, or
breakdown dimensions. The source and variable IDs remain stable selection
keys; their labels are returned separately for display.

For a `legacy` file, the isolated legacy reader handles the established dotted,
slash, Chronicle metadata, and known filter encodings. It does not interpret an
arbitrary underscore as a structural separator.

For a `mixed` file, fully legacy rows retain legacy behavior. Partially
structured rows use the compatibility precedence: Chronicle publisher ID,
then `source.id`, then legacy source parsing; `variable.id`, then legacy
variable parsing; structured dimensions, then known filter dimensions, then
legacy metadata and name dimensions. This prevents one partially migrated row
from changing unrelated legacy rows in the same file.

### Producer follow-up

Microcosm release producers must publish all of the following before the legacy
presentation and parsing adapters can be retired:
presentation and normalization readers can be retired:

- `release_manifest.country`;
- `release_manifest.presentation`;
- `release_manifest.publisher_labels`;
- `calibration_diagnostics.dimensions` and `targets[].dimensions`; and
- structured `targets[].source` and `targets[].variable` objects.
- `calibration_diagnostics.dimensions`;
- a `targets[].dimensions` object on every row, including `{}` where empty; and
- structured `targets[].source` and `targets[].variable` objects on every row.
18 changes: 16 additions & 2 deletions frontend/app/microcosm/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { AppShell } from "@/components/layout/app-shell";
import { MicrocosmOverviewView } from "@/components/microcosm/microcosm-overview-view";
import { parseCountry } from "@/lib/microcosm/countries";

interface MicrocosmOverviewPageProps {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}

export default async function MicrocosmOverviewPage({
searchParams,
}: MicrocosmOverviewPageProps) {
const params = await searchParams;
const rawRelease = Array.isArray(params?.release) ? params.release[0] : params?.release;
const rawCountry = Array.isArray(params?.country) ? params.country[0] : params?.country;

export default function MicrocosmOverviewPage() {
return (
<AppShell>
<MicrocosmOverviewView />
<MicrocosmOverviewView
initialCountry={parseCountry(rawCountry)}
initialRelease={rawRelease ?? ""}
/>
</AppShell>
);
}
7 changes: 7 additions & 0 deletions frontend/app/microcosm/targets/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AppShell } from "@/components/layout/app-shell";
import { MicrocosmTargetsView } from "@/components/microcosm/microcosm-targets-view";
import { parseCountry } from "@/lib/microcosm/countries";

interface MicrocosmTargetsPageProps {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
Expand All @@ -13,13 +14,19 @@ export default async function MicrocosmTargetsPage({
const initialScope = rawScope === "healthcare" ? "healthcare" : "all";
const rawSource = Array.isArray(params?.source) ? params.source[0] : params?.source;
const rawLevel = Array.isArray(params?.level) ? params.level[0] : params?.level;
const rawRelease = Array.isArray(params?.release) ? params.release[0] : params?.release;
const rawStart = Array.isArray(params?.start) ? params.start[0] : params?.start;
const rawCountry = Array.isArray(params?.country) ? params.country[0] : params?.country;

return (
<AppShell>
<MicrocosmTargetsView
initialScope={initialScope}
initialSource={rawSource ?? ""}
initialLevel={rawLevel ?? ""}
initialCountry={parseCountry(rawCountry)}
initialRelease={rawRelease ?? ""}
initialStep={rawStart === "explore" ? "pick" : "results"}
/>
</AppShell>
);
Expand Down
18 changes: 15 additions & 3 deletions frontend/components/layout/country-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, test } from "bun:test";

import { countrySwitchUrl, isCountry } from "./country-context";
import {
countrySwitchUrl,
isCountry,
selectedReleaseForCountry,
} from "./country-context";

test("country switching clears bundle-specific Cross-dataset state", () => {
const switched = new URL(
Expand All @@ -14,16 +18,24 @@ test("country switching clears bundle-specific Cross-dataset state", () => {
expect(switched.searchParams.toString()).toBe("country=be");
});

test("country switching preserves route state outside Cross-dataset", () => {
test("country switching preserves route filters but clears the selected release", () => {
const switched = new URL(
countrySwitchUrl(
"https://example.test/microcosm/targets?country=us&variable=income_tax",
"https://example.test/microcosm/targets?country=us&release=us-build&variable=income_tax",
"uk",
),
);

expect(switched.pathname).toBe("/microcosm/targets");
expect(switched.searchParams.toString()).toBe("country=uk&variable=income_tax");
expect(switched.searchParams.has("release")).toBe(false);
});

test("a release selection is used only by the country that supplied it", () => {
const selection = { country: "us" as const, value: "us-build" };

expect(selectedReleaseForCountry("us", selection)).toBe("us-build");
expect(selectedReleaseForCountry("be", selection)).toBe("");
});

test("the client country parser accepts every registered country and nothing else", () => {
Expand Down
26 changes: 21 additions & 5 deletions frontend/components/layout/country-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";

import {
DEFAULT_COUNTRY,
Expand All @@ -16,6 +17,11 @@ import {

export type Country = MicrocosmCountry;

export interface CountryReleaseSelection {
country: Country;
value: string;
}

export { isCountry };

const STORAGE_KEY = "microcosm-country";
Expand All @@ -24,11 +30,22 @@ export function countrySwitchUrl(currentUrl: string, next: Country): string {
const url = new URL(currentUrl);
if (url.pathname.endsWith("/microcosm/datasets")) {
url.search = "";
} else {
// Release identifiers are repository-specific. A release selected for one
// country must never be requested from another country's repository.
url.searchParams.delete("release");
}
url.searchParams.set("country", next);
return url.toString();
}

export function selectedReleaseForCountry(
country: Country,
selection: CountryReleaseSelection,
): string {
return selection.country === country ? selection.value : "";
}

function persistCountry(country: Country) {
try {
window.localStorage.setItem(STORAGE_KEY, country);
Expand All @@ -48,6 +65,7 @@ const CountryContext = createContext<CountryContextValue>({
});

export function CountryProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const [country, setCountryState] = useState<Country>(DEFAULT_COUNTRY);

useEffect(() => {
Expand All @@ -65,11 +83,9 @@ export function CountryProvider({ children }: { children: ReactNode }) {
if (next === country) return;
setCountryState(next);
persistCountry(next);
window.history.replaceState(
window.history.state,
"",
countrySwitchUrl(window.location.href, next),
);
// Route through Next so server page search parameters are recalculated;
// raw history replacement would leave initialRelease props stale.
router.replace(countrySwitchUrl(window.location.href, next), { scroll: false });
};

return (
Expand Down
25 changes: 7 additions & 18 deletions frontend/components/layout/nav-items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,11 @@ function datasetAccuracyItems() {
return group.items;
}

test("shows calibration targets directly under calibration fit", () => {
test("keeps calibration targets out of the sidebar", () => {
const items = datasetAccuracyItems();
const labels = items.map((item) => item.label);
const calibrationFitIndex = labels.indexOf("Calibration fit");
const calibrationTargetsIndex = labels.indexOf("Calibration targets");

expect(calibrationFitIndex).toBeGreaterThanOrEqual(0);
expect(calibrationTargetsIndex).toBe(calibrationFitIndex + 1);
expect(items[calibrationTargetsIndex]?.href).toBe("/microcosm/targets");
expect(items.some((item) => item.label === "Calibration targets")).toBe(false);
expect(items.some((item) => item.href === "/microcosm/targets")).toBe(false);
});

test("places external checks at the bottom of dataset accuracy", () => {
Expand All @@ -43,19 +39,17 @@ test("models the external checks icon separately from its label", () => {
expect(externalChecks?.external).toBe(true);
});

test("targets path activates calibration targets instead of calibration fit", () => {
test("targets path does not activate calibration fit", () => {
const items = datasetAccuracyItems();
const calibrationFit = items.find((item) => item.label === "Calibration fit");
const calibrationTargets = items.find((item) => item.label === "Calibration targets");

if (!calibrationFit || !calibrationTargets) {
throw new Error("Calibration nav items not found");
if (!calibrationFit) {
throw new Error("Calibration fit nav item not found");
}

expect(calibrationFit.also ?? []).not.toContain("/microcosm/targets");
expect(isActive("/microcosm", calibrationFit)).toBe(true);
expect(isActive("/microcosm/targets", calibrationFit)).toBe(false);
expect(isActive("/microcosm/targets", calibrationTargets)).toBe(true);
});
test("opens external navigation in a new tab without changing internal navigation", () => {
const items = datasetAccuracyItems();
Expand Down Expand Up @@ -105,14 +99,12 @@ test("Belgium navigation keeps country-ready pages and hides pages it lacks capa
const items = navGroupsForCountry("be").flatMap((group) => group.items);
expect(items.map((item) => item.href)).toEqual([
"/microcosm",
"/microcosm/targets",
"/microcosm/datasets",
"/microcosm/compare",
]);
expect(items.every((item) => hasCapability("be", item.capability!))).toBe(true);
expect(items.map((item) => navItemHref(item, "be"))).toEqual([
"/microcosm?country=be",
"/microcosm/targets?country=be",
"/microcosm/datasets?country=be",
"/microcosm/compare?country=be",
]);
Expand All @@ -127,10 +119,7 @@ test("US navigation lists every page", () => {
test("artifact-narrowed capabilities hide pages the release does not serve", () => {
const groups = navGroupsForCountry("us", ["calibration", "targets"]);
expect(groups.map((group) => group.label)).toEqual(["Dataset accuracy"]);
expect(groups[0].items.map((item) => item.href)).toEqual([
"/microcosm",
"/microcosm/targets",
]);
expect(groups[0].items.map((item) => item.href)).toEqual(["/microcosm"]);
});

test("shows Cross-dataset navigation for every selectable country", () => {
Expand Down
1 change: 0 additions & 1 deletion frontend/components/layout/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [
label: "Dataset accuracy",
items: [
{ href: "/microcosm", label: "Calibration fit", capability: "calibration" },
{ href: "/microcosm/targets", label: "Calibration targets", capability: "targets" },
{
href: "/microcosm/model-coverage",
label: "Validation reach",
Expand Down
5 changes: 4 additions & 1 deletion frontend/components/microcosm/calibration-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ function isSynthetic(key: string): boolean {
}

function variableName(leaf: MicrocosmTreemapLeaf): string {
return isSynthetic(leaf.key) ? leaf.variable : humanizeName(leaf.variable) || leaf.variable;
return isSynthetic(leaf.key)
? leaf.variable
: leaf.label ?? (humanizeName(leaf.variable) || leaf.variable);
}

function measureLabel(measure: string | null): string | null {
Expand Down Expand Up @@ -89,6 +91,7 @@ function aggregateLeaves(
key,
source,
variable,
label: null,
measure: null,
measure_counts: [],
n_targets: leaves.reduce((a, c) => a + c.n_targets, 0),
Expand Down
Loading
Loading