Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ typecheck:
cd frontend && bun run lint

test:
cd frontend && bun test
cd frontend && bun test && bun run test:dev-port
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +56,20 @@ Published-release endpoints accept `country=us|uk|be` (default `us`).
| `GET /api/microcosm/staging/run?id=<run_id>` | One staging run's progress and uploaded candidate diagnostics |
| `GET /api/microcosm/staging/target-diagnostics?id=<run_id>&...` | Faceted diagnostics for a staging candidate once diagnostics exist |
| `GET /api/microcosm/staging/compare?run=<run_id>&release=latest` | Diff staging candidate against a published release |
| `GET /api/microcosm/staging/target-change-tree?run=<run_id>&release=<resolved_id>&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.

Version and staging comparisons use the same normalized target matcher. It first
matches an exact period-normalized target name, then a unique Chronicle fact key,
then an exact structured source/statistic/measure/dimensions identity. A key must
identify one remaining target on each side; ambiguous keys are reported and left
unmatched. Comparison responses include the representation of each target, the
matching method, both release identifiers, and counts by matching method.

## Calibration target investigations

Expand All @@ -76,12 +91,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:

Expand Down
26 changes: 26 additions & 0 deletions docs/spec-driven-countries.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,32 @@ variable parsing; structured dimensions, then known filter dimensions, then
legacy metadata and name dimensions. This prevents one partially migrated row
from changing unrelated legacy rows in the same file.

### Cross-release target matching

The legacy and structured readers normalize each row independently. Candidate
validation and weighted target-error comparisons then use one collection-level
matcher with this order:

1. Exact, non-empty `base_name`, which uses `target_name` when supplied and
otherwise removes the period suffix from `name`.
2. Exact, non-empty Chronicle `fact_key`.
3. For two structured rows, an exact tuple of source ID, variable ID, measure,
and raw dimension ID/value pairs sorted by dimension ID.

At each step, a key is used only when it identifies exactly one still-unmatched
row in each release. Duplicate and many-to-one keys remain unmatched unless a
later identity uniquely resolves them. Chronicle semantic keys, source-record
IDs, display labels, benchmark values, and inferred identifiers are not used.
The current artifact contract does not publish an independent producer target
ID beyond `target_name`/`base_name`, so the dashboard does not invent one.

Comparison rows include `comparison_id`, `match_kind`, both original target
names, and both per-row representations. Comparison summaries include collection
representations, counts matched by each method, and ambiguous key-group counts.
The pinned production artifact remains legacy-format; structured cross-release
matching is therefore covered with synthetic fixtures until a production pair
using the structured representation is available.

### Producer follow-up

Microcosm release producers must publish all of the following before the legacy
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/api/microcosm/compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/api/microcosm/releases/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/api/microcosm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
72 changes: 72 additions & 0 deletions frontend/app/api/microcosm/staging/target-change-tree/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
2 changes: 1 addition & 1 deletion frontend/app/api/microcosm/target-diagnostics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/api/microcosm/target-investigation/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 3 additions & 44 deletions frontend/app/api/microcosm/target-tree/route.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,28 @@
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,
parseCountry,
scrub,
} from "@/lib/microcosm/latest-artifact";

export const revalidate = 300;
export const revalidate = 21_600;
export const runtime = "nodejs";
export const maxDuration = 300;

const CALIBRATION_STATUSES = new Set<CalibrationStatus>([
"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(
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/api/microcosm/target-treemap/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion frontend/app/api/microcosm/variable/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
staleTime: PUBLISHED_RELEASE_STALE_TIME_MS,
refetchOnWindowFocus: false,
retry: 2,
},
Expand Down
19 changes: 2 additions & 17 deletions frontend/components/microcosm/microcosm-overview-view.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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 (
<div className="min-w-0 flex-1 px-4 py-3.5 text-center sm:px-5">
<div className="flex h-8 items-start justify-center text-[10px] font-semibold uppercase leading-tight tracking-[0.12em] text-muted-foreground">
{label}
</div>
<div
className="mt-1 truncate text-2xl font-semibold tabular-nums text-foreground"
title={value}
>
{value}
</div>
</div>
);
}

export function MicrocosmOverviewView({
initialCountry = "us",
initialRelease = "",
Expand Down
Loading
Loading