diff --git a/frontend/app/api/populace/demographics/history/route.ts b/frontend/app/api/populace/demographics/history/route.ts
new file mode 100644
index 0000000..17ffb31
--- /dev/null
+++ b/frontend/app/api/populace/demographics/history/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from "next/server";
+
+import { loadDemographicsHistory } from "@/lib/populace/demographics";
+import { scrub } from "@/lib/populace/latest-artifact";
+
+export const revalidate = 300;
+
+export async function GET() {
+ try {
+ const history = await loadDemographicsHistory(revalidate);
+ return NextResponse.json(scrub(history));
+ } catch (error) {
+ return NextResponse.json(
+ { detail: error instanceof Error ? error.message : String(error) },
+ { status: 502 },
+ );
+ }
+}
diff --git a/frontend/app/api/populace/demographics/route.ts b/frontend/app/api/populace/demographics/route.ts
new file mode 100644
index 0000000..5ad2795
--- /dev/null
+++ b/frontend/app/api/populace/demographics/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from "next/server";
+
+import { hfResolveUrl, scrub } from "@/lib/populace/latest-artifact";
+import { DEMOGRAPHICS_FILE, loadDemographics } from "@/lib/populace/demographics";
+
+export const revalidate = 300;
+
+export async function GET(request: Request) {
+ const release = new URL(request.url).searchParams.get("release") ?? "latest";
+ try {
+ const demographics = await loadDemographics(release, revalidate);
+ if (!demographics.available) {
+ return NextResponse.json(demographics);
+ }
+ const prefix = `releases/${demographics.release_id}`;
+ return NextResponse.json(
+ scrub({
+ ...demographics,
+ source_artifact: {
+ name: "demographics",
+ path: `${prefix}/${DEMOGRAPHICS_FILE}`,
+ url: hfResolveUrl(`${prefix}/${DEMOGRAPHICS_FILE}`),
+ },
+ }),
+ );
+ } catch (error) {
+ return NextResponse.json(
+ { detail: error instanceof Error ? error.message : String(error) },
+ { status: 502 },
+ );
+ }
+}
diff --git a/frontend/app/populace/demographics/page.tsx b/frontend/app/populace/demographics/page.tsx
new file mode 100644
index 0000000..9dd3b34
--- /dev/null
+++ b/frontend/app/populace/demographics/page.tsx
@@ -0,0 +1,10 @@
+import { AppShell } from "@/components/layout/app-shell";
+import { PopulaceDemographicsView } from "@/components/populace/populace-demographics-view";
+
+export default function PopulaceDemographicsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/components/layout/nav-sidebar.tsx b/frontend/components/layout/nav-sidebar.tsx
index b0a525b..1a9f6fc 100644
--- a/frontend/components/layout/nav-sidebar.tsx
+++ b/frontend/components/layout/nav-sidebar.tsx
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
const navItems = [
{ href: "/populace", label: "Release summary" },
{ href: "/populace/targets", label: "Target diagnostics" },
+ { href: "/populace/demographics", label: "Demographics" },
{ href: "/populace/compare", label: "Compare versions" },
];
diff --git a/frontend/components/populace/populace-demographics-view.tsx b/frontend/components/populace/populace-demographics-view.tsx
new file mode 100644
index 0000000..b09797c
--- /dev/null
+++ b/frontend/components/populace/populace-demographics-view.tsx
@@ -0,0 +1,260 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+
+import { EmptyState } from "@/components/shared/empty-state";
+import { fmt, fmtCompact, fmtSigned, releaseLabel } from "@/components/shared/format";
+import { KpiCard } from "@/components/shared/kpi-card";
+import { LoadingBlock } from "@/components/shared/LoadingBlock";
+import { PageHeader } from "@/components/shared/page-header";
+import { SectionCard } from "@/components/shared/section-card";
+import { StatusPill } from "@/components/shared/status-pill";
+import { ToolbarSelect } from "@/components/shared/toolbar-select";
+import {
+ usePopulaceDemographics,
+ usePopulaceDemographicsHistory,
+ usePopulaceReleases,
+ type AgeBandRow,
+} from "@/lib/api/hooks/use-populace";
+
+function pct(value: number | null | undefined) {
+ return value == null ? "—" : fmt(value, { pct: true, digits: 1 });
+}
+
+function errorTone(absRel: number | null | undefined): "positive" | "neutral" | "negative" {
+ if (absRel == null) return "neutral";
+ if (absRel <= 0.05) return "positive";
+ if (absRel <= 0.15) return "neutral";
+ return "negative";
+}
+
+// Two overlaid share bars: populace (solid) vs Census benchmark (outline).
+function ShareBar({ share, benchmarkShare }: { share: number | null; benchmarkShare: number | null }) {
+ const max = Math.max(share ?? 0, benchmarkShare ?? 0, 0.001);
+ const w = (v: number | null) => `${Math.round(((v ?? 0) / max) * 100)}%`;
+ return (
+
+ {benchmarkShare != null && (
+
+ )}
+ {share != null && (
+
+ )}
+
+ );
+}
+
+function AgeTable({ bands }: { bands: AgeBandRow[] }) {
+ if (!bands.length) return ;
+ return (
+
+
+
+
+ | Age |
+ Population |
+ Share |
+ Census |
+ Share vs Census |
+ Error |
+
+
+
+ {bands.map((b) => (
+
+ | {b.label} |
+
+ {fmtCompact(b.population)}
+ |
+
+ {pct(b.share)}
+ |
+
+ {fmtCompact(b.benchmark)}
+ |
+
+
+ |
+
+ {b.relative_error == null ? "—" : fmtSigned(b.relative_error, { pct: true, digits: 0 })}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+export function PopulaceDemographicsView() {
+ const { data: releaseData, isLoading: releasesLoading } = usePopulaceReleases();
+ const releases = releaseData?.releases ?? [];
+ const [release, setRelease] = useState("");
+
+ useEffect(() => {
+ if (!release && releaseData?.latest_release_id) setRelease(releaseData.latest_release_id);
+ }, [releaseData, release]);
+
+ const { data, isLoading, error } = usePopulaceDemographics(release || undefined);
+ const { data: history } = usePopulaceDemographicsHistory();
+
+ const options = useMemo(
+ () => releases.map((r) => ({ value: r.release_id, label: releaseLabel(r.release_id, r.date) })),
+ [releases],
+ );
+
+ const trend = (history?.points ?? []).filter((p) => p.total_population != null);
+
+ return (
+
+
+
+
+ {releasesLoading ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ ) : data && !data.available ? (
+
+ The populace build publishes demographics.json (weighted population by
+ age) per release. This release doesn't have one yet ({data.expected_path}). Once a
+ build publishes it, the age distribution appears here.
+ >
+ }
+ />
+ ) : data && data.available ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ {trend.length >= 1 ? (
+
+
+
+
+ | Release |
+ Total population |
+ Total vs Census |
+ Mean band |error| |
+
+
+
+ {trend.map((p) => (
+
+ |
+ {releaseLabel(p.release_id, p.date)}
+ |
+
+ {fmtCompact(p.total_population)}
+ |
+
+ {p.total_vs_benchmark == null
+ ? "—"
+ : fmtSigned(p.total_vs_benchmark, { pct: true, digits: 1 })}
+ |
+
+ {pct(p.mean_abs_relative_error)}
+ |
+
+ ))}
+
+
+
+ ) : (
+
+
+ Run-over-run appears once more than one release publishes demographics.
+
+
+ )}
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/frontend/lib/api/hooks/use-populace.ts b/frontend/lib/api/hooks/use-populace.ts
index 8904b86..41a64ab 100644
--- a/frontend/lib/api/hooks/use-populace.ts
+++ b/frontend/lib/api/hooks/use-populace.ts
@@ -210,6 +210,69 @@ export interface PopulaceComparison {
rows: PopulaceComparisonRow[];
}
+export interface AgeBandRow {
+ label: string;
+ min_age?: number | null;
+ max_age?: number | null;
+ population?: number | null;
+ share?: number | null;
+ benchmark?: number | null;
+ benchmark_share?: number | null;
+ relative_error?: number | null;
+ abs_relative_error?: number | null;
+}
+
+export interface DemographicsResponse {
+ available: boolean;
+ release_id: string;
+ reason?: string;
+ expected_path?: string;
+ updated_at?: string | null;
+ schema_version?: number | null;
+ period?: number | null;
+ measure?: string | null;
+ total_population?: number | null;
+ benchmark_total_population?: number | null;
+ benchmark_source?: string | null;
+ bands?: AgeBandRow[];
+ summary?: {
+ n_bands: number;
+ n_benchmarked: number;
+ mean_abs_relative_error: number | null;
+ max_abs_relative_error: number | null;
+ total_vs_benchmark: number | null;
+ };
+ source_artifact?: { name: string; path: string; url: string };
+}
+
+export interface DemographicsHistoryResponse {
+ benchmark_total_population: number | null;
+ points: {
+ release_id: string;
+ date: string;
+ total_population: number | null;
+ total_vs_benchmark: number | null;
+ mean_abs_relative_error: number | null;
+ }[];
+}
+
+export function usePopulaceDemographics(release?: string) {
+ return useQuery({
+ queryKey: ["populace", "demographics", release ?? "latest"],
+ queryFn: () =>
+ apiGet("/populace/demographics", release ? { release } : undefined),
+ staleTime: 15 * 60 * 1000,
+ });
+}
+
+export function usePopulaceDemographicsHistory() {
+ return useQuery({
+ queryKey: ["populace", "demographics", "history"],
+ queryFn: () => apiGet("/populace/demographics/history"),
+ staleTime: 15 * 60 * 1000,
+ });
+}
+
export function usePopulaceCompare(a?: string, b?: string, enabled = true) {
return useQuery({
queryKey: ["populace", "compare", a, b],
diff --git a/frontend/lib/populace/demographics.test.ts b/frontend/lib/populace/demographics.test.ts
new file mode 100644
index 0000000..5809da4
--- /dev/null
+++ b/frontend/lib/populace/demographics.test.ts
@@ -0,0 +1,55 @@
+import { expect, test } from "bun:test";
+
+import { buildDemographics, buildDemographicsHistory } from "./demographics";
+
+function raw(bands: object[], extra: object = {}) {
+ return {
+ schema_version: 1,
+ period: 2024,
+ measure: "person_weight",
+ total_population: 360,
+ benchmark_total_population: 334,
+ benchmark_source: "Census",
+ age_bands: bands,
+ ...extra,
+ };
+}
+
+const BANDS = [
+ { label: "5–17", min_age: 5, max_age: 17, population: 86, share: 86 / 360, benchmark: 53, benchmark_share: 53 / 334, relative_error: (86 - 53) / 53 },
+ { label: "18–24", min_age: 18, max_age: 24, population: 21, share: 21 / 360, benchmark: 30, benchmark_share: 30 / 334, relative_error: (21 - 30) / 30 },
+ { label: "75+", min_age: 75, max_age: null, population: 25, share: 25 / 360, benchmark: 25, benchmark_share: 25 / 334, relative_error: 0 },
+];
+
+test("derives abs error and benchmark stats", () => {
+ const d = buildDemographics(raw(BANDS), "rel-a");
+ expect(d.bands).toHaveLength(3);
+ const youth = d.bands.find((b) => b.label === "5–17")!;
+ expect(youth.relative_error).toBeCloseTo((86 - 53) / 53, 6); // +62%
+ expect(youth.abs_relative_error).toBeCloseTo((86 - 53) / 53, 6);
+ expect(d.summary.n_benchmarked).toBe(3);
+ expect(d.summary.max_abs_relative_error).toBeCloseTo((86 - 53) / 53, 6);
+});
+
+test("total vs benchmark is signed", () => {
+ const d = buildDemographics(raw(BANDS), "rel-a");
+ expect(d.summary.total_vs_benchmark).toBeCloseTo((360 - 334) / 334, 6); // +7.8%
+});
+
+test("bands without a benchmark are excluded from the fit summary", () => {
+ const noBench = [{ label: "0–4", population: 20, share: 20 / 20, benchmark: null, relative_error: null }];
+ const d = buildDemographics(raw(noBench, { total_population: 20, benchmark_total_population: null }), "rel-a");
+ expect(d.summary.n_benchmarked).toBe(0);
+ expect(d.summary.mean_abs_relative_error).toBeNull();
+ expect(d.summary.total_vs_benchmark).toBeNull();
+});
+
+test("history is chronological with latest benchmark", () => {
+ const hist = buildDemographicsHistory([
+ { release_id: "r2", date: "20260201", demographics: buildDemographics(raw(BANDS), "r2") },
+ { release_id: "r1", date: "20260101", demographics: buildDemographics(raw(BANDS), "r1") },
+ ]);
+ expect(hist.points.map((p) => p.release_id)).toEqual(["r1", "r2"]);
+ expect(hist.benchmark_total_population).toBe(334);
+ expect(hist.points[0].total_population).toBe(360);
+});
diff --git a/frontend/lib/populace/demographics.ts b/frontend/lib/populace/demographics.ts
new file mode 100644
index 0000000..303ae19
--- /dev/null
+++ b/frontend/lib/populace/demographics.ts
@@ -0,0 +1,196 @@
+// Demographic diagnostics data layer. Reads demographics.json (published per
+// release by the populace build) live from Hugging Face: the dataset's weighted
+// population by age band, its share, the Census benchmark, and the fit error.
+// The fiscal release does not calibrate the age distribution, so this is an
+// emergent diagnostic — useful for seeing population-by-age and how it tracks
+// Census release over release.
+
+import { asObject, hfResolveUrl, loadPointerReleaseId, loadReleases } from "./latest-artifact";
+
+type JsonObject = Record;
+
+export const DEMOGRAPHICS_FILE = "demographics.json";
+
+function numberOrNull(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" && value ? value : null;
+}
+
+export interface AgeBandRow {
+ label: string;
+ min_age: number | null;
+ max_age: number | null;
+ population: number | null;
+ share: number | null;
+ benchmark: number | null;
+ benchmark_share: number | null;
+ relative_error: number | null;
+ abs_relative_error: number | null;
+}
+
+export interface Demographics {
+ available: true;
+ source: "huggingface_live";
+ release_id: string;
+ updated_at: string | null;
+ schema_version: unknown;
+ period: number | null;
+ measure: string | null;
+ total_population: number | null;
+ benchmark_total_population: number | null;
+ benchmark_source: string | null;
+ bands: AgeBandRow[];
+ summary: {
+ n_bands: number;
+ n_benchmarked: number;
+ mean_abs_relative_error: number | null;
+ max_abs_relative_error: number | null;
+ total_vs_benchmark: number | null; // (total − benchmark_total) / benchmark_total
+ };
+}
+
+export interface DemographicsMissing {
+ available: false;
+ release_id: string;
+ reason: string;
+ expected_path: string;
+}
+
+function enrichBand(raw: JsonObject): AgeBandRow {
+ const rel = numberOrNull(raw.relative_error);
+ return {
+ label: String(raw.label ?? ""),
+ min_age: numberOrNull(raw.min_age),
+ max_age: numberOrNull(raw.max_age),
+ population: numberOrNull(raw.population),
+ share: numberOrNull(raw.share),
+ benchmark: numberOrNull(raw.benchmark),
+ benchmark_share: numberOrNull(raw.benchmark_share),
+ relative_error: rel,
+ abs_relative_error: rel == null ? null : Math.abs(rel),
+ };
+}
+
+export function buildDemographics(
+ raw: JsonObject,
+ releaseId: string,
+ updatedAt: string | null = null,
+): Demographics {
+ const bands = (Array.isArray(raw.age_bands) ? (raw.age_bands as JsonObject[]) : []).map((b) =>
+ enrichBand(asObject(b)),
+ );
+ const benchmarked = bands.filter((b) => b.abs_relative_error != null);
+ const absErrs = benchmarked.map((b) => b.abs_relative_error as number);
+ const total = numberOrNull(raw.total_population);
+ const benchTotal = numberOrNull(raw.benchmark_total_population);
+ return {
+ available: true,
+ source: "huggingface_live",
+ release_id: String(raw.release_id ?? releaseId),
+ updated_at: updatedAt,
+ schema_version: raw.schema_version ?? null,
+ period: numberOrNull(raw.period),
+ measure: stringOrNull(raw.measure),
+ total_population: total,
+ benchmark_total_population: benchTotal,
+ benchmark_source: stringOrNull(raw.benchmark_source),
+ bands,
+ summary: {
+ n_bands: bands.length,
+ n_benchmarked: benchmarked.length,
+ mean_abs_relative_error: absErrs.length
+ ? absErrs.reduce((s, v) => s + v, 0) / absErrs.length
+ : null,
+ max_abs_relative_error: absErrs.length ? Math.max(...absErrs) : null,
+ total_vs_benchmark:
+ total != null && benchTotal != null && benchTotal !== 0
+ ? (total - benchTotal) / benchTotal
+ : null,
+ },
+ };
+}
+
+async function fetchDemographics(releaseId: string, revalidate: number): Promise {
+ const url = hfResolveUrl(`releases/${releaseId}/${DEMOGRAPHICS_FILE}`);
+ const res = await fetch(url, { next: { revalidate } });
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`HF fetch failed ${res.status}: ${url}`);
+ return asObject(await res.json());
+}
+
+export async function loadDemographics(
+ releaseId: string,
+ revalidate: number,
+): Promise {
+ let id = releaseId;
+ let updatedAt: string | null = null;
+ if (releaseId === "latest" || !releaseId) {
+ const ptr = await loadPointerReleaseId(revalidate);
+ id = ptr.release_id;
+ updatedAt = ptr.updated_at;
+ }
+ const raw = await fetchDemographics(id, revalidate);
+ if (!raw) {
+ return {
+ available: false,
+ release_id: id,
+ reason: `No ${DEMOGRAPHICS_FILE} published for this release yet.`,
+ expected_path: `releases/${id}/${DEMOGRAPHICS_FILE}`,
+ };
+ }
+ return buildDemographics(raw, id, updatedAt);
+}
+
+// --- run-over-run --------------------------------------------------------------
+export interface DemographicsHistoryPoint {
+ release_id: string;
+ date: string;
+ total_population: number | null;
+ total_vs_benchmark: number | null;
+ mean_abs_relative_error: number | null;
+}
+
+export interface DemographicsHistory {
+ benchmark_total_population: number | null;
+ points: DemographicsHistoryPoint[]; // chronological, oldest → newest
+}
+
+export function buildDemographicsHistory(
+ builds: { release_id: string; date: string; demographics: Demographics }[],
+): DemographicsHistory {
+ const chronological = [...builds].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
+ return {
+ benchmark_total_population:
+ chronological[chronological.length - 1]?.demographics.benchmark_total_population ?? null,
+ points: chronological.map((b) => ({
+ release_id: b.release_id,
+ date: b.date,
+ total_population: b.demographics.total_population,
+ total_vs_benchmark: b.demographics.summary.total_vs_benchmark,
+ mean_abs_relative_error: b.demographics.summary.mean_abs_relative_error,
+ })),
+ };
+}
+
+export async function loadDemographicsHistory(revalidate: number): Promise {
+ const releases = await loadReleases(revalidate);
+ const builds = await Promise.all(
+ releases.map(async (r) => {
+ try {
+ const raw = await fetchDemographics(r.release_id, revalidate);
+ if (!raw) return null;
+ return {
+ release_id: r.release_id,
+ date: r.date,
+ demographics: buildDemographics(raw, r.release_id),
+ };
+ } catch {
+ return null;
+ }
+ }),
+ );
+ return buildDemographicsHistory(builds.filter((b): b is NonNullable => b != null));
+}