From ced4ce1c3a99c80c361c612e691ce65cdfa01683 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Fri, 31 Jul 2026 11:01:05 +0200 Subject: [PATCH 1/4] Connect every model page to the microsimulation scoring pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pipeline strip (reform → pe-microsim → obr-macro → score) on /score and /models - /pe declares itself the hub and links to /score - /olg body now describes the two-way micro coupling incl. og+microsim - /svar, /frb-us, /us-hank callouts route to /pe and /score with the reason no bridge exists - Homepage gains a Score-a-reform strategy card; /connect gains the score CLI command and a /score link; /economy ties the baseline to scoring and the forecast record Co-Authored-By: Claude Fable 5 --- connect/index.html | 3 ++- economy/index.html | 5 ++++- frb-us/index.html | 6 +++++- index.html | 6 ++++++ models/index.html | 9 +++++++++ olg/index.html | 12 ++++++++++++ pe/index.html | 8 ++++++-- score/index.html | 11 ++++++++++- style.css | 21 +++++++++++++++++++++ svar/index.html | 6 ++++-- us-hank/index.html | 10 +++++++--- 11 files changed, 86 insertions(+), 11 deletions(-) diff --git a/connect/index.html b/connect/index.html index 748c7a7..f51c5d3 100644 --- a/connect/index.html +++ b/connect/index.html @@ -396,8 +396,9 @@

Run the shared CLI.

UK forecast
pe-macro forecast
US HANK shock
pe-macro hank-shock --kind monetary --size -0.0025
Household calculation
pe-macro household --country us --people '[{"age":35,"employment_income":50000}]'
+
Score a UK reform
pe-macro score --country uk --reform '{"gov.hmrc.income_tax.rates.uk[0].rate":0.21}' --model obr
-

View every CLI command →

+

View every CLI command → · How the micro-to-macro scoring pipeline works: score a reform.

diff --git a/economy/index.html b/economy/index.html index 4cfcfb3..f5a5a05 100644 --- a/economy/index.html +++ b/economy/index.html @@ -315,7 +315,10 @@

Activity, prices, work, the public finances, and market rates.

- + +

These figures are the backdrop every model runs + against — the baseline a scored reform perturbs and + the outturns the forecast record is judged on.

diff --git a/frb-us/index.html b/frb-us/index.html index 6ced3f1..06ed047 100644 --- a/frb-us/index.html +++ b/frb-us/index.html @@ -141,7 +141,11 @@

US monetary and fiscal shocks, quarter by quarter.

It cannot score a PolicyEngine reform — see the known limits. It cannot do forward guidance or anything needing model-consistent expectations. It cannot tell you who wins and loses — that is - PolicyEngine. And it says nothing about the UK. + PolicyEngine, whose costing-to-macro bridge exists + for the UK models (score a reform) but not yet + for FRB/US: score_reform refuses + model="frbus" rather than return a plausible-but-wrong + number. And it says nothing about the UK. diff --git a/index.html b/index.html index 15d133f..c52c1ac 100644 --- a/index.html +++ b/index.html @@ -176,6 +176,12 @@

Test a policy or shock

Choose among six open household and macro models, then inspect each model’s assumptions, evidence, and limits.

choose a macro model → + +
Score a reformUK
+

Score a tax or benefit change

+

The microsimulation computes the direct cost and who pays; the OBR emulator returns the economy-wide feedback — one reform, scored end to end.

+ score a reform → +
Forecast recordUK

Check forecasts against what happened

diff --git a/models/index.html b/models/index.html index 75405fc..0e0b108 100644 --- a/models/index.html +++ b/models/index.html @@ -60,6 +60,15 @@

Choose the model that fits the quest What would a reform cost — and what would it do to the economy?score a reform

The scoring pipeline chains two members: PolicyEngine's microsimulation produces the static costing from the actual tax-benefit rules, then the OBR emulator feeds it through the macro model for the second-round effects — the OBR's own "static costing in, second-round effects out" workflow.

+
Worked example: 1p on the basic rate — £6.46bn (2026) → £7.38bn (2030), GDP −0.02% on impact building to −0.06%
Static costing sits within HMRC's published ready-reckoner range
diff --git a/olg/index.html b/olg/index.html index 3c8872c..fde74f3 100644 --- a/olg/index.html +++ b/olg/index.html @@ -96,6 +96,18 @@

Households age, the economy clears.

"where does the economy end up, and why?" — with the behavioural mechanism visible rather than estimated.

+

+ It is coupled to the PolicyEngine microsimulation + in both directions: its tax functions are estimated from effective + and marginal rates the microsimulation computes on Enhanced FRS + microdata, and a reform is the same flat parameter dict the + microsimulation uses — so the model scores statute, not a stylised + stand-in. Going the other way, score_reform with + model="og+microsim" feeds the long-run wage and + labour-supply changes back into a second microsimulation run for a + dynamic, household-resolution score. The end-to-end workflow is on + the score a reform page. +

The rest of this page walks through a worked showcase reform with charts; the diff --git a/pe/index.html b/pe/index.html index fada266..e766a57 100644 --- a/pe/index.html +++ b/pe/index.html @@ -96,14 +96,18 @@

Statute as code, at household resolution.

version-pinned release bundles for reproducibility.

- It feeds two macro engines: the OLG model + It is the hub of the suite: every scoring path starts here. It feeds + two macro engines — the OLG model consumes its effective and marginal rates to build tax functions; the OBR emulator takes its static costing as the - direct effect and returns the second-round one. See also the + direct effect and returns the second-round one — and the OLG model + feeds back, scaling a second microsimulation run with its long-run + wage and labour-supply changes for a dynamic score. See also the code, the methodology, and how far to trust it.

+

Score a reform end to end →

diff --git a/score/index.html b/score/index.html index e3580c3..bfcd838 100644 --- a/score/index.html +++ b/score/index.html @@ -69,11 +69,20 @@

Two steps: direct effect, then macro feedback.

two together so a statutory reform can be scored end to end.

+ -

Long-run incentive effects are a different question — that is the OLG model. For choosing between models, see the catalog and comparison.

+

Long-run incentive effects are a different question — that is the OLG model, which also loops back: og+microsim feeds its long-run wage and labour-supply changes into a second microsimulation run for a dynamic score. For choosing between models, see the catalog and comparison.

diff --git a/style.css b/style.css index df58a9d..c8d527e 100644 --- a/style.css +++ b/style.css @@ -1186,6 +1186,27 @@ h2 { .chooser-note { margin-top: var(--s-5); color: var(--paper-dim); font-size: var(--fs-body-sm); } .chooser-note a { color: var(--brand-strong); } +/* Micro→macro pipeline strip: the scoring flow at a glance. */ +.pipeline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2) var(--s-3); + margin-block: var(--s-4); +} +.pipeline .pl-node { + border: 1px solid var(--line, currentColor); + border-radius: 6px; + padding: 6px 12px; + font-size: var(--fs-body-sm); + line-height: 1.35; +} +.pipeline .pl-node strong { display: block; font-family: inherit; } +.pipeline .pl-node .mono { display: block; color: var(--paper-dim); } +.pipeline .pl-arrow { color: var(--brand); } +.pipeline a.pl-node { text-decoration: none; color: inherit; } +.pipeline a.pl-node:hover { border-color: var(--brand); } + @media (max-width: 620px) { .nav { min-height: 112px; diff --git a/svar/index.html b/svar/index.html index 68ee38f..e70c6b1 100644 --- a/svar/index.html +++ b/svar/index.html @@ -182,8 +182,10 @@

What's happening now, and why.

into identified structural innovations is conceptually dubious, not merely unbuilt. For reform scoring use the OBR emulator (short-run, fiscal) or the - OLG model (long-run, structural); this model tells - you what you are scoring against. + OLG model (long-run, structural) — both fed by the + PolicyEngine microsimulation's static costing, as + the score a reform workflow shows; this model + tells you what you are scoring against.
diff --git a/us-hank/index.html b/us-hank/index.html index 1b39451..f4562fb 100644 --- a/us-hank/index.html +++ b/us-hank/index.html @@ -128,9 +128,13 @@

Stylized US shocks, with the household distribution attached.

It cannot forecast — responses are deviations around a calibrated steady state, not a projection of the US economy. It - cannot score a PolicyEngine reform: score_reform refuses - model="hank" outright — three stylized instruments and - an endogenous labor tax mean no mapping from statute exists. + cannot score a PolicyEngine reform: + score_reform refuses model="hank" outright — + three stylized instruments and an endogenous labor tax mean no + mapping from statute exists. For statutory reforms scored end to end, + use the micro-to-macro workflow; for who wins + and loses from a reform at household resolution, the + microsimulation answers directly. Responses are first-order — linear in the shock, with no state dependence — and the distributional cuts are first-order approximations from steady-state policies, not full From ca7240c0611cbdccef58888eed2fc6380bc3671f Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Sat, 1 Aug 2026 00:01:10 +0200 Subject: [PATCH 2/4] Forecasts: show latest round in model-vs-official; UI and copy fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - score.py picks the newest boe-svar round instead of hardcoding 2026-07-21; §03 names the displayed round and explains the surge-sample mean reversion behind the sticky CPI path; §02 states the error-sign convention - us-hank: 'It cannot' callout becomes a bullet list - connect: centered client tabs, full-width hero lede, compact Python picker - forecasts charts widen to 1080px Co-Authored-By: Claude Fable 5 --- connect/index.html | 17 ++++--- forecasts/index.html | 113 +++++++++++++++++++++---------------------- forecasts/score.py | 42 +++++++++++++--- style.css | 14 +++++- us-hank/index.html | 29 ++++++----- 5 files changed, 131 insertions(+), 84 deletions(-) diff --git a/connect/index.html b/connect/index.html index f51c5d3..27ec364 100644 --- a/connect/index.html +++ b/connect/index.html @@ -102,8 +102,8 @@ } .steps.dimmed { opacity: 0.55; } /* Every model stays visible: no horizontal tab scrolling. */ -.model-seg { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); max-width: 100%; margin: 6px 0 22px; border: 0; padding: 0; gap: 8px; } -.model-seg button { min-width: 0; min-height: 68px; border: 1px solid var(--line-soft); border-radius: 6px; padding: 11px 13px; text-align: left; transition: all 0.15s ease; } +.model-seg { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); max-width: 100%; margin: 6px 0 12px; border: 0; padding: 0; gap: 6px; } +.model-seg button { min-width: 0; min-height: 0; border: 1px solid var(--line-soft); border-radius: 6px; padding: 8px 11px; text-align: left; transition: all 0.15s ease; } .model-seg button strong, .model-seg button span { display: block; } .model-seg button strong { color: inherit; font-family: var(--mono); font-size: var(--fs-small); font-weight: 600; } @@ -205,13 +205,18 @@ .command-grid .copyblock code { font-size: var(--fs-small); line-height: 1.55; } .model-picker { margin-top: var(--s-5); } .compact-pane { - min-height: 210px; - padding: clamp(22px, 4vw, 36px); + min-height: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 14px; + padding: 16px clamp(16px, 3vw, 24px); border: 1px solid var(--line-soft); border-radius: var(--radius); } -.compact-pane h3 { margin: 18px 0 8px; font-family: var(--display); font-size: var(--fs-h3); font-weight: 500; } -.compact-pane p { max-width: 58ch; margin-bottom: 24px; color: var(--paper-dim); line-height: 1.6; } +.compact-pane h3 { margin: 0; font-family: var(--display); font-size: var(--fs-h3); font-weight: 500; } +.compact-pane p { flex-basis: 100%; max-width: 58ch; margin: 6px 0 0; color: var(--paper-dim); line-height: 1.6; } +.compact-pane .btn { margin-top: 14px; } @media (max-width: 760px) { .command-grid { grid-template-columns: 1fr; } .compact-pane { min-height: 0; } diff --git a/forecasts/index.html b/forecasts/index.html index 33cb89f..d4fe8fb 100644 --- a/forecasts/index.html +++ b/forecasts/index.html @@ -122,6 +122,7 @@

Forecast, outturn, and the two benchmarks.

+
Errors are signed as forecast − outturn: a positive error means the forecast was too high, a negative one too low.
Naive baseline is a random walk: the last outturn available at the round's data edge, held flat. Read from the current data vintage, not the real-time one, so revisions can flatter or hurt it slightly.
The official number is the OBR March 2026 EFO — fixed months earlier on less data, so its larger error partly reflects the information gap.
@@ -137,26 +138,26 @@

Where the archived view differs from the OBR's.

-

The archived GDP-growth path beside the official forecast — the gap between the lines is where the views differ.

+

The GDP-growth path from the 2026-07-29 round beside the official forecast — the gap between the lines is where the views differ.

boe-svar UK real GDP growth forecast vs OBR March 2026 EFO Line chart of UK real GDP, year-on-year growth, percent. The boe-svar archived median with its 68 percent band is shown beside the OBR March 2026 EFO path over the overlapping quarters. - - - - - + + + + + 0 - 0.5 - 1 - 1.5 - 2 - 2.5 - - - + 0.5 + 1 + 1.5 + 2 + 2.5 + + + 2026Q2 2026Q3 2026Q4 @@ -173,45 +174,43 @@

Where the archived view differs from the OBR's.

Quarterboe-svar median68% bandOBR March 2026 EFOBoE Feb 2026 MPRConsensus (SEF, Apr 2026) 2026Q21.1%0.6–1.7%0.9%0.8%— - 2026Q31.4%0.5–2.2%1.2%1.0%— - 2026Q41.7%0.6–2.7%1.5%1.1%— - 2027Q11.4%0.1–2.6%1.5%1.2%— - 2027Q21.4%0.1–2.6%1.6%1.4%1.1% - 2027Q31.4%0.2–2.6%1.6%1.6%— - 2027Q41.4%0.1–2.6%1.6%1.8%— + 2026Q31.5%0.6–2.3%1.2%1.0%— + 2026Q41.8%0.8–2.7%1.5%1.1%— + 2027Q11.5%0.3–2.6%1.5%1.2%— + 2027Q21.5%0.3–2.6%1.6%1.4%1.1% + 2027Q31.4%0.2–2.5%1.6%1.6%— + 2027Q41.4%0.2–2.6%1.6%1.8%—
-

Same comparison for CPI — the archived rounds see a stickier inflation path than the EFO's return to target.

+

Same comparison for CPI, from the 2026-07-29 round: the model sees a stickier inflation path than the EFO's return to target. That gap has a mechanical cause — a stationary VAR estimated on a sample that includes the 2021–23 inflation surge mean-reverts toward a higher sample mean. Re-estimating the coefficients through 2025Q1 (this round) lowered the long-horizon CPI medians by roughly 0.3pp relative to the July 21 round.

boe-svar UK CPI inflation forecast vs OBR March 2026 EFO Line chart of UK CPI, year-on-year inflation, percent. The boe-svar archived median with its 68 percent band is shown beside the OBR March 2026 EFO path over the overlapping quarters. - - - - - - - - - - + + + + + + + + + 0 - 0.5 - 1 - 1.5 - 2 - 2.5 - 3 - 3.5 - 4 - 4.5 - 5 - - - + 0.5 + 1 + 1.5 + 2 + 2.5 + 3 + 3.5 + 4 + 4.5 + + + 2026Q2 2026Q3 2026Q4 @@ -233,19 +232,19 @@

Where the archived view differs from the OBR's.

UK CPI inflation, quarter by quarter: the archived median and band beside the OBR, Bank of England and consensus paths. Quarterboe-svar median68% bandOBR March 2026 EFOBoE Feb 2026 MPRConsensus (SEF, Apr 2026) - 2026Q22.7%2.2–3.2%2.1%2.1%2.9% - 2026Q32.7%1.9–3.5%2.1%2.0%— - 2026Q43.1%2.1–4.1%1.9%2.0%— - 2027Q13.1%1.9–4.5%1.9%1.7%— - 2027Q23.2%1.9–4.6%2.0%2.0%2.9% - 2027Q33.3%1.8–4.6%2.0%1.9%— - 2027Q43.2%1.9–4.6%1.9%1.8%— - 2028Q13.2%1.9–4.6%1.8%1.8%— - 2028Q23.2%1.9–4.7%2.0%2.0%2.1% - 2028Q33.3%1.8–4.7%2.0%2.0%— - 2028Q43.3%1.8–4.7%2.1%2.0%— - 2029Q13.2%1.7–4.8%2.1%2.0%— - 2029Q23.2%1.7–4.9%2.0%—2.0% + 2026Q22.6%2.2–3.1%2.1%2.1%2.9% + 2026Q32.5%1.8–3.3%2.1%2.0%— + 2026Q42.9%2.0–4.0%1.9%2.0%— + 2027Q13.0%1.7–4.2%1.9%1.7%— + 2027Q23.1%1.7–4.3%2.0%2.0%2.9% + 2027Q33.1%1.7–4.4%2.0%1.9%— + 2027Q43.0%1.6–4.3%1.9%1.8%— + 2028Q12.9%1.6–4.3%1.8%1.8%— + 2028Q22.9%1.5–4.4%2.0%2.0%2.1% + 2028Q32.9%1.5–4.3%2.0%2.0%— + 2028Q42.9%1.5–4.3%2.1%2.0%— + 2029Q13.0%1.5–4.3%2.1%2.0%— + 2029Q22.9%1.6–4.4%2.0%—2.0%
diff --git a/forecasts/score.py b/forecasts/score.py index 5a8dbdb..976f6d0 100644 --- a/forecasts/score.py +++ b/forecasts/score.py @@ -249,7 +249,25 @@ def build() -> dict: EFO_CSV = ROOT / "papers" / "obr-macro" / "figures" / "fig_anchored_data.csv" EFO_CPI_CSV = ROOT / "papers" / "obr-macro" / "figures" / "efo_march_2026_cpi.csv" -SVAR_ROUND = ROUNDS / "2026-07-21" / "boe-svar.json" +def _latest_svar_round() -> Path: + """Newest dated round with a boe-svar artifact, for the §03 display. + + Scoring (§02) is unaffected: it walks every archived round and scores each + against outturns, so the scored-example card keeps using whichever round + was actually scored. Only the model-vs-official comparison tracks the + latest archived view. + """ + candidates = sorted( + p + for p in ROUNDS.glob("*/boe-svar.json") + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", p.parent.name) + ) + if not candidates: + raise SystemExit("no rounds//boe-svar.json artifact found") + return candidates[-1] + + +SVAR_ROUND = _latest_svar_round() OFFICIAL_LABEL = "OBR March 2026 EFO" @@ -367,8 +385,8 @@ def build_vs_official(variable: str) -> list[dict]: "in papers/obr-macro/figures/fig_anchored_data.csv" ), "intro": ( - "The archived GDP-growth path beside the official forecast — the " - "gap between the lines is where the views differ." + "The GDP-growth path from the {round_id} round beside the official " + "forecast — the gap between the lines is where the views differ." ), }, "cpi": { @@ -381,8 +399,14 @@ def build_vs_official(variable: str) -> list[dict]: "papers/obr-macro/figures/efo_march_2026_cpi.csv" ), "intro": ( - "Same comparison for CPI — the archived rounds see a stickier " - "inflation path than the EFO's return to target." + "Same comparison for CPI, from the {round_id} round: the model " + "sees a stickier inflation path than the EFO's return to target. " + "That gap has a mechanical cause — a stationary VAR estimated on " + "a sample that includes the 2021–23 inflation surge " + "mean-reverts toward a higher sample mean. Re-estimating the " + "coefficients through 2025Q1 (this round) lowered the " + "long-horizon CPI medians by roughly 0.3pp relative to the " + "July 21 round." ), }, } @@ -443,7 +467,8 @@ def y(v: float) -> float: svg = "\n".join( [ - f'

{spec["intro"]}

', + '

' + f'{spec["intro"].format(round_id=SVAR_ROUND.parent.name)}

', '
', f' ', @@ -643,7 +668,10 @@ def render_results(card: dict) -> str: " " ) body.append(" ") - notes = [] + notes = [ + "Errors are signed as forecast − outturn: a positive error means " + "the forecast was too high, a negative one too low." + ] if any(e.get("naive_rw") is not None for _, e in rows): notes.append(esc(NAIVE_NOTE)) if any(e.get("official") is not None for _, e in rows): diff --git a/style.css b/style.css index c8d527e..dfd75d6 100644 --- a/style.css +++ b/style.css @@ -1085,7 +1085,19 @@ h2 { justify-content: center; gap: 9px; min-height: 46px; + min-width: 148px; + flex: 1 1 auto; } +/* client picker: centered pill group, full-width tap targets on mobile */ +#seg-client { + display: flex; + flex-wrap: wrap; + width: max-content; + max-width: 100%; + margin-inline: auto; +} +/* the connect hero lede is one sentence of plain prose — let it run the line */ +.connect-hero .lede { max-width: none; } .seg button[data-client] img { display: block; flex: 0 0 auto; } .seg button[data-client="chatgpt"] img { color: currentColor; } .seg button[data-client="chatgpt"][aria-selected="true"] img { filter: invert(1); } @@ -3065,7 +3077,7 @@ h2 { .mo-tablecard card idiom used on the model overview pages. */ .fc-vs-cards .vchart-figure { margin: var(--s-6) auto; - max-width: 820px; + max-width: 1080px; padding: var(--s-4) var(--s-5) var(--s-5); border: 1px solid var(--line-soft); border-radius: 10px; diff --git a/us-hank/index.html b/us-hank/index.html index f4562fb..b3113b6 100644 --- a/us-hank/index.html +++ b/us-hank/index.html @@ -126,19 +126,22 @@

Stylized US shocks, with the household distribution attached.

of a HANK model, which a representative-agent model cannot say.

- It cannot forecast — responses are deviations around - a calibrated steady state, not a projection of the US economy. It - cannot score a PolicyEngine reform: - score_reform refuses model="hank" outright — - three stylized instruments and an endogenous labor tax mean no - mapping from statute exists. For statutory reforms scored end to end, - use the micro-to-macro workflow; for who wins - and loses from a reform at household resolution, the - microsimulation answers directly. - Responses are first-order — linear in the shock, - with no state dependence — and the distributional cuts are - first-order approximations from steady-state policies, not full - household-level dynamics. + It cannot: +
    +
  • Forecast — responses are deviations around a + calibrated steady state, not a projection of the US economy.
  • +
  • Score a PolicyEngine reform — + score_reform refuses model="hank" + outright: three stylized instruments and an endogenous labor tax + mean no mapping from statute exists. For statutory reforms scored + end to end, use the micro-to-macro workflow; + for who wins and loses at household resolution, the + microsimulation answers directly.
  • +
  • Go beyond first order — responses are linear + in the shock, with no state dependence, and the distributional + cuts are first-order approximations from steady-state policies, + not full household-level dynamics.
  • +
From 8f9945bbb64c4c0d5ca3058ebef4a6b0cd7cfe89 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Sat, 1 Aug 2026 15:53:54 +0200 Subject: [PATCH 3/4] Macro-to-micro incidence bridges + DEFINE-UK joins the suite Integration: - EconomicAssumptions.from_frbus_result / from_hank_result: annual-mean wage(+hours/labor) deviations become a pre-tax earnings factor with the OG overlay's double-counting invariant and plausibility gates - frbus_shock_incidence, hank_shock_incidence, svar_inflation_incidence: reform-free automatic-stabilizer incidence via _pe_population_incidence; score_reform refusals unchanged, now pointing at the incidence tools - hank_shock surfaces the pre-tax real wage w and labor N IRFs - svar_inflation_incidence prices the SVAR-vs-EFO CPI gap through a curated slice of CPI-uprated benefits (validated run: +0.94pp gap -> GBP 0.09bn in 2028, gains in deciles 2-5) - frbus_payload/hank_payload two-process escape hatches (8GB hosts jetsam-kill the combined solve+microsim process); CLI flags to match - 30 unit tests; full suite 245 passed Website: - DEFINE-UK added as model 07 (experimental, pre-validation; unlicensed upstream run unmodified at a pinned commit via PolicyEngine/define-uk-model) - /svar, /frb-us, /us-hank gain incidence examples; /pe and /score describe the macro-to-micro return direction; counts six -> seven Co-Authored-By: Claude Fable 5 --- define/index.html | 191 ++++ frb-us/index.html | 23 +- index.html | 9 +- integration/README.md | 4 +- .../src/policyengine_macro/assumptions.py | 287 ++++++ integration/src/policyengine_macro/cli.py | 213 +++++ integration/src/policyengine_macro/core.py | 834 ++++++++++++++++-- .../src/policyengine_macro/mcp_server.py | 165 ++++ integration/tests/test_hank.py | 11 +- integration/tests/test_incidence.py | 563 ++++++++++++ integration/tests/tool_surface.py | 5 +- models/index.html | 39 +- pe/index.html | 10 +- score/index.html | 2 +- site_contract.py | 5 +- site_nav.py | 4 +- sitemap.xml | 1 + svar/index.html | 14 + us-hank/index.html | 10 + 19 files changed, 2287 insertions(+), 103 deletions(-) create mode 100644 define/index.html create mode 100644 integration/tests/test_incidence.py diff --git a/define/index.html b/define/index.html new file mode 100644 index 0000000..35821f7 --- /dev/null +++ b/define/index.html @@ -0,0 +1,191 @@ + + + + + +DEFINE-UK ecological stock-flow model — PolicyEngine Macro + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

model 07 — ecological stock-flow consistent · define-uk · UK · experimental

+

+ Climate policy meets the UK financial system. +

+

+ An adapter for DEFINE-UK, the ecological stock-flow consistent model + of the UK macrofinancial system. Pre-validation: it runs the + upstream code end to end; replication of published results is pending. +

+
+
+ + + +
+
+ 01 — what it is +

An ecological stock-flow consistent model of the UK.

+
+
+

+ DEFINE-UK is the ecological stock-flow consistent (E-SFC) model of + the UK built by Dafermos, Nikolaidi and co-authors + (define-model.org/define-uk). + It simulates the UK macrofinancial system together with its + environmental impacts under climate-policy scenarios, with particular + attention to the UK housing stock, the distinction between monetary + and non-monetary financial institutions, and the power sector. It is + calibrated to UK national accounting data. +

+

+ Versions 1.0 and 1.1 were released in April 2026; version 1.1 adds + regulation policies. The accompanying paper, Evaluating climate + policy mixes in the UK: an E-SFC approach, evaluates scenario + blocks covering a fossil-fuel ban, power-sector subsidies, housing + regulation, green public investment, and a mixed policy package. +

+

+ Our adapter, github.com/PolicyEngine/define-uk-model, + runs the authors' unmodified code. The upstream repository + (github.com/DEFINE-model/DEFINE_UK_1.1) + is public but carries no licence, so we vendor nothing: the adapter + fetches the upstream code at a pinned commit + (846081a) at runtime and executes it as published, in R. + On 2026-08-01 the full notebook ran end to end through the adapter + (R 4.3.0), producing 151 output files across all four scenario + blocks. +

+
+
+ +
+
+ 02 — status and limits +

Runs end to end. Not yet validated.

+
+
+
+ Pre-validation — execution is not validation. The + adapter runs the upstream code end to end, but none of its outputs + have been compared against the published figures yet, so no numerical + result from this model should be treated as meaningful. The status + label is: experimental; pre-validation — runs upstream code end to + end, replication of published results pending. +
+

+ The limits today, stated plainly: +

+
+
No replication check yet — outputs have not been compared to the paper's published figures
+
Unlicensed upstream — nothing is vendored or hosted; the code is fetched at a pinned commit and run unmodified
+
No hosted surface — not available through the MCP server or CLI
+
No reform bridge — score_reform will not accept it
+
No microsimulation connection yet — see the roadmap in the adapter repository
+
+

+ The planned connection to the rest of the suite: once the replication + is validated, scenario income and energy-price paths are planned to + feed the PolicyEngine microsimulation's incidence + overlay through EconomicAssumptions — showing who bears + a climate-policy scenario, household by household. That link does not + exist today, and the model plays no part in the + score a reform workflow. +

+
+
+ +
+
+ 03 — sources +

The model, the paper, the adapter.

+
+
+
+ + + + + + + + + + + + + + + + + + + + +
Where DEFINE-UK's inputs and outputs live
whatwhere
The modeldefine-model.org/define-uk — DEFINE-UK versions 1.0 and 1.1 (April 2026), Dafermos, Nikolaidi et al.
The paperEvaluating climate policy mixes in the UK: an E-SFC approach — the scenario definitions and published results our replication will be judged against.
Upstream codegithub.com/DEFINE-model/DEFINE_UK_1.1 — public but unlicensed; fetched at pinned commit 846081a and run unmodified.
Our adaptergithub.com/PolicyEngine/define-uk-model — scaffold, pre-replication; the run harness, status, and roadmap.
+
+
+ +
+ + + + + + diff --git a/frb-us/index.html b/frb-us/index.html index 06ed047..90e6766 100644 --- a/frb-us/index.html +++ b/frb-us/index.html @@ -140,13 +140,24 @@

US monetary and fiscal shocks, quarter by quarter.

It cannot score a PolicyEngine reform — see the known limits. It cannot do forward guidance or anything needing model-consistent - expectations. It cannot tell you who wins and loses — that is - PolicyEngine, whose costing-to-macro bridge exists - for the UK models (score a reform) but not yet - for FRB/US: score_reform refuses - model="frbus" rather than return a plausible-but-wrong - number. And it says nothing about the UK. + expectations. It cannot score a reform: + score_reform refuses model="frbus" rather + than return a plausible-but-wrong number, because no mapping exists + from a PolicyEngine reform to FRB/US levers. And + it says nothing about the UK.
+

+ What it can hand the microsimulation is its labour-market + path. The frbus_shock_incidence tool takes the wage + and hours deviations a shock produces — the 100bp tightening above + moves the 2027 wage bill −0.65% — and scales + the US microsimulation's employment income with + them, so the automatic stabilizers answer who bears it: how much of + the earnings loss the federal and state tax system absorbs, and + what SNAP and the EITC give back, decile by decile. Uniform + scaling, an experimental illustration — real incidence concentrates + in job losers, and the caveats say so. +

diff --git a/index.html b/index.html index c52c1ac..6f09113 100644 --- a/index.html +++ b/index.html @@ -4,13 +4,13 @@ Open models with public evidence — PolicyEngine Macro - + - + @@ -173,7 +173,7 @@

See what changed

Macro scenariosUKUS

Test a policy or shock

-

Choose among six open household and macro models, then inspect each model’s assumptions, evidence, and limits.

+

Choose among seven open household and macro models, then inspect each model’s assumptions, evidence, and limits.

choose a macro model →
@@ -189,7 +189,7 @@

Check forecasts against what happened

inspect the record →
-

Explore all six models or compare their assumptions side by side.

+

Explore all seven models or compare their assumptions side by side.

@@ -212,6 +212,7 @@

What the evidence shows—and what it doesn't.

us-hankPublished replication

Hits every Auclert et al. (2021) calibration target; responses are first-order and stylized.

pe-microsimStatutory checks

Rules are tested against statute; population totals inherit survey uncertainty.

psl-ogCalibration only

Targets are met by construction; no independent outcome benchmark exists.

+
define-ukPre-validation

Experimental: runs the upstream E-SFC code end to end; no output checked against published figures yet.

diff --git a/integration/README.md b/integration/README.md index 3e43311..c81655e 100644 --- a/integration/README.md +++ b/integration/README.md @@ -212,8 +212,10 @@ benefits the MCP server). ## MCP server Runs over stdio via `python -m policyengine_macro.mcp_server`, exposing -twenty tools: +twenty-three tools: `score_reform` (a PolicyEngine reform through a chosen macro model), +`frbus_shock_incidence`, `hank_shock_incidence` and +`svar_inflation_incidence` (macro-to-microsimulation incidence overlays), `dynamic_reform_impact` (the OG-UK overlay dynamic score; local-only — the hosted server returns a "run locally" error), `format_score_report` (stable JSON or Markdown reports), diff --git a/integration/src/policyengine_macro/assumptions.py b/integration/src/policyengine_macro/assumptions.py index 0b3d7a4..cc0e5a0 100644 --- a/integration/src/policyengine_macro/assumptions.py +++ b/integration/src/policyengine_macro/assumptions.py @@ -56,6 +56,37 @@ # docstring), so accepting them would ship a plausible-looking no-op. OVERLAY_PARAM_PREFIX = "gov.economic_assumptions." +# Earnings concepts for the shock-incidence constructors (from_frbus_result, +# from_hank_result). "wage" scales by the wage/compensation price alone; +# "wage_bill" scales by wage x hours, so the aggregate labour-income change +# is carried — uniformly, which understates the concentration of actual +# incidence in job losers (stated in caveats). +INCOME_CONCEPTS = ("wage", "wage_bill") + +# Same plausibility gate for every constructor: a factor outside this band +# is a degenerate solve or a mis-sized shock, not an overlay to apply. +_FACTOR_BOUNDS = (0.5, 2.0) + + +def _check_factor(label: str, factor: float, source: str) -> None: + lo, hi = _FACTOR_BOUNDS + if not (factor == factor and lo <= factor <= hi): # NaN-safe + raise ValueError( + f"implausible {label} factor {factor!r} (outside " + f"[{lo}, {hi}]) from {source} — inspect the model run " + "rather than applying it as an overlay" + ) + + +def _check_income_concept(income_concept: str) -> None: + if income_concept not in INCOME_CONCEPTS: + raise ValueError( + f"income_concept must be one of {INCOME_CONCEPTS}, got " + f"{income_concept!r}. 'wage' scales employment income by the " + "wage price alone; 'wage_bill' also carries the hours change " + "(applied uniformly)." + ) + class EconomicAssumptions(BaseModel): """Macro-model price changes expressed as microsim input adjustments. @@ -89,6 +120,10 @@ class EconomicAssumptions(BaseModel): interest_rate_baseline: float interest_rate_reform: float notes: list[str] = [] + # Constructor-specific caveats. When set, caveat_strings() returns these + # verbatim; when empty (the OG constructor), the v1 OG-specific caveats + # below are used, so existing dynamic-score output is unchanged. + caveats: list[str] = [] @classmethod def from_og_result(cls, og_payload: dict) -> "EconomicAssumptions": @@ -154,6 +189,256 @@ def from_og_result(cls, og_payload: dict) -> "EconomicAssumptions": ], ) + @classmethod + def from_frbus_result( + cls, + payload: dict, + year: int, + income_concept: str = "wage_bill", + ) -> "EconomicAssumptions": + """Construct from an (unmodified) frbus_shock payload. + + Uses the FRB/US real labour-market deviations for the four quarters + of ``year``: ``pl`` (compensation per hour, % deviation from + baseline), ``lhp`` (aggregate hours, % deviation) and ``leh`` + (civilian employment, % deviation; reported for context). The + caller must have requested them: ``frbus_shock(..., + variables=["pl", "lhp", "leh"])``. + + earnings_factor = 1 + annual mean of (pl) [income_concept="wage"] + or (pl + lhp) [income_concept="wage_bill"] over the year's four + quarters, / 100 — a TRANSITION-QUARTER AVERAGE, not a steady state. + """ + _check_income_concept(income_concept) + year = int(year) + required = ("pl", "lhp", "leh") + try: + rows = payload["results"] + rows[0]["period"] + except (KeyError, TypeError, IndexError) as e: + raise ValueError( + "payload is not a frbus_shock result (no per-period " + f"'results' rows: {e}); pass the unmodified output of " + "frbus_shock" + ) from e + missing = [v for v in required if v not in rows[0]] + if missing: + raise ValueError( + f"frbus_shock payload lacks the series {missing}: the " + "incidence overlay needs the real labour-market deviations. " + "Re-run frbus_shock with variables=[\"pl\", \"lhp\", " + "\"leh\"] and pass that payload." + ) + quarters = [f"{year}Q{q}" for q in (1, 2, 3, 4)] + by_period = {r["period"]: r for r in rows} + absent = [q for q in quarters if q not in by_period] + if absent: + raise ValueError( + f"frbus_shock payload does not cover the four quarters of " + f"{year} (missing {absent}; payload covers " + f"{rows[0]['period']}-{rows[-1]['period']}). Re-run " + "frbus_shock with a start/horizon window that spans the " + "incidence year." + ) + + def _annual_mean(series: str) -> float: + return sum(float(by_period[q][series]) for q in quarters) / 4.0 + + pl, lhp, leh = (_annual_mean(v) for v in required) + earnings_pct = pl + lhp if income_concept == "wage_bill" else pl + earnings_factor = 1.0 + earnings_pct / 100.0 + labour_supply_factor = 1.0 + lhp / 100.0 + src = f"frbus_shock payload, {year} annual mean" + _check_factor("earnings", earnings_factor, src) + _check_factor("labour-supply", labour_supply_factor, src) + + notes = [ + f"transition-quarter average: annual mean of the {year} " + "quarterly FRB/US deviations, not a steady state", + "pre-tax scaling: the factor moves gross employment income; " + "PolicyEngine applies statutory taxes and benefits to the " + "scaled inputs", + "country pair: FRB/US (US model) feeding the PolicyEngine US " + "population microsimulation", + ] + if income_concept == "wage_bill": + notes.append( + f"income_concept='wage_bill': earnings factor carries " + f"compensation per hour ({pl:+.3f}%) PLUS aggregate hours " + f"({lhp:+.3f}%), applied uniformly" + ) + else: + notes.append( + f"income_concept='wage': earnings factor carries " + f"compensation per hour only ({pl:+.3f}%); the hours change " + f"({lhp:+.3f}%) is reported, not applied" + ) + caveats = [ + "uniform scaling understates distributional incidence: actual " + "labour-market adjustment concentrates in job losers " + f"(employment leh {leh:+.3f}%, hours lhp {lhp:+.3f}% annual " + "mean), while the overlay spreads the change evenly over all " + "employment income", + "earnings factor applied to employment income only; " + "self-employment and capital income are not adjusted", + "no price-level overlay: pl/lhp/leh enter as real deviations", + ] + + rff_note = ( + "interest_rate fields carry the rff pp deviation from baseline " + f"(annual mean over {year}) against a 0.0 baseline convention; " + "FRB/US baseline rate LEVELS are not part of the shock payload" + ) + if "rff" in rows[0]: + rate_base, rate_ref = 0.0, _annual_mean("rff") + else: + rate_base, rate_ref = 0.0, 0.0 + rff_note = ("interest_rate fields are 0.0/0.0: rff was not in " + "the frbus_shock payload") + notes.append(rff_note) + + return cls( + source=( + "FRB/US (VAR expectations) shock deviations, " + f"{year} annual mean, income_concept={income_concept!r}" + ), + start_year=year, + earnings_factor=earnings_factor, + labour_supply_factor=labour_supply_factor, + interest_rate_baseline=rate_base, + interest_rate_reform=rate_ref, + notes=notes, + caveats=caveats, + ) + + @classmethod + def from_hank_result( + cls, + payload: dict, + year: int, + income_concept: str = "wage_bill", + start_year: int = 2026, + ) -> "EconomicAssumptions": + """Construct from an (unmodified) hank_shock payload. + + Uses the model's pre-tax real wage ``w`` and labor ``N`` IRFs (both + % deviations from steady state), which hank_shock surfaces from the + general-equilibrium sequence-space Jacobian. HANK quarters are + offsets from the shock's start, so ``start_year`` maps quarter 0 to + Q1 of that calendar year (default 2026). + + earnings_factor = 1 + annual mean of (w) [income_concept="wage"] or + (w + N) [income_concept="wage_bill"] over the four quarters of + ``year``, / 100 — a transition-quarter average around the steady + state, not a steady-state shift. + """ + _check_income_concept(income_concept) + year, start_year = int(year), int(start_year) + if year < start_year: + raise ValueError( + f"year ({year}) is before start_year ({start_year}): HANK " + "quarters are offsets from the shock start, which " + f"start_year maps to {start_year}Q1" + ) + try: + rows = payload["results"] + rows[0]["quarter"] + except (KeyError, TypeError, IndexError) as e: + raise ValueError( + "payload is not a hank_shock result (no per-quarter " + f"'results' rows: {e}); pass the unmodified output of " + "hank_shock" + ) from e + missing = [v for v in ("w", "N") if v not in rows[0]] + if missing: + raise ValueError( + f"hank_shock payload lacks the series {missing}: the " + "incidence overlay needs the pre-tax real wage and labor " + "IRFs. This policyengine-macro version's hank_shock " + "surfaces them — re-run hank_shock from this package and " + "pass that payload." + ) + offset = 4 * (year - start_year) + if len(rows) < offset + 4: + raise ValueError( + f"hank_shock payload covers {len(rows)} quarters, but the " + f"four quarters of {year} are offsets {offset}-{offset + 3} " + f"from the shock start ({start_year}Q1). Re-run hank_shock " + f"with horizon >= {offset + 4}." + ) + window = rows[offset:offset + 4] + + def _annual_mean(series: str) -> float: + return sum(float(r[series]) for r in window) / 4.0 + + w, n = _annual_mean("w"), _annual_mean("N") + earnings_pct = w + n if income_concept == "wage_bill" else w + earnings_factor = 1.0 + earnings_pct / 100.0 + labour_supply_factor = 1.0 + n / 100.0 + src = f"hank_shock payload, {year} annual mean" + _check_factor("earnings", earnings_factor, src) + _check_factor("labour-supply", labour_supply_factor, src) + + notes = [ + f"transition-quarter average: annual mean of the {year} " + "quarterly HANK deviations from steady state (quarter offsets " + f"{offset}-{offset + 3}, shock start mapped to " + f"{start_year}Q1), not a steady-state shift", + "pre-tax scaling: the factor moves gross employment income; " + "PolicyEngine applies statutory taxes and benefits to the " + "scaled inputs", + "country pair: US HANK (stylized calibrated model) feeding the " + "PolicyEngine US population microsimulation", + ] + if income_concept == "wage_bill": + notes.append( + f"income_concept='wage_bill': earnings factor carries the " + f"real wage ({w:+.3f}%) PLUS labor N ({n:+.3f}%), applied " + "uniformly" + ) + else: + notes.append( + f"income_concept='wage': earnings factor carries the real " + f"wage only ({w:+.3f}%); the labor change ({n:+.3f}%) is " + "reported, not applied" + ) + rate_ref = 0.0 + rate_note = ("interest_rate fields are 0.0/0.0: 'r' was not in the " + "hank_shock payload") + if "r" in rows[0]: + rate_ref = _annual_mean("r") + rate_note = ( + "interest_rate fields carry the QUARTERLY real-rate pp " + f"deviation from steady state (annual mean over {year}) " + "against a 0.0 baseline convention" + ) + notes.append(rate_note) + caveats = [ + "uniform scaling understates distributional incidence: actual " + "labour-market adjustment concentrates in job losers, while " + "the overlay spreads the change evenly over all employment " + "income (HANK's N is aggregate labor, with no unemployment " + "margin to allocate)", + "earnings factor applied to employment income only; " + "self-employment and capital income are not adjusted", + "stylized calibrated model: the steady state is the " + "Auclert-Bardóczy-Rognlie-Straub (2021) calibration, not a US " + "forecast baseline; responses are linear/first-order", + ] + return cls( + source=( + "US HANK (sequence-space) shock deviations, " + f"{year} annual mean, income_concept={income_concept!r}" + ), + start_year=year, + earnings_factor=earnings_factor, + labour_supply_factor=labour_supply_factor, + interest_rate_baseline=0.0, + interest_rate_reform=rate_ref, + notes=notes, + caveats=caveats, + ) + def input_scaling_modifier(self): """The overlay as a simulation modifier, or None for a null result. @@ -204,6 +489,8 @@ def assumption_strings(self) -> list[str]: ] def caveat_strings(self) -> list[str]: + if self.caveats: + return list(self.caveats) labour_pct = 100.0 * (self.labour_supply_factor - 1.0) return [ f"aggregate effective-labour change {labour_pct:+.2f}% not " diff --git a/integration/src/policyengine_macro/cli.py b/integration/src/policyengine_macro/cli.py index 5783fc1..daaeabe 100644 --- a/integration/src/policyengine_macro/cli.py +++ b/integration/src/policyengine_macro/cli.py @@ -16,6 +16,20 @@ def _emit_json(obj) -> None: click.echo(json.dumps(obj, indent=2)) +def _load_payload_file(path: str | None, option: str) -> dict | None: + """Load a pre-computed macro payload JSON for a two-process pipeline.""" + if path is None: + return None + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + raise click.ClickException( + f"{option} {path}: not readable JSON ({e}); pass the unmodified " + "--json output of the corresponding shock command" + ) from e + + def _table(rows: list[dict], columns: list[str]) -> str: widths = {c: max(len(c), *(len(str(r.get(c, ""))) for r in rows)) for c in columns} head = " ".join(c.ljust(widths[c]) for c in columns) @@ -284,6 +298,205 @@ def frbus_shock(var, shock, start, periods, horizon, policy_rule, variables, click.echo(f"\nWARNING: {res['warning']}") +def _echo_incidence(res: dict, shock_line: str) -> None: + ea = res["economic_assumptions"] + click.echo(shock_line) + click.echo(f"Earnings factor: {ea['earnings_factor']} " + f"Labour-supply factor: {ea['labour_supply_factor']} " + f"applied: {res['application']['applied']}\n") + micro = res["microsim"] + click.echo(micro["headline"]) + sym = "£" if micro["country"] == "uk" else "$" + click.echo(f"Budget change: {sym}{micro['budgetary_impact_bn']}bn/year " + f"({micro['budgetary_impact_basis']})") + click.echo(f"Household net income change: " + f"{sym}{micro['household_net_income_change_bn']}bn/year") + click.echo(f"Winners: {micro['winners']:,} Losers: {micro['losers']:,}\n") + click.echo(_table(micro["decile_impacts"], + ["decile", "avg_income_change", "relative_change_pct", + "count_better_off", "count_worse_off"])) + for label, items in (("Assumptions", res.get("assumptions") or []), + ("Caveats", res.get("caveats") or [])): + click.echo(f"\n{label}:") + for it in items: + click.echo(f" - {it}") + + +@main.command("frbus-shock-incidence") +@click.option("--var", required=True, + help="FRB/US lever to shock (see `pe-macro frbus-variables`).") +@click.option("--shock", required=True, type=float, + help="Shock size in the lever's model units (units differ " + "per lever; see `pe-macro frbus-variables`).") +@click.option("--year", default=2027, show_default=True, + help="Calendar year whose four quarters are averaged into the " + "earnings overlay and scored by the microsim.") +@click.option("--start", default=core.FRBUS_DEFAULT_START, show_default=True, + help="First shocked quarter, e.g. 2026Q1.") +@click.option("--periods", default=1, show_default=True, + help="Quarters the shock is held.") +@click.option("--horizon", default=core.FRBUS_DEFAULT_HORIZON, + show_default=True, + help="Quarters simulated (must cover the incidence year).") +@click.option("--policy-rule", default="inertial_taylor", show_default=True, + type=click.Choice(sorted(core.FRBUS_POLICY_RULES)), + help="Monetary policy reaction.") +@click.option("--income-concept", default="wage_bill", show_default=True, + type=click.Choice(["wage", "wage_bill"]), + help="wage = compensation per hour only; wage_bill also " + "carries the hours change (applied uniformly).") +@click.option("--dataset", default=None, + help="Microdata dataset name override.") +@click.option("--frbus-payload", "frbus_payload_path", default=None, + help="Path to a pre-computed `pe-macro frbus-shock --json` " + "result (run with pl/lhp/leh in --variables), so the " + "FRB/US solve and the microsim run in separate processes " + "on memory-constrained machines.") +@click.option("--json", "as_json", is_flag=True, help="Emit JSON.") +def frbus_shock_incidence(var, shock, year, start, periods, horizon, + policy_rule, income_concept, dataset, + frbus_payload_path, as_json): + """Who bears a FRB/US shock, at household resolution (experimental). + + Runs frbus_shock, turns the year's mean pl/lhp deviations into a + pre-tax earnings factor, scales the US microsim's employment-income + inputs (no reform on either side), and reports the automatic-stabilizer + budget change plus decile impacts. NOT reform scoring. + """ + frbus_payload = _load_payload_file(frbus_payload_path, "--frbus-payload") + try: + res = core.frbus_shock_incidence( + var=var, shock=shock, year=year, start=start, periods=periods, + horizon=horizon, policy_rule=policy_rule, + income_concept=income_concept, dataset=dataset, + frbus_payload=frbus_payload, + ) + except (ValueError, ImportError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + if as_json: + _emit_json(res) + return + _echo_incidence(res, ( + f"FRB/US shock incidence: {res['frbus']['name']} " + f"(year {res['year']}, income_concept={res['income_concept']})" + )) + + +@main.command("hank-shock-incidence") +@click.option("--kind", required=True, + type=click.Choice([k["kind"] for k in core.HANK_SHOCK_KINDS]), + help="Shock kind (see `pe-macro hank-summary` for units).") +@click.option("--size", required=True, type=float, + help="Impact size in model units (units differ per kind).") +@click.option("--year", default=2026, show_default=True, + help="Calendar year whose four quarters are averaged into the " + "earnings overlay and scored by the microsim.") +@click.option("--persistence", default=0.9, show_default=True, type=float, + help="AR(1) decay of the shock path (in [0, 1)).") +@click.option("--horizon", default=core.HANK_DEFAULT_HORIZON, + show_default=True, + help="Quarters simulated (must cover the incidence year).") +@click.option("--variant", default="two_asset", show_default=True, + type=click.Choice(list(core.HANK_VARIANTS))) +@click.option("--income-concept", default="wage_bill", show_default=True, + type=click.Choice(["wage", "wage_bill"]), + help="wage = real wage w only; wage_bill also carries the " + "labor N change (applied uniformly).") +@click.option("--start-year", "start_year", default=2026, show_default=True, + help="Calendar year the shock's quarter 0 maps to.") +@click.option("--dataset", default=None, + help="Microdata dataset name override.") +@click.option("--hank-payload", "hank_payload_path", default=None, + help="Path to a pre-computed `pe-macro hank-shock --json` " + "result, so the HANK solve and the microsim run in " + "separate processes on memory-constrained machines.") +@click.option("--json", "as_json", is_flag=True, help="Emit JSON.") +def hank_shock_incidence(kind, size, year, persistence, horizon, variant, + income_concept, start_year, dataset, + hank_payload_path, as_json): + """Who bears a US HANK shock, at household resolution (experimental). + + Runs hank_shock (which surfaces the pre-tax real wage w and labor N + IRFs), turns the year's mean deviations into a pre-tax earnings factor, + scales the US microsim's employment-income inputs (no reform), and + reports the automatic-stabilizer budget change plus decile impacts. + Stylized calibrated model; NOT a forecaster, NOT reform scoring. + """ + hank_payload = _load_payload_file(hank_payload_path, "--hank-payload") + try: + res = core.hank_shock_incidence( + kind=kind, size=size, year=year, persistence=persistence, + horizon=horizon, variant=variant, income_concept=income_concept, + start_year=start_year, dataset=dataset, + hank_payload=hank_payload, + ) + except (ValueError, ImportError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + if as_json: + _emit_json(res) + return + _echo_incidence(res, ( + f"US HANK shock incidence: {res['hank']['name']} " + f"(year {res['year']}, income_concept={res['income_concept']})" + )) + + +@main.command("svar-inflation-incidence") +@click.option("--year", default=2027, show_default=True, + help="Forecast year whose CPI gap drives the following " + "April's uprating.") +@click.option("--horizons", default=12, show_default=True, + help="SVAR forecast horizon in quarters (must cover the year).") +@click.option("--draws", default=2000, show_default=True, + help="SVAR posterior draws (first call takes minutes).") +@click.option("--reference", default="obr", show_default=True, + type=click.Choice(["obr", "target"]), + help="CPI reference path: the March 2026 EFO (obr) or a flat " + "2.0% (target).") +@click.option("--dataset", default=None, + help="Microdata dataset name override.") +@click.option("--json", "as_json", is_flag=True, help="Emit JSON.") +def svar_inflation_incidence(year, horizons, draws, reference, dataset, + as_json): + """Cost and incidence of the SVAR-vs-reference CPI gap via uprating. + + Compares the UK SVAR's median CPI path for the year against the + reference, scales a short curated list of statutorily CPI-uprated + benefit parameters by the gap from the following 6 April, and scores + that real reform with the UK population microsim. Excludes the state + pension triple lock and frozen tax thresholds (stated in caveats). + """ + try: + res = core.svar_inflation_incidence( + year=year, horizons=horizons, draws=draws, reference=reference, + dataset=dataset, + ) + except (ValueError, ImportError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + if as_json: + _emit_json(res) + return + click.echo(f"UK SVAR inflation-uprating incidence, {res['year']} gap -> " + f"April {res['uprating_year']} uprating") + click.echo(f"SVAR CPI {res['svar_cpi_yoy_pct']}% vs reference " + f"{res['reference_cpi_yoy_pct']}% ({res['reference_description']}): " + f"gap {res['cpi_gap_pp']:+}pp\n") + click.echo(_table(res["parameters"], + ["path", "description", "unit", "baseline_value", + "counterfactual_value"])) + micro = res["microsim"] + click.echo(f"\n{res['headline']}") + click.echo(f"Winners: {micro['winners']:,} Losers: {micro['losers']:,}\n") + click.echo(_table(micro["decile_impacts"], + ["decile", "avg_income_change", "relative_change_pct", + "count_better_off", "count_worse_off"])) + for label, items in (("Assumptions", res["assumptions"]), + ("Caveats", res["caveats"])): + click.echo(f"\n{label}:") + for it in items: + click.echo(f" - {it}") + + @main.command("frbus-variables") @click.option("--json", "as_json", is_flag=True, help="Emit JSON.") def frbus_variables(as_json): diff --git a/integration/src/policyengine_macro/core.py b/integration/src/policyengine_macro/core.py index 78a9f34..26946b5 100644 --- a/integration/src/policyengine_macro/core.py +++ b/integration/src/policyengine_macro/core.py @@ -784,14 +784,20 @@ def frbus_summary() -> dict: _HANK_KIND_INDEX = {k["kind"]: k for k in HANK_SHOCK_KINDS} -# Series meaning per variant. Y/C/I are % deviations from steady state; pi/r -# are level deviations of QUARTERLY rates in percentage points. +# Series meaning per variant. Y/C/I/w/N are % deviations from steady state; +# pi/r are level deviations of QUARTERLY rates in percentage points. w and N +# are not in the model package's shock() output; hank_shock computes them +# from the same GE sequence-space Jacobian (they are solved unknowns/outputs +# in both variants) so the macro->micro incidence layer has a pre-tax wage +# and labor path to build on. HANK_HEADLINE = { "Y": "Output, % deviation from steady state", "C": "Aggregate consumption, % deviation from steady state", "I": "Investment, % deviation from steady state (two_asset only)", "pi": "Inflation (quarterly rate), pp deviation from steady state", "r": "Real interest rate (quarterly rate), pp deviation from steady state", + "w": "Pre-tax real wage, % deviation from steady state", + "N": "Labor, % deviation from steady state", } # Cache the steady state + GE jacobian per (variant, T). The steady state @@ -886,6 +892,11 @@ def hank_shock( first-order approximation from steady-state policies, capturing the MPC-heterogeneity channel only, not full household-level dynamics. + Alongside the package's Y/C/I/pi/r, the result surfaces the pre-tax + real wage ``w`` and labor ``N`` IRFs (% deviations from steady state), + computed from the same GE Jacobian — the inputs hank_shock_incidence + builds its household-level overlay on. + First call per variant pays the steady-state (~13s) + jacobian (~5s) solves; both are cached, so every later call is effectively instant. """ @@ -910,6 +921,29 @@ def hank_shock( ss, G = _hank_solved(variant) irf = mod.shock(kind, float(size), persistence, T=HANK_T, ss=ss, G_jac=G) + # The package's shock() returns Y/C/I/pi/r only; surface the pre-tax + # real wage w and labor N the same way it computes the others — from + # the GE Jacobian and the exogenous path — as % deviations from steady + # state. Both variants expose w and N (w is a solved unknown; N is a + # block output); if an upstream change ever drops one, the series is + # omitted rather than invented, and from_hank_result errors clearly. + for extra in ("w", "N"): + if extra not in irf: + # Membership checks, not a broad try/except: a genuine + # computation bug (shape mismatch, degenerate steady state) must + # raise here, not surface later as a confusing "series missing". + if ( + extra not in G + or irf["shock_input"] not in G[extra] + or extra not in ss + or not float(ss[extra]) + ): + continue + irf[extra] = ( + 100.0 * (G[extra][irf["shock_input"]] @ irf["shock"]) + / float(ss[extra]) + ) + series_names = [v for v in HANK_HEADLINE if v in irf] series = { v: [round(float(x), 6) for x in irf[v][:horizon]] for v in series_names @@ -1426,14 +1460,20 @@ def _pe_current_value(param): """ from datetime import date + return _pe_value_at(param, date.today()) + + +def _pe_value_at(param, on_date): + """The parameter value in force on ``on_date`` (same rules as + _pe_current_value: latest start_date <= on_date, end_date ignored).""" + def _d(v): return v.date() if hasattr(v, "date") else v - today = date.today() best_start, current = None, None for pv in param.parameter_values: start = _d(pv.start_date) if pv.start_date else None - if start is not None and start <= today and ( + if start is not None and start <= on_date and ( best_start is None or start > best_start ): best_start, current = start, pv.value @@ -1934,64 +1974,15 @@ def _pe_pop_sum(sim, variable: str) -> float: return float(agg.result) -def pe_population_impact( - country: str = "uk", - reform: dict | None = None, - year: int = 2026, - dataset: str | None = None, - reform_modifier=None, -) -> dict: - """Score a reform against the whole population with PolicyEngine. - - Runs baseline and reform microsimulations over representative household - microdata (UK: enhanced FRS; US: CPS-based) and returns the budgetary - impact — the change in government revenue net of spending, in £bn/$bn - per year (positive = the reform raises revenue) — plus income-decile - impacts and winner/loser counts. +def _pe_pop_outcomes(country: str, base, ref) -> dict: + """Budget, net-income and decile deltas of a counterfactual vs baseline. - reform is a flat {parameter_path: value} dict, e.g. equalising CGT with - income tax rates: {"gov.hmrc.cgt.basic_rate": 0.20, - "gov.hmrc.cgt.higher_rate": 0.40, "gov.hmrc.cgt.additional_rate": 0.45}. - - The baseline simulation is cached in-process per (country, year, - dataset). UK data needs HUGGING_FACE_TOKEN on first download. - - ``reform_modifier`` (internal; used by dynamic_population_reform_impact) - is an optional callable applied to the underlying engine simulation of - the REFORM run only, attached via the engine's supported - ``Dynamic(simulation_modifier=...)`` hook. The baseline — cached and - shared with static scores — NEVER sees it, which is the structural - guarantee that a macro overlay is applied exactly once, to the reform - side only. + Shared measurement core of pe_population_impact (reform runs) and + _pe_population_incidence (macro-shock runs): everything here is + counterfactual-minus-baseline, whatever produced the counterfactual. """ - reform = validate_reform(reform) - country = _validate_country(country) - - ds, base = _pe_pop_baseline(country, year, dataset) - pe = _import_pe() - from policyengine.core import Simulation from policyengine.outputs.decile_impact import calculate_decile_impacts - ref_kwargs = {} - if reform_modifier is not None: - from policyengine.core import Dynamic - - ref_kwargs["dynamic"] = Dynamic( - name="policyengine-macro EconomicAssumptions overlay", - simulation_modifier=reform_modifier, - # Exogenous macro input scaling, not a behavioural response: - # do not trigger the engine's labour-supply-response outputs. - affects_labor_supply_response=False, - ) - ref = Simulation( - dataset=ds, - tax_benefit_model_version=getattr(pe, country).model, - policy=dict(reform), - extra_variables=_pe_pop_extra_variables(country), - **ref_kwargs, - ) - ref.run() - if country == "uk": budget_bn = ( _pe_pop_sum(ref, "gov_balance") - _pe_pop_sum(base, "gov_balance") @@ -2050,6 +2041,76 @@ def pe_population_impact( winners += int(d.count_better_off) losers += int(d.count_worse_off) + return { + "budgetary_impact_bn": round(budget_bn, 3), + "budgetary_impact_basis": budget_basis, + "household_net_income_change_bn": round(net_income_change_bn, 3), + "decile_impacts": decile_rows, + "winners": int(winners), + "losers": int(losers), + } + + +def pe_population_impact( + country: str = "uk", + reform: dict | None = None, + year: int = 2026, + dataset: str | None = None, + reform_modifier=None, +) -> dict: + """Score a reform against the whole population with PolicyEngine. + + Runs baseline and reform microsimulations over representative household + microdata (UK: enhanced FRS; US: CPS-based) and returns the budgetary + impact — the change in government revenue net of spending, in £bn/$bn + per year (positive = the reform raises revenue) — plus income-decile + impacts and winner/loser counts. + + reform is a flat {parameter_path: value} dict, e.g. equalising CGT with + income tax rates: {"gov.hmrc.cgt.basic_rate": 0.20, + "gov.hmrc.cgt.higher_rate": 0.40, "gov.hmrc.cgt.additional_rate": 0.45}. + + The baseline simulation is cached in-process per (country, year, + dataset). UK data needs HUGGING_FACE_TOKEN on first download. + + ``reform_modifier`` (internal; used by dynamic_population_reform_impact) + is an optional callable applied to the underlying engine simulation of + the REFORM run only, attached via the engine's supported + ``Dynamic(simulation_modifier=...)`` hook. The baseline — cached and + shared with static scores — NEVER sees it, which is the structural + guarantee that a macro overlay is applied exactly once, to the reform + side only. + """ + reform = validate_reform(reform) + country = _validate_country(country) + + ds, base = _pe_pop_baseline(country, year, dataset) + pe = _import_pe() + from policyengine.core import Simulation + + ref_kwargs = {} + if reform_modifier is not None: + from policyengine.core import Dynamic + + ref_kwargs["dynamic"] = Dynamic( + name="policyengine-macro EconomicAssumptions overlay", + simulation_modifier=reform_modifier, + # Exogenous macro input scaling, not a behavioural response: + # do not trigger the engine's labour-supply-response outputs. + affects_labor_supply_response=False, + ) + ref = Simulation( + dataset=ds, + tax_benefit_model_version=getattr(pe, country).model, + policy=dict(reform), + extra_variables=_pe_pop_extra_variables(country), + **ref_kwargs, + ) + ref.run() + + outcomes = _pe_pop_outcomes(country, base, ref) + budget_bn = outcomes["budgetary_impact_bn"] + sym = "£" if country == "uk" else "$" out = { "model": "PolicyEngine population microsimulation", @@ -2065,21 +2126,89 @@ def pe_population_impact( "n_households": int(len(ds.data.household)), "currency": "GBP" if country == "uk" else "USD", "reform": dict(reform), - "budgetary_impact_bn": round(budget_bn, 3), - "budgetary_impact_basis": budget_basis, + **outcomes, "headline": ( f"The reform {'raises' if budget_bn >= 0 else 'costs'} " f"{sym}{abs(budget_bn):.1f}bn/year in {year}." ), - "household_net_income_change_bn": round(net_income_change_bn, 3), - "decile_impacts": decile_rows, - "winners": int(winners), - "losers": int(losers), } out["score"] = _pop_score_block(out) return out +def _pe_population_incidence( + country: str, + year: int, + dataset: str | None, + modifier, + label: str, +) -> dict: + """Population-level incidence of a macro shock: NO reform involved. + + Runs the cached stock baseline against a second simulation that differs + ONLY by the macro overlay — a ``Dynamic(simulation_modifier=...)`` + scaling the employment-income inputs (assumptions.py) — and measures + the same outcomes as pe_population_impact. budgetary_impact_bn is the + change in the government balance caused by the macro shock through the + automatic stabilizers (a negative earnings shock reduces tax revenue + and raises means-tested benefits), NOT the cost of any reform: policy + is identical on both sides. + + ``modifier`` may be None (a no-op macro result): the shocked simulation + is then construction-identical to the baseline and every delta is zero + by design — callers surface that as the honest null, not an error. + Deliberately NOT routed through validate_reform: there is no reform. + """ + country = _validate_country(country) + ds, base = _pe_pop_baseline(country, year, dataset) + pe = _import_pe() + from policyengine.core import Simulation + + kwargs = {} + if modifier is not None: + from policyengine.core import Dynamic + + kwargs["dynamic"] = Dynamic( + name=label, + simulation_modifier=modifier, + # Exogenous macro input scaling, not a behavioural response. + affects_labor_supply_response=False, + ) + shocked = Simulation( + dataset=ds, + tax_benefit_model_version=getattr(pe, country).model, + extra_variables=_pe_pop_extra_variables(country), + **kwargs, + ) + shocked.run() + + outcomes = _pe_pop_outcomes(country, base, shocked) + budget_bn = outcomes["budgetary_impact_bn"] + sym = "£" if country == "uk" else "$" + return { + "model": "PolicyEngine population microsimulation (macro-shock incidence)", + "provenance": _provenance( + model_id="pe-microsim", + distribution="policyengine", + data_vintage=ds.name, + baseline=f"baseline policy for {year}", + ), + "country": country, + "year": int(year), + "dataset": ds.name, + "n_households": int(len(ds.data.household)), + "currency": "GBP" if country == "uk" else "USD", + "shock_label": label, + **outcomes, + "headline": ( + f"Through the automatic stabilizers, the macro shock " + f"{'improves' if budget_bn >= 0 else 'worsens'} the government " + f"balance by {sym}{abs(budget_bn):.1f}bn/year in {year} " + "(policy unchanged)." + ), + } + + # --------------------------------------------------------------------------- # OG-UK overlapping-generations adapters (steady state only) # --------------------------------------------------------------------------- @@ -2918,6 +3047,574 @@ def dynamic_population_reform_impact( return out +# --------------------------------------------------------------------------- +# Macro -> micro shock incidence: who bears a macro shock, at household +# resolution. NOT reform scoring — there is still no reform bridge for +# FRB/US or HANK (SCORE_MODELS_WITHOUT_REFORM_BRIDGE stands). These tools +# answer the opposite question: given a raw macro shock, what do its +# labour-market (or price) deviations do to household incomes and the +# government balance through the automatic stabilizers? +# --------------------------------------------------------------------------- + +# The real labour-market series the FRB/US incidence overlay is built on. +FRBUS_INCIDENCE_VARIABLES = ("pl", "lhp", "leh") + + +def _incidence_score_block( + *, + model_id: str, + model_class: str, + analysis_type: str, + country: str, + year: int, + reform: dict, + micro: dict, + assumptions: list[str], + caveats: list[str], + baseline: str, + data_vintage: str, + horizon: str, + basis_suffix: str, +) -> dict: + """Common ScoreResult for the shock-incidence tools (illustrations).""" + return ScoreResult( + model=model_id, + model_class=model_class, + analysis_type=analysis_type, + result_type="illustration", + country=country, + reform=reform, + baseline=baseline, + provenance=_provenance( + model_id=model_id, + distribution="policyengine-macro", + data_vintage=data_vintage, + baseline=baseline, + ), + horizon=horizon, + quantities={ + "revenue": ScoreQuantity( + delta_bn=micro["budgetary_impact_bn"], + units=f"{micro['currency']} bn per year", + unit_code=f"{micro['currency']}_BN", + basis=f"{micro['budgetary_impact_basis']}, {basis_suffix}", + time_basis=f"annual {year}", + price_basis=( + "nominal microsimulation result with real " + "macro-deviation overlay" + ), + geography=country, + baseline_definition=( + f"PolicyEngine baseline policy for {year}" + ), + uncertainty="not estimated; experimental incidence overlay", + ), + }, + assumptions=assumptions, + caveats=caveats, + validation=[ + "input-scaling mechanism empirically gated; the shock-incidence " + "overlay itself is experimental" + ], + distributional=ScoreDistribution( + decile_impacts=micro["decile_impacts"], + winners=micro["winners"], + losers=micro["losers"], + ), + ).model_dump(mode="json") + + +def frbus_shock_incidence( + var: str, + shock: float, + year: int = 2027, + start: str = FRBUS_DEFAULT_START, + periods: int = 1, + horizon: int = FRBUS_DEFAULT_HORIZON, + policy_rule: str = "inertial_taylor", + income_concept: str = "wage_bill", + dataset: str | None = None, + frbus_payload: dict | None = None, +) -> dict: + """Household-level incidence of a FRB/US shock (experimental). + + ``frbus_payload`` accepts a pre-computed frbus_shock result (run with + ``variables=["pl", "lhp", "leh"]``) so the FRB/US solve and the microsim + can run in separate processes — the combined process peaks well past + small-machine memory (observed: an 8GB host jetsam-kills it), the same + two-step reality as ``og_payload`` in dynamic scoring. The payload's + var/shock must match the arguments; mixing is refused. + + Pipeline: + 1. frbus_shock with the real labour-market series requested + (pl compensation per hour, lhp hours, leh employment — all + % deviations from baseline); + 2. EconomicAssumptions.from_frbus_result — the annual mean of the + ``year`` quarters becomes a pre-tax earnings factor + (income_concept: 'wage_bill' = pl+lhp, 'wage' = pl alone); + 3. the factor scales the US population microsimulation's + employment-income inputs (reform-free: policy is identical on + both sides), so the deciles/winners/losers and the budget change + are the shock's incidence THROUGH THE AUTOMATIC STABILIZERS — + a negative earnings shock reduces tax revenue and raises + means-tested benefits. + + NOT reform scoring: FRB/US still has no PolicyEngine-reform bridge + (score_reform keeps refusing model='frbus'). Uniform scaling + understates the concentration of real incidence in job losers; the + caveats say so. US only. Runtime: one FRB/US solve (~seconds) plus + one microsim run (~tens of seconds warm; first-ever call downloads + microdata). + """ + from policyengine_macro.assumptions import ( + SCALED_INPUT_VARIABLES, EconomicAssumptions, + ) + + if frbus_payload is not None: + got = (frbus_payload.get("var"), frbus_payload.get("shock")) + if got != (var, float(shock)): + raise ValueError( + f"frbus_payload was produced for {got[0]!r} shock {got[1]!r}, " + f"not for {var!r} shock {shock!r}; refusing to mix them — " + "pass the unmodified output of frbus_shock run with " + "variables=['pl', 'lhp', 'leh']" + ) + payload = frbus_payload + else: + payload = frbus_shock( + var=var, shock=shock, start=start, periods=periods, + horizon=horizon, policy_rule=policy_rule, + variables=list(FRBUS_INCIDENCE_VARIABLES), + ) + ea = EconomicAssumptions.from_frbus_result( + payload, year=year, income_concept=income_concept, + ) + modifier = ea.input_scaling_modifier() + micro = _pe_population_incidence( + country="us", year=year, dataset=dataset, modifier=modifier, + label=f"FRB/US {var} {shock:+g} earnings incidence ({year})", + ) + + assumptions = ea.assumption_strings() + caveats = ea.caveat_strings() + [ + "automatic-stabilizer incidence of a raw macro shock, not a reform " + "score: FRB/US has no PolicyEngine-reform bridge, and policy is " + "identical on both sides of this comparison", + ] + out = { + "model": "FRB/US shock + PolicyEngine population microsimulation", + "country": "us", + "year": int(year), + "income_concept": income_concept, + "frbus": payload, + "economic_assumptions": ea.model_dump(), + "application": { + "method": "input-scaling", + "variables_tried": list(SCALED_INPUT_VARIABLES), + "earnings_factor": ea.earnings_factor, + "applied": modifier is not None, + }, + "microsim": micro, + "assumptions": assumptions, + "caveats": caveats, + } + out["score"] = _incidence_score_block( + model_id="frbus+microsim", + model_class="semi-structural overlay on microsim", + analysis_type="shock incidence (experimental)", + country="us", + year=year, + reform={}, + micro=micro, + assumptions=assumptions, + caveats=caveats, + baseline=( + f"PolicyEngine baseline policy for {year}, with FRB/US " + f"{var} {shock:+g} labour-market deviations applied as a " + "pre-tax earnings overlay" + ), + data_vintage=( + f"PolicyEngine dataset {micro.get('dataset', dataset or 'default')}; " + "FRB/US April 2026 LONGBASE" + ), + horizon=f"annual {year}, transition-quarter average deviations", + basis_suffix=( + "from the macro shock through the automatic stabilizers " + "(no reform)" + ), + ) + return out + + +def hank_shock_incidence( + kind: str, + size: float, + year: int = 2026, + persistence: float = 0.9, + horizon: int = HANK_DEFAULT_HORIZON, + variant: str = "two_asset", + income_concept: str = "wage_bill", + start_year: int = 2026, + dataset: str | None = None, + hank_payload: dict | None = None, +) -> dict: + """Household-level incidence of a US HANK shock (experimental). + + ``hank_payload`` accepts a pre-computed hank_shock result so the HANK + solve and the microsim can run in separate processes on + memory-constrained machines (same two-step escape hatch as + ``frbus_payload`` / ``og_payload``). The payload's kind/size must match + the arguments; mixing is refused. + + Same pattern as frbus_shock_incidence: hank_shock (which surfaces the + pre-tax real wage w and labor N IRFs, % deviations from steady state) + -> EconomicAssumptions.from_hank_result (annual mean over ``year``'s + quarters; HANK quarters are offsets from the shock start, which + ``start_year`` maps to that calendar year's Q1) -> a pre-tax earnings + factor scaling the US microsim's employment-income inputs, reform-free. + + The result is the shock's incidence through the automatic stabilizers + around the model's CALIBRATED steady state — HANK is a stylized model, + not a forecaster, and it still has no PolicyEngine-reform bridge + (score_reform keeps refusing model='hank'). US only. + """ + from policyengine_macro.assumptions import ( + SCALED_INPUT_VARIABLES, EconomicAssumptions, + ) + + if hank_payload is not None: + got = (hank_payload.get("kind"), hank_payload.get("size")) + if got != (kind, float(size)): + raise ValueError( + f"hank_payload was produced for kind {got[0]!r} size " + f"{got[1]!r}, not for {kind!r} size {size!r}; refusing to " + "mix them — pass the unmodified output of hank_shock" + ) + payload = hank_payload + else: + payload = hank_shock( + kind=kind, size=size, persistence=persistence, horizon=horizon, + variant=variant, + ) + ea = EconomicAssumptions.from_hank_result( + payload, year=year, income_concept=income_concept, + start_year=start_year, + ) + modifier = ea.input_scaling_modifier() + micro = _pe_population_incidence( + country="us", year=year, dataset=dataset, modifier=modifier, + label=f"HANK {kind} {size:+g} earnings incidence ({year})", + ) + + assumptions = ea.assumption_strings() + caveats = ea.caveat_strings() + [ + "automatic-stabilizer incidence of a stylized macro shock, not a " + "reform score: HANK has no PolicyEngine-reform bridge, and policy " + "is identical on both sides of this comparison", + ] + out = { + "model": "US HANK shock + PolicyEngine population microsimulation", + "country": "us", + "year": int(year), + "income_concept": income_concept, + "start_year": int(start_year), + "hank": payload, + "economic_assumptions": ea.model_dump(), + "application": { + "method": "input-scaling", + "variables_tried": list(SCALED_INPUT_VARIABLES), + "earnings_factor": ea.earnings_factor, + "applied": modifier is not None, + }, + "microsim": micro, + "assumptions": assumptions, + "caveats": caveats, + } + out["score"] = _incidence_score_block( + model_id="hank+microsim", + model_class="hank overlay on microsim", + analysis_type="shock incidence (experimental)", + country="us", + year=year, + reform={}, + micro=micro, + assumptions=assumptions, + caveats=caveats, + baseline=( + f"PolicyEngine baseline policy for {year}, with US HANK " + f"{kind} {size:+g} wage/labor deviations applied as a " + "pre-tax earnings overlay" + ), + data_vintage=( + f"PolicyEngine dataset {micro.get('dataset', dataset or 'default')}; " + "Auclert-Bardóczy-Rognlie-Straub (2021) calibration" + ), + horizon=f"annual {year}, transition-quarter average deviations", + basis_suffix=( + "from the macro shock through the automatic stabilizers " + "(no reform)" + ), + ) + return out + + +# The SHORT curated list of UK parameters that CPI inflation uprates by +# statute: working-age benefit and Child Benefit rates are uprated each +# April by the previous September's CPI (Social Security Administration Act +# 1992 s.150, as amended; annual Benefits Up-rating Orders). Deliberately +# EXCLUDED, and stated in caveats: the state pension (triple lock — highest +# of CPI, earnings, 2.5%, not a pure CPI link), income tax and NI +# thresholds (frozen by statute through the current freeze), Local Housing +# Allowance rates (frozen/periodically reset), and the benefit cap (not +# routinely uprated). Paths must resolve through pe.uk.model.get_parameter; +# svar_inflation_incidence errors clearly on any that do not. +SVAR_CPI_UPRATED_PARAMETERS = [ + { + "path": "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD", + "description": "Universal Credit standard allowance, single 25+", + "unit": "GBP per month", + }, + { + "path": "gov.dwp.universal_credit.standard_allowance.amount.COUPLE_OLD", + "description": "Universal Credit standard allowance, couple 25+", + "unit": "GBP per month", + }, + { + "path": "gov.dwp.universal_credit.elements.child.amount", + "description": "Universal Credit child element", + "unit": "GBP per month", + }, + { + "path": "gov.hmrc.child_benefit.amount.eldest", + "description": "Child Benefit, eldest or only child", + "unit": "GBP per week", + }, + { + "path": "gov.hmrc.child_benefit.amount.additional", + "description": "Child Benefit, additional children", + "unit": "GBP per week", + }, +] + +_SVAR_INCIDENCE_REFERENCES = ("obr", "target") + + +def _obr_efo_cpi_annual(year: int) -> float: + """Annual mean of the OBR EFO YoY CPI inflation path (CPIGR) for a year. + + Reads the March 2026 EFO economy tables packaged with the obr_macro + distribution. Raises with the reference='target' fallback named when the + package or the year is unavailable. + """ + try: + from obr_macro import load_obr_data + except ImportError as e: + raise ImportError( + "reference='obr' needs the obr_macro package for the EFO CPI " + f"path ({e}). Install it with: pip install " + "git+https://github.com/PolicyEngine/obr-macroeconomic-model — " + "or use reference='target' (a flat 2.0% path)." + ) from e + import pandas as pd + + series = load_obr_data()["CPIGR"].dropna() + quarters = [pd.Period(f"{year}Q{q}") for q in (1, 2, 3, 4)] + missing = [str(q) for q in quarters if q not in series.index] + if missing: + raise ValueError( + f"the packaged OBR EFO CPI path does not cover {missing} " + f"(available {series.index[0]}-{series.index[-1]}); use a year " + "inside that window or reference='target'." + ) + return float(sum(float(series.loc[q]) for q in quarters) / 4.0) + + +def svar_inflation_incidence( + year: int = 2027, + horizons: int = 12, + draws: int = _SVAR_DEFAULT_DRAWS, + reference: str = "obr", + dataset: str | None = None, +) -> dict: + """Household incidence of the SVAR-vs-reference CPI gap via uprating. + + A price-side mechanism, unlike the earnings-overlay incidence tools: + if the UK SVAR's median CPI inflation for ``year`` runs above (below) + the reference path, the statutory CPI uprating of working-age benefits + the following April is higher (lower). This tool builds that as a REAL + PolicyEngine reform — each parameter in SVAR_CPI_UPRATED_PARAMETERS is + scaled by (1 + gap/100) from 6 April ``year+1`` against its baseline + value in force then — and scores it with the UK population microsim + for ``year+1``. + + ``reference``: 'obr' (default) compares against the March 2026 EFO CPI + path packaged with obr_macro; 'target' against a flat 2.0%. The gap is + an annual-average approximation of the statutory September-CPI + reference month (stated in caveats). + + Deliberately NOT covered (see SVAR_CPI_UPRATED_PARAMETERS): the state + pension triple lock, frozen tax/NI thresholds, LHA, the benefit cap. + Runtime: svar_forecast takes a couple of minutes cold (cached + in-process, like forecast_uk on the hosted server) plus one microsim + run. UK only. + """ + year = int(year) + if reference not in _SVAR_INCIDENCE_REFERENCES: + raise ValueError( + f"reference must be one of {_SVAR_INCIDENCE_REFERENCES}, got " + f"{reference!r}: 'obr' is the March 2026 EFO CPI path, " + "'target' a flat 2.0%." + ) + + fc = svar_forecast(horizons=horizons, draws=draws) + quarters = [f"{year}Q{q}" for q in (1, 2, 3, 4)] + cpi_rows = {r["quarter"]: r for r in fc["cpi_inflation_yoy"]} + absent = [q for q in quarters if q not in cpi_rows] + if absent: + origin_year, origin_q = fc["forecast_origin"].split("Q") + needed = (year - int(origin_year)) * 4 + (4 - int(origin_q)) + raise ValueError( + f"the SVAR forecast (origin {fc['forecast_origin']}, horizons=" + f"{horizons}) does not cover {absent}; re-run with horizons>=" + f"{needed} (max {_SVAR_MAX_HORIZONS})." + ) + svar_cpi = sum(float(cpi_rows[q]["median"]) for q in quarters) / 4.0 + + if reference == "obr": + ref_cpi = _obr_efo_cpi_annual(year) + ref_desc = ("OBR March 2026 EFO CPI inflation path (CPIGR, annual " + f"mean of the {year} quarters)") + else: + ref_cpi = 2.0 + ref_desc = "flat 2.0% inflation target" + gap_pp = svar_cpi - ref_cpi + + pe = _import_pe() + from datetime import date + + if getattr(pe, "uk", None) is None or pe.uk.model is None: + raise RuntimeError( + "policyengine imported without its UK country model; install " + "policyengine[models] to resolve the uprated parameters." + ) + apply_date = f"{year + 1}-04-06" + factor = 1.0 + gap_pp / 100.0 + reform: dict[str, dict] = {} + parameters = [] + for entry in SVAR_CPI_UPRATED_PARAMETERS: + path = entry["path"] + try: + param = pe.uk.model.get_parameter(path) + except Exception as e: + raise ValueError( + f"could not resolve the curated uprating parameter {path!r} " + f"in the policyengine-uk parameter tree ({type(e).__name__}: " + f"{e}). The curated list (SVAR_CPI_UPRATED_PARAMETERS) has " + "gone stale against upstream — fix the list; refusing to " + "skip it silently." + ) from e + baseline_value = _pe_value_at(param, date(year + 1, 4, 6)) + if baseline_value is None: + raise ValueError( + f"the parameter {path!r} has no value in force on " + f"{apply_date}; cannot build the uprating counterfactual." + ) + baseline_value = float(baseline_value) + new_value = baseline_value * factor + reform[path] = {apply_date: new_value} + parameters.append({ + **entry, + "baseline_value": round(baseline_value, 6), + "counterfactual_value": round(new_value, 6), + }) + + micro = pe_population_impact( + country="uk", reform=reform, year=year + 1, dataset=dataset, + ) + # One authoritative score: drop the nested static-microsim score block, + # as dynamic_population_reform_impact does. + micro = dict(micro) + micro.pop("score", None) + + budget_bn = micro["budgetary_impact_bn"] + assumptions = [ + f"CPI gap: SVAR median YoY CPI for {year} " + f"({svar_cpi:+.2f}%) minus the reference ({ref_desc}: " + f"{ref_cpi:+.2f}%) = {gap_pp:+.2f}pp", + "statutory pass-through: working-age benefit and Child Benefit " + "rates are CPI-uprated each April (SSAA 1992 s.150 as amended), so " + f"the gap scales each curated parameter's {apply_date} baseline " + f"value by {factor:.5f}", + "annual-average approximation: the statute references September " + "CPI, this tool the annual mean of the year's four quarters", + ] + caveats = [ + "NOT included: the state pension (triple lock, not a pure CPI " + "link), income tax and NI thresholds (frozen by statute), Local " + "Housing Allowance, and the benefit cap — the true fiscal " + "sensitivity to inflation is larger than this curated slice", + "SVAR median only: the forecast's 68/90% bands are reported in the " + "embedded svar block but not propagated to the costing", + "price side only: no earnings or debt-interest channel is applied", + ] + out = { + "model": "UK SVAR CPI gap + PolicyEngine population microsimulation", + "country": "uk", + "year": int(year), + "uprating_year": year + 1, + "reference": reference, + "reference_description": ref_desc, + "svar_cpi_yoy_pct": round(svar_cpi, 3), + "reference_cpi_yoy_pct": round(ref_cpi, 3), + "cpi_gap_pp": round(gap_pp, 3), + "svar": { + "forecast_origin": fc["forecast_origin"], + "provenance": fc["provenance"], + "draws": fc["draws"], + "accepted_draws": fc["accepted_draws"], + "ess": fc["ess"], + "warnings": fc["warnings"], + "cpi_inflation_yoy": [cpi_rows[q] for q in quarters], + }, + "parameters": parameters, + "reform": reform, + "microsim": micro, + "assumptions": assumptions, + "caveats": caveats, + "headline": ( + f"If CPI inflation runs {gap_pp:+.2f}pp against the reference " + f"in {year}, the April {year + 1} uprating of these benefits " + f"{'saves' if budget_bn >= 0 else 'costs'} " + f"£{abs(budget_bn):.1f}bn/year." + ), + } + out["score"] = _incidence_score_block( + model_id="svar+microsim", + model_class="svar overlay on microsim", + analysis_type="inflation uprating incidence (experimental)", + country="uk", + year=year + 1, + reform=reform, + micro=micro, + assumptions=assumptions, + caveats=caveats, + baseline=( + f"PolicyEngine baseline policy for {year + 1}, uprating gap " + f"referenced to {ref_desc}" + ), + data_vintage=( + f"PolicyEngine dataset {micro.get('dataset', dataset or 'default')}; " + f"UK SVAR conditioned through {fc['forecast_origin']}" + ), + horizon=f"annual {year + 1} (April uprating of the {year} CPI gap)", + basis_suffix=( + "from CPI-gap-scaled statutory uprating of the curated " + "benefit parameters" + ), + ) + return out + + # --------------------------------------------------------------------------- # Unified reform scoring across the suite # --------------------------------------------------------------------------- @@ -2937,7 +3634,9 @@ def dynamic_population_reform_impact( "variable shocks are the supported entry point — use the frbus_shock " "tool (or `pe-macro frbus-shock`) with a lever and shock size in model " "units, and frbus_list_variables to discover the levers and their " - "units." + "units. For who bears a FRB/US shock at household resolution " + "(deciles, winners/losers through the automatic stabilizers), use " + "frbus_shock_incidence (`pe-macro frbus-shock-incidence`)." ), "hank": ( "The US HANK member has no PolicyEngine-reform bridge, by design: it " @@ -2947,7 +3646,10 @@ def dynamic_population_reform_impact( "inventing one would produce plausible-looking wrong numbers. Use the " "hank_shock tool (or `pe-macro hank-shock`) with a shock kind, size " "and persistence in model units; hank_summary documents the kinds " - "and their units." + "and their units. For who bears a HANK shock at household " + "resolution (deciles, winners/losers through the automatic " + "stabilizers), use hank_shock_incidence " + "(`pe-macro hank-shock-incidence`)." ), } SCORE_MODELS_WITHOUT_REFORM_BRIDGE["us-hank"] = ( diff --git a/integration/src/policyengine_macro/mcp_server.py b/integration/src/policyengine_macro/mcp_server.py index 38c39e5..2b7bef9 100644 --- a/integration/src/policyengine_macro/mcp_server.py +++ b/integration/src/policyengine_macro/mcp_server.py @@ -368,6 +368,171 @@ def hank_summary() -> dict: return core.hank_summary() +@mcp.tool() +def frbus_shock_incidence( + var: str, + shock: float, + year: int = 2027, + start: str = "2026Q1", + periods: int = 1, + horizon: int = 20, + policy_rule: str = "inertial_taylor", + income_concept: str = "wage_bill", + dataset: str | None = None, +) -> dict: + """Who bears a FRB/US shock, at household resolution (EXPERIMENTAL) — + the macro-to-micro incidence layer, NOT reform scoring (FRB/US still + has no PolicyEngine-reform bridge; score_reform keeps refusing + model='frbus'). + + Pipeline: frbus_shock with the real labour-market series (pl + compensation per hour, lhp hours, leh employment; % deviations from + baseline) -> the annual mean of the `year` quarters becomes a pre-tax + earnings factor -> the factor scales the US population microsim's + employment-income inputs with policy IDENTICAL on both sides. The + result is the shock's incidence through the AUTOMATIC STABILIZERS: + a negative earnings shock reduces tax revenue and raises means-tested + benefits (budgetary_impact_bn in $bn/year, positive = the shock + improves the government balance), with decile impacts and + winner/loser counts. + + Args: + var: FRB/US lever, e.g. 'rffintay_aerr' — see frbus_list_variables; + units differ per lever. + shock: Shock size in the lever's model units. + year: Calendar year averaged into the overlay and scored by the + microsim (default 2027; its four quarters must fall inside + start+horizon). + start / periods / horizon / policy_rule: As in frbus_shock. + income_concept: 'wage_bill' (default: pl+lhp — the aggregate + labour-income change, applied uniformly) or 'wage' (pl alone). + dataset: Optional microdata dataset name override. + + What this tool CANNOT do: score a tax reform (no reform bridge); + allocate job losses to individual households (uniform scaling + understates the concentration of real incidence in job losers — + stated in caveats). US only. Runtime: seconds for the FRB/US solve + plus a US microsim run (~tens of seconds warm; the first-ever call + downloads microdata). + + Returns the frbus payload, the economic_assumptions factors, an + `application` block, the microsim incidence block, and a common + `score` block (model 'frbus+microsim', result_type 'illustration'). + """ + return core.frbus_shock_incidence( + var=var, shock=shock, year=year, start=start, periods=periods, + horizon=horizon, policy_rule=policy_rule, + income_concept=income_concept, dataset=dataset, + ) + + +@mcp.tool() +def hank_shock_incidence( + kind: str, + size: float, + year: int = 2026, + persistence: float = 0.9, + horizon: int = 20, + variant: str = "two_asset", + income_concept: str = "wage_bill", + start_year: int = 2026, + dataset: str | None = None, +) -> dict: + """Who bears a US HANK shock, at household resolution (EXPERIMENTAL) — + the macro-to-micro incidence layer, NOT reform scoring (HANK still has + no PolicyEngine-reform bridge; score_reform keeps refusing + model='hank'). + + Pipeline: hank_shock — which surfaces the model's pre-tax real wage + (w) and labor (N) IRFs, % deviations from steady state, computed from + the GE sequence-space Jacobian — then the annual mean of the `year` + quarters becomes a pre-tax earnings factor scaling the US population + microsim's employment-income inputs, policy identical on both sides. + Reports the automatic-stabilizer budget change (budgetary_impact_bn, + $bn/year), decile impacts and winner/loser counts. + + Args: + kind / size / persistence / horizon / variant: As in hank_shock + (units differ per kind; see hank_summary). horizon must cover + the incidence year's quarters. + year: Calendar year averaged into the overlay and scored by the + microsim (default 2026). + income_concept: 'wage_bill' (default: w+N, applied uniformly) or + 'wage' (w alone). + start_year: Calendar year the shock's quarter 0 maps to (HANK + quarters are offsets from the shock start; default 2026). + dataset: Optional microdata dataset name override. + + What this tool CANNOT do: score a tax reform; forecast (HANK is a + stylized model around the Auclert-Bardóczy-Rognlie-Straub 2021 + calibrated steady state, not a US forecast baseline); allocate job + losses to individual households (uniform scaling — stated in + caveats). US only. + + Returns the hank payload, the economic_assumptions factors, an + `application` block, the microsim incidence block, and a common + `score` block (model 'hank+microsim', result_type 'illustration'). + """ + return core.hank_shock_incidence( + kind=kind, size=size, year=year, persistence=persistence, + horizon=horizon, variant=variant, income_concept=income_concept, + start_year=start_year, dataset=dataset, + ) + + +@mcp.tool() +def svar_inflation_incidence( + year: int = 2027, + horizons: Annotated[int, Field(ge=1, le=40)] = 12, + draws: Annotated[int, Field(ge=50, le=10_000)] = 2000, + reference: str = "obr", + dataset: str | None = None, +) -> dict: + """Cost and household incidence of the UK SVAR's CPI gap through + statutory benefit uprating (EXPERIMENTAL) — a price-side incidence + mechanism, unlike the earnings-overlay tools. + + Computes the CPI gap = the SVAR's median YoY CPI inflation for `year` + (annual mean of its four quarters, in %) minus the reference path, + then builds a REAL PolicyEngine reform: a short curated list of + statutorily CPI-uprated UK parameters (Universal Credit standard + allowances and child element, Child Benefit rates) each scaled by + (1 + gap/100) from 6 April `year+1` against its baseline value in + force then, scored with the UK population microsim for `year+1`. + budgetary_impact_bn is in £bn/year; a positive gap costs money + (negative budgetary impact) and lands in the deciles shown. + + Args: + year: Forecast year whose CPI gap drives the following April's + uprating (default 2027; must be within the SVAR horizon). + horizons: SVAR forecast horizon in quarters (1-40; default 12 — + must cover the year's four quarters). + draws: SVAR posterior draws (50-10,000; default 2000). The first + call estimates the SVAR — a couple of minutes cold, like + forecast_uk; results are cached in-process. + reference: 'obr' (default) — the March 2026 EFO CPI path packaged + with obr_macro — or 'target' — a flat 2.0%. + dataset: Optional microdata dataset name override. + + What this tool CANNOT do (stated in caveats): the state pension + (triple lock is not a pure CPI link), income tax and NI thresholds + (frozen by statute), Local Housing Allowance, and the benefit cap are + NOT included, so the true fiscal sensitivity to inflation is larger + than this curated slice; only the SVAR median is propagated, not its + uncertainty bands; the statutory September-CPI reference month is + approximated by the annual average. UK only. + + Returns the gap arithmetic, the curated parameters with baseline and + counterfactual values, the reform dict, the microsim result, and a + common `score` block (model 'svar+microsim', result_type + 'illustration'). + """ + return core.svar_inflation_incidence( + year=year, horizons=horizons, draws=draws, reference=reference, + dataset=dataset, + ) + + @mcp.tool() def forecast_uk( horizons: Annotated[int, Field(ge=1, le=40)] = 12, diff --git a/integration/tests/test_hank.py b/integration/tests/test_hank.py index 56cfdb7..3f5715c 100644 --- a/integration/tests/test_hank.py +++ b/integration/tests/test_hank.py @@ -144,11 +144,18 @@ def test_monetary_easing_is_expansionary_with_the_documented_shape(): assert "warning" not in res, res.get("warning") assert len(res["results"]) == core.HANK_DEFAULT_HORIZON assert res["shock_input"] == "rstar" - assert set(res["series_meaning"]) == {"Y", "C", "I", "pi", "r"} + assert set(res["series_meaning"]) == {"Y", "C", "I", "pi", "r", "w", "N"} first = res["results"][0] assert first["Y"] > 0, "an easing must be expansionary on impact" assert first["pi"] > 0, "an easing must raise inflation on impact" + # Ground-truth direction checks for the w/N series feeding the incidence + # bridge: higher demand raises labor and the real wage on impact, and + # under a standard production function the labor response must not be + # dwarfed by output (N moves with Y, not orders of magnitude below it). + assert first["N"] > 0, "an easing must raise labor on impact" + assert first["w"] > 0, "an easing must raise the real wage on impact" + assert first["N"] > 0.1 * first["Y"] assert res["peaks"]["Y"]["value"] > 0 assert res["peaks"]["C"]["value"] > 0 assert res["peaks"]["I"]["value"] > 0 @@ -199,7 +206,7 @@ def test_one_asset_variant_reports_no_investment_series(): res = core.hank_shock(kind="monetary", size=-0.0025, variant="one_asset", horizon=8) assert "I" not in res["results"][0] - assert set(res["series_meaning"]) == {"Y", "C", "pi", "r"} + assert set(res["series_meaning"]) == {"Y", "C", "pi", "r", "w", "N"} assert len(res["results"]) == 8 diff --git a/integration/tests/test_incidence.py b/integration/tests/test_incidence.py new file mode 100644 index 0000000..5640b54 --- /dev/null +++ b/integration/tests/test_incidence.py @@ -0,0 +1,563 @@ +"""Tests for the macro -> micro shock-incidence layer. + +Covers the two new EconomicAssumptions constructors (from_frbus_result, +from_hank_result), the reform-free _pe_population_incidence seam, the three +public incidence functions with stubbed model payloads, and the pointer +additions to the reform-bridge refusal messages. + +All fast tests are model-free (synthetic frbus/hank/svar payloads, fake +engine modules) per the repo's conventions; nothing here runs FRB/US, HANK, +the SVAR, or PolicyEngine. +""" + +from __future__ import annotations + +import json + +import pytest + +from policyengine_macro import core +from policyengine_macro.assumptions import ( + SCALED_INPUT_VARIABLES, + EconomicAssumptions, +) + + +# --------------------------------------------------------------------------- +# Synthetic payloads +# --------------------------------------------------------------------------- + +def _synthetic_frbus(pl=-1.0, lhp=-0.5, leh=-0.4, rff=-0.2, + start="2026Q1", quarters=8): + """A frbus_shock-shaped payload with constant deviations.""" + year, q = int(start[:4]), int(start[-1]) + rows = [] + for _ in range(quarters): + rows.append({ + "period": f"{year}Q{q}", + "xgdp": -0.3, "lur": 0.1, "picxfe": -0.05, "pcpi": -0.1, + "rff": rff, "pl": pl, "lhp": lhp, "leh": leh, + }) + q += 1 + if q == 5: + year, q = year + 1, 1 + return { + "name": "synthetic", "var": "trp_aerr", "shock": 0.01, + "results": rows, + } + + +def _synthetic_hank(w=1.0, n=0.5, r=-0.1, quarters=8): + """A hank_shock-shaped payload with constant deviations.""" + rows = [ + {"quarter": t, "Y": 1.5, "C": 1.0, "I": 2.0, "pi": 0.2, "r": r, + "w": w, "N": n, "shock": 0.01 * 0.9 ** t} + for t in range(quarters) + ] + return {"name": "synthetic", "kind": "productivity", "size": 0.01, + "results": rows} + + +# --------------------------------------------------------------------------- +# from_frbus_result +# --------------------------------------------------------------------------- + +def test_from_frbus_wage_bill_factor_math(): + ea = EconomicAssumptions.from_frbus_result( + _synthetic_frbus(pl=-1.0, lhp=-0.5), year=2027 + ) + assert ea.earnings_factor == pytest.approx(1 - 0.015) + assert ea.labour_supply_factor == pytest.approx(1 - 0.005) + assert ea.start_year == 2027 + # rff carried against the 0.0 baseline convention. + assert ea.interest_rate_baseline == 0.0 + assert ea.interest_rate_reform == pytest.approx(-0.2) + assert any("pre-tax" in n for n in ea.notes) + assert any("transition-quarter average" in n for n in ea.notes) + assert any("US" in n for n in ea.notes) + assert any("job losers" in c for c in ea.caveat_strings()) + json.dumps(ea.model_dump()) + + +def test_from_frbus_wage_concept_drops_hours(): + ea = EconomicAssumptions.from_frbus_result( + _synthetic_frbus(pl=-1.0, lhp=-0.5), year=2027, + income_concept="wage", + ) + assert ea.earnings_factor == pytest.approx(0.99) + assert ea.labour_supply_factor == pytest.approx(0.995) + assert any("reported, not applied" in n for n in ea.notes) + + +def test_from_frbus_rejects_unknown_income_concept(): + with pytest.raises(ValueError, match="income_concept"): + EconomicAssumptions.from_frbus_result( + _synthetic_frbus(), year=2027, income_concept="profits" + ) + + +def test_from_frbus_requires_the_labour_series(): + payload = _synthetic_frbus() + for row in payload["results"]: + del row["pl"], row["lhp"], row["leh"] + with pytest.raises(ValueError) as excinfo: + EconomicAssumptions.from_frbus_result(payload, year=2027) + # The error must tell the caller HOW to fix the call. + assert 'variables=["pl", "lhp", "leh"]' in str(excinfo.value) + + +def test_from_frbus_requires_the_years_quarters(): + with pytest.raises(ValueError, match="four quarters of 2031"): + EconomicAssumptions.from_frbus_result( + _synthetic_frbus(quarters=8), year=2031 + ) + + +def test_from_frbus_rejects_non_payloads(): + with pytest.raises(ValueError, match="frbus_shock"): + EconomicAssumptions.from_frbus_result({"nope": 1}, year=2027) + + +def test_from_frbus_plausibility_gate(): + with pytest.raises(ValueError, match="implausible"): + EconomicAssumptions.from_frbus_result( + _synthetic_frbus(pl=-60.0, lhp=0.0), year=2027 + ) + + +def test_from_frbus_noop_yields_no_modifier(): + """No-op invariant: zero deviations -> factor exactly 1.0 -> None.""" + ea = EconomicAssumptions.from_frbus_result( + _synthetic_frbus(pl=0.0, lhp=0.0, leh=0.0), year=2027 + ) + assert ea.earnings_factor == 1.0 + assert ea.input_scaling_modifier() is None + + +def test_from_frbus_notes_when_rff_absent(): + payload = _synthetic_frbus() + for row in payload["results"]: + del row["rff"] + ea = EconomicAssumptions.from_frbus_result(payload, year=2027) + assert ea.interest_rate_reform == 0.0 + assert any("rff was not in" in n for n in ea.notes) + + +# --------------------------------------------------------------------------- +# from_hank_result +# --------------------------------------------------------------------------- + +def test_from_hank_wage_bill_factor_math(): + ea = EconomicAssumptions.from_hank_result( + _synthetic_hank(w=1.0, n=0.5), year=2026 + ) + assert ea.earnings_factor == pytest.approx(1.015) + assert ea.labour_supply_factor == pytest.approx(1.005) + assert ea.interest_rate_reform == pytest.approx(-0.1) + assert any("pre-tax" in n for n in ea.notes) + assert any("job losers" in c for c in ea.caveat_strings()) + json.dumps(ea.model_dump()) + + +def test_from_hank_maps_year_to_quarter_offsets(): + """Quarters 4-7 are the second calendar year from start_year.""" + payload = _synthetic_hank(quarters=8) + for row in payload["results"][4:]: + row["w"], row["N"] = 2.0, 1.0 + ea = EconomicAssumptions.from_hank_result( + payload, year=2027, start_year=2026 + ) + assert ea.earnings_factor == pytest.approx(1.03) + + +def test_from_hank_requires_enough_horizon(): + with pytest.raises(ValueError, match="horizon >= 12"): + EconomicAssumptions.from_hank_result( + _synthetic_hank(quarters=8), year=2028, start_year=2026 + ) + + +def test_from_hank_rejects_year_before_start_year(): + with pytest.raises(ValueError, match="before start_year"): + EconomicAssumptions.from_hank_result( + _synthetic_hank(), year=2025, start_year=2026 + ) + + +def test_from_hank_requires_wage_and_labor_series(): + payload = _synthetic_hank() + for row in payload["results"]: + del row["w"], row["N"] + with pytest.raises(ValueError, match="re-run hank_shock"): + EconomicAssumptions.from_hank_result(payload, year=2026) + + +def test_from_hank_noop_yields_no_modifier(): + ea = EconomicAssumptions.from_hank_result( + _synthetic_hank(w=0.0, n=0.0), year=2026 + ) + assert ea.earnings_factor == 1.0 + assert ea.input_scaling_modifier() is None + + +def test_from_hank_plausibility_gate(): + with pytest.raises(ValueError, match="implausible"): + EconomicAssumptions.from_hank_result( + _synthetic_hank(w=80.0, n=40.0), year=2026 + ) + + +# --------------------------------------------------------------------------- +# Refusal-message pointers +# --------------------------------------------------------------------------- + +def test_frbus_refusal_points_to_the_incidence_tool(): + msg = core.SCORE_MODELS_WITHOUT_REFORM_BRIDGE["frbus"] + assert "frbus_shock_incidence" in msg + with pytest.raises(ValueError, match="frbus_shock_incidence"): + core.score_reform(country="us", reform={"x": 1.0}, model="frbus") + + +def test_hank_refusal_points_to_the_incidence_tool(): + for key in ("hank", "us-hank"): + assert "hank_shock_incidence" in ( + core.SCORE_MODELS_WITHOUT_REFORM_BRIDGE[key] + ) + + +# --------------------------------------------------------------------------- +# _pe_population_incidence seam: reform-free, Dynamic on the shocked side +# (fake engine modules, mirroring test_dynamic.py's fake_engine) +# --------------------------------------------------------------------------- + +class _FakeDS: + name = "fake-ds" + + class data: + household = [0] * 3 + + +class _FakeAggregate: + def __init__(self, simulation=None, variable=None, aggregate_type=None, + entity=None): + self.result = 0.0 + + def run(self): + pass + + +class _FakeDynamic: + def __init__(self, name=None, simulation_modifier=None, + affects_labor_supply_response=None): + self.name = name + self.simulation_modifier = simulation_modifier + self.affects_labor_supply_response = affects_labor_supply_response + + +class _CaptureSimulation: + captured: list = [] + + def __init__(self, **kwargs): + _CaptureSimulation.captured.append(kwargs) + + def run(self): + pass + + +@pytest.fixture +def fake_engine(monkeypatch): + import sys + import types + + _CaptureSimulation.captured = [] + pe_mod = types.ModuleType("policyengine") + pe_mod.us = types.SimpleNamespace(model="fake-us-model") + pe_mod.uk = types.SimpleNamespace(model="fake-uk-model") + core_mod = types.ModuleType("policyengine.core") + core_mod.Simulation = _CaptureSimulation + core_mod.Dynamic = _FakeDynamic + outputs_mod = types.ModuleType("policyengine.outputs") + agg_mod = types.ModuleType("policyengine.outputs.aggregate") + agg_mod.Aggregate = _FakeAggregate + agg_mod.AggregateType = types.SimpleNamespace(SUM="sum") + dec_mod = types.ModuleType("policyengine.outputs.decile_impact") + dec_mod.calculate_decile_impacts = ( + lambda **kw: types.SimpleNamespace(outputs=[]) + ) + for name, mod in { + "policyengine": pe_mod, + "policyengine.core": core_mod, + "policyengine.outputs": outputs_mod, + "policyengine.outputs.aggregate": agg_mod, + "policyengine.outputs.decile_impact": dec_mod, + }.items(): + monkeypatch.setitem(sys.modules, name, mod) + monkeypatch.setattr(core, "_import_pe", lambda: pe_mod) + monkeypatch.setattr( + core, "_pe_pop_baseline", lambda c, y, d: (_FakeDS(), object()) + ) + return _CaptureSimulation + + +def test_incidence_seam_attaches_dynamic_and_no_policy(fake_engine): + sentinel = object() + res = core._pe_population_incidence( + country="us", year=2027, dataset=None, modifier=sentinel, + label="test overlay", + ) + (sim_kwargs,) = fake_engine.captured + assert "policy" not in sim_kwargs, "incidence runs must carry NO reform" + dyn = sim_kwargs["dynamic"] + assert dyn.simulation_modifier is sentinel + assert dyn.affects_labor_supply_response is False + assert res["currency"] == "USD" + assert "reform" not in res + assert "automatic stabilizers" in res["headline"] + json.dumps(res) + + +def test_incidence_seam_none_modifier_attaches_no_dynamic(fake_engine): + res = core._pe_population_incidence( + country="us", year=2027, dataset=None, modifier=None, + label="null overlay", + ) + (sim_kwargs,) = fake_engine.captured + assert "dynamic" not in sim_kwargs + assert res["budgetary_impact_bn"] == 0.0 + + +# --------------------------------------------------------------------------- +# Wiring: the public incidence functions with stubbed models + microsim +# --------------------------------------------------------------------------- + +_FAKE_MICRO = { + "model": "PolicyEngine population microsimulation (macro-shock incidence)", + "country": "us", "year": 2027, "dataset": "fake-cps", + "n_households": 3, "currency": "USD", "shock_label": "x", + "budgetary_impact_bn": -12.3, + "budgetary_impact_basis": "change in household_tax minus change in " + "household_benefits", + "household_net_income_change_bn": -40.0, + "decile_impacts": [], "winners": 0, "losers": 100, + "headline": "Through the automatic stabilizers, ...", +} + + +@pytest.fixture +def fake_frbus_incidence(monkeypatch): + calls = {} + + def fake_shock(**kwargs): + calls["frbus"] = kwargs + return _synthetic_frbus(pl=-1.0, lhp=-0.5, quarters=8) + + def fake_incidence(country, year, dataset, modifier, label): + calls["micro"] = {"country": country, "year": year, + "modifier": modifier, "label": label} + return dict(_FAKE_MICRO) + + monkeypatch.setattr(core, "frbus_shock", fake_shock) + monkeypatch.setattr(core, "_pe_population_incidence", fake_incidence) + return calls + + +def test_frbus_shock_incidence_wiring(fake_frbus_incidence): + res = core.frbus_shock_incidence(var="trp_aerr", shock=0.01, year=2027) + # The labour series were requested from frbus_shock. + assert fake_frbus_incidence["frbus"]["variables"] == ["pl", "lhp", "leh"] + micro = fake_frbus_incidence["micro"] + assert micro["country"] == "us" + assert micro["year"] == 2027 + assert callable(micro["modifier"]) + assert res["application"]["method"] == "input-scaling" + assert res["application"]["applied"] is True + assert res["application"]["variables_tried"] == list(SCALED_INPUT_VARIABLES) + assert res["economic_assumptions"]["earnings_factor"] == pytest.approx(0.985) + assert res["score"]["model"] == "frbus+microsim" + assert res["score"]["result_type"] == "illustration" + assert res["score"]["analysis_type"] == "shock incidence (experimental)" + assert res["score"]["reform"] == {} + assert "automatic stabilizers" in res["score"]["quantities"]["revenue"]["basis"] + assert any("not a reform score" in c for c in res["caveats"]) + json.dumps(res) + + +def test_frbus_shock_incidence_noop_attaches_no_modifier(monkeypatch): + monkeypatch.setattr( + core, "frbus_shock", + lambda **kw: _synthetic_frbus(pl=0.0, lhp=0.0, leh=0.0), + ) + seen = {} + + def fake_incidence(country, year, dataset, modifier, label): + seen["modifier"] = modifier + return dict(_FAKE_MICRO) + + monkeypatch.setattr(core, "_pe_population_incidence", fake_incidence) + res = core.frbus_shock_incidence(var="trp_aerr", shock=0.0, year=2027) + assert seen["modifier"] is None + assert res["application"]["applied"] is False + + +def test_hank_shock_incidence_wiring(monkeypatch): + calls = {} + + def fake_shock(**kwargs): + calls["hank"] = kwargs + return _synthetic_hank(w=1.0, n=0.5, quarters=8) + + def fake_incidence(country, year, dataset, modifier, label): + calls["micro"] = {"country": country, "year": year, + "modifier": modifier} + return dict(_FAKE_MICRO) + + monkeypatch.setattr(core, "hank_shock", fake_shock) + monkeypatch.setattr(core, "_pe_population_incidence", fake_incidence) + res = core.hank_shock_incidence(kind="productivity", size=0.01, year=2026) + assert calls["hank"]["kind"] == "productivity" + assert calls["micro"]["country"] == "us" + assert callable(calls["micro"]["modifier"]) + assert res["economic_assumptions"]["earnings_factor"] == pytest.approx(1.015) + assert res["score"]["model"] == "hank+microsim" + assert res["score"]["result_type"] == "illustration" + json.dumps(res) + + +# --------------------------------------------------------------------------- +# svar_inflation_incidence with a stubbed forecast, parameter tree, microsim +# --------------------------------------------------------------------------- + +def _fake_forecast(cpi_median=3.0, origin="2026Q1", horizons=12): + year, q = int(origin[:4]), int(origin[-1]) + rows = [] + for _ in range(horizons): + q += 1 + if q == 5: + year, q = year + 1, 1 + rows.append({"quarter": f"{year}Q{q}", "median": cpi_median, + "lo68": cpi_median - 1, "hi68": cpi_median + 1, + "lo90": cpi_median - 2, "hi90": cpi_median + 2}) + return { + "forecast_origin": origin, + "provenance": {"model_id": "boe-svar"}, + "draws": 2000, "accepted_draws": 135, "ess": 65.3, "warnings": [], + "cpi_inflation_yoy": rows, + "gdp_growth_yoy": rows, + } + + +class _FakeParamValue: + def __init__(self, start, value): + from datetime import date + + y, m, d = (int(x) for x in start.split("-")) + self.start_date = date(y, m, d) + self.value = value + + +class _FakeParam: + def __init__(self, value): + self.parameter_values = [_FakeParamValue("2020-04-06", value)] + + +@pytest.fixture +def fake_svar_incidence(monkeypatch): + import sys + import types + + calls = {} + monkeypatch.setattr( + core, "svar_forecast", + lambda horizons, draws: _fake_forecast(horizons=horizons), + ) + values = {p["path"]: 100.0 for p in core.SVAR_CPI_UPRATED_PARAMETERS} + + class _FakeModel: + @staticmethod + def get_parameter(path): + return _FakeParam(values[path]) + + pe_mod = types.ModuleType("policyengine") + pe_mod.uk = types.SimpleNamespace(model=_FakeModel()) + monkeypatch.setitem(sys.modules, "policyengine", pe_mod) + monkeypatch.setattr(core, "_import_pe", lambda: pe_mod) + + def fake_pop(country, reform, year, dataset=None): + calls["micro"] = {"country": country, "reform": reform, "year": year} + return { + "currency": "GBP", "dataset": "fake-frs", + "budgetary_impact_bn": -0.8, + "budgetary_impact_basis": "change in gov_balance", + "household_net_income_change_bn": 0.8, + "decile_impacts": [], "winners": 100, "losers": 0, + "headline": "The reform costs £0.8bn/year in 2028.", + "score": {"model": "pe-microsim"}, + } + + monkeypatch.setattr(core, "pe_population_impact", fake_pop) + calls["values"] = values + return calls + + +def test_svar_inflation_incidence_builds_the_scaled_reform(fake_svar_incidence): + res = core.svar_inflation_incidence(year=2027, reference="target") + micro_call = fake_svar_incidence["micro"] + assert micro_call["country"] == "uk" + assert micro_call["year"] == 2028, "the uprating lands the FOLLOWING year" + # gap = 3.0 - 2.0 = 1.0pp; every parameter scaled by 1.01 from 6 April. + assert res["cpi_gap_pp"] == pytest.approx(1.0) + for entry in core.SVAR_CPI_UPRATED_PARAMETERS: + assert micro_call["reform"][entry["path"]] == { + "2028-04-06": pytest.approx(101.0) + } + assert res["score"]["model"] == "svar+microsim" + assert res["score"]["result_type"] == "illustration" + # One authoritative score: the nested static block is stripped. + assert "score" not in res["microsim"] + assert any("triple lock" in c for c in res["caveats"]) + assert "costs" in res["headline"] + json.dumps(res) + + +def test_svar_inflation_incidence_rejects_bad_reference(fake_svar_incidence): + with pytest.raises(ValueError, match="reference"): + core.svar_inflation_incidence(year=2027, reference="boe") + + +def test_svar_inflation_incidence_requires_horizon_coverage(fake_svar_incidence): + with pytest.raises(ValueError, match="horizons>="): + core.svar_inflation_incidence(year=2031, reference="target") + + +def test_svar_inflation_incidence_errors_on_unresolvable_parameter( + fake_svar_incidence, +): + """A stale curated path must error loudly, never be skipped.""" + del fake_svar_incidence["values"][ + core.SVAR_CPI_UPRATED_PARAMETERS[0]["path"] + ] + with pytest.raises(ValueError, match="refusing to skip"): + core.svar_inflation_incidence(year=2027, reference="target") + + +def test_svar_curated_list_is_short_and_documented(): + assert 3 <= len(core.SVAR_CPI_UPRATED_PARAMETERS) <= 6 + for entry in core.SVAR_CPI_UPRATED_PARAMETERS: + assert entry["path"].startswith("gov.") + assert entry["description"] and entry["unit"] + + +def test_frbus_incidence_refuses_mismatched_payload(): + with pytest.raises(ValueError, match="refusing to mix"): + core.frbus_shock_incidence( + var="rffintay_aerr", shock=1.0, + frbus_payload={"var": "trp_aerr", "shock": 0.01}, + ) + + +def test_hank_incidence_refuses_mismatched_payload(): + with pytest.raises(ValueError, match="refusing to mix"): + core.hank_shock_incidence( + kind="monetary", size=-0.0025, + hank_payload={"kind": "fiscal_spending", "size": 0.01}, + ) diff --git a/integration/tests/tool_surface.py b/integration/tests/tool_surface.py index 4270269..ec32d4f 100644 --- a/integration/tests/tool_surface.py +++ b/integration/tests/tool_surface.py @@ -21,9 +21,12 @@ "format_score_report", "frbus_list_variables", "frbus_shock", + "frbus_shock_incidence", "frbus_summary", "hank_shock", + "hank_shock_incidence", "hank_summary", + "svar_inflation_incidence", "household_reform_impact", "latest_shocks", "list_model_capabilities", @@ -38,7 +41,7 @@ } ) -GOLDEN_TOOL_COUNT = 20 +GOLDEN_TOOL_COUNT = 23 assert len(GOLDEN_TOOLS) == GOLDEN_TOOL_COUNT diff --git a/models/index.html b/models/index.html index 0e0b108..a4137b1 100644 --- a/models/index.html +++ b/models/index.html @@ -4,10 +4,10 @@ Models — PolicyEngine Macro - + - + @@ -42,7 +42,7 @@

model catalog

Choose the model that fits the question.

-

Six open UK and US models cover household policy, fiscal and monetary shocks, near-term forecasts, and long-run structural change.

+

Seven open UK and US models cover household policy, fiscal and monetary shocks, near-term forecasts, long-run structural change, and — experimentally — climate-policy scenarios.