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 ( +
+ + + + + + + + + + + + + {bands.map((b) => ( + + + + + + + + + ))} + +
AgePopulationShareCensusShare vs CensusError
{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 ? ( +
+ + + + + + + + + + + {trend.map((p) => ( + + + + + + + ))} + +
ReleaseTotal populationTotal vs CensusMean band |error|
+ {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)); +}