access
- No account or API key. Capacity is shared on a fair-use basis
- — a few concurrent runs.
+ No account, no API key. Fair use, a few concurrent runs.
cold start
- The first household or population call in a fresh container
- takes around 20 seconds while the tax-benefit models load; a UK
- forecast runs a couple of minutes on first call. Other tools
- respond faster.
+ ~20s on the first household or population call while the
+ tax-benefit models load; a couple of minutes for a first UK forecast.
+ Warm calls are fast.
uk microdata
- Running a UK population or reform score locally needs
- HUGGING_FACE_TOKEN from an account with access to the
- private policyengine/policyengine-uk-data-private
- dataset. UK household calls, every US call, and every hosted call
- need nothing.
+ Only a local UK population or reform score needs
+ HUGGING_FACE_TOKEN (private
+ policyengine-uk-data-private). Hosted calls need
+ nothing.
local only
- OG-UK long-run scoring (score_reform with
- model='og') and dynamic_reform_impact take
- tens of minutes and are excluded from the hosted image;
- dynamic_reform_impact returns the two-step local recipe,
- while model='og' returns an import error — use the local
- CLI for both.
- The three define_* tools likewise return run instructions rather than results when called on the hosted server — the unlicensed upstream is never hosted. Everything else here runs hosted.
+ Three things are not hosted: model='og' and
+ dynamic_reform_impact (tens of minutes), and the
+ define_* tools (unlicensed upstream). Called here they
+ return run instructions or an import error, not results — use the
+ CLI. Everything else runs hosted.
Reconstruct a series as it was published on a date
+
Read a series as it was published on a date
- MANIFEST.json lists every snapshot date held for every
- series. Take the newest on or before the date you want, and read that
- file. Standard library only, no key, no account.
+ Take the newest snapshot on or before your date. No key, no account,
+ standard library only.
{recipe_block()}
- A later date returns a later snapshot, which may carry revised values
- for periods the earlier one already covered. That difference is the
- revision. The store begins {earliest}, when the first snapshot was
- taken — it is a forward-looking real-time record from that date, not a
- reconstruction of vintages predating it.
+ Two dates, two snapshots: the difference between them is the revision.
+ The store starts {earliest} and runs forward from there — it does not
+ reconstruct vintages older than itself.
Clean-room reimplementation, milestone 1 — §2.2 transactions and balance-sheet matrices on the §5 initial values
manual's 4-significant-figure printing precision
-
PASS (2026-08-04, 27 identity tests)
+
PASS (2026-08-04, 62 tests, 44 of them Table 1/2 row and column identities)
Calibration vs external observations (ONS/DESNZ/OBR)
diff --git a/economy/build.py b/economy/build.py
index b7f529ea..0f09a4b8 100644
--- a/economy/build.py
+++ b/economy/build.py
@@ -32,10 +32,16 @@ def _topics_module():
return module
-TOPIC_PAGES = _topics_module().TOPICS
-TOPIC_TITLE = {topic["slug"]: topic["title"] for topic in TOPIC_PAGES}
+_TOPICS = _topics_module()
+TOPIC_PAGES = _TOPICS.TOPICS
+# Keyed by series rather than by slug, because the same slug now exists in two
+# countries: /economy/topics/growth and /economy/us/topics/growth are different
+# pages reading different series. Series names carry their country prefix, so
+# the mapping stays unambiguous and each hub links into its own scope.
SERIES_TOPIC = {
- name: topic["slug"] for topic in TOPIC_PAGES for name in topic["series"]
+ name: (_TOPICS.topic_url(topic), topic["title"])
+ for topic in TOPIC_PAGES
+ for name in topic["series"]
}
# Reading order on the hub index: the ONS series grouped the way the topics
@@ -54,18 +60,20 @@ def _topics_module():
)
MARKET_SERIES = ("uk_bank_rate", "uk_gilt_5y", "uk_gilt_10y", "uk_gilt_20y")
-PUBLIC_LABELS = {
- "ABMI": "Real gross domestic product",
- "D7G7": "CPI inflation",
- "MGSX": "Unemployment rate",
- "DKO8": "Core CPI inflation",
- "KAB9": "Average weekly earnings",
- "AP2Y": "UK vacancies",
- "ECY2": "Monthly gross value added index",
- "J5II": "Public-sector net borrowing",
- "HF6X": "Public-sector net debt",
- "NPEL": "Real business investment",
-}
+# The same two groups for the US hub, in the same reading order: the series
+# the topic pages read, then the two rate series under their own subhead.
+FRED_SERIES = (
+ "us_real_gdp",
+ "us_cpi",
+ "us_unemployment_rate",
+ "us_payroll_employment",
+)
+US_MARKET_SERIES = ("us_federal_funds_rate", "us_treasury_10y")
+
+# Catalogue titles are not reader-facing labels. One table, shared with the
+# topic generator, so a series is called the same thing on the hub that indexes
+# it and on the topic page that reads it.
+PUBLIC_LABELS = _TOPICS.PUBLIC_LABELS
def load(name: str) -> dict:
@@ -90,10 +98,6 @@ def public_label(series: dict) -> str:
return PUBLIC_LABELS.get(series["cdid"], series["title"])
-def previous(series: dict, periods: int = 1) -> dict:
- return series["observations"][-1 - periods]
-
-
def gdp_growth(series: dict) -> list[dict]:
obs = series["observations"]
return [
@@ -213,42 +217,6 @@ def y(value: float) -> float:
"""
-def card(
- label: str,
- value: str,
- period: str,
- change: str,
- source: str,
- vintage: str,
- url: str,
- model_line: str | None = None,
- model_source: tuple[str, str, str] | None = None,
-) -> str:
- """One stat card; optionally paired with a model line under the outturn."""
- model_html = (
- f"\n
"
+ uk_start = min(index[name]["coverage"][0] for name in ONS_SERIES)
+ return f"""
+ The topic pages carry the current reading and, where one exists, the
+ conditioning baseline beside it; these three charts are the one place the
+ whole path is drawn. Each is generated from the same committed
+ point-in-time observations, and the source link opens the corresponding
+ official series. Two of the three are shorter than their UK counterparts:
+ GDPC1 is quarterly, so twenty points span {gdp[0]['period']}–{gdp[-1]['period']}, but CPIAUCSL
+ and UNRATE are monthly, so the same twenty-point window covers only
+ {cpi[0]['period']}–{cpi[-1]['period']} and {unemployment[0]['period']}–{unemployment[-1]['period']}. And no US series in this store
+ starts before {us_start}, where the ONS series behind the UK charts reach
+ back to {uk_start}: the long view here is as long as the store, not as long
+ as the published record.
+
"""
+
+
+def us_release_dates_absent(names: tuple[str, ...]) -> None:
+ """/economy/us says FRED announces no release dates. Check, do not assume.
+
+ The US hub carries no ``Released`` column and no release calendar, and
+ tells the reader why. Both absences are claims about the data, so they are
+ re-derived here: if a US snapshot ever arrives with either field populated,
+ the build fails instead of the page quietly under-reporting what is known.
+ """
+ announced = sorted(
+ name
+ for name in names
+ if load(name).get("release_updated") or load(name).get("next_release")
+ )
+ if announced:
+ raise RuntimeError(
+ f"{', '.join(announced)} now carries a release date, but "
+ "/economy/us tells readers FRED supplies none — give the US hub "
+ "its Released column and its release calendar back"
)
- return "\n".join(rows)
def topic_link(name: str) -> str:
@@ -503,34 +346,46 @@ def topic_link(name: str) -> str:
stops carrying a series, this raises rather than printing a link into a
page where the number is no longer shown.
"""
- slug = SERIES_TOPIC.get(name)
- if slug is None:
+ entry = SERIES_TOPIC.get(name)
+ if entry is None:
raise RuntimeError(
- f"{name} is indexed on /economy but no topic page in "
+ f"{name} is indexed on an economy hub but no topic page in "
f"economy/topics.py reads it — give it a topic home or drop it "
f"from the hub index"
)
- return f'{TOPIC_TITLE[slug]}'
+ url, title = entry
+ return f'{title}'
-def series_index_rows(names: tuple[str, ...]) -> str:
+def series_index_rows(names: tuple[str, ...], released: bool = True) -> str:
"""The hub index: what is tracked, which topic reads it, where it came from.
Deliberately carries no readings. The values, their period comparisons and
the model view of each one live on the topic pages; repeating them here is
what turned /economy and /economy/topics into two dashboards over one set
of numbers.
+
+ ``released`` drops the publisher's release date, which the US hub does: the
+ ONS stamps every series with one, FRED stamps none of them through this
+ site's fetcher, and a column of six identical "not supplied" cells says
+ less than one sentence of prose. ``us_release_dates_absent`` is what keeps
+ that sentence true.
"""
rows = []
for name in names:
series = load(name)
now = latest(series)
+ release = (
+ f"
{(series.get('release_updated') or 'not supplied').split('T')[0]}
"
+ if released
+ else ""
+ )
rows.append(
"
"
f'
{public_label(series)}
'
f"
{topic_link(name)}
"
f"
{now['period']}
"
- f"
{(series.get('release_updated') or 'not supplied').split('T')[0]}
"
@@ -728,12 +583,24 @@ def render_uk() -> str:
def render_us() -> str:
+ """The US hub, the same shape as the UK one: directory, long view, index.
+
+ No stat cards and no indicator table: every reading they carried is on the
+ topic page that owns the series, and the hub's job is the provenance
+ behind them.
+ """
+ us_release_dates_absent(FRED_SERIES + US_MARKET_SERIES)
html = US_PAGE.read_text()
- html = replace(html, "us-economy-cards", us_cards())
+ html = replace(html, "us-economy-trends-note", us_trends_note())
html = replace(html, "us-economy-trends-figures", us_figures())
- html = replace(html, "us-economy-indicators", us_indicator_rows())
- html = replace(html, "us-economy-markets", us_market_rows())
- html = replace(html, "us-economy-releases", us_release_rows())
+ html = replace(
+ html, "us-economy-series-index", series_index_rows(FRED_SERIES, released=False)
+ )
+ html = replace(
+ html,
+ "us-economy-market-index",
+ series_index_rows(US_MARKET_SERIES, released=False),
+ )
return html
diff --git a/economy/index.html b/economy/index.html
index 16c43970..3faaaaea 100644
--- a/economy/index.html
+++ b/economy/index.html
@@ -65,6 +65,7 @@
+ OverviewGrowthInflationJobs
diff --git a/economy/topics.py b/economy/topics.py
index ba2b3120..9e00e824 100644
--- a/economy/topics.py
+++ b/economy/topics.py
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
-"""Regenerate the topic-first entry pages under /economy/topics.
+"""Regenerate the topic-first entry pages for both countries.
+
+Six under /economy/topics and five under /economy/us/topics, plus the shared
+topic bar that both hubs and every topic page carry.
The rest of this site is organised by model — a reader has to know what a
structural VAR is before they can find the inflation forecast. These pages are
@@ -17,6 +20,15 @@
refreshed vintage or a renamed tool fails CI instead of quietly rotting on a
public page.
+The two countries fill those layers to different depths, and the generator is
+built so the difference cannot be papered over. boe-svar is the only member
+whose ``question_types`` include "forecast" and it is UK-only, so no US page
+may show a forecast with a range; ``no_us_forecaster`` re-derives that from the
+registry on every render and raises if it stops being true. The same applies to
+the topics that do not exist: ``us_public_finance_gap`` raises if a US fiscal
+series ever appears in the store while /economy/us still tells readers there is
+none.
+
Stdlib only, and nothing reads the clock or the network: the same inputs always
produce the same bytes.
"""
@@ -35,9 +47,40 @@
ROOT = Path(__file__).resolve().parents[1]
TOPICS_DIR = ROOT / "economy" / "topics"
ECONOMY_PAGE = ROOT / "economy" / "index.html"
+US_TOPICS_DIR = ROOT / "economy" / "us" / "topics"
+US_PAGE = ROOT / "economy" / "us" / "index.html"
SITEMAP = ROOT / "sitemap.xml"
SITE = "https://policyengine-macro.vercel.app"
+# The two country scopes this generator renders. Everything below is written
+# once and parameterised by this table rather than forked per country: a
+# second copy of the renderer is how /economy and /economy/us drifted into two
+# different shapes in the first place — one with six topic pages and a topic
+# strip, the other with four in-page anchors that looked like tabs.
+COUNTRIES = {
+ "uk": {
+ "label": "UK",
+ "hub": "/economy",
+ "hub_page": ECONOMY_PAGE,
+ "dir": TOPICS_DIR,
+ "nav_marker": "economy-topic-nav",
+ "directory_marker": "economy-topics",
+ },
+ "us": {
+ "label": "US",
+ "hub": "/economy/us",
+ "hub_page": US_PAGE,
+ "dir": US_TOPICS_DIR,
+ "nav_marker": "us-economy-topic-nav",
+ "directory_marker": "us-economy-topics",
+ },
+}
+
+
+def topic_url(topic: dict) -> str:
+ """Site path for one topic page, derived from its country."""
+ return f"{COUNTRIES[topic['country']]['hub']}/topics/{topic['slug']}"
+
sys.path.insert(0, str(ROOT))
import site_nav # noqa: E402 (canonical header/crumbs/footer renderer)
@@ -83,6 +126,16 @@ def _module(name: str, path: Path):
"J5II": "Public-sector net borrowing",
"HF6X": "Public-sector net debt",
"NPEL": "Real business investment",
+ # FRED titles all begin "US ..." because they are read on a site covering
+ # two countries; on a page whose scope is already the US that prefix is
+ # noise, and "10-year US Treasury constant maturity rate" is the
+ # publisher's catalogue name rather than the one a reader would say.
+ "GDPC1": "Real gross domestic product",
+ "CPIAUCSL": "Consumer price index, all urban consumers",
+ "UNRATE": "Unemployment rate",
+ "PAYEMS": "Nonfarm payroll employment",
+ "FEDFUNDS": "Effective federal funds rate",
+ "DGS10": "10-year Treasury yield",
}
FORECAST_FILE = ROOT / "papers" / "boe-svar" / "figures" / "current_forecast.json"
@@ -158,6 +211,13 @@ def display(series: dict, value: float) -> str:
return f"\u00a3{billions:,.1f}bn{borrowed}"
if "per week" in units:
return f"\u00a3{value:,.0f}/week"
+ # FRED levels. GDPC1 is billions of chained dollars at an annual rate and
+ # PAYEMS is thousands of persons; printed raw they read as "24,270.599"
+ # and "158,858k", which is the stored number rather than the reading.
+ if "billions of chained" in units:
+ return f"${value / 1000:,.1f}tn"
+ if "thousands of persons" in units:
+ return f"{value / 1000:,.1f}m"
if "thousand" in units:
return f"{value:,.0f}k"
if "percent" in units or units.startswith("%"):
@@ -178,17 +238,53 @@ def suffix(series: dict) -> str:
return "%" if series["units"].lower().startswith("percent") else ""
+def year_earlier(series: dict, period: str) -> dict | None:
+ """The observation one calendar year before ``period``, or None.
+
+ By period, never by position. The two are not the same thing: us_cpi and
+ us_unemployment_rate are both missing 2025-10, so ``observations[-13]``
+ on the CPI index is 2025-05 and a "year-on-year" rate taken from it reads
+ 3.7% where the true 2026-06 figure is 3.5%. The economy hub already
+ matches on period; a topic page that did not would publish a different
+ number for the same series on the same site.
+ """
+ if not re.fullmatch(r"\d{4}Q[1-4]|\d{4}-\d{2}", period):
+ return None
+ wanted = f"{int(period[:4]) - 1}{period[4:]}"
+ return next(
+ (row for row in series["observations"] if row["period"] == wanted), None
+ )
+
+
def yoy(series: dict, periods: int) -> list[dict]:
- """Year-on-year percent change computed from the stored levels."""
- observations = series["observations"]
- return [
- {
- "period": row["period"],
- "value": 100 * (row["value"] / observations[index - periods]["value"] - 1),
- }
- for index, row in enumerate(observations)
- if index >= periods
- ]
+ """Year-on-year percent change computed from the stored levels.
+
+ ``periods`` is 4 for a quarterly series and 12 for a monthly one — it
+ names the frequency, not an offset to index by. A period whose counterpart
+ a year earlier is absent from the store is skipped rather than silently
+ compared against whatever observation happens to sit that many rows back.
+ """
+ assert periods in (4, 12), "yoy() compares a period with the same period a year earlier"
+ out = []
+ for row in series["observations"]:
+ before = year_earlier(series, row["period"])
+ if before is None:
+ continue
+ out.append(
+ {
+ "period": row["period"],
+ "value": 100 * (row["value"] / before["value"] - 1),
+ }
+ )
+ return out
+
+
+def quarter_of(period: str) -> str:
+ """Map a monthly or daily period onto the quarter that contains it."""
+ if "Q" in period:
+ return period
+ year, month = period.split("-")[:2]
+ return f"{year}Q{(int(month) + 2) // 3}"
def next_open(forecast: dict, variable: str, last_observed: str) -> tuple[str, dict]:
@@ -287,20 +383,55 @@ def satellite_has_no_surface() -> bool:
# --------------------------------------------------------------- page pieces
-def stat_card(series: dict) -> str:
- now = latest(series)
+def _card(heading: str, value: str, note: str, series: dict, period: str) -> str:
return f"""
-
{esc(label(series).upper())}
-
{esc(display(series, now["value"]))}
-
stored as {esc(stored(now["value"]))} · {esc(series["units"])}
"""
+def stat_card(series: dict, kind: str | None = None) -> str:
+ """One reading, as the snapshot stores it — or explicitly derived from it.
+
+ The ONS publishes CPI and the unemployment rate as rates, so a UK card can
+ print the stored value and be done. FRED stores GDPC1 as a level in
+ billions of chained dollars and CPIAUCSL as an index, so the reading a US
+ page needs is not in the file at all: it is computed here, the card says
+ it was, and both endpoints it was computed from are printed beside it.
+ """
+ now = latest(series)
+ if kind is None:
+ return _card(
+ esc(label(series).upper()),
+ esc(display(series, now["value"])),
+ f'stored as {esc(stored(now["value"]))} · {esc(series["units"])}',
+ series,
+ now["period"],
+ )
+ before = year_earlier(series, now["period"])
+ if before is None:
+ raise RuntimeError(
+ f"{series['series']}: no {now['period']} counterpart a year "
+ "earlier in the store, so a year-on-year card cannot be derived"
+ )
+ change = 100 * (now["value"] / before["value"] - 1)
+ return _card(
+ esc(f"{label(series).upper()} · YEAR ON YEAR"),
+ f"{change:+.1f}%",
+ f'derived here, not stored: {esc(stored(now["value"]))} in '
+ f'{esc(now["period"])} against {esc(stored(before["value"]))} in '
+ f'{esc(before["period"])} · {esc(series["units"])}',
+ series,
+ now["period"],
+ )
+
+
# How each series' movement is read. /economy used to carry these period
# comparisons in an indicator table beside the same values the topic pages
# show; the comparison belongs with the series, on the page that reads it.
@@ -320,6 +451,14 @@ def stat_card(series: dict) -> str:
"uk_gilt_5y": ("pp", 5, 3, "five observations earlier"),
"uk_gilt_10y": ("pp", 5, 3, "five observations earlier"),
"uk_gilt_20y": ("pp", 5, 3, "five observations earlier"),
+ # FRED. GDPC1 and CPIAUCSL are levels, so the comparison that means
+ # anything is a move in the derived year-on-year rate, not in the level.
+ "us_real_gdp": ("yoy_pp", 1, 1, "the prior quarter"),
+ "us_cpi": ("yoy12_pp", 1, 1, "the prior month"),
+ "us_unemployment_rate": ("pp", 1, 1, "the prior month"),
+ "us_payroll_employment": ("pct", 12, 1, "a year earlier"),
+ "us_federal_funds_rate": ("pp", 5, 3, "five months earlier"),
+ "us_treasury_10y": ("pp", 5, 3, "five observations earlier"),
}
@@ -329,15 +468,29 @@ def movement(name: str) -> str:
The comparison each series supports is not the same: a published rate moves
in percentage points, a level in percent, and J5II is a negative balance so
the honest comparison is between amounts borrowed, not between balances.
+
+ A window of "a year earlier" is resolved by period, not by counting rows
+ back, so a series with a hole in it — us_cpi is missing 2025-10 — compares
+ against the month it names rather than the month that happens to sit there.
"""
kind, periods, digits, window = MOVEMENT[name]
series = load(name)
observations = series["observations"]
- now, before = observations[-1], observations[-1 - periods]
- if kind == "yoy_pp":
- rates = yoy(series, 4)
+ now = observations[-1]
+ if window == "a year earlier":
+ before = year_earlier(series, now["period"])
+ if before is None:
+ raise RuntimeError(
+ f"{name}: MOVEMENT compares against a year earlier, but the "
+ f"store has no counterpart for {now['period']}"
+ )
+ else:
+ before = observations[-1 - periods]
+ if kind in ("yoy_pp", "yoy12_pp"):
+ rates = yoy(series, 4 if kind == "yoy_pp" else 12)
gap = rates[-1]["value"] - rates[-1 - periods]["value"]
change = f"{gap:+.{digits}f}pp on the year-on-year rate"
+ before = rates[-1 - periods]
elif kind == "pp":
change = f"{now['value'] - before['value']:+.{digits}f}pp"
elif kind == "pct":
@@ -362,10 +515,18 @@ def movement_note(names: tuple[str, ...]) -> str:
)
-def stands(names: tuple[str, ...], note: str) -> str:
- cards = "\n".join(stat_card(load(name)) for name in names)
+def stands(cards: tuple[tuple[str, str | None], ...], note: str,
+ names: tuple[str, ...]) -> str:
+ """The card grid, the method note, and the period comparisons.
+
+ ``cards`` is (series, derivation) pairs — a series can appear twice, as a
+ derived rate and as the level it was derived from. ``names`` is the
+ distinct series the topic reads, which is what the movement note and the
+ provenance table are keyed on.
+ """
+ rendered = "\n".join(stat_card(load(name), kind) for name, kind in cards)
return f"""
"""
-# --------------------------------------------------------------- the six
+# ------------------------------------------------------ US: shared apparatus
+
+LONGBASE_FILE = (
+ ROOT / "papers" / "frb-us" / "figures" / "longbase_baseline_yoy.csv"
+)
+
+
+def longbase() -> list[dict]:
+ """The committed FRB/US LONGBASE conditioning baseline, near-term path."""
+ lines = [
+ line
+ for line in LONGBASE_FILE.read_text().splitlines()
+ if line and not line.startswith("#")
+ ]
+ header = lines[0].split(",")
+ return [
+ {
+ key: value if key == "quarter" else float(value)
+ for key, value in zip(header, line.split(","))
+ }
+ for line in lines[1:]
+ ]
+
+
+def next_baseline(last_observed: str) -> dict:
+ """First baseline quarter the official data has not printed yet."""
+ edge = quarter_of(last_observed)
+ rows = longbase()
+ for row in rows:
+ if row["quarter"] > edge:
+ return row
+ return rows[-1]
+
+
+def forecasters(country: str) -> list[str]:
+ """Registry models whose question types include a forecast, by country."""
+ return sorted(
+ model_id
+ for model_id, model in MODELS.items()
+ if "forecast" in model["question_types"] and country in model["geography"]
+ )
+
+
+def no_us_forecaster() -> str:
+ """The sentence every US topic page is built on, checked before printing.
+
+ boe-svar is the only member whose ``question_types`` include "forecast",
+ and its geography is UK-only; both US models are shock models that say so
+ themselves. If that stops being true this raises rather than letting five
+ public pages keep printing a refusal that has quietly become false.
+ """
+ us = forecasters("us")
+ if us:
+ raise RuntimeError(
+ "every US topic page states that no model here forecasts the US "
+ f"economy, but the capability registry now lists {', '.join(us)} "
+ 'with "forecast" in question_types — write the forecast layer '
+ "instead of shipping the refusal"
+ )
+ return (
+ "No model in this suite forecasts the US economy. The only member "
+ 'whose question types include forecast is '
+ f'{listed(forecasters("uk"))}, and its geography '
+ "is UK-only."
+ )
+
+
+def us_hub_forecast_note() -> str:
+ """The claim /economy/us is built on, re-derived on every render.
+
+ The hub used to assert this in hand-written prose above six stat cards and
+ a band of model cards. The cards are gone — every reading they carried is
+ on the topic page that owns the series — so the claim is what remains, and
+ it is generated rather than typed: ``no_us_forecaster`` fails the build,
+ not the reader, if a US forecaster ever enters the registry.
+ """
+ frbus = MODELS["frb-us"]["quality"]["predictive_validation"]
+ hank = MODELS["us-hank"]["quality"]["predictive_validation"]
+ return f"""
+ {no_us_forecaster()} Both US members are shock models
+ and the registry says so in their own words:
+ frb-us records predictive validation
+ {registry_terms([frbus["level"]])} —
+ “{esc(frbus["evidence"])}” — and
+ us-hank records
+ {registry_terms([hank["level"]])}. Where a model view exists at all it
+ is on the topic page that reads the series, in the same breath as the
+ limits that qualify it.
+
"""
+
+
+def us_model_limits(extra: list[str] | None = None) -> str:
+ """Both US models, quoted out of the registry rather than characterised."""
+ frbus, hank = MODELS["frb-us"], MODELS["us-hank"]
+ frbus_quality = frbus["quality"]["predictive_validation"]
+ hank_quality = hank["quality"]["predictive_validation"]
+ return limits_list([
+ f'frb-us answers '
+ f'{registry_terms(frbus["question_types"])} and nothing else. Its '
+ f'predictive validation is {registry_terms([frbus_quality["level"]])} '
+ f'— “{esc(frbus_quality["evidence"])}”',
+ f'us-hank answers '
+ f'{registry_terms(hank["question_types"])}, and the first thing it '
+ f'cannot answer is {esc(hank["cannot_answer"][0])}. Its predictive '
+ f'validation is {registry_terms([hank_quality["level"]])} — '
+ f'“{esc(hank_quality["evidence"])}”',
+ ] + (extra or []))
+
+
+LONGBASE_COLUMNS = {
+ "gdp_yoy_pct": ("year-on-year real GDP growth", "%"),
+ "cpi_yoy_pct": ("year-on-year CPI inflation", "%"),
+ "unemployment_pct": ("the unemployment rate", "%"),
+}
+
+
+def longbase_layer(column: str, last_observed: str) -> str:
+ """The conditioning baseline, framed as what it is: not a forecast.
+
+ /economy/us and the homepage already show this path beside the outturns.
+ It is repeated here rather than omitted because the alternative is a topic
+ page that is silent about the one model artifact covering the series — but
+ it carries no interval, because it has none, and the caption and the row
+ labels say what it is every time it appears.
+ """
+ description, unit = LONGBASE_COLUMNS[column]
+ edge = quarter_of(last_observed)
+ rows = []
+ for row in longbase():
+ published = row["quarter"] <= edge
+ rows.append(
+ "
"
+ f'
{esc(row["quarter"])}
'
+ f"
{row[column]:.2f}{unit}
"
+ f"
{'outturn already published' if published else 'no outturn yet'}
"
+ "
none — the baseline carries no interval
"
+ "
"
+ )
+ return f"""
+
+
FRB/US April 2026 LONGBASE, {esc(description)}. This is the conditioning baseline that frb-us shock experiments deviate from — not a forecast, and not scored on the forecast record.
+
Quarter
Baseline
Status against the outturn
Uncertainty
+
+{chr(10).join(rows)}
+
+
+
"""
+
+
+def us_run_note() -> str:
+ return f"""
+ Over MCP the same two models are {tool("frbus_shock")} and
+ {tool("hank_shock")}, their metadata and scope limits are
+ {tool("frbus_summary")} and {tool("hank_summary")}, and
+ {tool("get_model_status")} returns the limitations quoted above as
+ structured data rather than prose. {tool("recommend_model")} returns an
+ explicit warning rather than a guess when no model supports a request.
+ Connect a client →
+
"""
+
+
+US_DATA_CLOSING = (
+ "FRED supplies no announced next-release date through this site's "
+ "fetcher, so the column says so rather than guessing a schedule from the "
+ "publisher's calendar."
+)
+
+
+# ------------------------------------------------------------- US 01 growth
+
+def us_growth_facts() -> dict:
+ gdp = load("us_real_gdp")
+ return {"gdp": gdp, "now": yoy(gdp, 4)[-1]}
+
-TOPICS = [
+def us_growth_hook() -> str:
+ facts = us_growth_facts()
+ now = latest(facts["gdp"])
+ baseline = next_baseline(facts["now"]["period"])
+ return (
+ f"Real GDP is {facts['now']['value']:.1f}% up on the year in "
+ f"{facts['now']['period']}, derived here from a stored level of "
+ f"{stored(now['value'])}; no model on this site forecasts US output, "
+ f"and the {baseline['gdp_yoy_pct']:.1f}% shown for "
+ f"{baseline['quarter']} below is a conditioning baseline, not a "
+ "forecast."
+ )
+
+
+def us_growth_note() -> str:
+ gdp = load("us_real_gdp")
+ now = gdp["observations"][-1]
+ year_ago = year_earlier(gdp, now["period"])
+ change = 100 * (now["value"] / year_ago["value"] - 1)
+ return (
+ "GDPC1 is a level, not a growth rate: FRED stores it as "
+ f"{esc(gdp['units'])}. The UK growth topic can print a published "
+ "year-on-year rate because the ONS publishes one; there is no "
+ "equivalent series in this store for the US, so the rate above is "
+ f"derived from the stored levels — {stored(now['value'])} in "
+ f"{esc(now['period'])} against {stored(year_ago['value'])} in "
+ f"{esc(year_ago['period'])} is {change:.1f}%."
+ )
+
+
+def us_growth_model() -> str:
+ facts = us_growth_facts()
+ frbus = MODELS["frb-us"]
+ return f"""
+
+ {no_us_forecaster()} What the two US models do
+ instead is trace deviations from a fixed baseline, and the registry is
+ specific about how far that goes:
+
+{us_model_limits([
+ f'frb-us outputs {registry_terms(frbus["outputs"])} — as responses to a '
+ f'reviewed shock under a declared policy rule, over a {esc(frbus["horizon"])} '
+ 'horizon, never as a path anyone is asked to believe in.',
+ ])}
+
+ The baseline those deviations are measured from is published, so it is
+ shown rather than hidden. It is the Federal Reserve staff-style
+ conditioning path packaged with the model, extracted from
+ LONGBASE.TXT, and it carries no bands because
+ none exist:
+
+ The UK growth topic carries a model forecast with 68% and 90% ranges
+ because boe-svar produces one and it is scored before the outturn
+ exists. This page has no such layer, and inventing one out of a
+ tracking baseline is the specific mistake it refuses to make —
+ see the UK page for the contrast →
+
+ There is no forecast command to run. What is runnable is the model's
+ own account of itself, the levers it exposes, and a shock: a 1
+ percentage-point funds-rate surprise under the default inertial Taylor
+ rule, read out over twenty quarters.
+
+{codeblock(
+ command("pe-macro frbus-summary", "implementation, provenance and scope limits"),
+ command("pe-macro frbus-variables", "the shockable FRB/US levers and their units"),
+ command("pe-macro frbus-shock --var rffintay_aerr --shock 1.0 --horizon 20", "output response to a 1pp policy surprise"),
+ command("pe-macro hank-shock --kind monetary --size -0.0025 --persistence 0.6", "the same question in the HANK model, as an IRF"),
+ )}
+{us_run_note()}
+
"""
+
+
+# ---------------------------------------------------------- US 02 inflation
+
+def us_inflation_facts() -> dict:
+ cpi = load("us_cpi")
+ return {"cpi": cpi, "now": yoy(cpi, 12)[-1]}
+
+
+def us_inflation_hook() -> str:
+ facts = us_inflation_facts()
+ now = latest(facts["cpi"])
+ return (
+ f"CPI is {facts['now']['value']:.1f}% up on the year in "
+ f"{facts['now']['period']}, derived here from a stored index of "
+ f"{stored(now['value'])}; the store carries no US core CPI series, so "
+ "this page has no core reading — and no model here forecasts the "
+ "headline either."
+ )
+
+
+def us_inflation_note() -> str:
+ cpi = load("us_cpi")
+ now = cpi["observations"][-1]
+ year_ago = year_earlier(cpi, now["period"])
+ change = 100 * (now["value"] / year_ago["value"] - 1)
+ months = len(cpi["observations"])
+ span = 12 * (int(now["period"][:4]) - int(cpi["first_period"][:4])) + (
+ int(now["period"][5:]) - int(cpi["first_period"][5:])
+ ) + 1
+ missing = span - months
+ hole = (
+ f" {'One' if missing == 1 else missing} month"
+ f"{'s are' if missing != 1 else ' is'} missing from "
+ f"the stored index between {esc(cpi['first_period'])} and "
+ f"{esc(now['period'])}, which is why the comparison above is matched "
+ "on the period it names rather than counted back twelve rows."
+ if missing > 0
+ else ""
+ )
+ return (
+ f"CPIAUCSL is an index ({esc(cpi['units'])}), not a rate. The UK "
+ "inflation topic shows two published rates because the ONS publishes "
+ "them as rates; the figure above is derived from the stored index "
+ f"instead — {stored(now['value'])} in {esc(now['period'])} against "
+ f"{stored(year_ago['value'])} in {esc(year_ago['period'])} is "
+ f"{change:.1f}%.{hole}"
+ )
+
+
+def us_inflation_model() -> str:
+ facts = us_inflation_facts()
+ hank = MODELS["us-hank"]
+ return f"""
+
+ {no_us_forecaster()} Both US models report a price
+ response, and neither reports a price path:
+
+{us_model_limits([
+ f'us-hank outputs {registry_terms(hank["outputs"])} as '
+ f'{esc(hank["horizon"])} around a calibrated steady state, with '
+ f'uncertainty {esc(hank["uncertainty"])}.',
+ "Neither model is scored against an inflation outturn anywhere on this "
+ "site, because neither produces one to score.",
+ ])}
+
+ The conditioning baseline the frb-us experiments deviate from does
+ carry a CPI path, and it is shown for the same reason as on the growth
+ page — with the same caveat, in the same table:
+
+ No core CPI. The UK inflation topic carries core CPI
+ beside the headline because the ONS series DKO8 is in the store. There
+ is no US core series in the store at all: adding one means adding
+ CPILFESL to the FRED table in
+ data/fetch.py and letting the append-only
+ fetcher accumulate vintages for it. Until that happens this page shows
+ headline only, and says so rather than leaving the gap to be noticed.
+
+ The price response to a policy surprise, in both models. The FRB/US
+ run is a reviewed add-factor shock in model units; the HANK run is a
+ first-order impulse response around the paper's calibration.
+
+{codeblock(
+ command("pe-macro frbus-shock --var rffintay_aerr --shock 1.0 --horizon 20", "price response to a 1pp policy surprise"),
+ command("pe-macro hank-summary", "shock catalogue, units and scope limits"),
+ command("pe-macro hank-shock --kind monetary --size -0.0025 --persistence 0.6", "a 25bp easing, quarterly IRF"),
+ )}
+{us_run_note()}
+
"""
+
+
+# --------------------------------------------------------------- US 03 jobs
+
+def us_jobs_hook() -> str:
+ unemployment = load("us_unemployment_rate")
+ payrolls = load("us_payroll_employment")
+ now = latest(unemployment)
+ return (
+ f"Unemployment is {stored(now['value'])}% in {now['period']} and "
+ f"payrolls {display(payrolls, latest(payrolls)['value'])} in "
+ f"{latest(payrolls)['period']}; there is no US vacancies series in the "
+ "store, and no model here forecasts either number."
+ )
+
+
+def registry_output(model_id: str, needle: str) -> str:
+ """One named output, found by what it is rather than by list position."""
+ for output in MODELS[model_id]["outputs"]:
+ if needle in output.lower():
+ return output
+ raise RuntimeError(
+ f"{model_id} no longer lists an output matching {needle!r}; a US topic "
+ "page names it, so update the page rather than the claim"
+ )
+
+
+def us_jobs_model() -> str:
+ unemployment = load("us_unemployment_rate")
+ frbus = MODELS["frb-us"]
+ hank = MODELS["us-hank"]
+ return f"""
+
+ {no_us_forecaster()} The labor market is also where
+ the two US models differ most from each other, and the difference is
+ worth stating plainly:
+
+{us_model_limits([
+ f'frb-us does list {registry_terms([registry_output("frb-us", "unemploy")])} among its '
+ "outputs — but as a deviation from the conditioning baseline under a "
+ "declared policy rule, not a level anyone should read as a projection.",
+ f'us-hank outputs {registry_terms(hank["outputs"])}. There is no labor '
+ "market variable in that list at all: it reports no unemployment rate "
+ "and no employment level, so on this topic it has nothing to say.",
+ "Neither model reports payroll employment, so the second series above "
+ "has no model view of any kind beside it.",
+ ])}
+
+ The unemployment path in the conditioning baseline is the one model
+ artifact covering this topic, and it is a baseline rather than a view:
+
+ The UK jobs topic has a satellite that maps the boe-svar GDP forecast
+ onto the unemployment rate through a fitted Okun relation, and archives
+ every round before the outturn exists. Nothing equivalent exists for
+ the US, because the forecast it would map does not exist —
+ see the UK page →
+
+
+ The UK page also carries vacancies and average weekly earnings as
+ outturns. Neither has a US counterpart in this store: no JOLTS openings
+ series and no earnings series is tracked, so this page is two series
+ where the UK page is three.
+
+
"""
+
+
+def us_jobs_run() -> str:
+ return f"""
+
+ lur is the FRB/US unemployment rate, so the
+ runnable question on this topic is what a policy or spending surprise
+ does to it — and, through the incidence bridge, whose earnings move
+ when it does.
+
+{codeblock(
+ command("pe-macro frbus-variables", "the shockable levers and their units"),
+ command("pe-macro frbus-shock --var rffintay_aerr --shock 1.0 --horizon 20", "unemployment response to a 1pp policy surprise"),
+ command("pe-macro frbus-shock-incidence --var rffintay_aerr --shock 1.0 --year 2027", "the same shock carried into household earnings, by decile"),
+ )}
+
+ Over MCP: {tool("frbus_shock")} for the macro response and
+ {tool("frbus_shock_incidence")} for the household overlay, which
+ applies the wage-bill change uniformly and reports that it did.
+ {tool("hank_shock_incidence")} is the same bridge for the HANK model.
+ Connect a client →
+
+
"""
+
+
+# -------------------------------------------------------------- US 04 rates
+
+def us_rates_hook() -> str:
+ funds = load("us_federal_funds_rate")
+ ten = load("us_treasury_10y")
+ return (
+ f"The effective federal funds rate is {stored(latest(funds)['value'])}% "
+ f"in {latest(funds)['period']} and the 10-year Treasury "
+ f"{stored(latest(ten)['value'])}% on {latest(ten)['period']} — and no "
+ "model on this site forecasts either."
+ )
+
+
+def us_rates_model() -> str:
+ frbus = MODELS["frb-us"]
+ hank = MODELS["us-hank"]
+ microsim = MODELS["pe-microsim"]
+ return f"""
+
+ No model in this suite forecasts the federal funds rate or the
+ Treasury curve. That is the honest answer, and this page will
+ not dress it up as coverage. What the registry actually says:
+
+{limits_list([
+ f'frb-us does list a '
+ f'{esc(registry_output("frb-us", "federal funds"))} among its outputs — but its question '
+ f'types are {registry_terms(frbus["question_types"])}, so that rate is '
+ "a reaction-function response to a shock under a declared policy rule, "
+ "computed as a deviation from a fixed baseline. It is not a rate view.",
+ f'us-hank reports a '
+ f'{registry_terms([registry_output("us-hank", "real_rate")])} — the '
+ "model-consistent real rate implied by its own calibrated steady "
+ f'state, as {esc(hank["horizon"])}. That is not a market yield, and '
+ f'the model cannot answer {esc(hank["cannot_answer"][0])}.',
+ f'pe-microsim lists {esc(microsim["cannot_answer"][2])} '
+ "in its own cannot-answer field.",
+ "Nothing in the suite models the Treasury term structure at any "
+ "maturity. There is no US counterpart to the gilt curve on the UK page "
+ "because there is no US model that would read one.",
+ ])}
+
+ So the two readings above stand alone: observed Federal Reserve data,
+ dated and archived, with no model path beside them. They still do work
+ here — they are the market backdrop the other US topics are read
+ against, and the conditioning environment any future rate model would
+ have to beat.
+
+
+ This is the one US topic where the useful output is a refusal, and it
+ is the same refusal the UK rates and
+ gilts page reaches for Bank Rate and gilt yields. If you need a
+ rate forecast, the suite does not have one; the
+ model directory shows what it does have, and
+ the evidence page shows how well.
+
+ With no model to run, what is runnable is the data itself and the claim
+ above. The vintage store is static files over HTTPS — no key, no
+ account, stdlib only:
+
And the refusal is checkable rather than asserted:
+{codeblock(
+ command("pe-macro model-status", "every model, its country, status and access"),
+ command("pe-macro model-status frb-us --json", "outputs, question types and cannot_answer, verbatim"),
+ command("pe-macro model-status us-hank --json", "the same for the HANK member"),
+ )}
+
+ Over MCP the same registry is {tool("list_model_capabilities")} and
+ {tool("get_model_status")}; {tool("recommend_model")} returns an
+ explicit warning rather than a guess when no model supports a request.
+ Connect a client →
+
+
"""
+
+
+# ------------------------------------------------------------- US 05 reform
+
+def registry_cannot(model_id: str, needle: str) -> str:
+ """One cannot-answer entry, found by what it says rather than its index."""
+ for item in MODELS[model_id]["cannot_answer"]:
+ if needle in item.lower():
+ return item
+ raise RuntimeError(
+ f"{model_id} no longer records a cannot_answer entry matching "
+ f"{needle!r}, but the US reform page quotes it"
+ )
+
+
+def us_reform_hook() -> str:
+ microsim = MODELS["pe-microsim"]
+ return (
+ "No series stands behind this one: pe-microsim scores a US reform over "
+ f"a {microsim['horizon']}, and neither US macro model accepts a "
+ "PolicyEngine reform at all."
+ )
+
+
+def us_reform_stands() -> str:
+ microsim = MODELS["pe-microsim"]
+ frbus = MODELS["frb-us"]
+ hank = MODELS["us-hank"]
+ cards = "\n".join((
+ reform_capability_card(
+ "pe-microsim", ", ".join(microsim["geography"]).upper(),
+ microsim["model_class"],
+ [("Horizon", microsim["horizon"]), ("Runtime", microsim["runtime"]),
+ ("Access", ", ".join(microsim["access"]))],
+ ),
+ reform_capability_card(
+ "frb-us", ", ".join(frbus["geography"]).upper(),
+ frbus["model_class"],
+ [("Horizon", frbus["horizon"]), ("Runtime", frbus["runtime"]),
+ ("Reform bridge",
+ f"none; cannot answer {registry_cannot('frb-us', 'policyengine')}")],
+ ),
+ reform_capability_card(
+ "us-hank", ", ".join(hank["geography"]).upper(),
+ hank["model_class"],
+ [("Horizon", hank["horizon"]), ("Runtime", hank["runtime"]),
+ ("Reform bridge",
+ f"none; cannot answer {registry_cannot('us-hank', 'policyengine')}")],
+ ),
+ ))
+ return f"""
+{cards}
+
+
+ This is the one topic with no official series behind it, so there is no
+ vintage to date: what stands is a capability, and the cards above are
+ read straight out of the committed capability registry rather than
+ written by hand. pe-microsim is the only member covering both countries —
+ the two US macro models are shock models, and the last row of each of
+ their cards is the reason this page stops where it does.
+
+ pe-microsim is the scorer, and for the US it is the
+ whole of it: it applies the reform to household microdata and reports
+ {registry_terms(microsim["outputs"])}. It is
+ {esc(microsim["status"])}, and its uncertainty is
+ {esc(microsim["uncertainty"])}.
+
+
+ The limit is the same structural one the UK page describes — a static
+ costing has no macro feedback in it — but the US answer to it is
+ different, and worse:
+
+{limits_list([
+ f"pe-microsim cannot answer {(cannot('pe-microsim'))}. A US costing "
+ "from it is a static costing.",
+ f'On the UK side that gap is bridged: obr-macro '
+ f'supplies macro feedback through a reviewed reform translation, and '
+ f'{site_name("og+microsim")} adds '
+ f'{esc(dynamic["model_class"])}. Both are '
+ f'{registry_terms(obr["geography"])}-only.',
+ "There is no US equivalent of either. Both US models "
+ f'record {esc(registry_cannot("frb-us", "policyengine"))} in their own '
+ "cannot-answer field, and us-hank adds "
+ f'{esc(registry_cannot("us-hank", "detailed tax"))}. No mapping exists '
+ "from a US statutory reform to a US macro model, and none is invented "
+ "here.",
+ ])}
+
+ One bridge does exist, and it runs the other way. The incidence
+ commands take a macro shock, not a reform, and push its
+ earnings consequences through the microsimulation — so the suite can
+ answer “who bears this shock” for the US, and cannot answer
+ “what would this reform do to output”.
+
+ One reform vocabulary, and for the US one scoring route. A $1,000
+ adult-dependent credit, scored statically on the population, then the
+ macro-to-household bridge that does exist:
+
+{codeblock(
+ command("pe-macro parameters", "curated reform parameter paths, live-resolved, both countries"),
+ command(f"pe-macro score --country us --reform '{reform}' --model microsim", "static population costing"),
+ command(f"pe-macro population-impact --country us --reform '{reform}' --year 2027", "revenue and distribution for one policy year"),
+ command("pe-macro frbus-shock-incidence --var rffintay_aerr --shock 1.0 --year 2027", "a macro shock's earnings incidence — not a reform"),
+ )}
+
+ Over MCP: {tool("list_reform_parameters")}, {tool("score_reform")},
+ {tool("population_reform_impact")} for the population costing and
+ {tool("household_reform_impact")} for a single household.
+ {tool("recommend_model")} routes a question to a model, or refuses —
+ and for a US reform needing macro feedback it refuses, which is the
+ correct answer.
+ Connect a client →
+
+ There is no vintage table on this page because no series in the store
+ feeds a reform score. The provenance is different in kind: the
+ microdata and parameter tree come from the
+ policyengine-us country package, and the
+ registry describes that vintage as
+ “{esc(microsim["data_vintage"])}”. Every run records its
+ own, which is why two scores taken months apart can differ without
+ either being wrong.
+
+
+ The store still matters here, one step removed: the six FRED series it
+ holds are the outturns any macro leg of a US score would have to be
+ judged against — and the absence of a US fiscal series in it is why
+ this site has no US public-finances topic to carry the other half of a
+ costing.
+
"""
+
+
+# ------------------------------------------------------------ the UK's six
+
+UK_TOPICS = [
{
"slug": "growth",
"title": "Growth",
@@ -1162,46 +2026,177 @@ def reform_data() -> str:
},
]
-TOPIC_BY_SLUG = {topic["slug"]: topic for topic in TOPICS}
+# ------------------------------------------------------------ the US's five
+#
+# Five, not six. The UK's public-finances topic has no US counterpart and this
+# is a deliberate omission rather than an oversight: `data/vintages/` holds no
+# US fiscal series at all — no federal balance, no debt-to-GDP — so layer 01
+# of that page would have no number, no period and no vintage in it, and layer
+# 02 no model either (obr-macro is UK-only, and neither US model outputs a
+# fiscal balance). Every other page here can fill at least one layer from a
+# dated artifact. A sixth tab leading to four empty layers would be a worse
+# answer than an absent tab and a stated reason, so the reason is stated: on
+# /economy/us, in the topic directory, where a reader comparing the two
+# countries meets it.
+
+US_TOPICS = [
+ {
+ "slug": "growth",
+ "title": "Growth",
+ "eyebrow": "US topic · output",
+ "heading": "Is the US economy growing, and what can these models honestly say?",
+ "series": ("us_real_gdp",),
+ "cards": (("us_real_gdp", "yoy"), ("us_real_gdp", None)),
+ "hook": us_growth_hook,
+ "stands_note": us_growth_note,
+ "model": us_growth_model,
+ "run": us_growth_run,
+ "data_note": (
+ "BEA revises the national accounts three times in the quarter "
+ "after a first estimate and again at annual and comprehensive "
+ "revisions, so the snapshot date matters as much as the "
+ "observation quarter. " + US_DATA_CLOSING
+ ),
+ },
+ {
+ "slug": "inflation",
+ "title": "Inflation",
+ "eyebrow": "US topic · prices",
+ "heading": "Where are US prices now — and what is missing from this page?",
+ "series": ("us_cpi",),
+ "cards": (("us_cpi", "yoy"), ("us_cpi", None)),
+ "hook": us_inflation_hook,
+ "stands_note": us_inflation_note,
+ "model": us_inflation_model,
+ "run": us_inflation_run,
+ "data_note": (
+ "One series, where the UK inflation topic has two: no US core CPI "
+ "is tracked in this store. " + US_DATA_CLOSING
+ ),
+ },
+ {
+ "slug": "jobs",
+ "title": "Jobs",
+ "eyebrow": "US topic · labor market",
+ "heading": "What is happening to US work and hiring?",
+ "series": ("us_unemployment_rate", "us_payroll_employment"),
+ "cards": (
+ ("us_unemployment_rate", None),
+ ("us_payroll_employment", None),
+ ("us_payroll_employment", "yoy"),
+ ),
+ "hook": us_jobs_hook,
+ "stands_note": (
+ "The unemployment rate is published as a rate and is shown as "
+ "stored. Payroll employment is a level in thousands of persons: "
+ "the headline reads it in millions and the year-on-year card is "
+ "derived from the stored levels, neither of which is a "
+ "transformation the publisher applied."
+ ),
+ "model": us_jobs_model,
+ "run": us_jobs_run,
+ "data_note": (
+ "PAYEMS is revised twice after first publication and again at the "
+ "annual benchmark, and UNRATE comes from a household survey with "
+ "its own sampling error; the snapshot column is the difference "
+ "between a reproducible number and a moving one. " + US_DATA_CLOSING
+ ),
+ },
+ {
+ "slug": "rates",
+ "title": "Rates and Treasuries",
+ "eyebrow": "US topic · policy rate and the Treasury curve",
+ "heading": "What do US rates say — and what can this site honestly say back?",
+ "series": ("us_federal_funds_rate", "us_treasury_10y"),
+ "hook": us_rates_hook,
+ "stands_note": (
+ "Federal Reserve observations as FRED distributes them: FEDFUNDS "
+ "is a monthly average of the effective rate and DGS10 a "
+ "business-day constant-maturity quote. Nothing is rounded on the "
+ "way in or out."
+ ),
+ "model": us_rates_model,
+ "run": us_rates_run,
+ "data_note": (
+ "One monthly series and one daily series, so the two observation "
+ "dates above never line up — that is the data, not a presentation "
+ "choice. " + US_DATA_CLOSING
+ ),
+ },
+ {
+ "slug": "reform",
+ "title": "Tax and benefit reform",
+ "eyebrow": "US topic · scoring a policy change",
+ "heading": "What would a US reform do — and how far can the models follow it?",
+ "series": (),
+ "hook": us_reform_hook,
+ "stands": us_reform_stands,
+ "model": us_reform_model,
+ "run": us_reform_run,
+ "data": us_reform_data,
+ },
+]
+
+for _topic in UK_TOPICS:
+ _topic["country"] = "uk"
+for _topic in US_TOPICS:
+ _topic["country"] = "us"
+
+TOPICS = UK_TOPICS + US_TOPICS
+TOPIC_BY_SLUG = {(topic["country"], topic["slug"]): topic for topic in TOPICS}
+
+
+def topics_for(country: str) -> list[dict]:
+ return [topic for topic in TOPICS if topic["country"] == country]
-def hook(slug: str) -> str:
- return TOPIC_BY_SLUG[slug]["hook"]()
+
+def hook(country: str, slug: str) -> str:
+ return TOPIC_BY_SLUG[(country, slug)]["hook"]()
# --------------------------------------------------------------- page render
-def subnav(current: str | None) -> str:
- """The one control shared by the hub and every topic page.
+def subnav(country: str, current: str | None) -> str:
+ """The one control shared by both hubs and every topic page.
- Same markup, same vocabulary, same order everywhere, so moving between
- /economy and a topic reads as one section rather than a page swap. The hub
- is always reachable through the UK scope link, and the page the reader is
- on is the only element carrying ``aria-current="page"``: on a topic page
- the UK scope link is an ancestor, so it takes ``aria-current="true"``
- rather than claiming to be the current URL.
+ Same markup, same vocabulary, same order in both countries, so moving
+ between a hub and a topic reads as one section rather than a page swap.
+ Two groups: the scope switch (UK / US) and the topic strip for the scope
+ the reader is in.
- ``current`` is a topic slug, or ``None`` for the hub itself.
+ ``current`` is a topic slug, or ``None`` for the hub itself — which is the
+ Overview tab. Overview exists because the strip is a tab set and a tab set
+ with nothing selected tells the reader they are nowhere: on the hub, the
+ four sections below the bar *are* a page, and Overview is its name. It is
+ the only tab whose target is the hub, so it is also the way back.
- /economy/us keeps its own copy of this bar with the same scope switch but
- its own in-page anchors: US topic pages do not exist, and a topic strip
- there would be six links out of the country the reader chose.
+ The page the reader is on is the only element carrying
+ ``aria-current="page"``. On a topic page the country scope link is an
+ ancestor, not the current URL, so it takes ``aria-current="true"``.
"""
+ hub = COUNTRIES[country]["hub"]
scope = []
- for href, label, state in (
- ("/economy", "UK", "page" if current is None else "true"),
- ("/economy/us", "US", None),
- ):
+ for code, entry in COUNTRIES.items():
+ if code == country:
+ state = "page" if current is None else "true"
+ else:
+ state = None
attr = f' aria-current="{state}"' if state else ""
scope.append(
- f' {label}'
+ f' {entry["label"]}'
)
- links = []
- for topic in TOPICS:
+ overview_attr = ' aria-current="page"' if current is None else ""
+ links = [
+ f' Overview'
+ ]
+ for topic in topics_for(country):
attr = ' aria-current="page"' if topic["slug"] == current else ""
links.append(
f' {esc(topic["title"])}'
+ f'href="{topic_url(topic)}">{esc(topic["title"])}'
)
return f"""