From 16cba92f779bfa2c88b9cd5704157ad389d233b0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:57:29 +0400 Subject: [PATCH 01/20] Extend Microcosm release cache duration --- frontend/app/api/microcosm/compare/route.ts | 2 +- frontend/app/api/microcosm/releases/route.ts | 2 +- frontend/app/api/microcosm/route.ts | 2 +- frontend/app/api/microcosm/target-diagnostics/route.ts | 2 +- frontend/app/api/microcosm/target-investigation/route.ts | 2 +- frontend/app/api/microcosm/target-tree/route.ts | 2 +- frontend/app/api/microcosm/target-treemap/route.ts | 2 +- frontend/app/api/microcosm/variable/route.ts | 3 ++- frontend/app/providers.tsx | 3 ++- frontend/lib/api/cache-policy.ts | 2 ++ frontend/lib/api/hooks/use-microcosm.ts | 9 +++++---- frontend/lib/microcosm/latest-artifact.ts | 4 +++- 12 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 frontend/lib/api/cache-policy.ts diff --git a/frontend/app/api/microcosm/compare/route.ts b/frontend/app/api/microcosm/compare/route.ts index 1ca81e01..bc6fa558 100644 --- a/frontend/app/api/microcosm/compare/route.ts +++ b/frontend/app/api/microcosm/compare/route.ts @@ -8,7 +8,7 @@ import { scrub, } from "@/lib/microcosm/latest-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export const runtime = "nodejs"; export const maxDuration = 300; diff --git a/frontend/app/api/microcosm/releases/route.ts b/frontend/app/api/microcosm/releases/route.ts index cc2b0558..d2a861d5 100644 --- a/frontend/app/api/microcosm/releases/route.ts +++ b/frontend/app/api/microcosm/releases/route.ts @@ -7,7 +7,7 @@ import { scrub, } from "@/lib/microcosm/latest-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export async function GET(request: Request) { const country = parseCountry(new URL(request.url).searchParams.get("country")); diff --git a/frontend/app/api/microcosm/route.ts b/frontend/app/api/microcosm/route.ts index f59c0ee6..7f43ad0d 100644 --- a/frontend/app/api/microcosm/route.ts +++ b/frontend/app/api/microcosm/route.ts @@ -13,7 +13,7 @@ import { scrub, } from "@/lib/microcosm/latest-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export const runtime = "nodejs"; export const maxDuration = 300; diff --git a/frontend/app/api/microcosm/target-diagnostics/route.ts b/frontend/app/api/microcosm/target-diagnostics/route.ts index e9be3698..befa44bc 100644 --- a/frontend/app/api/microcosm/target-diagnostics/route.ts +++ b/frontend/app/api/microcosm/target-diagnostics/route.ts @@ -9,7 +9,7 @@ import { } from "@/lib/microcosm/latest-artifact"; import { loadStagingTargetDiagnostics } from "@/lib/microcosm/staging-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export const runtime = "nodejs"; export const maxDuration = 300; diff --git a/frontend/app/api/microcosm/target-investigation/route.ts b/frontend/app/api/microcosm/target-investigation/route.ts index cfef1005..3d19e740 100644 --- a/frontend/app/api/microcosm/target-investigation/route.ts +++ b/frontend/app/api/microcosm/target-investigation/route.ts @@ -8,7 +8,7 @@ import { scrub, } from "@/lib/microcosm/latest-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export async function GET(request: Request) { try { diff --git a/frontend/app/api/microcosm/target-tree/route.ts b/frontend/app/api/microcosm/target-tree/route.ts index d527da19..11a114bc 100644 --- a/frontend/app/api/microcosm/target-tree/route.ts +++ b/frontend/app/api/microcosm/target-tree/route.ts @@ -17,7 +17,7 @@ import { scrub, } from "@/lib/microcosm/latest-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export const runtime = "nodejs"; export const maxDuration = 300; diff --git a/frontend/app/api/microcosm/target-treemap/route.ts b/frontend/app/api/microcosm/target-treemap/route.ts index b6049b8f..db910387 100644 --- a/frontend/app/api/microcosm/target-treemap/route.ts +++ b/frontend/app/api/microcosm/target-treemap/route.ts @@ -8,7 +8,7 @@ import { scrub, } from "@/lib/microcosm/latest-artifact"; -export const revalidate = 300; +export const revalidate = 21_600; export const runtime = "nodejs"; export const maxDuration = 300; diff --git a/frontend/app/api/microcosm/variable/route.ts b/frontend/app/api/microcosm/variable/route.ts index eed9a34e..f5254e0a 100644 --- a/frontend/app/api/microcosm/variable/route.ts +++ b/frontend/app/api/microcosm/variable/route.ts @@ -4,6 +4,7 @@ import { promisify } from "node:util"; import { NextResponse } from "next/server"; +import { PUBLISHED_RELEASE_CACHE_SECONDS } from "@/lib/api/cache-policy"; import { MICROCOSM_HF_REPO, loadPointerReleaseId, @@ -116,7 +117,7 @@ export async function GET(request: Request) { try { const release = requestedRelease === "latest" - ? (await loadPointerReleaseId(300)).release_id + ? (await loadPointerReleaseId(PUBLISHED_RELEASE_CACHE_SECONDS)).release_id : requestedRelease; if (process.env.VERCEL === "1" && !process.env.PYTHON) { return NextResponse.redirect( diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index 584c451b..91fb9f0c 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -5,6 +5,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { useState } from "react"; import { CountryProvider } from "@/components/layout/country-context"; +import { PUBLISHED_RELEASE_STALE_TIME_MS } from "@/lib/api/cache-policy"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -12,7 +13,7 @@ export function Providers({ children }: { children: React.ReactNode }) { new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, + staleTime: PUBLISHED_RELEASE_STALE_TIME_MS, refetchOnWindowFocus: false, retry: 2, }, diff --git a/frontend/lib/api/cache-policy.ts b/frontend/lib/api/cache-policy.ts new file mode 100644 index 00000000..81f1167f --- /dev/null +++ b/frontend/lib/api/cache-policy.ts @@ -0,0 +1,2 @@ +export const PUBLISHED_RELEASE_CACHE_SECONDS = 6 * 60 * 60; +export const PUBLISHED_RELEASE_STALE_TIME_MS = PUBLISHED_RELEASE_CACHE_SECONDS * 1000; diff --git a/frontend/lib/api/hooks/use-microcosm.ts b/frontend/lib/api/hooks/use-microcosm.ts index 77ff7e99..0a1ee975 100644 --- a/frontend/lib/api/hooks/use-microcosm.ts +++ b/frontend/lib/api/hooks/use-microcosm.ts @@ -4,6 +4,7 @@ import { useCountry, type Country, } from "@/components/layout/country-context"; +import { PUBLISHED_RELEASE_STALE_TIME_MS } from "@/lib/api/cache-policy"; import { withBasePath } from "@/lib/base-path"; import type { ExplorerState } from "@/lib/microcosm/calibration-explorer"; import type { CalibrationTreeResponse } from "@/lib/microcosm/calibration-tree"; @@ -698,7 +699,7 @@ export function useMicrocosmReleases() { return useQuery({ queryKey: ["microcosm", "releases", country], queryFn: () => apiGet("/microcosm/releases", { country }), - staleTime: 5 * 60 * 1000, + staleTime: PUBLISHED_RELEASE_STALE_TIME_MS, }); } @@ -739,7 +740,7 @@ export function useMicrocosm(release?: string) { queryKey: ["microcosm", country, release ?? "latest"], queryFn: () => apiGet("/microcosm", { release: release || undefined, country }), - staleTime: 5 * 60 * 1000, + staleTime: PUBLISHED_RELEASE_STALE_TIME_MS, }); } @@ -794,7 +795,7 @@ export function useMicrocosmTargetTreemap(release?: string, breakdown?: "program breakdown: breakdown || undefined, country, }), - staleTime: 5 * 60 * 1000, + staleTime: PUBLISHED_RELEASE_STALE_TIME_MS, }); } @@ -846,7 +847,7 @@ export function microcosmCalibrationTreeQueryOptions( release: release || undefined, country, }), - staleTime: 5 * 60 * 1000, + staleTime: PUBLISHED_RELEASE_STALE_TIME_MS, }; } diff --git a/frontend/lib/microcosm/latest-artifact.ts b/frontend/lib/microcosm/latest-artifact.ts index 6032263d..de9f2c31 100644 --- a/frontend/lib/microcosm/latest-artifact.ts +++ b/frontend/lib/microcosm/latest-artifact.ts @@ -2030,7 +2030,9 @@ export function hfResolveUrl(path: string, country: MicrocosmCountry = "us"): st } // A hung HF request would otherwise block the function for the whole route -// maxDuration and pin the shared in-flight cache promise; cap each fetch. +// maxDuration and pin the shared in-flight cache promise. The calibration +// diagnostics artifact is large enough that a cold authenticated download can +// exceed 20 seconds, so leave adequate room below the routes' 300-second limit. const MICROCOSM_RELEASE_FETCH_TIMEOUT_MS = 120_000; async function hfFetch(url: string, revalidate: number): Promise { From e883e740c8282cc955170e7f1394afc3fb2cccf1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:16:14 +0400 Subject: [PATCH 02/20] Improve staging candidates and local app startup --- Makefile | 2 +- README.md | 11 +- .../microcosm/microcosm-overview-view.tsx | 19 +- .../microcosm/microcosm-staging-view.tsx | 960 ++++++++++-------- frontend/components/shared/format.test.ts | 9 +- frontend/components/shared/format.ts | 7 + .../components/shared/overview-metric.tsx | 28 + frontend/lib/api/client.ts | 4 +- frontend/lib/api/hooks/use-microcosm.ts | 2 - frontend/lib/microcosm/staging-status.test.ts | 34 + frontend/lib/microcosm/staging-status.ts | 40 + frontend/package.json | 5 +- frontend/scripts/available-port.mjs | 62 ++ frontend/scripts/available-port.node-test.mjs | 87 ++ frontend/scripts/dev.mjs | 66 ++ 15 files changed, 876 insertions(+), 460 deletions(-) create mode 100644 frontend/components/shared/overview-metric.tsx create mode 100644 frontend/lib/microcosm/staging-status.test.ts create mode 100644 frontend/lib/microcosm/staging-status.ts create mode 100644 frontend/scripts/available-port.mjs create mode 100644 frontend/scripts/available-port.node-test.mjs create mode 100644 frontend/scripts/dev.mjs diff --git a/Makefile b/Makefile index 0b7b10d4..c16ff170 100644 --- a/Makefile +++ b/Makefile @@ -13,4 +13,4 @@ typecheck: cd frontend && bun run lint test: - cd frontend && bun test + cd frontend && bun test && bun run test:dev-port diff --git a/README.md b/README.md index 7c65c8a5..7dc8d801 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,19 @@ combining their evidence into one report. ```bash make install # cd frontend && bun install -make dev # next dev (http://localhost:3000) +make dev # first available loopback port, starting at 3000 make typecheck # tsc --noEmit -make test # bun test (data-layer suite) +make test # frontend data and development-launcher tests make build # next build ``` +The development launcher prints the selected dashboard URL and records its port +in `frontend/.next/dev-port`. Set `PORT` to begin the search at a different +port; if that port is occupied on the IPv4 or IPv6 loopback address, the +launcher increments by one until it finds a port that is free on both network +families. Next.js binds to `127.0.0.1`, and the launcher prints that exact URL +to avoid hostname resolution selecting a different local process. + Run the Python Chronicle evaluation harness and its public numerical adapter gate manually when reviewing Chronicle or dependency updates: diff --git a/frontend/components/microcosm/microcosm-overview-view.tsx b/frontend/components/microcosm/microcosm-overview-view.tsx index f5f3a757..1ef2b437 100644 --- a/frontend/components/microcosm/microcosm-overview-view.tsx +++ b/frontend/components/microcosm/microcosm-overview-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Button } from "@policyengine/ui-kit"; import { @@ -19,6 +19,7 @@ import { EmptyState } from "@/components/shared/empty-state"; import { fmt, fmtCompact } from "@/components/shared/format"; import { HelpHint } from "@/components/shared/help-hint"; import { LoadingBlock } from "@/components/shared/LoadingBlock"; +import { OverviewMetric } from "@/components/shared/overview-metric"; import { PageHeader } from "@/components/shared/page-header"; import { SectionCard } from "@/components/shared/section-card"; import { StatusPill } from "@/components/shared/status-pill"; @@ -59,22 +60,6 @@ function fmtLoss(value: number | null | undefined, kind: LossKind): string { return value.toExponential(3).replace("e+", "e"); } -function OverviewMetric({ label, value }: { label: ReactNode; value: string }) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} - export function MicrocosmOverviewView({ initialCountry = "us", initialRelease = "", diff --git a/frontend/components/microcosm/microcosm-staging-view.tsx b/frontend/components/microcosm/microcosm-staging-view.tsx index e0a774b2..33119cf5 100644 --- a/frontend/components/microcosm/microcosm-staging-view.tsx +++ b/frontend/components/microcosm/microcosm-staging-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useCountry } from "@/components/layout/country-context"; import { EmptyState } from "@/components/shared/empty-state"; @@ -10,10 +10,14 @@ import { fmtCompact, fmtMoney, fmtSignedMoney, - releaseLabel, + shortReleaseId, } from "@/components/shared/format"; import { KpiCard } from "@/components/shared/kpi-card"; import { LoadingBlock } from "@/components/shared/LoadingBlock"; +import { + overviewMetricLabelTypographyClassName, + overviewMetricValueClassName, +} from "@/components/shared/overview-metric"; import { PageHeader } from "@/components/shared/page-header"; import { SectionCard } from "@/components/shared/section-card"; import { StatusPill, type StatusTone } from "@/components/shared/status-pill"; @@ -21,10 +25,15 @@ import { useMicrocosmStagingCompare, useMicrocosmStagingRun, useMicrocosmStagingRuns, + type MicrocosmStagingRunResponse, type MicrocosmStagingRunSummary, type ReformValidationRow, } from "@/lib/api/hooks/use-microcosm"; import { countryRegistration, hasCapability } from "@/lib/microcosm/countries"; +import { + formatStagingCurrentStatus, + formatStagingStatus, +} from "@/lib/microcosm/staging-status"; type LossKind = "normalized_target_loss" | "raw_optimizer_objective" | undefined; @@ -65,6 +74,22 @@ function timeLabel(value: string | null | undefined): string { }); } +function timestampFromId(value: string | null | undefined): string | null { + const match = value?.match(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + if (!match) return null; + const [, year, month, day, hour, minute, second] = match; + return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`; +} + +function runStartTime(run: MicrocosmStagingRunSummary): string | null | undefined { + return ( + run.started_at ?? + timestampFromId(run.candidate_release_id) ?? + timestampFromId(run.run_id) ?? + run.updated_at + ); +} + // A "running" run that hasn't reported for two hours is dead in practice — // builds emit events at least every stage, and stages run minutes, not hours. const STALL_MS = 2 * 60 * 60 * 1000; @@ -149,10 +174,8 @@ function RunList({
- {releaseLabel(run.candidate_release_id || run.run_id)} -
-
- {run.stage || "—"} · {timeLabel(run.updated_at)} + {timeLabel(runStartTime(run))} ·{" "} + {shortReleaseId(run.candidate_release_id || run.run_id)}
{(() => { @@ -190,6 +213,323 @@ function LossSparkline({ values }: { values: number[] }) { ); } +function RunInternalsPanel({ + runData, + lossValues, + status, + stage, + buildManifest, + artifacts, + open, + onOpenChange, + className = "", +}: { + runData: MicrocosmStagingRunResponse; + lossValues: number[]; + status: string | null; + stage: string | null; + buildManifest: Record | null; + artifacts: Record; + open: boolean; + onOpenChange: (open: boolean) => void; + className?: string; +}) { + return ( +
onOpenChange(event.currentTarget.open)} + className={`group overflow-hidden rounded-lg border border-border/80 bg-card shadow-[var(--elev-1)] ${className}`} + > + +
+
Run internals
+
+ Optimizer progress, stage timeline with logged numbers, build manifest (versions, + hashes, validation results), and uploaded artifacts. +
+
+ + ▾ + +
+
+ +
+ +
+
+
+ Latest loss +
+
{fmt(lossValues.at(-1), { digits: 4 })}
+
+
+
+ Best loss +
+
+ {lossValues.length ? fmt(Math.min(...lossValues), { digits: 4 }) : "—"} +
+
+
+
Stage
+ {stage || status || "unknown"} +
+
+
+
+ + {runData.calibration ? ( + +
+ + + + +
+
+ ) : ( + + + + )} + + + {(runData.events ?? []).length ? ( +
+ + + + + + + + + + + {(runData.events ?? []).map((event, index, all) => { + const time = typeof event.time === "string" ? event.time : null; + const next = all[index + 1]; + const nextTime = next && typeof next.time === "string" ? next.time : null; + const duration = + time && nextTime + ? new Date(nextTime).valueOf() - new Date(time).valueOf() + : null; + const chips = detailChips(event.details); + const failed = event.status === "failed"; + return ( + + + + + + + ); + })} + +
StageStartedDurationDetail
+ + {String(event.stage ?? "—")} + + {failed && failed} + + {timeLabel(time)} + + {index === all.length - 1 && event.stage !== "complete" && !failed + ? status === "stalled" + ? "⚠ last event" + : "…" + : durationLabel(duration)} + +
+ {String(event.message ?? "—")} +
+ {chips.length > 0 && ( +
+ {chips.map(([key, value]) => ( + + {key}={value} + + ))} +
+ )} +
+
+ ) : ( + + )} +
+ + {buildManifest && ( + +
+ {(() => { + const code = (buildManifest.code ?? {}) as Record; + const runtime = (buildManifest.runtime ?? {}) as Record; + const gates = (buildManifest.gates ?? {}) as Record; + const dataset = (buildManifest.dataset ?? {}) as Record; + return ( + <> +
+ + {Object.entries(runtime) + .filter(([key]) => + ["python", "policyengine-us", "policyengine-core", "torch"].includes( + key, + ), + ) + .map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+ Dataset sha256 + + {String(dataset.sha256 ?? "—").slice(0, 12)}… + +
+
+ {Object.keys(gates).length > 0 && ( +
+
+ Validation results +
+
+ {Object.entries(gates).map(([name, result]) => { + const validation = (result ?? {}) as Record; + const passed = validation.passed === true; + const failures = Array.isArray(validation.failures) + ? validation.failures + : []; + return ( +
+ {name}{" "} + {passed ? "passed" : "failed"} + {failures.length > 0 && ( + + {failures.map((failure) => String(failure)).join("; ")} + + )} +
+ ); + })} +
+
+ )} + + ); + })()} +
+
+ )} + + {Object.keys(artifacts).length > 0 && ( + + + + {Object.entries(artifacts).map(([name, metadata]) => ( + + + + + ))} + +
{name} + {metadata.staging_path ? ( + + {metadata.staging_path} + + ) : ( + (metadata.path ?? "—") + )} +
+
+ )} +
+
+ ); +} + function ReformValidationTable({ rows }: { rows: ReformValidationRow[] }) { const ordered = [...rows] .filter((row) => row.microcosm_estimate != null || row.jct_score != null) @@ -249,7 +589,7 @@ function ReformValidationTable({ rows }: { rows: ReformValidationRow[] }) { ); } -// Common-target fit stats for the candidate-vs-published verdict: computed on +// Common-target fit stats for the candidate-vs-current-release verdict: computed on // the SAME targets, since headline within-10% rates over different target sets // (32k national-only vs 4k) are not comparable. interface SideStats { @@ -274,44 +614,44 @@ function sideStats(errors: number[]): SideStats { // One row of the validation scorecard: a metric on both sides plus a verdict. function ScoreRow({ label, - published, + currentRelease, candidate, higherBetter, render = pct, }: { label: string; - published: number | null; + currentRelease: number | null; candidate: number | null; higherBetter: boolean; render?: (v: number | null | undefined) => string; }) { const better = - published != null && candidate != null + currentRelease != null && candidate != null ? higherBetter - ? candidate > published + 1e-6 - : candidate < published - 1e-6 + ? candidate > currentRelease + 1e-6 + : candidate < currentRelease - 1e-6 : null; const worse = - published != null && candidate != null + currentRelease != null && candidate != null ? higherBetter - ? candidate < published - 1e-6 - : candidate > published + 1e-6 + ? candidate < currentRelease - 1e-6 + : candidate > currentRelease + 1e-6 : null; return ( - {label} - - {render(published)} + {label} + + {render(currentRelease)} - + {render(candidate)} - {better ? "candidate better" : worse ? "candidate worse" : published == null || candidate == null ? "—" : "tie"} + {better ? "candidate better" : worse ? "candidate worse" : currentRelease == null || candidate == null ? "—" : "tie"} ); @@ -340,10 +680,17 @@ function MicrocosmStagingRunsView() { const runs = runsData?.runs ?? []; const [selectedRun, setSelectedRun] = useState(""); const [targetSearch, setTargetSearch] = useState(""); + const [runInternalsOpen, setRunInternalsOpen] = useState(false); + + const resetRunVisualState = useCallback((runId: string) => { + setTargetSearch(""); + setRunInternalsOpen(false); + setSelectedRun(runId); + }, []); useEffect(() => { - if (!selectedRun && runs[0]) setSelectedRun(runs[0].run_id); - }, [runs, selectedRun]); + if (!selectedRun && runs[0]) resetRunVisualState(runs[0].run_id); + }, [resetRunVisualState, runs, selectedRun]); const { data: runData, isLoading: runLoading, error: runError } = useMicrocosmStagingRun(selectedRun); @@ -375,18 +722,24 @@ function MicrocosmStagingRunsView() { .filter((value): value is number => value != null), [calibrationEvents], ); - const lastCalibrationEvent = calibrationEvents.at(-1); const progress = runData?.progress ?? {}; const rawStatus = typeof progress.status === "string" ? progress.status : null; const stage = typeof progress.stage === "string" ? progress.stage : null; - const message = typeof progress.message === "string" ? progress.message : null; const updatedAt = typeof progress.updated_at === "string" ? progress.updated_at : null; const status = effectiveStatus(rawStatus, updatedAt); - const progressDetails = detailChips(progress.details); + const statusLabel = formatStagingStatus(status); + const lastUpdate = `${timeLabel(updatedAt)}${agoLabel(updatedAt) ? ` · ${agoLabel(updatedAt)}` : ""}`; + const currentStatus = formatStagingCurrentStatus(progress); const candidateReleaseId = runData?.candidate_release_id ?? selectedRun; const buildManifest = (runData?.build_manifest ?? null) as Record | null; const artifacts = ((runData?.run_manifest as Record | null)?.artifacts ?? {}) as Record; + const hasCandidateValidation = Boolean(compareData?.summary || runData?.reform_validation); + const targetComparisonPending = Boolean( + runData?.has_calibration && compareLoading && !compareData, + ); + const candidateValidationPending = targetComparisonPending && !hasCandidateValidation; + const showsCandidateValidation = hasCandidateValidation || candidateValidationPending; return (
@@ -397,14 +750,7 @@ function MicrocosmStagingRunsView() { />
- + {runsLoading ? ( ) : runsError ? ( @@ -420,114 +766,138 @@ function MicrocosmStagingRunsView() { variant="compact" /> ) : ( - + )} -
- {!selectedRun ? ( - + {!selectedRun ? ( +
+ +
) : runLoading ? ( - +
+ +
) : runError || !runData ? ( - +
+ +
) : ( <> -
- - - - -
- - {progressDetails.length > 0 && ( -
- Last stage detail: - {progressDetails.map(([k, v]) => ( - - {k}={v} - - ))} +
+ +
+
+ Candidate +
+
+ {candidateReleaseId} +
+
+ Status +
+
+ {statusLabel} +
+
+ Last update +
+
+ {lastUpdate} +
+
+ Current status +
+
+ {currentStatus} +
- )} +
- {runData.has_calibration && compareLoading && !compareData && ( - + {candidateValidationPending && ( + + + )} - {(compareData?.summary || runData.reform_validation) && ( + {hasCandidateValidation && ( - +
- - - - - + + + + + + {targetComparisonPending && ( + + + + )} {compareData?.summary && ( <> @@ -536,8 +906,8 @@ function MicrocosmStagingRunsView() { {runData.reform_validation && ( <> 0 ? (runData.reform_validation.summary?.out_of_sample_within_10pct ?? @@ -560,14 +930,14 @@ function MicrocosmStagingRunsView() { )} {compareData?.summary && ( - - + - - @@ -576,23 +946,38 @@ function MicrocosmStagingRunsView() {
Validation pointPublishedCandidateVerdict
Validation pointCurrent releaseCandidateVerdict
+ Loading target comparison… +
Coverage · target surface + Coverage target surface {fmt(compareData.a.total_targets, { digits: 0 })} + {fmt(compareData.b.total_targets, { digits: 0 })} + +{fmt(compareData.summary.added, { digits: 0 })} new / - {fmt(compareData.summary.removed, { digits: 0 })} dropped
{compareData?.summary && ( -
+
{fmt(compareData.summary.improved, { digits: 0 })} targets improved {" · "} {fmt(compareData.summary.regressed, { digits: 0 })} regressed - {" "} - — search the breakdown below for any statistic. +
)} )} - {(compareData?.rows ?? []).length > 0 && ( + {!showsCandidateValidation && ( + + )} +
+ +
+ {(compareData?.rows ?? []).length > 0 && ( Target - Published + Current release Candidate Δ @@ -712,326 +1097,33 @@ function MicrocosmStagingRunsView() { )} - {runData.reform_validation ? ( + {runData.reform_validation && ( - ) : ( - - - - )} - -
- -
-
- Run internals -
-
- Optimizer progress, stage timeline with logged numbers, build manifest - (versions, hashes, gates), and uploaded artifacts. -
-
- - ▾ - -
-
- -
- -
-
-
- Latest loss -
-
{fmt(lossValues.at(-1), { digits: 4 })}
-
-
-
- Best loss -
-
- {lossValues.length ? fmt(Math.min(...lossValues), { digits: 4 }) : "—"} -
-
-
-
- Stage -
- {stage || status || "unknown"} -
-
-
-
- - {runData.calibration ? ( - -
- - - - -
-
- ) : ( - - - - )} - - - - - {(runData.events ?? []).length ? ( -
- - - - - - - - - - - {(runData.events ?? []).map((event, index, all) => { - const time = typeof event.time === "string" ? event.time : null; - const next = all[index + 1]; - const nextTime = - next && typeof next.time === "string" ? next.time : null; - const duration = - time && nextTime - ? new Date(nextTime).valueOf() - new Date(time).valueOf() - : null; - const chips = detailChips(event.details); - const failed = event.status === "failed"; - return ( - - - - - - - ); - })} - -
StageStartedDurationDetail
- - {String(event.stage ?? "—")} - - {failed && ( - failed - )} - - {timeLabel(time)} - - {index === all.length - 1 && - event.stage !== "complete" && - !failed - ? status === "stalled" - ? "⚠ last event" - : "…" - : durationLabel(duration)} - -
- {String(event.message ?? "—")} -
- {chips.length > 0 && ( -
- {chips.map(([k, v]) => ( - - {k}={v} - - ))} -
- )} -
-
- ) : ( - - )} -
- - {buildManifest && ( - -
- {(() => { - const code = (buildManifest.code ?? {}) as Record; - const runtime = (buildManifest.runtime ?? {}) as Record; - const gates = (buildManifest.gates ?? {}) as Record; - const dataset = (buildManifest.dataset ?? {}) as Record; - return ( - <> -
- - {Object.entries(runtime) - .filter(([k]) => - ["python", "policyengine-us", "policyengine-core", "torch"].includes(k), - ) - .map(([k, v]) => ( -
- {k} - {String(v)} -
- ))} -
- Dataset sha256 - - {String(dataset.sha256 ?? "—").slice(0, 12)}… - -
-
- {Object.keys(gates).length > 0 && ( -
-
- Gates -
-
- {Object.entries(gates).map(([name, result]) => { - const r = (result ?? {}) as Record; - const passed = r.passed === true; - const failures = Array.isArray(r.failures) ? r.failures : []; - return ( -
- {name}{" "} - {passed ? "passed" : "failed"} - {failures.length > 0 && ( - - {failures.map((f) => String(f)).join("; ")} - - )} -
- ); - })} -
-
- )} - - ); - })()} -
-
)} - {Object.keys(artifacts).length > 0 && ( - - - - {Object.entries(artifacts).map(([name, meta]) => ( - - - - - ))} - -
{name} - {meta.staging_path ? ( - - {meta.staging_path} - - ) : ( - (meta.path ?? "—") - )} -
-
+ {showsCandidateValidation && ( + )} -
-
+
)} -
); diff --git a/frontend/components/shared/format.test.ts b/frontend/components/shared/format.test.ts index 1d9031ae..7c631d67 100644 --- a/frontend/components/shared/format.test.ts +++ b/frontend/components/shared/format.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { fmtUnitValue, releaseLabel } from "./format"; +import { fmtUnitValue, releaseLabel, shortReleaseId } from "./format"; test("percent-unit values render as percentages from decimal fractions", () => { expect(fmtUnitValue(0.134, "percent")).toBe("13.4%"); @@ -20,3 +20,10 @@ test("Belgium Chronicle release labels expose the distinguishing commit", () => releaseLabel("microcosm-be-2026-chronicle-3cef97b-20260823T134247Z"), ).toBe("2026-08-23 13:42Z · 3cef97b"); }); + +test("short release ids use six characters from the varying identifier", () => { + expect( + shortReleaseId("populace-us-2024-32fcf6b-480cc7c54024-20260702T134754Z"), + ).toBe("32fcf6"); + expect(shortReleaseId("rel-1")).toBe("rel"); +}); diff --git a/frontend/components/shared/format.ts b/frontend/components/shared/format.ts index 3976491a..ed4f0a13 100644 --- a/frontend/components/shared/format.ts +++ b/frontend/components/shared/format.ts @@ -31,6 +31,13 @@ function releaseDateFromId(releaseId: string): string { return releaseId.match(/-(\d{8}(?:T\d{6}Z)?)$/)?.[1] ?? ""; } +// Release identifiers begin with a stable product/year prefix. Return the +// varying identifier segment so compact labels distinguish one release from +// another instead of all beginning with "popula". +export function shortReleaseId(releaseId: string, length = 6): string { + return releaseId.replace(/^populace-us-\d{4}-/, "").split("-")[0].slice(0, length); +} + // A readable label for a release: "2026-06-14 · f32c2e5". export function releaseLabel(releaseId: string, date?: string | null): string { // Deprecated upstream identifier: Microcosm release IDs still use the former diff --git a/frontend/components/shared/overview-metric.tsx b/frontend/components/shared/overview-metric.tsx new file mode 100644 index 00000000..b0aff2b5 --- /dev/null +++ b/frontend/components/shared/overview-metric.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +export const overviewMetricLabelTypographyClassName = + "text-[10px] font-semibold uppercase leading-tight tracking-[0.12em]"; + +export const overviewMetricLabelClassName = + `${overviewMetricLabelTypographyClassName} text-muted-foreground`; + +export const overviewMetricValueClassName = + "font-semibold tabular-nums text-foreground"; + +export function OverviewMetric({ label, value }: { label: ReactNode; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 36285db8..5f32df33 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -9,7 +9,9 @@ type ParamValue = string | number | boolean | undefined | null | (string | numbe function apiUrl(path: string): URL { if (EXPLICIT_API_BASE) return new URL(path, EXPLICIT_API_BASE); const origin = - typeof window === "undefined" ? "http://localhost:3000" : window.location.origin; + typeof window === "undefined" + ? `http://127.0.0.1:${process.env.PORT ?? "3000"}` + : window.location.origin; // Next.js API route handlers live under the app basePath; the native Python // function is routed back to root by a rewrite (see next.config.ts), so all // API calls resolve through `${origin}${BASE_PATH}/api`. diff --git a/frontend/lib/api/hooks/use-microcosm.ts b/frontend/lib/api/hooks/use-microcosm.ts index 0a1ee975..c2329617 100644 --- a/frontend/lib/api/hooks/use-microcosm.ts +++ b/frontend/lib/api/hooks/use-microcosm.ts @@ -673,7 +673,6 @@ export function useMicrocosmStagingRun(runId?: string) { country, }), enabled: staging && Boolean(runId), - placeholderData: keepPreviousData, staleTime: 10 * 1000, refetchInterval: staging ? 30 * 1000 : false, }); @@ -689,7 +688,6 @@ export function useMicrocosmStagingCompare(runId?: string, release = "latest") { { run: runId, release, country }, ), enabled: hasCapability(country, "staging") && Boolean(runId), - placeholderData: keepPreviousData, staleTime: 30 * 1000, }); } diff --git a/frontend/lib/microcosm/staging-status.test.ts b/frontend/lib/microcosm/staging-status.test.ts new file mode 100644 index 00000000..84b5c1a1 --- /dev/null +++ b/frontend/lib/microcosm/staging-status.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; + +import { formatStagingCurrentStatus, formatStagingStatus } from "./staging-status"; + +test("sentence-cases status values", () => { + expect(formatStagingStatus("running")).toBe("Running"); + expect(formatStagingStatus("waiting_for_input")).toBe("Waiting for input"); + expect(formatStagingStatus(null)).toBe("Unknown"); +}); + +test("formats ordinary progress stages as readable current statuses", () => { + expect( + formatStagingCurrentStatus({ + status: "running", + stage: "base_population_repair", + }), + ).toBe("Base population repair"); +}); + +test("formats calibration progress with the current and total epochs", () => { + expect( + formatStagingCurrentStatus({ + status: "running", + stage: "calibrating", + calibration: { epoch: 125, epochs: 6000 }, + }), + ).toBe("Calibration, epoch 125 of 6000"); +}); + +test("falls back safely when stage details are incomplete", () => { + expect(formatStagingCurrentStatus({ stage: "calibrating" })).toBe("Calibration"); + expect(formatStagingCurrentStatus({ status: "queued" })).toBe("Queued"); + expect(formatStagingCurrentStatus({})).toBe("Unknown"); +}); diff --git a/frontend/lib/microcosm/staging-status.ts b/frontend/lib/microcosm/staging-status.ts new file mode 100644 index 00000000..68eb9cd3 --- /dev/null +++ b/frontend/lib/microcosm/staging-status.ts @@ -0,0 +1,40 @@ +type JsonObject = Record; + +function objectValue(value: unknown): JsonObject { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function sentenceCaseIdentifier(value: string): string { + const words = value.replaceAll("_", " ").trim(); + return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "Unknown"; +} + +export function formatStagingStatus(status: unknown): string { + const value = stringValue(status); + return value ? sentenceCaseIdentifier(value) : "Unknown"; +} + +export function formatStagingCurrentStatus(progress: JsonObject): string { + const stage = stringValue(progress.stage); + if (stage === "calibrating" || stage === "calibration") { + const calibration = objectValue(progress.calibration); + const epoch = finiteNumber(calibration.epoch); + const epochs = finiteNumber(calibration.epochs); + return epoch != null && epochs != null + ? `Calibration, epoch ${epoch} of ${epochs}` + : "Calibration"; + } + if (stage) return sentenceCaseIdentifier(stage); + + return formatStagingStatus(progress.status); +} diff --git a/frontend/package.json b/frontend/package.json index e8a15c37..36c32a72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,10 +4,11 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev --turbopack", + "dev": "node scripts/dev.mjs", "build": "next build", "start": "next start", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test:dev-port": "node --test scripts/available-port.node-test.mjs" }, "dependencies": { "@policyengine/ui-kit": "^0.9.0", diff --git a/frontend/scripts/available-port.mjs b/frontend/scripts/available-port.mjs new file mode 100644 index 00000000..bd979296 --- /dev/null +++ b/frontend/scripts/available-port.mjs @@ -0,0 +1,62 @@ +import { createServer } from "node:net"; + +const MIN_PORT = 1; +const MAX_PORT = 65_535; + +function assertPort(port, label) { + if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) { + throw new RangeError(`${label} must be an integer between ${MIN_PORT} and ${MAX_PORT}`); + } +} + +function canListen(port, host, ipv6Only = false) { + return new Promise((resolve, reject) => { + const server = createServer(); + + server.once("error", (error) => { + if (error.code === "EADDRINUSE") { + resolve(false); + return; + } + if ( + ipv6Only && + (error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL") + ) { + resolve(true); + return; + } + reject(error); + }); + + server.listen({ port, host, exclusive: true, ipv6Only }, () => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(true); + }); + }); + }); +} + +export async function isPortAvailable(port) { + assertPort(port, "port"); + + if (!(await canListen(port, "127.0.0.1"))) return false; + return canListen(port, "::1", true); +} + +export async function findAvailablePort(startPort = 3000, maxPort = MAX_PORT) { + assertPort(startPort, "startPort"); + assertPort(maxPort, "maxPort"); + if (maxPort < startPort) { + throw new RangeError("maxPort must be greater than or equal to startPort"); + } + + for (let port = startPort; port <= maxPort; port += 1) { + if (await isPortAvailable(port)) return port; + } + + throw new Error(`No available port found from ${startPort} through ${maxPort}`); +} diff --git a/frontend/scripts/available-port.node-test.mjs b/frontend/scripts/available-port.node-test.mjs new file mode 100644 index 00000000..49e566e1 --- /dev/null +++ b/frontend/scripts/available-port.node-test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, test } from "node:test"; +import { createServer } from "node:net"; + +import { findAvailablePort, isPortAvailable } from "./available-port.mjs"; + +const openServers = []; + +afterEach(async () => { + await Promise.all( + openServers.splice(0).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); +}); + +async function occupyEphemeralIpv4Port() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ port: 0, host: "127.0.0.1" }, resolve); + }); + openServers.push(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected the test server to have a TCP address"); + } + return address.port; +} + +async function occupyEphemeralIpv6Port() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ port: 0, host: "::1", ipv6Only: true }, resolve); + }); + openServers.push(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected the test server to have a TCP address"); + } + return address.port; +} + +describe("development port selection", () => { + test("recognizes a port occupied only on IPv4", async () => { + const occupiedPort = await occupyEphemeralIpv4Port(); + + assert.equal(await isPortAvailable(occupiedPort), false); + }); + + test("recognizes a port occupied only on IPv6", async (context) => { + let occupiedPort; + try { + occupiedPort = await occupyEphemeralIpv6Port(); + } catch (error) { + if (error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL") { + context.skip("IPv6 loopback is not available on this host"); + return; + } + throw error; + } + + assert.equal(await isPortAvailable(occupiedPort), false); + }); + + test("increments until it finds a free port", async () => { + const occupiedPort = await occupyEphemeralIpv4Port(); + + const selectedPort = await findAvailablePort(occupiedPort); + + assert.ok(selectedPort > occupiedPort); + assert.equal(await isPortAvailable(selectedPort), true); + }); + + test("reports when the requested range is fully occupied", async () => { + const occupiedPort = await occupyEphemeralIpv4Port(); + + await assert.rejects( + findAvailablePort(occupiedPort, occupiedPort), + new Error(`No available port found from ${occupiedPort} through ${occupiedPort}`), + ); + }); +}); diff --git a/frontend/scripts/dev.mjs b/frontend/scripts/dev.mjs new file mode 100644 index 00000000..ded2ca42 --- /dev/null +++ b/frontend/scripts/dev.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import process from "node:process"; + +import { findAvailablePort } from "./available-port.mjs"; + +const DEFAULT_START_PORT = 3000; +const APP_PATH = "/calibration/dashboard"; +const DEV_PORT_FILE = new URL("../.next/dev-port", import.meta.url); + +function startPort() { + const configured = process.env.PORT?.trim(); + if (!configured) return DEFAULT_START_PORT; + + const parsed = Number(configured); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new Error("PORT must be an integer between 1 and 65535"); + } + return parsed; +} + +async function recordPort(port) { + await mkdir(new URL("../.next", import.meta.url), { recursive: true }); + await writeFile(DEV_PORT_FILE, `${port}\n`, "utf8"); +} + +async function removePortRecord(port) { + try { + const recordedPort = (await readFile(DEV_PORT_FILE, "utf8")).trim(); + if (recordedPort === String(port)) await rm(DEV_PORT_FILE); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +const port = await findAvailablePort(startPort()); +await recordPort(port); + +const origin = `http://127.0.0.1:${port}`; +console.log(`Starting calibration dashboard at ${origin}${APP_PATH}`); + +const child = spawn( + "next", + ["dev", "--turbopack", "--hostname", "127.0.0.1", "--port", String(port)], + { + env: { ...process.env, PORT: String(port) }, + stdio: "inherit", + }, +); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => child.kill(signal)); +} + +child.once("error", async (error) => { + await removePortRecord(port); + console.error(`Unable to start Next.js: ${error.message}`); + process.exitCode = 1; +}); + +child.once("exit", async (code) => { + await removePortRecord(port); + process.exitCode = code ?? 1; +}); From f83060ca908181fffa393288f071c244e867cba8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:06:29 +0400 Subject: [PATCH 03/20] Improve candidate validation layout and help --- .../microcosm/microcosm-staging-view.tsx | 86 +++++++++---- frontend/components/shared/help-hint.tsx | 119 ++++++++---------- 2 files changed, 113 insertions(+), 92 deletions(-) diff --git a/frontend/components/microcosm/microcosm-staging-view.tsx b/frontend/components/microcosm/microcosm-staging-view.tsx index 33119cf5..7eba3d3a 100644 --- a/frontend/components/microcosm/microcosm-staging-view.tsx +++ b/frontend/components/microcosm/microcosm-staging-view.tsx @@ -12,6 +12,7 @@ import { fmtSignedMoney, shortReleaseId, } from "@/components/shared/format"; +import { HelpHint } from "@/components/shared/help-hint"; import { KpiCard } from "@/components/shared/kpi-card"; import { LoadingBlock } from "@/components/shared/LoadingBlock"; import { @@ -599,6 +600,21 @@ interface SideStats { mean: number | null; } +const VALIDATION_METHOD_HELP = { + targetWithin10: + "Measures the share of targets present in both releases whose absolute relative error is at most 10% of the benchmark value; higher is better. This metric is restricted to shared targets to ensure direct comparability.", + targetMedianAbsoluteError: + "Calculates the median absolute relative error across targets present in both releases. Lower is better.", + targetMeanAbsoluteError: + "Calculates the mean absolute relative error across targets present in both releases. Lower is better.", + reformMeanAbsoluteError: + "Calculates the mean absolute relative error between the candidate's estimated reform effects and the external benchmarks for out-of-sample reforms. Out-of-sample means that the reform includes values that are not used as calibration targets; lower is better.", + reformWithin10: + "Measures the share of scored out-of-sample reforms whose estimated effect is within 10% of the external benchmark. Out-of-sample means that the reform includes values that are not used as calibration targets; higher is better.", + targetCoverage: + "Counts the complete set of calibration targets available in each release, including targets that are not shared. The verdict reports how many targets the candidate adds and removes relative to the current release.", +} as const; + function sideStats(errors: number[]): SideStats { if (!errors.length) return { n: 0, within10: 0, median: null, mean: null }; const sorted = [...errors].sort((a, b) => a - b); @@ -614,12 +630,14 @@ function sideStats(errors: number[]): SideStats { // One row of the validation scorecard: a metric on both sides plus a verdict. function ScoreRow({ label, + about, currentRelease, candidate, higherBetter, render = pct, }: { label: string; + about: string; currentRelease: number | null; candidate: number | null; higherBetter: boolean; @@ -638,21 +656,29 @@ function ScoreRow({ : candidate > currentRelease + 1e-6 : null; return ( - - {label} - + + {label} + {render(currentRelease)} - + {render(candidate)} {better ? "candidate better" : worse ? "candidate worse" : currentRelease == null || candidate == null ? "—" : "tie"} + + About {label}} + tooltip={about} + interaction="click" + underline={false} + /> + ); } @@ -861,21 +887,23 @@ function MicrocosmStagingRunsView() { title="Candidate validation" padded={false} > - - - - - - - +
+
Validation pointCurrent releaseCandidateVerdict
+ + + + + + + - + {targetComparisonPending && ( - + @@ -885,18 +913,21 @@ function MicrocosmStagingRunsView() { <> 0 @@ -929,22 +962,31 @@ function MicrocosmStagingRunsView() { )} {compareData?.summary && ( - - - + + - - + )} -
Validation pointCurrent releaseCandidateVerdictAbout
Loading target comparison…
Coverage target surface +
Coverage target surface {fmt(compareData.a.total_targets, { digits: 0 })} + {fmt(compareData.b.total_targets, { digits: 0 })} + +{fmt(compareData.summary.added, { digits: 0 })} new / - {fmt(compareData.summary.removed, { digits: 0 })} dropped + About Coverage target surface} + tooltip={VALIDATION_METHOD_HELP.targetCoverage} + interaction="click" + underline={false} + /> +
+ +
{compareData?.summary && (
diff --git a/frontend/components/shared/help-hint.tsx b/frontend/components/shared/help-hint.tsx index 397af042..4ae9dc9a 100644 --- a/frontend/components/shared/help-hint.tsx +++ b/frontend/components/shared/help-hint.tsx @@ -1,6 +1,15 @@ "use client"; -import { type ReactNode, useEffect, useId, useRef, useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@policyengine/ui-kit"; +import type { ReactNode } from "react"; interface HelpHintProps { label: ReactNode; @@ -17,84 +26,54 @@ export function HelpHint({ underline = true, inheritTypography = false, }: HelpHintProps) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const tooltipId = useId(); - - useEffect(() => { - if (interaction !== "click" || !open) return; - - const onPointerDown = (event: MouseEvent) => { - if (rootRef.current && !rootRef.current.contains(event.target as Node)) { - setOpen(false); - } - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [interaction, open]); - const typography = inheritTypography ? "" : "normal-case tracking-normal"; const labelClass = underline ? "underline decoration-dotted underline-offset-2" : ""; + const triggerClassName = `inline-flex cursor-pointer items-center gap-1 text-inherit ${typography} ${ + inheritTypography ? "uppercase" : "" + }`; + const trigger = ( + + ); + const overlayClassName = + "z-[100] max-h-[var(--radix-popper-available-height)] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto border-border bg-popover p-3 text-left text-xs font-normal normal-case leading-snug tracking-normal text-popover-foreground shadow-lg"; if (interaction === "click") { return ( - - - {open && ( - - {tooltip} - - )} - + {tooltip} + + ); } return ( - - {label} - - ? - - - {tooltip} - - + + + {trigger} + + {tooltip} + + + ); } From 85bc2afd814bbe24a951e3d4170d88ef41e70054 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:43:45 +0400 Subject: [PATCH 04/20] Add weighted target change attribution --- frontend/lib/microcosm/target-change.test.ts | 170 ++++++++++ frontend/lib/microcosm/target-change.ts | 332 +++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 frontend/lib/microcosm/target-change.test.ts create mode 100644 frontend/lib/microcosm/target-change.ts diff --git a/frontend/lib/microcosm/target-change.test.ts b/frontend/lib/microcosm/target-change.test.ts new file mode 100644 index 00000000..423eaba0 --- /dev/null +++ b/frontend/lib/microcosm/target-change.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; + +import type { Calibration } from "./latest-artifact"; +import { + buildTargetChangeDataset, + type TargetChangeSide, +} from "./target-change"; + +type RowInput = { + name: string; + contribution: number; + share: number; + error: number; + source?: string; + variable?: string; + geography?: string; +}; + +function calibration( + releaseId: string, + rows: RowInput[], + options: { + status?: Calibration["target_loss_attribution"]["status"]; + cap?: number; + basis?: string; + } = {}, +): Calibration { + const aggregate = rows.reduce((sum, row) => sum + row.contribution, 0); + const status = options.status ?? "reported"; + return { + release_id: releaseId, + rows: rows.map((row) => ({ + name: `${row.name}@2024`, + base_name: row.name, + source: row.source ?? "source", + variable: row.variable ?? row.name, + variable_key: row.variable ?? row.name, + geography: row.geography ?? "United States", + target: 100, + final_estimate: 100 + row.error * 100, + target_loss_weight: row.share * 100, + target_loss_weight_share: row.share, + target_loss_scale: 100, + final_capped_scaled_error: row.error, + final_loss_contribution: row.contribution, + })), + target_loss_attribution: { + status, + aggregate: status === "unavailable" ? null : aggregate, + historical_final_loss: aggregate, + cap: options.cap ?? 2, + basis_identifier: options.basis ?? "sqrt:target", + basis_hash: "hash", + verification: null, + producer_warnings: [], + reason: status === "unavailable" ? "fixture unavailable" : null, + targets: [], + }, + } as unknown as Calibration; +} + +function side(row: TargetChangeSide | null): TargetChangeSide { + if (!row) throw new Error("Expected target side"); + return row; +} + +describe("target change attribution", () => { + test("reported changes preserve weights, additions, removals, and the aggregate delta", () => { + const current = calibration("current", [ + { name: "shared", contribution: 0.1, share: 0.4, error: 0.25 }, + { name: "removed", contribution: 0.2, share: 0.6, error: 1 / 3 }, + ]); + const candidate = calibration("candidate", [ + { name: "shared", contribution: 0.15, share: 0.75, error: 0.2 }, + { name: "added", contribution: 0.05, share: 0.25, error: 0.2 }, + ]); + + const result = buildTargetChangeDataset(current, candidate); + expect(result.available).toBe(true); + expect(result.rows.find((row) => row.name === "shared")?.reported_change).toBeCloseTo(0.05); + expect(result.rows.find((row) => row.name === "added")?.reported_change).toBeCloseTo(0.05); + expect(result.rows.find((row) => row.name === "removed")?.reported_change).toBeCloseTo(-0.2); + expect(result.summaries.reported).toEqual(expect.objectContaining({ + shared: 1, + added: 1, + removed: 1, + })); + expect(result.summaries.reported?.currentScore).toBeCloseTo(0.3); + expect(result.summaries.reported?.candidateScore).toBeCloseTo(0.2); + expect(result.summaries.reported?.grossIncrease).toBeCloseTo(0.1); + expect(result.summaries.reported?.grossReduction).toBeCloseTo(0.2); + expect(result.summaries.reported?.netChange).toBeCloseTo(-0.1); + expect(result.summaries.reported?.reconciliationDifference).toBeCloseTo(0); + }); + + test("shared mode normalizes each side over shared targets and applies pooled weights", () => { + const current = calibration("current", [ + { name: "one", contribution: 0.08, share: 0.8, error: 0.1 }, + { name: "two", contribution: 0.02, share: 0.1, error: 0.2 }, + { name: "removed", contribution: 0.03, share: 0.1, error: 0.3 }, + ]); + const candidate = calibration("candidate", [ + { name: "one", contribution: 0.08, share: 0.2, error: 0.4 }, + { name: "two", contribution: 0.04, share: 0.4, error: 0.1 }, + { name: "added", contribution: 0.08, share: 0.4, error: 0.2 }, + ]); + + const result = buildTargetChangeDataset(current, candidate); + const one = result.rows.find((row) => row.name === "one"); + const two = result.rows.find((row) => row.name === "two"); + const expectedOne = ((0.8 / 0.9) + (0.2 / 0.6)) / 2; + expect(one?.pooled_weight_share).toBeCloseTo(expectedOne); + expect((one?.pooled_weight_share ?? 0) + (two?.pooled_weight_share ?? 0)).toBeCloseTo(1); + expect(one?.shared_change).toBeCloseTo(expectedOne * (0.4 - 0.1)); + expect(result.summaries.shared?.comparisonTargets).toBe(2); + expect(result.summaries.shared?.reconciliationDifference).toBeCloseTo(0); + }); + + test("shared rows use candidate metadata while removed rows keep current metadata", () => { + const current = calibration("current", [ + { name: "shared", contribution: 0.1, share: 0.5, error: 0.2, source: "old" }, + { name: "removed", contribution: 0.1, share: 0.5, error: 0.2, source: "old" }, + ]); + const candidate = calibration("candidate", [ + { name: "shared", contribution: 0.2, share: 1, error: 0.2, source: "new" }, + ]); + + const result = buildTargetChangeDataset(current, candidate); + expect(result.rows.find((row) => row.name === "shared")?.source).toBe("new"); + expect(result.rows.find((row) => row.name === "removed")?.source).toBe("old"); + }); + + test("fails closed when attribution is unavailable or a row is incomplete", () => { + const unavailable = buildTargetChangeDataset( + calibration("current", [], { status: "unavailable" }), + calibration("candidate", []), + ); + expect(unavailable.available).toBe(false); + expect(unavailable.rows).toEqual([]); + + const current = calibration("current", [ + { name: "shared", contribution: 0.1, share: 1, error: 0.1 }, + ]); + delete current.rows[0].final_loss_contribution; + const incomplete = buildTargetChangeDataset( + current, + calibration("candidate", [ + { name: "shared", contribution: 0.1, share: 1, error: 0.1 }, + ]), + ); + expect(incomplete.available).toBe(false); + expect(incomplete.reason).toContain("incomplete"); + }); + + test("retains additive results while warning about methodology differences", () => { + const current = calibration("current", [ + { name: "shared", contribution: 0.1, share: 1, error: 0.1 }, + ], { cap: 1, basis: "old" }); + const candidate = calibration("candidate", [ + { name: "shared", contribution: 0.2, share: 1, error: 0.2 }, + ], { cap: 2, basis: "new" }); + + const result = buildTargetChangeDataset(current, candidate); + expect(result.available).toBe(true); + expect(result.methodology.comparable).toBe(false); + expect(result.methodology.warning).toContain("different"); + expect(side(result.rows[0].current).contribution).toBe(0.1); + expect(side(result.rows[0].candidate).contribution).toBe(0.2); + }); +}); diff --git a/frontend/lib/microcosm/target-change.ts b/frontend/lib/microcosm/target-change.ts new file mode 100644 index 00000000..65c760ac --- /dev/null +++ b/frontend/lib/microcosm/target-change.ts @@ -0,0 +1,332 @@ +import type { Calibration } from "./latest-artifact"; + +type TargetRow = Calibration["rows"][number]; + +export const TARGET_CHANGE_EPSILON = 1e-12; + +export type TargetChangeMode = "reported" | "shared"; +export type TargetSurfaceStatus = "shared" | "added" | "removed"; + +export interface TargetChangeSide { + target: number | null; + finalEstimate: number | null; + weight: number; + weightShare: number; + scale: number; + cappedError: number; + contribution: number; +} + +export interface TargetChangeRow extends Record { + name: string; + base_name: string; + comparison_status: TargetSurfaceStatus; + current: TargetChangeSide | null; + candidate: TargetChangeSide | null; + reported_change: number; + pooled_weight_share: number | null; + shared_current_contribution: number | null; + shared_candidate_contribution: number | null; + shared_change: number | null; +} + +export interface TargetChangeAttributionSide { + releaseId: string; + status: Calibration["target_loss_attribution"]["status"]; + aggregate: number | null; + cap: number | null; + basisIdentifier: string | null; +} + +export interface TargetChangeMethodology { + comparable: boolean; + warning: string | null; +} + +export interface TargetChangeSummary { + mode: TargetChangeMode; + currentScore: number; + candidateScore: number; + netChange: number; + grossIncrease: number; + grossReduction: number; + reconciliationDifference: number; + comparisonTargets: number; + shared: number; + added: number; + removed: number; + changed: number; + unchanged: number; +} + +export interface TargetChangeDataset { + available: boolean; + reason: string | null; + current: TargetChangeAttributionSide; + candidate: TargetChangeAttributionSide; + methodology: TargetChangeMethodology; + rows: TargetChangeRow[]; + summaries: Record; + modeReasons: Record; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function targetKey(row: TargetRow): string { + return String(row.base_name ?? row.name ?? ""); +} + +function attributionSide(calibration: Calibration): TargetChangeAttributionSide { + const attribution = calibration.target_loss_attribution; + return { + releaseId: calibration.release_id, + status: attribution.status, + aggregate: finiteNumber(attribution.aggregate), + cap: finiteNumber(attribution.cap), + basisIdentifier: attribution.basis_identifier, + }; +} + +function targetSide(row: TargetRow | undefined): TargetChangeSide | null { + if (!row) return null; + const weight = finiteNumber(row.target_loss_weight); + const weightShare = finiteNumber(row.target_loss_weight_share); + const scale = finiteNumber(row.target_loss_scale); + const cappedError = finiteNumber(row.final_capped_scaled_error); + const contribution = finiteNumber(row.final_loss_contribution); + if ( + weight == null || weight < 0 || + weightShare == null || weightShare < 0 || + scale == null || scale <= 0 || + cappedError == null || cappedError < 0 || + contribution == null || contribution < 0 + ) { + return null; + } + return { + target: finiteNumber(row.target ?? row.value), + finalEstimate: finiteNumber(row.final_estimate ?? row.estimate), + weight, + weightShare, + scale, + cappedError, + contribution, + }; +} + +function close(left: number | null, right: number | null): boolean { + if (left == null || right == null) return left === right; + return Math.abs(left - right) <= TARGET_CHANGE_EPSILON; +} + +function methodology( + current: TargetChangeAttributionSide, + candidate: TargetChangeAttributionSide, +): TargetChangeMethodology { + const capMatches = close(current.cap, candidate.cap); + const basisMatches = current.basisIdentifier === candidate.basisIdentifier; + const comparable = capMatches && basisMatches; + return { + comparable, + warning: comparable + ? null + : "The current release and candidate use different target-loss caps or weighting methods. The reported change remains additive, but it includes that methodology difference.", + }; +} + +function summarize( + rows: TargetChangeRow[], + mode: TargetChangeMode, + currentScore: number, + candidateScore: number, +): TargetChangeSummary { + const included = mode === "reported" + ? rows + : rows.filter((row) => row.comparison_status === "shared"); + const changeOf = (row: TargetChangeRow) => + mode === "reported" ? row.reported_change : row.shared_change ?? 0; + const rowTotal = included.reduce((sum, row) => sum + changeOf(row), 0); + const grossIncrease = included.reduce( + (sum, row) => sum + Math.max(changeOf(row), 0), + 0, + ); + const grossReduction = included.reduce( + (sum, row) => sum + Math.max(-changeOf(row), 0), + 0, + ); + const shared = rows.filter((row) => row.comparison_status === "shared").length; + const added = rows.filter((row) => row.comparison_status === "added").length; + const removed = rows.filter((row) => row.comparison_status === "removed").length; + const changed = included.filter( + (row) => Math.abs(changeOf(row)) > TARGET_CHANGE_EPSILON, + ).length; + return { + mode, + currentScore, + candidateScore, + netChange: candidateScore - currentScore, + grossIncrease, + grossReduction, + reconciliationDifference: rowTotal - (candidateScore - currentScore), + comparisonTargets: included.length, + shared, + added, + removed, + changed, + unchanged: included.length - changed, + }; +} + +function unavailableDataset( + current: TargetChangeAttributionSide, + candidate: TargetChangeAttributionSide, + reason: string, +): TargetChangeDataset { + return { + available: false, + reason, + current, + candidate, + methodology: methodology(current, candidate), + rows: [], + summaries: { reported: null, shared: null }, + modeReasons: { reported: reason, shared: reason }, + }; +} + +export function targetChangeForMode( + row: TargetChangeRow, + mode: TargetChangeMode, +): number | null { + return mode === "reported" ? row.reported_change : row.shared_change; +} + +export function buildTargetChangeDataset( + currentCalibration: Calibration, + candidateCalibration: Calibration, +): TargetChangeDataset { + const current = attributionSide(currentCalibration); + const candidate = attributionSide(candidateCalibration); + if (current.status === "unavailable" || current.aggregate == null) { + return unavailableDataset( + current, + candidate, + "Weighted target-error attribution is unavailable for the current release.", + ); + } + if (candidate.status === "unavailable" || candidate.aggregate == null) { + return unavailableDataset( + current, + candidate, + "Weighted target-error attribution is unavailable for the candidate.", + ); + } + + const currentByName = new Map( + currentCalibration.rows.map((row) => [targetKey(row), row]), + ); + const candidateByName = new Map( + candidateCalibration.rows.map((row) => [targetKey(row), row]), + ); + const names = [...new Set([...currentByName.keys(), ...candidateByName.keys()])] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + const rows: TargetChangeRow[] = []; + + for (const name of names) { + const currentRow = currentByName.get(name); + const candidateRow = candidateByName.get(name); + const currentTarget = targetSide(currentRow); + const candidateTarget = targetSide(candidateRow); + if ((currentRow && !currentTarget) || (candidateRow && !candidateTarget)) { + return unavailableDataset( + current, + candidate, + `Weighted target-error attribution is incomplete for target ${name}.`, + ); + } + const categoryRow = candidateRow ?? currentRow ?? {}; + const comparisonStatus: TargetSurfaceStatus = currentRow && candidateRow + ? "shared" + : candidateRow + ? "added" + : "removed"; + rows.push({ + ...categoryRow, + name, + base_name: name, + comparison_status: comparisonStatus, + current: currentTarget, + candidate: candidateTarget, + reported_change: + (candidateTarget?.contribution ?? 0) - + (currentTarget?.contribution ?? 0), + pooled_weight_share: null, + shared_current_contribution: null, + shared_candidate_contribution: null, + shared_change: null, + }); + } + + const sharedRows = rows.filter( + (row) => row.comparison_status === "shared" && row.current && row.candidate, + ); + const currentSharedWeight = sharedRows.reduce( + (sum, row) => sum + (row.current?.weightShare ?? 0), + 0, + ); + const candidateSharedWeight = sharedRows.reduce( + (sum, row) => sum + (row.candidate?.weightShare ?? 0), + 0, + ); + let sharedReason: string | null = null; + let sharedSummary: TargetChangeSummary | null = null; + if (!sharedRows.length) { + sharedReason = "The current release and candidate have no shared targets."; + } else if (currentSharedWeight <= 0 || candidateSharedWeight <= 0) { + sharedReason = "Shared targets do not have positive total weight on both sides."; + } else { + for (const row of sharedRows) { + const pooledShare = ( + (row.current?.weightShare ?? 0) / currentSharedWeight + + (row.candidate?.weightShare ?? 0) / candidateSharedWeight + ) / 2; + const currentContribution = pooledShare * (row.current?.cappedError ?? 0); + const candidateContribution = pooledShare * (row.candidate?.cappedError ?? 0); + row.pooled_weight_share = pooledShare; + row.shared_current_contribution = currentContribution; + row.shared_candidate_contribution = candidateContribution; + row.shared_change = candidateContribution - currentContribution; + } + const currentSharedScore = sharedRows.reduce( + (sum, row) => sum + (row.shared_current_contribution ?? 0), + 0, + ); + const candidateSharedScore = sharedRows.reduce( + (sum, row) => sum + (row.shared_candidate_contribution ?? 0), + 0, + ); + sharedSummary = summarize( + rows, + "shared", + currentSharedScore, + candidateSharedScore, + ); + } + + return { + available: true, + reason: null, + current, + candidate, + methodology: methodology(current, candidate), + rows, + summaries: { + reported: summarize(rows, "reported", current.aggregate, candidate.aggregate), + shared: sharedSummary, + }, + modeReasons: { reported: null, shared: sharedReason }, + }; +} From c6ab6dc46917abab505527d47ec312bfe063b8b1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:47:01 +0400 Subject: [PATCH 05/20] Add staging target change hierarchy API --- .../staging/target-change-tree/route.ts | 72 +++++++++ .../app/api/microcosm/target-tree/route.ts | 45 +----- frontend/lib/api/hooks/use-microcosm.ts | 43 ++++++ .../lib/microcosm/calibration-tree-request.ts | 42 ++++++ frontend/lib/microcosm/calibration-tree.ts | 36 +++++ .../microcosm/calibration-treemap-layout.ts | 16 ++ .../lib/microcosm/staging-artifact.test.ts | 10 ++ frontend/lib/microcosm/staging-artifact.ts | 66 ++++++++ .../lib/microcosm/target-change-tree.test.ts | 141 ++++++++++++++++++ frontend/lib/microcosm/target-change-tree.ts | 79 ++++++++++ frontend/lib/microcosm/target-change.ts | 23 ++- 11 files changed, 528 insertions(+), 45 deletions(-) create mode 100644 frontend/app/api/microcosm/staging/target-change-tree/route.ts create mode 100644 frontend/lib/microcosm/calibration-tree-request.ts create mode 100644 frontend/lib/microcosm/target-change-tree.test.ts create mode 100644 frontend/lib/microcosm/target-change-tree.ts diff --git a/frontend/app/api/microcosm/staging/target-change-tree/route.ts b/frontend/app/api/microcosm/staging/target-change-tree/route.ts new file mode 100644 index 00000000..02bbd2fa --- /dev/null +++ b/frontend/app/api/microcosm/staging/target-change-tree/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; + +import { calibrationTreeRequestState } from "@/lib/microcosm/calibration-tree-request"; +import { + classifyApiError, + parseCountry, + scrub, +} from "@/lib/microcosm/latest-artifact"; +import { loadStagingTargetChangeDataset } from "@/lib/microcosm/staging-artifact"; +import type { TargetChangeMode } from "@/lib/microcosm/target-change"; +import { buildTargetChangeTree } from "@/lib/microcosm/target-change-tree"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const runtime = "nodejs"; +export const maxDuration = 300; + +export async function GET(request: Request) { + const params = new URL(request.url).searchParams; + const runId = params.get("run")?.trim(); + const releaseId = params.get("release")?.trim(); + const requestedMode = params.get("mode")?.trim() || "reported"; + if (!runId) { + return NextResponse.json( + { detail: "Provide a staging run id via ?run=." }, + { status: 400 }, + ); + } + if (!releaseId || releaseId === "latest") { + return NextResponse.json( + { detail: "Provide the resolved current release id via ?release=." }, + { status: 400 }, + ); + } + if (requestedMode !== "reported" && requestedMode !== "shared") { + return NextResponse.json( + { detail: "Comparison mode must be reported or shared." }, + { status: 400 }, + ); + } + const mode = requestedMode as TargetChangeMode; + const country = parseCountry(params.get("country")); + try { + const dataset = await loadStagingTargetChangeDataset( + runId, + releaseId, + country, + ); + if (!dataset) { + return NextResponse.json( + { + available: false, + reason: "This staging run has not uploaded calibration diagnostics yet.", + }, + { headers: { "Cache-Control": "no-store" } }, + ); + } + return NextResponse.json( + scrub( + buildTargetChangeTree( + dataset, + calibrationTreeRequestState(params), + mode, + ), + ), + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + const { status, body } = classifyApiError(error); + return NextResponse.json(body, { status }); + } +} diff --git a/frontend/app/api/microcosm/target-tree/route.ts b/frontend/app/api/microcosm/target-tree/route.ts index 11a114bc..0e314c9b 100644 --- a/frontend/app/api/microcosm/target-tree/route.ts +++ b/frontend/app/api/microcosm/target-tree/route.ts @@ -1,15 +1,10 @@ import { NextResponse } from "next/server"; -import { - FIT_BANDS, - createExplorerState, - type CalibrationStatus, - type FitBand, -} from "@/lib/microcosm/calibration-explorer"; import { buildCalibrationTree, type CalibrationTreeTarget, } from "@/lib/microcosm/calibration-tree"; +import { calibrationTreeRequestState } from "@/lib/microcosm/calibration-tree-request"; import { classifyApiError, loadRelease, @@ -21,49 +16,13 @@ export const revalidate = 21_600; export const runtime = "nodejs"; export const maxDuration = 300; -const CALIBRATION_STATUSES = new Set([ - "included", - "skipped", -]); - -function requestState(params: URLSearchParams) { - const state = createExplorerState(); - state.breakdown = params.get("breakdown") === "geography" ? "geography" : "program"; - const source = params.get("source")?.trim(); - const program = params.get("program")?.trim(); - const geography = params.get("path_geography")?.trim(); - if (geography) state.path.geography = geography; - if (source && program) { - state.path.source = source; - state.path.program = program; - for (const [key, value] of params.entries()) { - if (key.startsWith("dim.") && value.trim()) { - state.path.dimensions.push({ key: key.slice(4), value: value.trim() }); - } - } - state.path.target = params.get("target")?.trim() || undefined; - } - state.filters.geographyLevels = params.getAll("geography_level"); - state.filters.geographies = params.getAll("geography"); - state.filters.fitBands = params - .getAll("fit_band") - .filter((value): value is FitBand => FIT_BANDS.includes(value as FitBand)); - state.filters.calibrationStatuses = params - .getAll("status") - .map((value) => (value === "not_materialized" ? "skipped" : value)) - .filter((value): value is CalibrationStatus => - CALIBRATION_STATUSES.has(value as CalibrationStatus), - ); - return state; -} - export async function GET(request: Request) { const params = new URL(request.url).searchParams; const release = params.get("release") ?? "latest"; const country = parseCountry(params.get("country")); try { const calibration = await loadRelease(release, revalidate, country); - const state = requestState(params); + const state = calibrationTreeRequestState(params); return NextResponse.json( scrub( buildCalibrationTree( diff --git a/frontend/lib/api/hooks/use-microcosm.ts b/frontend/lib/api/hooks/use-microcosm.ts index c2329617..bd2b3734 100644 --- a/frontend/lib/api/hooks/use-microcosm.ts +++ b/frontend/lib/api/hooks/use-microcosm.ts @@ -8,6 +8,8 @@ import { PUBLISHED_RELEASE_STALE_TIME_MS } from "@/lib/api/cache-policy"; import { withBasePath } from "@/lib/base-path"; import type { ExplorerState } from "@/lib/microcosm/calibration-explorer"; import type { CalibrationTreeResponse } from "@/lib/microcosm/calibration-tree"; +import type { TargetChangeMode } from "@/lib/microcosm/target-change"; +import type { TargetChangeTreeApiResponse } from "@/lib/microcosm/target-change-tree"; import { hasCapability, type CountryCapability, @@ -692,6 +694,47 @@ export function useMicrocosmStagingCompare(runId?: string, release = "latest") { }); } +export function useMicrocosmStagingTargetChangeTree({ + runId, + releaseId, + mode, + state, +}: { + runId?: string; + releaseId?: string; + mode: TargetChangeMode; + state: ExplorerState; +}) { + const { country } = useCountry(); + return useQuery({ + queryKey: [ + "microcosm", + "staging", + "target-change-tree", + country, + runId, + releaseId, + mode, + state, + ], + queryFn: () => + apiGet( + "/microcosm/staging/target-change-tree", + { + ...explorerApiParams(state), + run: runId, + release: releaseId, + mode, + country, + }, + ), + enabled: + hasCapability(country, "staging") && + Boolean(runId && releaseId && releaseId !== "latest"), + staleTime: 30 * 1000, + }); +} + export function useMicrocosmReleases() { const { country } = useCountry(); return useQuery({ diff --git a/frontend/lib/microcosm/calibration-tree-request.ts b/frontend/lib/microcosm/calibration-tree-request.ts new file mode 100644 index 00000000..f3dbadb1 --- /dev/null +++ b/frontend/lib/microcosm/calibration-tree-request.ts @@ -0,0 +1,42 @@ +import { + FIT_BANDS, + createExplorerState, + type CalibrationStatus, + type FitBand, +} from "./calibration-explorer"; + +const CALIBRATION_STATUSES = new Set([ + "included", + "skipped", +]); + +export function calibrationTreeRequestState(params: URLSearchParams) { + const state = createExplorerState(); + state.breakdown = params.get("breakdown") === "geography" ? "geography" : "program"; + const source = params.get("source")?.trim(); + const program = params.get("program")?.trim(); + const geography = params.get("path_geography")?.trim(); + if (geography) state.path.geography = geography; + if (source && program) { + state.path.source = source; + state.path.program = program; + for (const [key, value] of params.entries()) { + if (key.startsWith("dim.") && value.trim()) { + state.path.dimensions.push({ key: key.slice(4), value: value.trim() }); + } + } + state.path.target = params.get("target")?.trim() || undefined; + } + state.filters.geographyLevels = params.getAll("geography_level"); + state.filters.geographies = params.getAll("geography"); + state.filters.fitBands = params + .getAll("fit_band") + .filter((value): value is FitBand => FIT_BANDS.includes(value as FitBand)); + state.filters.calibrationStatuses = params + .getAll("status") + .map((value) => (value === "not_materialized" ? "skipped" : value)) + .filter((value): value is CalibrationStatus => + CALIBRATION_STATUSES.has(value as CalibrationStatus), + ); + return state; +} diff --git a/frontend/lib/microcosm/calibration-tree.ts b/frontend/lib/microcosm/calibration-tree.ts index 80290aae..e3290c44 100644 --- a/frontend/lib/microcosm/calibration-tree.ts +++ b/frontend/lib/microcosm/calibration-tree.ts @@ -34,11 +34,24 @@ export interface CalibrationTreeTarget { target_loss_weight_share?: number | null; final_capped_scaled_error?: number | null; final_loss_contribution?: number | null; + target_change?: number | null; + comparison_status?: "shared" | "added" | "removed" | null; calibration_status?: CalibrationStatus | "not_materialized" | null; target_dimensions?: CalibrationTreeDimension[] | null; [key: string]: unknown; } +export interface CalibrationTreeChangeMetrics { + increasedError: number; + reducedError: number; + netChange: number; + changedTargets: number; + unchangedTargets: number; + sharedTargets: number; + addedTargets: number; + removedTargets: number; +} + export interface CalibrationTreeMetrics { nTargets: number; scored: number; @@ -48,6 +61,7 @@ export interface CalibrationTreeMetrics { weightedAverageCappedError: number | null; meanAbsRelativeError: number | null; medianAbsRelativeError: number | null; + change?: CalibrationTreeChangeMetrics; } export interface CalibrationTreeNode { @@ -89,6 +103,10 @@ export interface CalibrationTreeResponse { export type CalibrationTreeSizeMode = "targets" | "weight" | "loss"; +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function finiteError(row: CalibrationTreeTarget): number | null { const value = row.abs_relative_error; return typeof value === "number" && Number.isFinite(value) ? Math.abs(value) : null; @@ -156,6 +174,23 @@ export function calibrationTreeMetrics( (sum, row) => sum + (finiteTargetLossWeightShare(row) ?? 0), 0, ); + const changes = rows + .map((row) => finiteNumber(row.target_change)) + .filter((value): value is number => value != null); + const increasedError = changes.reduce((sum, value) => sum + Math.max(value, 0), 0); + const reducedError = changes.reduce((sum, value) => sum + Math.max(-value, 0), 0); + const change = changes.length + ? { + increasedError, + reducedError, + netChange: changes.reduce((sum, value) => sum + value, 0), + changedTargets: changes.filter((value) => Math.abs(value) > 1e-12).length, + unchangedTargets: changes.filter((value) => Math.abs(value) <= 1e-12).length, + sharedTargets: rows.filter((row) => row.comparison_status === "shared").length, + addedTargets: rows.filter((row) => row.comparison_status === "added").length, + removedTargets: rows.filter((row) => row.comparison_status === "removed").length, + } + : undefined; return { nTargets: rows.length, scored: errors.length, @@ -169,6 +204,7 @@ export function calibrationTreeMetrics( ? errors.reduce((sum, error) => sum + error, 0) / errors.length : null, medianAbsRelativeError: median(errors), + change, }; } diff --git a/frontend/lib/microcosm/calibration-treemap-layout.ts b/frontend/lib/microcosm/calibration-treemap-layout.ts index 4be639eb..b46fb334 100644 --- a/frontend/lib/microcosm/calibration-treemap-layout.ts +++ b/frontend/lib/microcosm/calibration-treemap-layout.ts @@ -75,6 +75,21 @@ export function aggregateCalibrationTreeMetrics( (sum, item) => sum + item.targetLossWeightShare, 0, ); + const changeMetrics = metrics + .map((item) => item.change) + .filter((item): item is NonNullable => item != null); + const change = changeMetrics.length + ? { + increasedError: changeMetrics.reduce((sum, item) => sum + item.increasedError, 0), + reducedError: changeMetrics.reduce((sum, item) => sum + item.reducedError, 0), + netChange: changeMetrics.reduce((sum, item) => sum + item.netChange, 0), + changedTargets: changeMetrics.reduce((sum, item) => sum + item.changedTargets, 0), + unchangedTargets: changeMetrics.reduce((sum, item) => sum + item.unchangedTargets, 0), + sharedTargets: changeMetrics.reduce((sum, item) => sum + item.sharedTargets, 0), + addedTargets: changeMetrics.reduce((sum, item) => sum + item.addedTargets, 0), + removedTargets: changeMetrics.reduce((sum, item) => sum + item.removedTargets, 0), + } + : undefined; return { nTargets: metrics.reduce((sum, item) => sum + item.nTargets, 0), scored, @@ -86,6 +101,7 @@ export function aggregateCalibrationTreeMetrics( : null, meanAbsRelativeError: weightedError(metrics, "meanAbsRelativeError"), medianAbsRelativeError: weightedError(metrics, "medianAbsRelativeError"), + change, }; } diff --git a/frontend/lib/microcosm/staging-artifact.test.ts b/frontend/lib/microcosm/staging-artifact.test.ts index 85ce9501..7aa70530 100644 --- a/frontend/lib/microcosm/staging-artifact.test.ts +++ b/frontend/lib/microcosm/staging-artifact.test.ts @@ -5,6 +5,7 @@ import { loadStagingRun, loadStagingRuns, loadStagingTargetDiagnostics, + stagingTargetChangeCacheTtlSeconds, stagingUnavailableReason, } from "./staging-artifact"; @@ -21,6 +22,15 @@ test("names the country when staging is unavailable", () => { ); }); +test("target-change cache duration follows whether a run can still change", () => { + expect(stagingTargetChangeCacheTtlSeconds("passed")).toBe(21_600); + expect(stagingTargetChangeCacheTtlSeconds("published")).toBe(21_600); + expect(stagingTargetChangeCacheTtlSeconds("failed")).toBe(21_600); + expect(stagingTargetChangeCacheTtlSeconds("running")).toBe(30); + expect(stagingTargetChangeCacheTtlSeconds("stalled")).toBe(30); + expect(stagingTargetChangeCacheTtlSeconds(null)).toBe(30); +}); + test("Belgium staging loaders return an empty state before resolving artifacts", async () => { const unavailable = { available: false as const, diff --git a/frontend/lib/microcosm/staging-artifact.ts b/frontend/lib/microcosm/staging-artifact.ts index b53faddb..b7628f65 100644 --- a/frontend/lib/microcosm/staging-artifact.ts +++ b/frontend/lib/microcosm/staging-artifact.ts @@ -15,9 +15,23 @@ import { type ReformValidation, buildReformValidation, } from "@/lib/microcosm/reforms"; +import { + buildTargetChangeDataset, + type TargetChangeDataset, +} from "@/lib/microcosm/target-change"; type JsonObject = Record; +interface TargetChangeCacheEntry { + expiresAt: number; + promise: Promise; +} + +const TARGET_CHANGE_FINAL_CACHE_SECONDS = 21_600; +const TARGET_CHANGE_MUTABLE_CACHE_SECONDS = 30; +const TARGET_CHANGE_CACHE_LIMIT = 8; +const targetChangeCache = new Map(); + export const MICROCOSM_STAGING_HF_REPO_ENV = "POPULACE_STAGING_HF_REPO"; export const MICROCOSM_STAGING_HF_REVISION_ENV = "POPULACE_STAGING_HF_REVISION"; export const MICROCOSM_STAGING_HF_REPO = @@ -150,6 +164,12 @@ function stringValue(value: unknown): string | null { return typeof value === "string" && value.trim() ? value : null; } +export function stagingTargetChangeCacheTtlSeconds(status: unknown): number { + return ["passed", "published", "failed"].includes(String(status ?? "").trim()) + ? TARGET_CHANGE_FINAL_CACHE_SECONDS + : TARGET_CHANGE_MUTABLE_CACHE_SECONDS; +} + function summaryFromProgress(runId: string, progress: JsonObject | null): StagingRunSummary { const candidate = stringValue(progress?.candidate_release_id); return { @@ -315,6 +335,52 @@ export async function loadStagingCalibration( ); } +export async function loadStagingTargetChangeDataset( + runId: string, + releaseId: string, + country: MicrocosmCountry = "us", +): Promise { + if (stagingUnavailableReason(country)) return null; + assertSafeReleaseId(runId, "run"); + assertSafeReleaseId(releaseId, "release"); + const progress = await stagingJsonOrNull( + `runs/${runId}/progress.json`, + TARGET_CHANGE_MUTABLE_CACHE_SECONDS, + ); + const ttlSeconds = stagingTargetChangeCacheTtlSeconds(progress?.status); + const cacheKey = `${country}:${runId}:${releaseId}`; + const now = Date.now(); + for (const [key, entry] of targetChangeCache) { + if (entry.expiresAt <= now) targetChangeCache.delete(key); + } + const cached = targetChangeCache.get(cacheKey); + if (cached && cached.expiresAt > now) return cached.promise; + + const promise = Promise.all([ + loadRelease(releaseId, TARGET_CHANGE_FINAL_CACHE_SECONDS, country), + loadStagingCalibration(runId, ttlSeconds, country), + ]).then(([release, candidate]) => + candidate ? buildTargetChangeDataset(release, candidate) : null, + ); + targetChangeCache.set(cacheKey, { + expiresAt: now + ttlSeconds * 1000, + promise, + }); + while (targetChangeCache.size > TARGET_CHANGE_CACHE_LIMIT) { + const oldest = targetChangeCache.keys().next().value; + if (typeof oldest !== "string") break; + targetChangeCache.delete(oldest); + } + try { + const result = await promise; + if (!result) targetChangeCache.delete(cacheKey); + return result; + } catch (error) { + targetChangeCache.delete(cacheKey); + throw error; + } +} + export async function loadStagingRun( runId: string, revalidate: number, diff --git a/frontend/lib/microcosm/target-change-tree.test.ts b/frontend/lib/microcosm/target-change-tree.test.ts new file mode 100644 index 00000000..c4aaacb5 --- /dev/null +++ b/frontend/lib/microcosm/target-change-tree.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; + +import { + createExplorerState, + type ExplorerState, +} from "./calibration-explorer"; +import type { Calibration } from "./latest-artifact"; +import { buildTargetChangeDataset } from "./target-change"; +import { buildTargetChangeTree } from "./target-change-tree"; + +interface RowInput { + name: string; + contribution: number; + share: number; + error: number; + source?: string; + variable?: string; + geography?: string; +} + +function calibration(releaseId: string, rows: RowInput[]): Calibration { + const aggregate = rows.reduce((sum, row) => sum + row.contribution, 0); + return { + release_id: releaseId, + rows: rows.map((row) => ({ + name: `${row.name}@2024`, + base_name: row.name, + source: row.source ?? "tax", + variable: row.variable ?? "benefits", + variable_key: row.variable ?? "benefits", + geography: row.geography ?? "United States", + level: row.geography ? "state" : "national", + target: 100, + final_estimate: 100 + row.error * 100, + target_loss_weight: row.share * 100, + target_loss_weight_share: row.share, + target_loss_scale: 100, + final_capped_scaled_error: row.error, + final_loss_contribution: row.contribution, + })), + target_loss_attribution: { + status: "reported", + aggregate, + historical_final_loss: aggregate, + cap: 2, + basis_identifier: "sqrt:target", + basis_hash: "hash", + verification: null, + producer_warnings: [], + reason: null, + targets: [], + }, + } as unknown as Calibration; +} + +function fixture() { + return buildTargetChangeDataset( + calibration("current", [ + { name: "one", contribution: 0.1, share: 0.4, error: 0.25, geography: "CA" }, + { name: "two", contribution: 0.2, share: 0.4, error: 0.5, geography: "NY" }, + { name: "removed", contribution: 0.05, share: 0.2, error: 0.25, variable: "income", geography: "CA" }, + ]), + calibration("candidate", [ + { name: "one", contribution: 0.2, share: 0.5, error: 0.4, geography: "CA" }, + { name: "two", contribution: 0.1, share: 0.25, error: 0.4, geography: "NY" }, + { name: "added", contribution: 0.05, share: 0.25, error: 0.2, variable: "income", geography: "TX" }, + ]), + ); +} + +function programState(): ExplorerState { + const state = createExplorerState(); + state.path = { + source: "tax", + program: "benefits", + dimensions: [], + }; + return state; +} + +describe("target change hierarchy", () => { + test("keeps increases and reductions separate inside a net-zero category", () => { + const tree = buildTargetChangeTree(fixture(), createExplorerState(), "reported"); + const benefits = tree.groups + .flatMap((group) => group.nodes) + .find((node) => node.id === "benefits"); + expect(benefits?.metrics.change).toEqual(expect.objectContaining({ + increasedError: 0.1, + reducedError: 0.1, + netChange: 0, + changedTargets: 2, + })); + expect(tree.filteredMetrics.change?.increasedError).toBeCloseTo(0.15); + expect(tree.filteredMetrics.change?.reducedError).toBeCloseTo(0.15); + }); + + test("supports geography-first and program-first drill-down", () => { + const geographyState = createExplorerState(); + geographyState.breakdown = "geography"; + const geographyTree = buildTargetChangeTree(fixture(), geographyState, "reported"); + expect(geographyTree.groups[0].nodes.map((node) => node.id).sort()).toEqual([ + "CA", + "NY", + "TX", + ]); + + const programTree = buildTargetChangeTree(fixture(), programState(), "reported"); + expect(programTree.currentLevel.kind).toBe("geography"); + expect(programTree.groups[0].nodes.map((node) => node.id).sort()).toEqual([ + "CA", + "NY", + ]); + }); + + test("returns selected compact target detail at the target level", () => { + const state = programState(); + state.path.geography = "CA"; + state.path.target = "one"; + const tree = buildTargetChangeTree(fixture(), state, "reported"); + expect(tree.currentLevel.kind).toBe("target"); + expect(tree.selectedTarget).toEqual(expect.objectContaining({ + name: "one", + comparison_status: "shared", + reported_change: 0.1, + })); + expect(tree.selectedTarget?.current).toEqual(expect.objectContaining({ + contribution: 0.1, + })); + expect(tree.selectedTarget?.candidate).toEqual(expect.objectContaining({ + contribution: 0.2, + })); + }); + + test("shared mode excludes added and removed target leaves", () => { + const tree = buildTargetChangeTree(fixture(), createExplorerState(), "shared"); + expect(tree.summary?.comparisonTargets).toBe(2); + expect(tree.filteredMetrics.change?.addedTargets).toBe(0); + expect(tree.filteredMetrics.change?.removedTargets).toBe(0); + expect(tree.filteredMetrics.change?.sharedTargets).toBe(2); + }); +}); diff --git a/frontend/lib/microcosm/target-change-tree.ts b/frontend/lib/microcosm/target-change-tree.ts new file mode 100644 index 00000000..f92da896 --- /dev/null +++ b/frontend/lib/microcosm/target-change-tree.ts @@ -0,0 +1,79 @@ +import type { ExplorerState } from "./calibration-explorer"; +import { + buildCalibrationTree, + type CalibrationTreeResponse, + type CalibrationTreeTarget, +} from "./calibration-tree"; +import { + targetChangeForMode, + type TargetChangeAttributionSide, + type TargetChangeDataset, + type TargetChangeMethodology, + type TargetChangeMode, + type TargetChangeRow, + type TargetChangeSummary, +} from "./target-change"; + +export interface TargetChangeTreeResponse extends CalibrationTreeResponse { + available: boolean; + reason: string | null; + mode: TargetChangeMode; + current: TargetChangeAttributionSide; + candidate: TargetChangeAttributionSide; + methodology: TargetChangeMethodology; + summary: TargetChangeSummary | null; + selectedTarget: TargetChangeRow | null; +} + +export interface TargetChangeTreeUnavailableResponse { + available: false; + reason: string; +} + +export type TargetChangeTreeApiResponse = + | TargetChangeTreeResponse + | TargetChangeTreeUnavailableResponse; + +function rowsForMode( + dataset: TargetChangeDataset, + mode: TargetChangeMode, +): CalibrationTreeTarget[] { + return dataset.rows.flatMap((row) => { + if (mode === "shared" && row.comparison_status !== "shared") return []; + const change = targetChangeForMode(row, mode); + return change == null ? [] : [{ ...row, target_change: change }]; + }); +} + +export function buildTargetChangeTree( + dataset: TargetChangeDataset, + state: ExplorerState, + mode: TargetChangeMode, +): TargetChangeTreeResponse { + const summary = dataset.summaries[mode]; + const reason = dataset.reason ?? dataset.modeReasons[mode]; + const comparisonRows = summary ? rowsForMode(dataset, mode) : []; + const tree = buildCalibrationTree( + comparisonRows, + state, + dataset.candidate.releaseId, + dataset.available, + ); + const selectedTarget = state.path.target + ? dataset.rows.find((row) => { + if (row.name !== state.path.target) return false; + return mode === "reported" || row.comparison_status === "shared"; + }) ?? null + : null; + return { + ...tree, + available: dataset.available && summary != null, + reason, + mode, + current: dataset.current, + candidate: dataset.candidate, + methodology: dataset.methodology, + summary, + selectedTarget, + }; +} diff --git a/frontend/lib/microcosm/target-change.ts b/frontend/lib/microcosm/target-change.ts index 65c760ac..03a3dcae 100644 --- a/frontend/lib/microcosm/target-change.ts +++ b/frontend/lib/microcosm/target-change.ts @@ -78,6 +78,25 @@ function targetKey(row: TargetRow): string { return String(row.base_name ?? row.name ?? ""); } +function hierarchyFields(row: TargetRow): Record { + return { + source: row.source ?? null, + source_label: row.source_label ?? null, + variable: row.variable ?? null, + variable_key: row.variable_key ?? null, + measure: row.measure ?? null, + source_measure_id: row.source_measure_id ?? null, + level: row.level ?? null, + geography: row.geography ?? null, + family: row.family ?? null, + breakdown: row.breakdown ?? null, + dims: row.dims ?? null, + target_dimensions: row.target_dimensions ?? null, + calibration_status: row.calibration_status ?? null, + abs_relative_error: row.abs_relative_error ?? null, + }; +} + function attributionSide(calibration: Calibration): TargetChangeAttributionSide { const attribution = calibration.target_loss_attribution; return { @@ -247,14 +266,14 @@ export function buildTargetChangeDataset( `Weighted target-error attribution is incomplete for target ${name}.`, ); } - const categoryRow = candidateRow ?? currentRow ?? {}; + const categoryRow = candidateRow ?? currentRow; const comparisonStatus: TargetSurfaceStatus = currentRow && candidateRow ? "shared" : candidateRow ? "added" : "removed"; rows.push({ - ...categoryRow, + ...(categoryRow ? hierarchyFields(categoryRow) : {}), name, base_name: name, comparison_status: comparisonStatus, From 79e57382d6ad5d96667ec284584b0844e812402a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:50:22 +0400 Subject: [PATCH 06/20] Add target error change visualization --- .../microcosm/staging-target-change-map.tsx | 685 ++++++++++++++++++ .../microcosm/calibration-treemap-layout.ts | 32 +- .../target-change-visualization.test.ts | 74 ++ .../microcosm/target-change-visualization.ts | 91 +++ 4 files changed, 875 insertions(+), 7 deletions(-) create mode 100644 frontend/components/microcosm/staging-target-change-map.tsx create mode 100644 frontend/lib/microcosm/target-change-visualization.test.ts create mode 100644 frontend/lib/microcosm/target-change-visualization.ts diff --git a/frontend/components/microcosm/staging-target-change-map.tsx b/frontend/components/microcosm/staging-target-change-map.tsx new file mode 100644 index 00000000..71a3a811 --- /dev/null +++ b/frontend/components/microcosm/staging-target-change-map.tsx @@ -0,0 +1,685 @@ +"use client"; + +import { + useEffect, + useReducer, + useRef, + useState, +} from "react"; + +import { + explorerBreadcrumbs, + explorerNodeLabel, + explorerUpLabel, +} from "@/components/microcosm/calibration-explorer-view"; +import { fmt } from "@/components/shared/format"; +import { HelpHint } from "@/components/shared/help-hint"; +import { LoadingBlock } from "@/components/shared/LoadingBlock"; +import { StatusPill } from "@/components/shared/status-pill"; +import { useMicrocosmStagingTargetChangeTree } from "@/lib/api/hooks/use-microcosm"; +import { + createExplorerState, + explorerReducer, +} from "@/lib/microcosm/calibration-explorer"; +import type { + CalibrationTreeGroup, + CalibrationTreeMetrics, +} from "@/lib/microcosm/calibration-tree"; +import { + aggregateCalibrationTreeMetrics, + condenseCalibrationTreemapByMetric, + expandedGroupsForNode, + type CalibrationTreemapGroup, + type CalibrationTreemapNode, +} from "@/lib/microcosm/calibration-treemap-layout"; +import type { + TargetChangeMode, + TargetChangeRow, +} from "@/lib/microcosm/target-change"; +import type { TargetChangeTreeResponse } from "@/lib/microcosm/target-change-tree"; +import { + formatTargetChange, + formatWeightedTargetError, + targetChangeDetailValues, + targetChangeDirectionAreas, + targetChangeDirectionValue, + type TargetChangeDirection, + type TargetChangeDirectionData, +} from "@/lib/microcosm/target-change-visualization"; +import { squarify, type Placed, type Rect } from "@/lib/treemap/squarify"; + +const DIRECTION_GAP = 8; +const DIRECTION_HEADER_HEIGHT = 34; +const GROUP_GAP = 7; +const GROUP_HEADER_HEIGHT = 22; +const NODE_GAP = 3; + +const MODE_HELP: Record = { + reported: + "Uses each release's actual target weights and complete target surface. Added targets contribute candidate error, while removed targets subtract their current-release contribution.", + shared: + "Uses only targets present in both releases and applies the same pooled target weights to both. Each release still uses its own verified benchmark, scale, cap, and capped error.", +}; + +interface LaidGroup { + group: CalibrationTreemapGroup; + rect: Placed; + headerHeight: number; + nodes: Placed[]; +} + +interface LaidDirection { + direction: TargetChangeDirection; + label: string; + rect: Rect; + groups: LaidGroup[]; +} + +function metricFor(direction: TargetChangeDirection) { + return (metrics: CalibrationTreeMetrics) => + targetChangeDirectionValue(metrics, direction); +} + +function insetRect(rect: Rect, gap: number): Rect { + return { + x: rect.x + gap / 2, + y: rect.y + gap / 2, + w: Math.max(rect.w - gap, 0), + h: Math.max(rect.h - gap, 0), + }; +} + +function layoutGroups( + groups: CalibrationTreeGroup[], + direction: TargetChangeDirection, + rect: Rect, +): LaidGroup[] { + const metric = metricFor(direction); + const condensed = condenseCalibrationTreemapByMetric( + groups, + metric, + rect.w, + rect.h, + ); + const placedGroups = squarify( + condensed + .map((group) => ({ value: metric(group.metrics), data: group })) + .filter((entry) => entry.value > 0), + rect, + ); + return placedGroups.map((placed) => { + const groupRect = insetRect(placed, GROUP_GAP); + const headerHeight = + !placed.data.synthetic && groupRect.h >= 58 && groupRect.w >= 90 + ? GROUP_HEADER_HEIGHT + : 0; + const inner = { + x: groupRect.x, + y: groupRect.y + headerHeight, + w: groupRect.w, + h: Math.max(groupRect.h - headerHeight, 0), + }; + const nodes = squarify( + placed.data.nodes + .map((node) => ({ value: metric(node.metrics), data: node })) + .filter((entry) => entry.value > 0), + inner, + ); + return { + group: placed.data, + rect: { ...placed, ...groupRect }, + headerHeight, + nodes, + }; + }); +} + +function layoutDirections( + data: TargetChangeTreeResponse, + width: number, + height: number, + expanded: { + label: string; + groups: CalibrationTreeGroup[]; + direction: TargetChangeDirection; + } | null, +): LaidDirection[] { + const areas: Array> = expanded + ? [{ + x: 0, + y: 0, + w: width, + h: height, + value: targetChangeDirectionValue( + aggregateCalibrationTreeMetrics(expanded.groups), + expanded.direction, + ), + data: { + direction: expanded.direction, + label: expanded.direction === "increase" + ? "Increased weighted target error" + : "Reduced weighted target error", + }, + }] + : targetChangeDirectionAreas(data.filteredMetrics.change, width, height); + return areas.map((area) => { + const directionRect = insetRect(area, DIRECTION_GAP); + const contentRect = { + x: directionRect.x, + y: directionRect.y + DIRECTION_HEADER_HEIGHT, + w: directionRect.w, + h: Math.max(directionRect.h - DIRECTION_HEADER_HEIGHT, 0), + }; + return { + direction: area.data.direction, + label: area.data.label, + rect: directionRect, + groups: layoutGroups( + expanded?.groups ?? data.groups, + area.data.direction, + contentRect, + ), + }; + }); +} + +function isTreeResponse( + data: ReturnType["data"], +): data is TargetChangeTreeResponse { + return Boolean(data && "groups" in data); +} + +function Control({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: Array<{ value: T; label: string }>; + onChange: (value: T) => void; +}) { + return ( +
+ + {label} + +
+ {options.map((option) => ( + + ))} +
+
+ ); +} + +function SummaryMetric({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "positive" | "negative" | "neutral"; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function targetStatus(target: TargetChangeRow) { + if (target.comparison_status === "added") { + return Added target; + } + if (target.comparison_status === "removed") { + return Removed target; + } + return Shared target; +} + +function nullableNumber(value: number | null | undefined): string { + return value == null ? "—" : fmt(value, { digits: 2 }); +} + +function nullablePercent(value: number | null | undefined): string { + return value == null ? "—" : formatWeightedTargetError(value); +} + +function TargetChangeDetail({ + target, + mode, + onClose, +}: { + target: TargetChangeRow; + mode: TargetChangeMode; + onClose: () => void; +}) { + const detail = targetChangeDetailValues(target, mode); + const rows = [ + { + label: "Benchmark", + current: nullableNumber(target.current?.target), + candidate: nullableNumber(target.candidate?.target), + }, + { + label: "Final estimate", + current: nullableNumber(target.current?.finalEstimate), + candidate: nullableNumber(target.candidate?.finalEstimate), + }, + { + label: "Artifact target weight", + current: nullablePercent(detail.currentWeightShare), + candidate: nullablePercent(detail.candidateWeightShare), + }, + { + label: "Capped scaled error", + current: nullablePercent(target.current?.cappedError), + candidate: nullablePercent(target.candidate?.cappedError), + }, + { + label: mode === "reported" ? "Weighted error contribution" : "Pooled-weight contribution", + current: nullablePercent(detail.currentContribution), + candidate: nullablePercent(detail.candidateContribution), + }, + ]; + return ( +
+
+
+
+

+ {String(target.variable ?? target.name)} +

+ {targetStatus(target)} +
+

{target.name}

+
+ +
+ {mode === "shared" && detail.comparisonWeight != null ? ( +
+ Shared comparison weight: {formatWeightedTargetError(detail.comparisonWeight)} on both sides. +
+ ) : null} +
+ + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
ValueCurrent releaseCandidate
{row.label}{row.current}{row.candidate}
+
+
+ Selected-mode change + 0 + ? "font-semibold tabular-nums tone-neg" + : (detail.change ?? 0) < 0 + ? "font-semibold tabular-nums tone-pos" + : "font-semibold tabular-nums text-muted-foreground" + }> + {formatTargetChange(detail.change)} + +
+ {target.comparison_status === "removed" ? ( +

+ Removing this target lowers the reported aggregate mechanically. It does not show that the candidate fits this target better. +

+ ) : null} +
+ ); +} + +export function StagingTargetChangeMap({ + runId, + releaseId, +}: { + runId: string; + releaseId: string; +}) { + const [state, dispatch] = useReducer(explorerReducer, undefined, createExplorerState); + const [mode, setMode] = useState("reported"); + const [expanded, setExpanded] = useState<{ + label: string; + groups: CalibrationTreeGroup[]; + direction: TargetChangeDirection; + } | null>(null); + const containerRef = useRef(null); + const [size, setSize] = useState({ width: 960, height: 520 }); + const { data, isLoading, error } = useMicrocosmStagingTargetChangeTree({ + runId, + releaseId, + mode, + state, + }); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setSize({ width: Math.round(rect.width), height: Math.round(rect.height) }); + } + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [data]); + + if (isLoading && !data) { + return ; + } + if (error) { + return ( +
+ {error instanceof Error ? error.message : "Target error change is unavailable."} +
+ ); + } + if (!data || !isTreeResponse(data)) { + return ( +
+ {data?.reason ?? "Target error change is unavailable."} +
+ ); + } + if (!data.available || !data.summary) { + return ( +
+ {data.reason ?? "Weighted target-error attribution is unavailable for this comparison."} +
+ ); + } + + const directions = layoutDirections(data, size.width, size.height, expanded); + const breadcrumbs = explorerBreadcrumbs(state); + const upLabel = expanded ? `Up to ${data.currentLevel.label.toLowerCase()}` : explorerUpLabel(state); + const netTone = data.summary.netChange > 1e-12 + ? "negative" + : data.summary.netChange < -1e-12 + ? "positive" + : "neutral"; + + return ( +
+
+
+ { + setExpanded(null); + dispatch({ type: "clear_target" }); + setMode(nextMode); + }} + /> + { + setExpanded(null); + dispatch({ type: "breakdown", breakdown }); + }} + /> +
+ +
+ +
+ + + + + +
+ +
+ {fmt(data.summary.comparisonTargets, { digits: 0 })} targets compared + · + {fmt(data.summary.changed, { digits: 0 })} changed + · + {fmt(data.summary.unchanged, { digits: 0 })} unchanged + {mode === "reported" ? ( + <> + · + {fmt(data.summary.added, { digits: 0 })} added + · + {fmt(data.summary.removed, { digits: 0 })} removed + + ) : null} +
+ + {data.methodology.warning ? ( +
+ {data.methodology.warning} +
+ ) : null} + +
+ {upLabel ? ( + + ) : null} + +
+ +
+ {directions.length === 0 ? ( +
+ No weighted target-error changes exceed the display tolerance at this level. +
+ ) : directions.map((direction) => { + const directionSign = direction.direction === "increase" ? 1 : -1; + const tone = direction.direction === "increase" ? "var(--neg)" : "var(--pos)"; + return ( +
+
+
+ {direction.label} +
+ {direction.groups.map(({ group, rect, headerHeight, nodes }) => ( +
+ {headerHeight > 0 ? ( +
+ {group.label} + + {formatTargetChange(directionSign * targetChangeDirectionValue(group.metrics, direction.direction), false)} + +
+ ) : null} + {nodes.map((placed) => { + const item = placed.data; + const tile = insetRect(placed, NODE_GAP); + if (tile.w < 2 || tile.h < 2) return null; + const itemLabel = item.kind === "grouped" + ? item.label + : explorerNodeLabel({ id: item.id, kind: item.kind, label: item.label }); + const value = targetChangeDirectionValue(item.metrics, direction.direction); + const showLabel = tile.w >= 44 && tile.h >= 24; + const showValue = tile.w >= 72 && tile.h >= 48; + const selected = item.kind === "target" && item.id === state.path.target; + return ( + + ); + })} +
+ ))} +
+ ); + })} +
+ +

+ Area shows how much each category contributes to the selected mode's gross increase or reduction in weighted target error. Select a category to drill down or select a target to compare its values directly. +

+ + {data.selectedTarget ? ( + dispatch({ type: "clear_target" })} + /> + ) : null} +
+ ); +} diff --git a/frontend/lib/microcosm/calibration-treemap-layout.ts b/frontend/lib/microcosm/calibration-treemap-layout.ts index b46fb334..c97a8081 100644 --- a/frontend/lib/microcosm/calibration-treemap-layout.ts +++ b/frontend/lib/microcosm/calibration-treemap-layout.ts @@ -39,11 +39,15 @@ function metricValue( return metrics.loss; } +export type CalibrationTreemapMetricSelector = ( + metrics: CalibrationTreeMetrics, +) => number; + function effectiveMetricValues( items: Array<{ metrics: CalibrationTreeMetrics }>, - mode: CalibrationTreeSizeMode, + metric: CalibrationTreemapMetricSelector, ): number[] { - const values = items.map((item) => metricValue(item.metrics, mode)); + const values = items.map((item) => metric(item.metrics)); return values.some((value) => value > 0) ? values : items.map((item) => item.metrics.nTargets); @@ -121,10 +125,10 @@ function groupedNode( function condenseNodes( group: CalibrationTreeGroup, - mode: CalibrationTreeSizeMode, + metric: CalibrationTreemapMetricSelector, projectedGroupArea: number, ): CalibrationTreemapNode[] { - const values = effectiveMetricValues(group.nodes, mode); + const values = effectiveMetricValues(group.nodes, metric); const total = values.reduce((sum, value) => sum + value, 0); if (total <= 0) return group.nodes; @@ -161,9 +165,23 @@ export function condenseCalibrationTreemap( mode: CalibrationTreeSizeMode, width: number, height: number, +): CalibrationTreemapGroup[] { + return condenseCalibrationTreemapByMetric( + groups, + (metrics) => metricValue(metrics, mode), + width, + height, + ); +} + +export function condenseCalibrationTreemapByMetric( + groups: CalibrationTreeGroup[], + metric: CalibrationTreemapMetricSelector, + width: number, + height: number, ): CalibrationTreemapGroup[] { const canvasArea = Math.max(width, 0) * Math.max(height, 0); - const groupValues = effectiveMetricValues(groups, mode); + const groupValues = effectiveMetricValues(groups, metric); const total = groupValues.reduce((sum, value) => sum + value, 0); if (total <= 0 || canvasArea <= 0) return groups; @@ -180,7 +198,7 @@ export function condenseCalibrationTreemap( const kept: CalibrationTreemapGroup[] = large.map(({ group, value }) => ({ ...group, - nodes: condenseNodes(group, mode, (value / total) * canvasArea), + nodes: condenseNodes(group, metric, (value / total) * canvasArea), })); if (small.length < 2) { @@ -190,7 +208,7 @@ export function condenseCalibrationTreemap( ...group, nodes: condenseNodes( group, - mode, + metric, (groupValues[groups.indexOf(group)] / total) * canvasArea, ), })), diff --git a/frontend/lib/microcosm/target-change-visualization.test.ts b/frontend/lib/microcosm/target-change-visualization.test.ts new file mode 100644 index 00000000..10c0c8f0 --- /dev/null +++ b/frontend/lib/microcosm/target-change-visualization.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; + +import type { TargetChangeRow } from "./target-change"; +import { + formatTargetChange, + targetChangeDetailValues, + targetChangeDirectionAreas, +} from "./target-change-visualization"; + +describe("target change visualization data", () => { + test("allocates direction area in exact proportion to gross movement", () => { + const areas = targetChangeDirectionAreas({ + increasedError: 0.3, + reducedError: 0.1, + netChange: 0.2, + changedTargets: 2, + unchangedTargets: 0, + sharedTargets: 2, + addedTargets: 0, + removedTargets: 0, + }, 400, 200); + const increase = areas.find((area) => area.data.direction === "increase"); + const reduction = areas.find((area) => area.data.direction === "reduction"); + expect((increase?.w ?? 0) * (increase?.h ?? 0)).toBeCloseTo(60_000); + expect((reduction?.w ?? 0) * (reduction?.h ?? 0)).toBeCloseTo(20_000); + }); + + test("omits an empty direction without assigning false visual area", () => { + const areas = targetChangeDirectionAreas({ + increasedError: 0, + reducedError: 0.1, + netChange: -0.1, + changedTargets: 1, + unchangedTargets: 0, + sharedTargets: 1, + addedTargets: 0, + removedTargets: 0, + }, 400, 200); + expect(areas).toHaveLength(1); + expect(areas[0].data.direction).toBe("reduction"); + expect(areas[0].w * areas[0].h).toBeCloseTo(80_000); + }); + + test("formats weighted error changes as percentage points", () => { + expect(formatTargetChange(0.004)).toBe("+0.40 pp"); + expect(formatTargetChange(-0.004)).toBe("−0.40 pp"); + expect(formatTargetChange(0)).toBe("±0.00 pp"); + expect(formatTargetChange(null)).toBe("—"); + }); + + test("selects actual or pooled contributions for target detail", () => { + const target = { + reported_change: 0.02, + pooled_weight_share: 0.4, + shared_current_contribution: 0.04, + shared_candidate_contribution: 0.06, + shared_change: 0.02, + current: { weightShare: 0.3, contribution: 0.03 }, + candidate: { weightShare: 0.5, contribution: 0.05 }, + } as TargetChangeRow; + expect(targetChangeDetailValues(target, "reported")).toEqual(expect.objectContaining({ + comparisonWeight: null, + currentContribution: 0.03, + candidateContribution: 0.05, + change: 0.02, + })); + expect(targetChangeDetailValues(target, "shared")).toEqual(expect.objectContaining({ + comparisonWeight: 0.4, + currentContribution: 0.04, + candidateContribution: 0.06, + change: 0.02, + })); + }); +}); diff --git a/frontend/lib/microcosm/target-change-visualization.ts b/frontend/lib/microcosm/target-change-visualization.ts new file mode 100644 index 00000000..af88ba9c --- /dev/null +++ b/frontend/lib/microcosm/target-change-visualization.ts @@ -0,0 +1,91 @@ +import type { CalibrationTreeChangeMetrics } from "./calibration-tree"; +import { squarify, type Placed } from "@/lib/treemap/squarify"; +import type { + TargetChangeMode, + TargetChangeRow, +} from "./target-change"; + +export type TargetChangeDirection = "increase" | "reduction"; + +export interface TargetChangeDirectionData { + direction: TargetChangeDirection; + label: string; +} + +export function targetChangeDirectionValue( + metrics: { change?: CalibrationTreeChangeMetrics }, + direction: TargetChangeDirection, +): number { + return direction === "increase" + ? metrics.change?.increasedError ?? 0 + : metrics.change?.reducedError ?? 0; +} + +export function targetChangeDirectionAreas( + metrics: CalibrationTreeChangeMetrics | undefined, + width: number, + height: number, +): Placed[] { + const directions: Array<{ value: number; data: TargetChangeDirectionData }> = [ + { + value: metrics?.increasedError ?? 0, + data: { direction: "increase", label: "Increased weighted target error" }, + }, + { + value: metrics?.reducedError ?? 0, + data: { direction: "reduction", label: "Reduced weighted target error" }, + }, + ]; + return squarify( + directions.filter((item) => item.value > 0), + { x: 0, y: 0, w: Math.max(width, 0), h: Math.max(height, 0) }, + ); +} + +export function formatWeightedTargetError(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "—"; + return `${(value * 100).toFixed(2)}%`; +} + +export function formatTargetChange( + value: number | null | undefined, + alwaysSign = true, +): string { + if (value == null || !Number.isFinite(value)) return "—"; + const percentagePoints = value * 100; + const sign = percentagePoints > 0 + ? "+" + : percentagePoints < 0 + ? "−" + : alwaysSign + ? "±" + : ""; + return `${sign}${Math.abs(percentagePoints).toFixed(2)} pp`; +} + +export interface TargetChangeDetailValues { + currentWeightShare: number | null; + candidateWeightShare: number | null; + comparisonWeight: number | null; + currentContribution: number | null; + candidateContribution: number | null; + change: number | null; +} + +export function targetChangeDetailValues( + target: TargetChangeRow, + mode: TargetChangeMode, +): TargetChangeDetailValues { + return { + currentWeightShare: target.current?.weightShare ?? null, + candidateWeightShare: target.candidate?.weightShare ?? null, + comparisonWeight: mode === "shared" ? target.pooled_weight_share : null, + currentContribution: mode === "reported" + ? target.current?.contribution ?? null + : target.shared_current_contribution, + candidateContribution: mode === "reported" + ? target.candidate?.contribution ?? null + : target.shared_candidate_contribution, + change: mode === "reported" ? target.reported_change : target.shared_change, + }; +} From eeaae3274463a90239593976d3edb8fe5ba341f9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:02:53 +0400 Subject: [PATCH 07/20] Integrate target change map into staging --- README.md | 10 +++++++++- .../microcosm/microcosm-staging-view.tsx | 16 ++++++++++++++++ .../target-change-visualization.test.ts | 10 ++++++++++ .../microcosm/target-change-visualization.ts | 4 ++++ frontend/lib/microcosm/target-change.test.ts | 13 +++++++++++++ frontend/lib/microcosm/target-change.ts | 17 +++++++++++++++-- 6 files changed, 67 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7dc8d801..c640c101 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ separate service layer — the Next.js API routes are the API layer. surfaced. - **Staging runs** (`/microcosm/staging`) — monitor pre-release US Microcosm build runs from the staging Hub repo: current stage, calibration loss progress, - final candidate diagnostics once uploaded, and candidate-vs-latest fit. + final candidate diagnostics once uploaded, candidate-vs-current-release fit, + and a hierarchical map of weighted target-error increases and reductions. Countries without a staging repository show an explicit unavailable state. - **Calibration target investigations** (`docs/ai/`) — tool-independent procedures, specialist review responsibilities, and a reusable checklist for identifying @@ -55,6 +56,13 @@ Published-release endpoints accept `country=us|uk|be` (default `us`). | `GET /api/microcosm/staging/run?id=` | One staging run's progress and uploaded candidate diagnostics | | `GET /api/microcosm/staging/target-diagnostics?id=&...` | Faceted diagnostics for a staging candidate once diagnostics exist | | `GET /api/microcosm/staging/compare?run=&release=latest` | Diff staging candidate against a published release | +| `GET /api/microcosm/staging/target-change-tree?run=&release=&mode=reported\|shared&...` | One hierarchy level of weighted target-error changes for a staging candidate and an explicit current release | + +The staging target-change route supports two comparison modes. `reported` uses +each release's actual target weights and complete target surface, including +added and removed targets. `shared` restricts the calculation to shared targets, +normalizes each release's weights over that shared set, averages the two shares +target by target, and applies the resulting pooled weights to both releases. ## Calibration target investigations diff --git a/frontend/components/microcosm/microcosm-staging-view.tsx b/frontend/components/microcosm/microcosm-staging-view.tsx index 7eba3d3a..b5291b9b 100644 --- a/frontend/components/microcosm/microcosm-staging-view.tsx +++ b/frontend/components/microcosm/microcosm-staging-view.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useCountry } from "@/components/layout/country-context"; +import { StagingTargetChangeMap } from "@/components/microcosm/staging-target-change-map"; import { EmptyState } from "@/components/shared/empty-state"; import { fmtUnitValue, @@ -35,6 +36,7 @@ import { formatStagingCurrentStatus, formatStagingStatus, } from "@/lib/microcosm/staging-status"; +import { targetChangeMapIdentity } from "@/lib/microcosm/target-change-visualization"; type LossKind = "normalized_target_loss" | "raw_optimizer_objective" | undefined; @@ -1016,6 +1018,20 @@ function MicrocosmStagingRunsView() {
+ {compareData?.summary && runData.has_calibration && ( + + + + )} + {(compareData?.rows ?? []).length > 0 && ( { @@ -48,6 +49,15 @@ describe("target change visualization data", () => { expect(formatTargetChange(null)).toBe("—"); }); + test("changes visual identity when the run or resolved release changes", () => { + expect(targetChangeMapIdentity("run-a", "release-a")).not.toBe( + targetChangeMapIdentity("run-b", "release-a"), + ); + expect(targetChangeMapIdentity("run-a", "release-a")).not.toBe( + targetChangeMapIdentity("run-a", "release-b"), + ); + }); + test("selects actual or pooled contributions for target detail", () => { const target = { reported_change: 0.02, diff --git a/frontend/lib/microcosm/target-change-visualization.ts b/frontend/lib/microcosm/target-change-visualization.ts index af88ba9c..9149d822 100644 --- a/frontend/lib/microcosm/target-change-visualization.ts +++ b/frontend/lib/microcosm/target-change-visualization.ts @@ -7,6 +7,10 @@ import type { export type TargetChangeDirection = "increase" | "reduction"; +export function targetChangeMapIdentity(runId: string, releaseId: string): string { + return `${runId}:${releaseId}`; +} + export interface TargetChangeDirectionData { direction: TargetChangeDirection; label: string; diff --git a/frontend/lib/microcosm/target-change.test.ts b/frontend/lib/microcosm/target-change.test.ts index 423eaba0..88b3ed6a 100644 --- a/frontend/lib/microcosm/target-change.test.ts +++ b/frontend/lib/microcosm/target-change.test.ts @@ -152,6 +152,19 @@ describe("target change attribution", () => { expect(incomplete.reason).toContain("incomplete"); }); + test("fails closed when matched rows cannot reconcile to both aggregates", () => { + const current = calibration("current", [ + { name: "duplicate", contribution: 0.1, share: 0.5, error: 0.2 }, + { name: "duplicate", contribution: 0.2, share: 0.5, error: 0.4 }, + ]); + const candidate = calibration("candidate", [ + { name: "duplicate", contribution: 0.2, share: 1, error: 0.2 }, + ]); + const result = buildTargetChangeDataset(current, candidate); + expect(result.available).toBe(false); + expect(result.reason).toContain("do not reconcile"); + }); + test("retains additive results while warning about methodology differences", () => { const current = calibration("current", [ { name: "shared", contribution: 0.1, share: 1, error: 0.1 }, diff --git a/frontend/lib/microcosm/target-change.ts b/frontend/lib/microcosm/target-change.ts index 03a3dcae..616e278d 100644 --- a/frontend/lib/microcosm/target-change.ts +++ b/frontend/lib/microcosm/target-change.ts @@ -151,7 +151,7 @@ function methodology( comparable, warning: comparable ? null - : "The current release and candidate use different target-loss caps or weighting methods. The reported change remains additive, but it includes that methodology difference.", + : "The current release and candidate use different target-loss caps or weighting methods. The comparison still uses each artifact's verified values, but it includes that methodology difference.", }; } @@ -292,6 +292,19 @@ export function buildTargetChangeDataset( const sharedRows = rows.filter( (row) => row.comparison_status === "shared" && row.current && row.candidate, ); + const reportedSummary = summarize( + rows, + "reported", + current.aggregate, + candidate.aggregate, + ); + if (Math.abs(reportedSummary.reconciliationDifference) > TARGET_CHANGE_EPSILON) { + return unavailableDataset( + current, + candidate, + "Per-target weighted error changes do not reconcile to the two attribution aggregates.", + ); + } const currentSharedWeight = sharedRows.reduce( (sum, row) => sum + (row.current?.weightShare ?? 0), 0, @@ -343,7 +356,7 @@ export function buildTargetChangeDataset( methodology: methodology(current, candidate), rows, summaries: { - reported: summarize(rows, "reported", current.aggregate, candidate.aggregate), + reported: reportedSummary, shared: sharedSummary, }, modeReasons: { reported: null, shared: sharedReason }, From 065f2f9aaf0352e53560b084e680761d8958fdeb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:23:15 +0400 Subject: [PATCH 08/20] Support completed staging attribution --- .../target-loss-attribution-manifest.ts | 24 +++++++++++--- .../microcosm/target-loss-attribution.test.ts | 31 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/frontend/lib/microcosm/target-loss-attribution-manifest.ts b/frontend/lib/microcosm/target-loss-attribution-manifest.ts index 0c57915e..42afca77 100644 --- a/frontend/lib/microcosm/target-loss-attribution-manifest.ts +++ b/frontend/lib/microcosm/target-loss-attribution-manifest.ts @@ -58,10 +58,11 @@ const CONCEPT_BUDGET_WEIGHTING = "sqrt_value_concept_budget_weighted_mape_50_50_amount_count_target_scale_cap_100pct"; // This manifest is required because older builds do not expose target-importance -// weights in their published diagnostic files. It was audited against every US -// release eligible for the picker on 2026-08-18. These fingerprints cover -// ordered diagnostic row names, not downloaded files; runtime reconstruction -// must refuse a release whose target surface changes. +// weights in their diagnostic files. It was audited against every US release +// eligible for the picker on 2026-08-18 and includes explicitly verified staging +// candidates when needed. These fingerprints cover ordered diagnostic row names, +// not downloaded files; runtime reconstruction must refuse an artifact whose +// target surface changes. export const HISTORICAL_ATTRIBUTION_SUPPORT: readonly HistoricalAttributionSupport[] = [ { releaseId: "populace-us-2024-f32c2e5-20260614", @@ -213,6 +214,21 @@ export const HISTORICAL_ATTRIBUTION_SUPPORT: readonly HistoricalAttributionSuppo expectedStatus: "exact_reconstructed", tolerance: FLOATING_POINT_TOLERANCE, }, + { + releaseId: "populace-us-2024-f0af251-0ad74ed34493-20260619T181855Z", + buildId: "populace-us-2024-f0af251-0ad74ed34493-20260619T181855Z", + buildSha: "0ad74ed", + producerCommit: "0ad74ed344935e29d7c46703ce457ae6e8847b4d", + releaseFamily: "national", + diagnosticsSchema: 2, + targetCount: 4356, + orderedTargetNamesSha256: "0f562e9142dd33adb31339b457c6d5959575070b1a53c1dba5d0093b64664d60", + producerTargetSurfaceSha256: "67b491fe59f72e4622fd0d13c0f6a71e43e1c6ed74afae4032cb238515bd0269", + weightingIdentifier: HISTORICAL_WEIGHTING, + recipe: "newer_sqrt_value_50_50_v2", + expectedStatus: "exact_reconstructed", + tolerance: FLOATING_POINT_TOLERANCE, + }, { releaseId: "populace-us-2024-f0af251-703bd81a565c-20260620T201958Z", buildId: "populace-us-2024-f0af251-703bd81a565c-20260620T201958Z", diff --git a/frontend/lib/microcosm/target-loss-attribution.test.ts b/frontend/lib/microcosm/target-loss-attribution.test.ts index 9c31befd..fb3e8f7a 100644 --- a/frontend/lib/microcosm/target-loss-attribution.test.ts +++ b/frontend/lib/microcosm/target-loss-attribution.test.ts @@ -144,9 +144,9 @@ describe("schema-version-6 reported target-loss attribution", () => { }); describe("audited historical support manifest", () => { - test("classifies every picker release from all pinned evidence", () => { - expect(HISTORICAL_ATTRIBUTION_SUPPORT).toHaveLength(22); - expect(new Set(HISTORICAL_ATTRIBUTION_SUPPORT.map((entry) => entry.releaseId)).size).toBe(22); + test("classifies every pinned release and staging candidate from all evidence", () => { + expect(HISTORICAL_ATTRIBUTION_SUPPORT).toHaveLength(23); + expect(new Set(HISTORICAL_ATTRIBUTION_SUPPORT.map((entry) => entry.releaseId)).size).toBe(23); for (const entry of HISTORICAL_ATTRIBUTION_SUPPORT) { expect(classifyHistoricalAttributionEvidence(evidence(entry))).toEqual({ @@ -159,12 +159,35 @@ describe("audited historical support manifest", () => { HISTORICAL_ATTRIBUTION_SUPPORT.filter( (entry) => entry.expectedStatus === "exact_reconstructed", ), - ).toHaveLength(16); + ).toHaveLength(17); expect( HISTORICAL_ATTRIBUTION_SUPPORT.filter((entry) => entry.expectedStatus === "derived"), ).toHaveLength(6); }); + test("pins the completed schema-version-2 staging candidate", () => { + const entry = HISTORICAL_ATTRIBUTION_SUPPORT.find( + (candidate) => + candidate.releaseId === + "populace-us-2024-f0af251-0ad74ed34493-20260619T181855Z", + ); + + expect(entry).toMatchObject({ + buildSha: "0ad74ed", + diagnosticsSchema: 2, + targetCount: 4356, + producerTargetSurfaceSha256: + "67b491fe59f72e4622fd0d13c0f6a71e43e1c6ed74afae4032cb238515bd0269", + recipe: "newer_sqrt_value_50_50_v2", + expectedStatus: "exact_reconstructed", + }); + expect(classifyHistoricalAttributionEvidence(evidence(entry!))).toEqual({ + status: "exact_reconstructed", + recipe: "newer_sqrt_value_50_50_v2", + reason: null, + }); + }); + test("refuses a familiar weighting name on an unexpected target surface", () => { const entry = HISTORICAL_ATTRIBUTION_SUPPORT.find( (candidate) => candidate.expectedStatus === "exact_reconstructed", From 23a5816305b706a135bfb9b3c0260d46f178986e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:25:15 +0400 Subject: [PATCH 09/20] Record per-target diagnostics representation --- frontend/lib/api/hooks/use-microcosm.ts | 1 + frontend/lib/microcosm/latest-artifact.test.ts | 6 +++++- frontend/lib/microcosm/latest-artifact.ts | 2 ++ frontend/lib/microcosm/legacy-target-regression.test.ts | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/lib/api/hooks/use-microcosm.ts b/frontend/lib/api/hooks/use-microcosm.ts index bd2b3734..31880378 100644 --- a/frontend/lib/api/hooks/use-microcosm.ts +++ b/frontend/lib/api/hooks/use-microcosm.ts @@ -84,6 +84,7 @@ export interface MicrocosmTargetRow { rank?: number; }[] | null; dimension_adapter?: "structured" | "legacy_filter" | "legacy_name" | null; + target_representation?: "legacy" | "structured" | null; variable_key?: string | null; // schema v2 published registry metadata (null on v1). source_citation?: string | null; diff --git a/frontend/lib/microcosm/latest-artifact.test.ts b/frontend/lib/microcosm/latest-artifact.test.ts index 73acb2b0..148a2a3b 100644 --- a/frontend/lib/microcosm/latest-artifact.test.ts +++ b/frontend/lib/microcosm/latest-artifact.test.ts @@ -260,6 +260,7 @@ test("structured dimensions shape rows and honor artifact value order", () => { target_representation: "structured", }); expect(cal.rows.every((row) => row.dimension_adapter === "structured")).toBe(true); + expect(cal.rows.every((row) => row.target_representation === "structured")).toBe(true); expect(cal.rows[0]).toMatchObject({ family: "novastat_agency/population", geography: "North", @@ -554,7 +555,7 @@ test("live-US-shaped schema 5 rows preserve the legacy dotted contract", () => { ).targets[0]; // JSON round-tripping matches the API boundary and locks every legacy field; - // the four new contract fields are strictly additive. + // the additional contract fields are strictly additive. expect(JSON.parse(JSON.stringify(responseRow))).toEqual({ name: "bea_nipa.cy2023.proprietors_income.a041rc.amount@2024", target: 100, @@ -599,6 +600,7 @@ test("live-US-shaped schema 5 rows preserve the legacy dotted contract", () => { }, ], dimension_adapter: "legacy_name", + target_representation: "legacy", variable_key: "bea_nipa / proprietors income · total", source_citation: sourceCitation, source_url: null, @@ -2084,6 +2086,7 @@ test("mixed diagnostics dispatch complete legacy and structured rows independent source: "bea", variable: "amount", dimension_adapter: "legacy_name", + target_representation: "legacy", }); expect(cal.rows[1]).toMatchObject({ source: "artifact_agency", @@ -2093,6 +2096,7 @@ test("mixed diagnostics dispatch complete legacy and structured rows independent variable_label: "Resident population", measure: "count", dimension_adapter: "structured", + target_representation: "structured", }); }); diff --git a/frontend/lib/microcosm/latest-artifact.ts b/frontend/lib/microcosm/latest-artifact.ts index de9f2c31..39dffc79 100644 --- a/frontend/lib/microcosm/latest-artifact.ts +++ b/frontend/lib/microcosm/latest-artifact.ts @@ -1127,6 +1127,7 @@ function enrichTargetRow( dims, target_dimensions: targetDimensions, dimension_adapter: dimensionAdapter, + target_representation: rowRepresentation, variable_key: variableKey, // v2 published metadata (null on v1). source_citation: @@ -2284,6 +2285,7 @@ function targetResponseRow(row: TargetRow): TargetRow { dims: row.dims, target_dimensions: row.target_dimensions, dimension_adapter: row.dimension_adapter, + target_representation: row.target_representation, variable_key: row.variable_key, source_citation: row.source_citation, source_url: row.source_url, diff --git a/frontend/lib/microcosm/legacy-target-regression.test.ts b/frontend/lib/microcosm/legacy-target-regression.test.ts index c4d4fd3c..beb0352f 100644 --- a/frontend/lib/microcosm/legacy-target-regression.test.ts +++ b/frontend/lib/microcosm/legacy-target-regression.test.ts @@ -52,6 +52,7 @@ test("the pinned US release retains its legacy source and statistic grouping", ( ); expect(calibration.target_schema.target_representation).toBe("legacy"); + expect(calibration.rows.every((row) => row.target_representation === "legacy")).toBe(true); expect(calibration.rows).toHaveLength(5_659); expect(tree.groups).toHaveLength(15); expect(Object.values(nodeCounts).reduce((sum, count) => sum + count, 0)).toBe(53); From 48a3812e949ecc9fb3decc5f063a6747d4b21f7b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:26:50 +0400 Subject: [PATCH 10/20] Add deterministic target surface matching --- .../microcosm/target-surface-matcher.test.ts | 167 ++++++++++++ .../lib/microcosm/target-surface-matcher.ts | 243 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 frontend/lib/microcosm/target-surface-matcher.test.ts create mode 100644 frontend/lib/microcosm/target-surface-matcher.ts diff --git a/frontend/lib/microcosm/target-surface-matcher.test.ts b/frontend/lib/microcosm/target-surface-matcher.test.ts new file mode 100644 index 00000000..7adf1b7c --- /dev/null +++ b/frontend/lib/microcosm/target-surface-matcher.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; + +import type { Calibration } from "./latest-artifact"; +import { matchTargetSurfaces } from "./target-surface-matcher"; + +type Row = Calibration["rows"][number]; + +function calibration( + rows: Row[], + representation: "legacy" | "structured" | "mixed" = "legacy", +): Calibration { + return { + rows, + target_schema: { + diagnostics_schema_version: 7, + structured_dimensions: representation !== "legacy", + target_representation: representation, + }, + } as unknown as Calibration; +} + +function legacy( + name: string, + options: { baseName?: string; factKey?: string | null } = {}, +): Row { + return { + name, + base_name: options.baseName ?? name.replace(/@[^@]+$/, ""), + target_representation: "legacy", + dimension_adapter: "legacy_name", + chronicle: { fact_key: options.factKey ?? null }, + }; +} + +function structured( + name: string, + dimensions: Record, + options: { + baseName?: string; + factKey?: string | null; + source?: string; + variable?: string; + measure?: string; + } = {}, +): Row { + return { + name, + base_name: options.baseName ?? name.replace(/@[^@]+$/, ""), + target_representation: "structured", + dimension_adapter: "structured", + source: options.source ?? "agency", + variable: options.variable ?? "population", + measure: options.measure ?? "count", + dimensions, + chronicle: { fact_key: options.factKey ?? null }, + }; +} + +describe("target surface matching", () => { + test("matches exact period-normalized target names before other identities", () => { + const result = matchTargetSurfaces( + calibration([legacy("population@2024", { factKey: "old-fact" })]), + calibration([legacy("population@2025", { factKey: "new-fact" })]), + ); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + comparison_status: "shared", + match_kind: "base_name", + current_name: "population@2024", + candidate_name: "population@2025", + }); + expect(result.matching.matched_by).toEqual({ + base_name: 1, + chronicle_fact_key: 0, + structured_identity: 0, + }); + }); + + test("uses a unique Chronicle fact key to bridge legacy and structured names", () => { + const result = matchTargetSurfaces( + calibration([legacy("legacy-population", { factKey: "agency.population.total" })]), + calibration( + [structured("new-population", {}, { factKey: "agency.population.total" })], + "structured", + ), + ); + + expect(result.matches[0]).toMatchObject({ + match_kind: "chronicle_fact_key", + current_representation: "legacy", + candidate_representation: "structured", + }); + expect(result.matching).toMatchObject({ + current_representation: "legacy", + candidate_representation: "structured", + matched_by: { chronicle_fact_key: 1 }, + }); + }); + + test("canonicalizes structured dimension order without using labels", () => { + const current = structured("old-name", { region: "north", sex: "female" }); + current.source_label = "Old display label"; + const candidate = structured("new-name", { sex: "female", region: "north" }); + candidate.source_label = "New display label"; + const result = matchTargetSurfaces( + calibration([current], "structured"), + calibration([candidate], "structured"), + ); + + expect(result.matches[0].match_kind).toBe("structured_identity"); + expect(result.matching.matched_by.structured_identity).toBe(1); + }); + + test("lets unique fallback keys resolve a duplicated target name", () => { + const result = matchTargetSurfaces( + calibration([ + legacy("same@2024", { factKey: "fact-a" }), + legacy("same@2025", { factKey: "fact-b" }), + ]), + calibration([ + legacy("same@2026", { factKey: "fact-b" }), + legacy("same@2027", { factKey: "fact-a" }), + ]), + ); + + expect(result.matches.map((match) => match.match_kind)).toEqual([ + "chronicle_fact_key", + "chronicle_fact_key", + ]); + expect(result.matching.ambiguous_key_groups.base_name).toBe(1); + }); + + test("does not guess when a fallback key is many-to-one", () => { + const result = matchTargetSurfaces( + calibration([ + legacy("old-a", { factKey: "shared-fact" }), + legacy("old-b", { factKey: "shared-fact" }), + ]), + calibration([legacy("new", { factKey: "shared-fact" })]), + ); + + expect(result.matches.map((match) => match.comparison_status).sort()).toEqual([ + "added", + "removed", + "removed", + ]); + expect(result.matching.ambiguous_key_groups.chronicle_fact_key).toBe(1); + }); + + test("preserves every row once and generates deterministic unique identifiers", () => { + const current = calibration([ + legacy(""), + legacy("duplicate"), + legacy("duplicate"), + ]); + const candidate = calibration([legacy("candidate-only")]); + const first = matchTargetSurfaces(current, candidate); + const second = matchTargetSurfaces(current, candidate); + const ids = first.matches.map((match) => match.comparison_id); + + expect(first.matches).toHaveLength(4); + expect(new Set(ids).size).toBe(4); + expect(ids).toEqual(second.matches.map((match) => match.comparison_id)); + expect(first.matches.every((match) => match.match_kind == null)).toBe(true); + }); +}); diff --git a/frontend/lib/microcosm/target-surface-matcher.ts b/frontend/lib/microcosm/target-surface-matcher.ts new file mode 100644 index 00000000..95391dd2 --- /dev/null +++ b/frontend/lib/microcosm/target-surface-matcher.ts @@ -0,0 +1,243 @@ +import type { Calibration } from "./latest-artifact"; +import type { + TargetRepresentation, + TargetRowRepresentation, +} from "./target-representation"; + +type TargetRow = Calibration["rows"][number]; + +export type TargetMatchKind = + | "base_name" + | "chronicle_fact_key" + | "structured_identity"; +export type TargetSurfaceStatus = "shared" | "added" | "removed"; + +export interface TargetMatchingCounts { + base_name: number; + chronicle_fact_key: number; + structured_identity: number; +} + +export interface TargetMatchingSummary { + current_representation: TargetRepresentation; + candidate_representation: TargetRepresentation; + matched_by: TargetMatchingCounts; + ambiguous_key_groups: TargetMatchingCounts; +} + +export interface TargetSurfaceMatch { + comparison_id: string; + comparison_status: TargetSurfaceStatus; + match_kind: TargetMatchKind | null; + current: TargetRow | null; + candidate: TargetRow | null; + current_name: string | null; + candidate_name: string | null; + current_representation: TargetRowRepresentation | null; + candidate_representation: TargetRowRepresentation | null; +} + +export interface TargetSurfaceMatchResult { + matches: TargetSurfaceMatch[]; + matching: TargetMatchingSummary; +} + +interface IndexedTarget { + index: number; + row: TargetRow; +} + +interface MatchStrategy { + kind: TargetMatchKind; + key: (row: TargetRow) => string | null; +} + +function asObject(value: unknown): Record { + return value != null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizedTargetName(row: TargetRow): string | null { + return nonEmptyString(row.base_name) ?? nonEmptyString(row.name); +} + +function originalTargetName(row: TargetRow): string | null { + return nonEmptyString(row.name) ?? nonEmptyString(row.base_name); +} + +function rowRepresentation(row: TargetRow): TargetRowRepresentation { + if (row.target_representation === "structured") return "structured"; + if (row.target_representation === "legacy") return "legacy"; + return row.dimension_adapter === "structured" ? "structured" : "legacy"; +} + +function collectionRepresentation(calibration: Calibration): TargetRepresentation { + const published = calibration.target_schema?.target_representation; + if ( + published === "legacy" || + published === "structured" || + published === "mixed" || + published === "unknown" + ) { + return published; + } + if (!calibration.rows.length) return "unknown"; + const representations = new Set(calibration.rows.map(rowRepresentation)); + if (representations.size > 1) return "mixed"; + return representations.has("structured") ? "structured" : "legacy"; +} + +function chronicleFactKey(row: TargetRow): string | null { + return nonEmptyString(asObject(row.chronicle).fact_key); +} + +function structuredIdentity(row: TargetRow): string | null { + if (rowRepresentation(row) !== "structured") return null; + const source = nonEmptyString(row.source); + const variable = nonEmptyString(row.variable); + if (!source || !variable) return null; + const dimensions = Object.entries(asObject(row.dimensions)) + .flatMap(([id, value]) => { + const rawValue = nonEmptyString(value); + return id && rawValue ? [[id, rawValue] as const] : []; + }) + .sort(([left], [right]) => left.localeCompare(right)); + return JSON.stringify({ + source, + variable, + measure: nonEmptyString(row.measure), + dimensions, + }); +} + +function groupsByKey( + rows: Map, + keyOf: MatchStrategy["key"], +): Map { + const groups = new Map(); + for (const indexed of rows.values()) { + const key = keyOf(indexed.row); + if (!key) continue; + const group = groups.get(key) ?? []; + group.push(indexed); + groups.set(key, group); + } + return groups; +} + +function encoded(value: string): string { + return encodeURIComponent(value); +} + +function sharedMatch( + kind: TargetMatchKind, + key: string, + current: IndexedTarget, + candidate: IndexedTarget, +): TargetSurfaceMatch { + return { + comparison_id: `shared:${kind}:${encoded(key)}`, + comparison_status: "shared", + match_kind: kind, + current: current.row, + candidate: candidate.row, + current_name: originalTargetName(current.row), + candidate_name: originalTargetName(candidate.row), + current_representation: rowRepresentation(current.row), + candidate_representation: rowRepresentation(candidate.row), + }; +} + +function sideOnlyMatch( + side: "current" | "candidate", + indexed: IndexedTarget, +): TargetSurfaceMatch { + const row = indexed.row; + const name = originalTargetName(row); + return { + comparison_id: `${side}:${indexed.index}:${encoded(normalizedTargetName(row) ?? name ?? "")}`, + comparison_status: side === "current" ? "removed" : "added", + match_kind: null, + current: side === "current" ? row : null, + candidate: side === "candidate" ? row : null, + current_name: side === "current" ? name : null, + candidate_name: side === "candidate" ? name : null, + current_representation: side === "current" ? rowRepresentation(row) : null, + candidate_representation: side === "candidate" ? rowRepresentation(row) : null, + }; +} + +const MATCH_STRATEGIES: MatchStrategy[] = [ + { kind: "base_name", key: normalizedTargetName }, + { kind: "chronicle_fact_key", key: chronicleFactKey }, + { kind: "structured_identity", key: structuredIdentity }, +]; + +function zeroCounts(): TargetMatchingCounts { + return { + base_name: 0, + chronicle_fact_key: 0, + structured_identity: 0, + }; +} + +export function matchTargetSurfaces( + currentCalibration: Calibration, + candidateCalibration: Calibration, +): TargetSurfaceMatchResult { + const current = new Map( + currentCalibration.rows.map((row, index) => [index, { index, row }]), + ); + const candidate = new Map( + candidateCalibration.rows.map((row, index) => [index, { index, row }]), + ); + const matchedBy = zeroCounts(); + const ambiguousKeyGroups = zeroCounts(); + const matches: TargetSurfaceMatch[] = []; + + for (const strategy of MATCH_STRATEGIES) { + const currentGroups = groupsByKey(current, strategy.key); + const candidateGroups = groupsByKey(candidate, strategy.key); + const keys = [...new Set([...currentGroups.keys(), ...candidateGroups.keys()])] + .sort((left, right) => left.localeCompare(right)); + for (const key of keys) { + const currentGroup = currentGroups.get(key) ?? []; + const candidateGroup = candidateGroups.get(key) ?? []; + if (!currentGroup.length || !candidateGroup.length) continue; + if (currentGroup.length !== 1 || candidateGroup.length !== 1) { + ambiguousKeyGroups[strategy.kind] += 1; + continue; + } + const currentTarget = currentGroup[0]; + const candidateTarget = candidateGroup[0]; + matches.push(sharedMatch(strategy.kind, key, currentTarget, candidateTarget)); + matchedBy[strategy.kind] += 1; + current.delete(currentTarget.index); + candidate.delete(candidateTarget.index); + } + } + + matches.push( + ...[...current.values()] + .sort((left, right) => left.index - right.index) + .map((row) => sideOnlyMatch("current", row)), + ...[...candidate.values()] + .sort((left, right) => left.index - right.index) + .map((row) => sideOnlyMatch("candidate", row)), + ); + + return { + matches, + matching: { + current_representation: collectionRepresentation(currentCalibration), + candidate_representation: collectionRepresentation(candidateCalibration), + matched_by: matchedBy, + ambiguous_key_groups: ambiguousKeyGroups, + }, + }; +} From ae00d7372784f2e41bbc8e9d521f3f1f0e9cd135 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:28:02 +0400 Subject: [PATCH 11/20] Use shared matching for candidate validation --- frontend/lib/api/hooks/use-microcosm.ts | 20 ++++ .../lib/microcosm/latest-artifact.test.ts | 76 ++++++++++++ frontend/lib/microcosm/latest-artifact.ts | 112 +++++++++--------- 3 files changed, 151 insertions(+), 57 deletions(-) diff --git a/frontend/lib/api/hooks/use-microcosm.ts b/frontend/lib/api/hooks/use-microcosm.ts index 31880378..ecb07fa0 100644 --- a/frontend/lib/api/hooks/use-microcosm.ts +++ b/frontend/lib/api/hooks/use-microcosm.ts @@ -381,6 +381,12 @@ export interface MicrocosmTargetDiagnostics { export interface MicrocosmComparisonRow { name: string; + comparison_id?: string; + match_kind?: "base_name" | "chronicle_fact_key" | "structured_identity" | null; + current_name?: string | null; + candidate_name?: string | null; + current_representation?: "legacy" | "structured" | null; + candidate_representation?: "legacy" | "structured" | null; target_label?: string | null; source?: string | null; variable_key?: string | null; @@ -456,6 +462,20 @@ export interface MicrocosmComparison { unchanged: number; losses_comparable: boolean; loss_kind: "normalized_target_loss" | "raw_optimizer_objective" | "mixed"; + matching: { + current_representation: "legacy" | "structured" | "mixed" | "unknown"; + candidate_representation: "legacy" | "structured" | "mixed" | "unknown"; + matched_by: { + base_name: number; + chronicle_fact_key: number; + structured_identity: number; + }; + ambiguous_key_groups: { + base_name: number; + chronicle_fact_key: number; + structured_identity: number; + }; + }; }; variables: MicrocosmComparisonVariableRow[]; rows: MicrocosmComparisonRow[]; diff --git a/frontend/lib/microcosm/latest-artifact.test.ts b/frontend/lib/microcosm/latest-artifact.test.ts index 148a2a3b..53fedacc 100644 --- a/frontend/lib/microcosm/latest-artifact.test.ts +++ b/frontend/lib/microcosm/latest-artifact.test.ts @@ -1250,6 +1250,82 @@ test("comparison matches on base_name across the @period boundary", () => { expect(Array.isArray(cmp.rows[0].target_dimensions)).toBe(true); }); +test("comparison matches renamed legacy and structured targets by Chronicle fact key", () => { + const current = calibration([ + { + name: "legacy.population.total@2024", + target_name: "legacy.population.total", + metadata: { ledger_fact_key: "agency.population.total" }, + target: 100, + initial_estimate: 90, + final_estimate: 95, + }, + ], "legacy-current"); + const candidate = calibration([ + { + name: "resident-population@2024", + source: { id: "agency", label: "Statistical agency" }, + variable: { id: "resident_population", measure: "count" }, + dimensions: {}, + metadata: { ledger_fact_key: "agency.population.total" }, + target: 100, + initial_estimate: 90, + final_estimate: 99, + }, + ], "structured-candidate"); + + const cmp = buildComparison(current, candidate); + expect(cmp.summary).toMatchObject({ + common: 1, + added: 0, + removed: 0, + improved: 1, + matching: { + current_representation: "legacy", + candidate_representation: "structured", + matched_by: { chronicle_fact_key: 1 }, + }, + }); + expect(cmp.rows[0]).toMatchObject({ + match_kind: "chronicle_fact_key", + current_name: "legacy.population.total@2024", + candidate_name: "resident-population@2024", + current_representation: "legacy", + candidate_representation: "structured", + }); +}); + +test("comparison preserves duplicate names and resolves them by unique fallback keys", () => { + const row = (name: string, targetName: string, factKey: string) => ({ + name, + target_name: targetName, + metadata: { ledger_fact_key: factKey }, + target: 100, + initial_estimate: 100, + final_estimate: 100, + }); + const current = calibration([ + row("same@2024", "same", "fact-a"), + row("same@2025", "same", "fact-b"), + ], "duplicate-current"); + const candidate = calibration([ + row("renamed-a@2026", "renamed-a", "fact-a"), + row("renamed-b@2026", "renamed-b", "fact-b"), + ], "duplicate-candidate"); + + const cmp = buildComparison(current, candidate); + expect(cmp.summary).toMatchObject({ + common: 2, + added: 0, + removed: 0, + matching: { + matched_by: { chronicle_fact_key: 2 }, + ambiguous_key_groups: { base_name: 0 }, + }, + }); + expect(new Set(cmp.rows.map((comparison) => comparison.comparison_id)).size).toBe(2); +}); + test("new target loss weighting metadata marks loss as normalized", () => { const normalized = buildCalibration( { diff --git a/frontend/lib/microcosm/latest-artifact.ts b/frontend/lib/microcosm/latest-artifact.ts index 39dffc79..5a0c6de6 100644 --- a/frontend/lib/microcosm/latest-artifact.ts +++ b/frontend/lib/microcosm/latest-artifact.ts @@ -36,6 +36,7 @@ import { type TargetRepresentation, } from "./target-representation"; import { readStructuredTarget } from "./structured-target-reader"; +import { matchTargetSurfaces } from "./target-surface-matcher"; // The registry is the registration point; these re-exports keep the server // modules and routes that import country helpers from here working. @@ -2801,67 +2802,63 @@ function comparisonVariableRows(rows: TargetRow[]) { }); } -// Diff two releases' calibration by matching targets on name. Common targets -// get a fit delta (|b rel err| - |a rel err|; negative = b fits better); -// targets present in only one release are listed as added/removed. Losses -// across releases are NOT comparable when the surfaces differ — flagged. +// Diff two releases' calibration using normalized target identities. Shared +// targets get a fit delta (|b rel err| - |a rel err|; negative = b fits +// better); targets present in only one release are listed as added/removed. +// Losses across releases are not comparable when the surfaces differ. export function buildComparison(a: Calibration, b: Calibration) { - // Match on base_name (the period-stripped name) so v1 and v2 releases align — - // v2 appends an @ suffix the older convention lacks. - const key = (r: TargetRow) => String(r.base_name ?? r.name); - const aByName = new Map(a.rows.map((r) => [key(r), r])); - const bByName = new Map(b.rows.map((r) => [key(r), r])); - const names = new Set([...aByName.keys(), ...bByName.keys()]); - + const matched = matchTargetSurfaces(a, b); const common: TargetRow[] = []; - let added = 0; - let removed = 0; + const added = matched.matches.filter((match) => match.comparison_status === "added").length; + const removed = matched.matches.filter((match) => match.comparison_status === "removed").length; let improved = 0; let regressed = 0; - for (const name of names) { - const ar = aByName.get(name); - const br = bByName.get(name); - if (ar && br) { - const aAbs = absRel(ar); - const bAbs = absRel(br); - const delta = aAbs != null && bAbs != null ? bAbs - aAbs : null; - if (delta != null && delta < -1e-9) improved += 1; - else if (delta != null && delta > 1e-9) regressed += 1; - const aRelative = comparableRelative(ar); - const bRelative = comparableRelative(br); - const errorKind = aRelative != null && bRelative != null ? "relative" : "absolute"; - common.push({ - name, - target_label: [br.geography ?? ar.geography, br.breakdown ?? ar.breakdown] - .filter(Boolean) - .join(" · "), - source: br.source ?? ar.source, - variable_key: br.variable_key ?? ar.variable_key, - variable: br.variable ?? ar.variable, - measure: br.measure ?? ar.measure, - level: br.level ?? ar.level, - breakdown: br.breakdown ?? ar.breakdown, - dims: br.dims ?? ar.dims, - target_dimensions: br.target_dimensions ?? ar.target_dimensions, - geography: br.geography ?? ar.geography, - a_target: numberOrNull(ar.target), - b_target: numberOrNull(br.target), - a_final_estimate: ar.final_estimate ?? null, - b_final_estimate: br.final_estimate ?? null, - error_kind: errorKind, - a_error: errorKind === "relative" ? aRelative : absoluteMiss(ar), - b_error: errorKind === "relative" ? bRelative : absoluteMiss(br), - a_relative_error: aRelative, - b_relative_error: bRelative, - a_within_tolerance: ar.within_tolerance ?? null, - b_within_tolerance: br.within_tolerance ?? null, - abs_rel_delta: delta, - }); - } else if (ar) { - removed += 1; - } else { - added += 1; - } + for (const match of matched.matches) { + if (!match.current || !match.candidate) continue; + const ar = match.current; + const br = match.candidate; + const name = String(br.base_name ?? br.name ?? ar.base_name ?? ar.name ?? ""); + const aAbs = absRel(ar); + const bAbs = absRel(br); + const delta = aAbs != null && bAbs != null ? bAbs - aAbs : null; + if (delta != null && delta < -1e-9) improved += 1; + else if (delta != null && delta > 1e-9) regressed += 1; + const aRelative = comparableRelative(ar); + const bRelative = comparableRelative(br); + const errorKind = aRelative != null && bRelative != null ? "relative" : "absolute"; + common.push({ + comparison_id: match.comparison_id, + match_kind: match.match_kind, + current_name: match.current_name, + candidate_name: match.candidate_name, + current_representation: match.current_representation, + candidate_representation: match.candidate_representation, + name, + target_label: [br.geography ?? ar.geography, br.breakdown ?? ar.breakdown] + .filter(Boolean) + .join(" · "), + source: br.source ?? ar.source, + variable_key: br.variable_key ?? ar.variable_key, + variable: br.variable ?? ar.variable, + measure: br.measure ?? ar.measure, + level: br.level ?? ar.level, + breakdown: br.breakdown ?? ar.breakdown, + dims: br.dims ?? ar.dims, + target_dimensions: br.target_dimensions ?? ar.target_dimensions, + geography: br.geography ?? ar.geography, + a_target: numberOrNull(ar.target), + b_target: numberOrNull(br.target), + a_final_estimate: ar.final_estimate ?? null, + b_final_estimate: br.final_estimate ?? null, + error_kind: errorKind, + a_error: errorKind === "relative" ? aRelative : absoluteMiss(ar), + b_error: errorKind === "relative" ? bRelative : absoluteMiss(br), + a_relative_error: aRelative, + b_relative_error: bRelative, + a_within_tolerance: ar.within_tolerance ?? null, + b_within_tolerance: br.within_tolerance ?? null, + abs_rel_delta: delta, + }); } common.sort( (x, y) => @@ -2899,6 +2896,7 @@ export function buildComparison(a: Calibration, b: Calibration) { unchanged: common.length - improved - regressed, losses_comparable: !surfacesDiffer && a.loss_kind === b.loss_kind, loss_kind: a.loss_kind === b.loss_kind ? a.loss_kind : "mixed", + matching: matched.matching, }, variables: comparisonVariableRows(common), rows: common, From 33cee47f97b64dde238b1ee7b23a51e54dab5baa Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:29:06 +0400 Subject: [PATCH 12/20] Use shared matching for target error changes --- frontend/lib/microcosm/target-change-tree.ts | 3 + frontend/lib/microcosm/target-change.test.ts | 44 ++++++++++++++- frontend/lib/microcosm/target-change.ts | 59 ++++++++++++-------- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/frontend/lib/microcosm/target-change-tree.ts b/frontend/lib/microcosm/target-change-tree.ts index f92da896..edb55b10 100644 --- a/frontend/lib/microcosm/target-change-tree.ts +++ b/frontend/lib/microcosm/target-change-tree.ts @@ -13,6 +13,7 @@ import { type TargetChangeRow, type TargetChangeSummary, } from "./target-change"; +import type { TargetMatchingSummary } from "./target-surface-matcher"; export interface TargetChangeTreeResponse extends CalibrationTreeResponse { available: boolean; @@ -21,6 +22,7 @@ export interface TargetChangeTreeResponse extends CalibrationTreeResponse { current: TargetChangeAttributionSide; candidate: TargetChangeAttributionSide; methodology: TargetChangeMethodology; + matching: TargetMatchingSummary; summary: TargetChangeSummary | null; selectedTarget: TargetChangeRow | null; } @@ -73,6 +75,7 @@ export function buildTargetChangeTree( current: dataset.current, candidate: dataset.candidate, methodology: dataset.methodology, + matching: dataset.matching, summary, selectedTarget, }; diff --git a/frontend/lib/microcosm/target-change.test.ts b/frontend/lib/microcosm/target-change.test.ts index 88b3ed6a..f020d18f 100644 --- a/frontend/lib/microcosm/target-change.test.ts +++ b/frontend/lib/microcosm/target-change.test.ts @@ -130,6 +130,36 @@ describe("target change attribution", () => { expect(result.rows.find((row) => row.name === "removed")?.source).toBe("old"); }); + test("uses cross-format Chronicle matches without changing contribution arithmetic", () => { + const current = calibration("current", [ + { name: "legacy-name", contribution: 0.1, share: 1, error: 0.1 }, + ]); + const candidate = calibration("candidate", [ + { name: "structured-name", contribution: 0.2, share: 1, error: 0.2 }, + ]); + current.rows[0].chronicle = { fact_key: "agency.population.total" }; + candidate.rows[0].chronicle = { fact_key: "agency.population.total" }; + candidate.rows[0].target_representation = "structured"; + candidate.rows[0].dimension_adapter = "structured"; + candidate.rows[0].dimensions = {}; + + const result = buildTargetChangeDataset(current, candidate); + expect(result.available).toBe(true); + expect(result.matching).toMatchObject({ + current_representation: "legacy", + candidate_representation: "structured", + matched_by: { chronicle_fact_key: 1 }, + }); + expect(result.rows[0]).toMatchObject({ + comparison_status: "shared", + match_kind: "chronicle_fact_key", + current_name: "legacy-name@2024", + candidate_name: "structured-name@2024", + reported_change: 0.1, + }); + expect(result.summaries.reported?.reconciliationDifference).toBeCloseTo(0); + }); + test("fails closed when attribution is unavailable or a row is incomplete", () => { const unavailable = buildTargetChangeDataset( calibration("current", [], { status: "unavailable" }), @@ -152,7 +182,7 @@ describe("target change attribution", () => { expect(incomplete.reason).toContain("incomplete"); }); - test("fails closed when matched rows cannot reconcile to both aggregates", () => { + test("preserves ambiguous duplicate rows as additions and removals", () => { const current = calibration("current", [ { name: "duplicate", contribution: 0.1, share: 0.5, error: 0.2 }, { name: "duplicate", contribution: 0.2, share: 0.5, error: 0.4 }, @@ -161,8 +191,16 @@ describe("target change attribution", () => { { name: "duplicate", contribution: 0.2, share: 1, error: 0.2 }, ]); const result = buildTargetChangeDataset(current, candidate); - expect(result.available).toBe(false); - expect(result.reason).toContain("do not reconcile"); + expect(result.available).toBe(true); + expect(result.rows).toHaveLength(3); + expect(result.summaries.reported).toMatchObject({ + shared: 0, + added: 1, + removed: 2, + reconciliationDifference: 0, + }); + expect(result.matching.ambiguous_key_groups.base_name).toBe(1); + expect(result.modeReasons.shared).toContain("no shared targets"); }); test("retains additive results while warning about methodology differences", () => { diff --git a/frontend/lib/microcosm/target-change.ts b/frontend/lib/microcosm/target-change.ts index 616e278d..e004aad2 100644 --- a/frontend/lib/microcosm/target-change.ts +++ b/frontend/lib/microcosm/target-change.ts @@ -1,11 +1,16 @@ import type { Calibration } from "./latest-artifact"; +import { + matchTargetSurfaces, + type TargetMatchingSummary, + type TargetSurfaceStatus, +} from "./target-surface-matcher"; type TargetRow = Calibration["rows"][number]; export const TARGET_CHANGE_EPSILON = 1e-12; export type TargetChangeMode = "reported" | "shared"; -export type TargetSurfaceStatus = "shared" | "added" | "removed"; +export type { TargetSurfaceStatus } from "./target-surface-matcher"; export interface TargetChangeSide { target: number | null; @@ -20,6 +25,12 @@ export interface TargetChangeSide { export interface TargetChangeRow extends Record { name: string; base_name: string; + comparison_id: string; + match_kind: "base_name" | "chronicle_fact_key" | "structured_identity" | null; + current_name: string | null; + candidate_name: string | null; + current_representation: "legacy" | "structured" | null; + candidate_representation: "legacy" | "structured" | null; comparison_status: TargetSurfaceStatus; current: TargetChangeSide | null; candidate: TargetChangeSide | null; @@ -65,6 +76,7 @@ export interface TargetChangeDataset { current: TargetChangeAttributionSide; candidate: TargetChangeAttributionSide; methodology: TargetChangeMethodology; + matching: TargetMatchingSummary; rows: TargetChangeRow[]; summaries: Record; modeReasons: Record; @@ -74,10 +86,6 @@ function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function targetKey(row: TargetRow): string { - return String(row.base_name ?? row.name ?? ""); -} - function hierarchyFields(row: TargetRow): Record { return { source: row.source ?? null, @@ -201,6 +209,7 @@ function summarize( function unavailableDataset( current: TargetChangeAttributionSide, candidate: TargetChangeAttributionSide, + matching: TargetMatchingSummary, reason: string, ): TargetChangeDataset { return { @@ -209,6 +218,7 @@ function unavailableDataset( current, candidate, methodology: methodology(current, candidate), + matching, rows: [], summaries: { reported: null, shared: null }, modeReasons: { reported: reason, shared: reason }, @@ -228,10 +238,12 @@ export function buildTargetChangeDataset( ): TargetChangeDataset { const current = attributionSide(currentCalibration); const candidate = attributionSide(candidateCalibration); + const matched = matchTargetSurfaces(currentCalibration, candidateCalibration); if (current.status === "unavailable" || current.aggregate == null) { return unavailableDataset( current, candidate, + matched.matching, "Weighted target-error attribution is unavailable for the current release.", ); } @@ -239,44 +251,43 @@ export function buildTargetChangeDataset( return unavailableDataset( current, candidate, + matched.matching, "Weighted target-error attribution is unavailable for the candidate.", ); } - const currentByName = new Map( - currentCalibration.rows.map((row) => [targetKey(row), row]), - ); - const candidateByName = new Map( - candidateCalibration.rows.map((row) => [targetKey(row), row]), - ); - const names = [...new Set([...currentByName.keys(), ...candidateByName.keys()])] - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)); const rows: TargetChangeRow[] = []; - for (const name of names) { - const currentRow = currentByName.get(name); - const candidateRow = candidateByName.get(name); + for (const match of matched.matches) { + const currentRow = match.current ?? undefined; + const candidateRow = match.candidate ?? undefined; + const name = String( + candidateRow?.base_name ?? candidateRow?.name ?? + currentRow?.base_name ?? currentRow?.name ?? + match.comparison_id, + ); const currentTarget = targetSide(currentRow); const candidateTarget = targetSide(candidateRow); if ((currentRow && !currentTarget) || (candidateRow && !candidateTarget)) { return unavailableDataset( current, candidate, + matched.matching, `Weighted target-error attribution is incomplete for target ${name}.`, ); } const categoryRow = candidateRow ?? currentRow; - const comparisonStatus: TargetSurfaceStatus = currentRow && candidateRow - ? "shared" - : candidateRow - ? "added" - : "removed"; rows.push({ ...(categoryRow ? hierarchyFields(categoryRow) : {}), name, base_name: name, - comparison_status: comparisonStatus, + comparison_id: match.comparison_id, + match_kind: match.match_kind, + current_name: match.current_name, + candidate_name: match.candidate_name, + current_representation: match.current_representation, + candidate_representation: match.candidate_representation, + comparison_status: match.comparison_status, current: currentTarget, candidate: candidateTarget, reported_change: @@ -302,6 +313,7 @@ export function buildTargetChangeDataset( return unavailableDataset( current, candidate, + matched.matching, "Per-target weighted error changes do not reconcile to the two attribution aggregates.", ); } @@ -354,6 +366,7 @@ export function buildTargetChangeDataset( current, candidate, methodology: methodology(current, candidate), + matching: matched.matching, rows, summaries: { reported: reportedSummary, From db0666f6f0e6c8fe176cce037575e8c7585c7db7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:30:55 +0400 Subject: [PATCH 13/20] Explain cross-format target matches --- .../microcosm/microcosm-staging-view.tsx | 20 ++++-- .../microcosm/staging-target-change-map.tsx | 30 +++++++- frontend/lib/microcosm/calibration-tree.ts | 3 +- .../lib/microcosm/target-change-tree.test.ts | 30 +++++++- frontend/lib/microcosm/target-change-tree.ts | 2 +- .../target-matching-presentation.test.ts | 48 +++++++++++++ .../microcosm/target-matching-presentation.ts | 72 +++++++++++++++++++ 7 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 frontend/lib/microcosm/target-matching-presentation.test.ts create mode 100644 frontend/lib/microcosm/target-matching-presentation.ts diff --git a/frontend/components/microcosm/microcosm-staging-view.tsx b/frontend/components/microcosm/microcosm-staging-view.tsx index b5291b9b..dec6ab15 100644 --- a/frontend/components/microcosm/microcosm-staging-view.tsx +++ b/frontend/components/microcosm/microcosm-staging-view.tsx @@ -36,6 +36,7 @@ import { formatStagingCurrentStatus, formatStagingStatus, } from "@/lib/microcosm/staging-status"; +import { targetMatchingSummaryText } from "@/lib/microcosm/target-matching-presentation"; import { targetChangeMapIdentity } from "@/lib/microcosm/target-change-visualization"; type LossKind = "normalized_target_loss" | "raw_optimizer_objective" | undefined; @@ -991,13 +992,18 @@ function MicrocosmStagingRunsView() {
{compareData?.summary && (
- - {fmt(compareData.summary.improved, { digits: 0 })} targets improved - - {" · "} - - {fmt(compareData.summary.regressed, { digits: 0 })} regressed - +
+ + {fmt(compareData.summary.improved, { digits: 0 })} targets improved + + {" · "} + + {fmt(compareData.summary.regressed, { digits: 0 })} regressed + +
+

+ {targetMatchingSummaryText(compareData.summary.matching)} +

)} diff --git a/frontend/components/microcosm/staging-target-change-map.tsx b/frontend/components/microcosm/staging-target-change-map.tsx index 71a3a811..b1bd493b 100644 --- a/frontend/components/microcosm/staging-target-change-map.tsx +++ b/frontend/components/microcosm/staging-target-change-map.tsx @@ -37,6 +37,11 @@ import type { TargetChangeRow, } from "@/lib/microcosm/target-change"; import type { TargetChangeTreeResponse } from "@/lib/microcosm/target-change-tree"; +import { + targetMatchingSummaryText, + targetMatchKindExplanation, + targetRepresentationPairLabel, +} from "@/lib/microcosm/target-matching-presentation"; import { formatTargetChange, formatWeightedTargetError, @@ -289,6 +294,11 @@ function TargetChangeDetail({ }) { const detail = targetChangeDetailValues(target, mode); const rows = [ + { + label: "Target identifier", + current: target.current_name ?? "—", + candidate: target.candidate_name ?? "—", + }, { label: "Benchmark", current: nullableNumber(target.current?.target), @@ -325,7 +335,12 @@ function TargetChangeDetail({ {targetStatus(target)}
-

{target.name}

+

+ {targetRepresentationPairLabel( + target.current_representation, + target.candidate_representation, + )} +