From d82b42a031bded4ef458c01b49aa555481a25d8a Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Wed, 8 Apr 2026 11:23:30 +0100 Subject: [PATCH 1/2] feat: add structural reform hooks to Python wrapper Adds StructuralReform(pre=..., post=...) with the signature: hook(year, persons, benunits, households) -> (persons, benunits, households) The year argument enables multi-year reforms. Pre-hooks mutate input DataFrames before the Rust binary runs; post-hooks mutate microdata output after the binary produces results, with aggregation then done in Python. Also extracts a standalone aggregate_microdata() function used both by post-hook runs and (replacing the old _aggregate_persons_only) for persons-only datasets. --- .../policyengine_uk_compiled/__init__.py | 2 + .../python/policyengine_uk_compiled/engine.py | 171 ++++++++++- .../policyengine_uk_compiled/structural.py | 282 ++++++++++++++++++ 3 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 interfaces/python/policyengine_uk_compiled/structural.py diff --git a/interfaces/python/policyengine_uk_compiled/__init__.py b/interfaces/python/policyengine_uk_compiled/__init__.py index 80335b6..3df0ef0 100644 --- a/interfaces/python/policyengine_uk_compiled/__init__.py +++ b/interfaces/python/policyengine_uk_compiled/__init__.py @@ -67,10 +67,12 @@ def print_guide(): BENUNIT_DEFAULTS, HOUSEHOLD_DEFAULTS, ) +from policyengine_uk_compiled.structural import StructuralReform from policyengine_uk_compiled.data import download_all, ensure_year, ensure_dataset, DATASETS __all__ = [ "Simulation", + "StructuralReform", "PERSON_DEFAULTS", "BENUNIT_DEFAULTS", "HOUSEHOLD_DEFAULTS", diff --git a/interfaces/python/policyengine_uk_compiled/engine.py b/interfaces/python/policyengine_uk_compiled/engine.py index b222ff9..597e330 100644 --- a/interfaces/python/policyengine_uk_compiled/engine.py +++ b/interfaces/python/policyengine_uk_compiled/engine.py @@ -15,6 +15,7 @@ HAS_PANDAS = False from policyengine_uk_compiled.models import MicrodataResult, Parameters, SimulationResult, HbaiIncomes, PovertyHeadcounts +from policyengine_uk_compiled.structural import StructuralReform, aggregate_microdata # The binary and parameters/ dir are bundled inside the package at build time. _PKG_DIR = Path(__file__).resolve().parent @@ -95,6 +96,30 @@ def _build_stdin_payload(persons_csv: str, benunits_csv: str, households_csv: st ) +def _parse_stdin_payload(payload: str): + """Parse a stdin protocol payload back into three DataFrames.""" + import io + import pandas as pd + sections: dict[str, str] = {} + current_name = None + current_lines: list[str] = [] + for line in payload.split("\n"): + if line.startswith("===") and line.endswith("==="): + if current_name is not None: + sections[current_name] = "\n".join(current_lines) + current_name = line.strip("=").lower() + current_lines = [] + else: + current_lines.append(line) + if current_name is not None: + sections[current_name] = "\n".join(current_lines) + return ( + pd.read_csv(io.StringIO(sections.get("persons", ""))), + pd.read_csv(io.StringIO(sections.get("benunits", ""))), + pd.read_csv(io.StringIO(sections.get("households", ""))), + ) + + def _parse_microdata_stdout(raw: str) -> MicrodataResult: """Parse the concatenated CSV protocol output into a MicrodataResult.""" sections = {} @@ -308,9 +333,29 @@ class Simulation: sim = Simulation(year=2025, data_dir="data/frs/2023") result = sim.run() - # With a reform + # With a parametric reform reform = Parameters(income_tax=IncomeTaxParams(personal_allowance=20000)) result = sim.run(policy=reform) + + # With a structural reform (pre-hook: mutate inputs before simulation) + from policyengine_uk_compiled import StructuralReform + + def cap_wages(year, persons, benunits, households): + persons["employment_income"] = persons["employment_income"].clip(upper=100_000) + return persons, benunits, households + + result = sim.run(structural=StructuralReform(pre=cap_wages)) + + # With a structural reform (post-hook: adjust outputs after simulation) + def add_ubi(year, persons, benunits, households): + ubi = 50 * 52 # £50/wk per adult + adults = persons["age"] >= 18 + adult_counts = persons[adults].groupby("household_id").size() + households["reform_net_income"] += households["household_id"].map(adult_counts).fillna(0) * ubi + households["reform_total_tax"] = households["baseline_total_tax"] # unchanged + return persons, benunits, households + + result = sim.run(structural=StructuralReform(post=add_ubi)) """ def __init__( @@ -340,10 +385,17 @@ def __init__( self._frs_raw = frs_raw self._dataset = dataset self._persons_only = dataset in ("spi",) + # Store DataFrames when passed directly so pre-hooks can use them + self._persons_df = None + self._benunits_df = None + self._households_df = None if persons is not None and benunits is not None and households is not None: # DataFrame or CSV string mode if HAS_PANDAS and hasattr(persons, "to_csv"): + self._persons_df = persons + self._benunits_df = benunits + self._households_df = households persons_csv = _df_to_csv(persons) benunits_csv = _df_to_csv(benunits) households_csv = _df_to_csv(households) @@ -361,10 +413,70 @@ def __init__( elif data_dir is not None: self._data_dir = str(data_dir) - def _build_cmd(self, policy: Optional[Parameters] = None, extra_args: Optional[list[str]] = None) -> list[str]: + def _apply_pre_hook(self, structural: Optional[StructuralReform]) -> Optional[str]: + """Apply the pre-hook if present and return a stdin payload string. + + For file-based data sources, loads the CSVs into DataFrames first so + the hook can mutate them, then re-serialises to the stdin protocol. + Returns None if there is no pre-hook (caller uses the original payload). + """ + if structural is None or structural.pre is None: + return self._stdin_payload # unchanged + + if not HAS_PANDAS: + raise ImportError("pandas is required for structural pre-hooks") + + import io + import pandas as pd + + # Obtain DataFrames — either already stored or loaded from files + if self._persons_df is not None: + persons = self._persons_df.copy() + benunits = self._benunits_df.copy() + households = self._households_df.copy() + elif self._stdin_payload is not None: + # Parse the existing stdin payload back into DataFrames + parsed = _parse_stdin_payload(self._stdin_payload) + persons = parsed[0] + benunits = parsed[1] + households = parsed[2] + else: + # File-based source: load the CSVs from disk + data_path = self._resolve_data_path() + import os + year_dir = os.path.join(data_path, str(self.year)) + if not os.path.isdir(year_dir): + # Try direct path (data_dir may already include year) + year_dir = data_path + persons = pd.read_csv(os.path.join(year_dir, "persons.csv")) + benunits = pd.read_csv(os.path.join(year_dir, "benunits.csv")) + households = pd.read_csv(os.path.join(year_dir, "households.csv")) + + persons, benunits, households = structural.pre( + self.year, persons, benunits, households + ) + return _build_stdin_payload( + _df_to_csv(persons), _df_to_csv(benunits), _df_to_csv(households) + ) + + def _resolve_data_path(self) -> str: + """Return the base data directory for the current configuration.""" + if self._data_dir: + return self._data_dir + if self._clean_frs_base: + return self._clean_frs_base + if self._clean_frs: + return self._clean_frs + if self._dataset is not None: + from policyengine_uk_compiled.data import ensure_dataset + return ensure_dataset(self._dataset, self.year) + from policyengine_uk_compiled.data import ensure_frs + return ensure_frs(self.year) + + def _build_cmd(self, policy: Optional[Parameters] = None, extra_args: Optional[list[str]] = None, stdin_override: bool = False) -> list[str]: cmd = [self.binary_path, "--year", str(self.year)] - if self._stdin_payload is not None: + if self._stdin_payload is not None or stdin_override: cmd.append("--stdin-data") elif self._data_dir: cmd += ["--data", self._data_dir] @@ -397,22 +509,38 @@ def _build_cmd(self, policy: Optional[Parameters] = None, extra_args: Optional[l return cmd - def run(self, policy: Optional[Parameters] = None, timeout: int = 120) -> SimulationResult: + def run( + self, + policy: Optional[Parameters] = None, + structural: Optional[StructuralReform] = None, + timeout: int = 120, + ) -> SimulationResult: """Run the simulation and return typed results. Args: - policy: Reform parameters (overlay on baseline). None = baseline only. + policy: Parametric reform overlay (changes parameter values). + structural: Structural reform with optional pre/post hooks. + pre(year, persons, benunits, households) mutates inputs before + the binary runs. post(year, persons, benunits, households) + mutates microdata outputs; aggregation is then done in Python. timeout: Maximum seconds to wait for the binary. Returns: SimulationResult with budgetary impact, program breakdown, decile impacts, etc. - For persons-only datasets (e.g. SPI), household/benefit fields are zeroed. """ - cmd = self._build_cmd(policy, extra_args=["--output", "json"]) + # If a post-hook is present we must go through microdata and re-aggregate + if structural is not None and structural.post is not None: + microdata = self.run_microdata(policy=policy, structural=structural, timeout=timeout) + return aggregate_microdata( + microdata.persons, microdata.benunits, microdata.households, self.year + ) + + stdin_payload = self._apply_pre_hook(structural) + cmd = self._build_cmd(policy, extra_args=["--output", "json"], stdin_override=stdin_payload is not None) cwd = _find_cwd(self.binary_path) result = subprocess.run( cmd, - input=self._stdin_payload, + input=stdin_payload, capture_output=True, text=True, timeout=timeout, @@ -428,16 +556,24 @@ def run(self, policy: Optional[Parameters] = None, timeout: int = 120) -> Simula return SimulationResult(**data) def run_microdata( - self, policy: Optional[Parameters] = None, timeout: int = 120 + self, + policy: Optional[Parameters] = None, + structural: Optional[StructuralReform] = None, + timeout: int = 120, ) -> MicrodataResult: - """Run the simulation and return per-entity microdata as DataFrames.""" + """Run the simulation and return per-entity microdata as DataFrames. + + If a structural post-hook is provided it is applied to the DataFrames + after the binary produces its output. + """ if not HAS_PANDAS: raise ImportError("pandas is required for run_microdata") - cmd = self._build_cmd(policy, extra_args=["--output-microdata-stdout"]) + stdin_payload = self._apply_pre_hook(structural) + cmd = self._build_cmd(policy, extra_args=["--output-microdata-stdout"], stdin_override=stdin_payload is not None) cwd = _find_cwd(self.binary_path) result = subprocess.run( cmd, - input=self._stdin_payload, + input=stdin_payload, capture_output=True, text=True, timeout=timeout, @@ -447,7 +583,16 @@ def run_microdata( raise RuntimeError( f"Simulation failed (exit {result.returncode}):\n{result.stderr}" ) - return _parse_microdata_stdout(result.stdout) + microdata = _parse_microdata_stdout(result.stdout) + if structural is not None and structural.post is not None: + persons, benunits, households = structural.post( + self.year, + microdata.persons.copy(), + microdata.benunits.copy(), + microdata.households.copy(), + ) + return MicrodataResult(persons=persons, benunits=benunits, households=households) + return microdata def get_baseline_params(self, timeout: int = 10) -> dict: """Export the baseline parameters for the configured year as a dict.""" diff --git a/interfaces/python/policyengine_uk_compiled/structural.py b/interfaces/python/policyengine_uk_compiled/structural.py new file mode 100644 index 0000000..a1c3da9 --- /dev/null +++ b/interfaces/python/policyengine_uk_compiled/structural.py @@ -0,0 +1,282 @@ +"""Structural reform hooks and Python-side aggregation. + +A StructuralReform holds two optional callables: + + pre(year, persons, benunits, households) -> (persons, benunits, households) + Runs before the Rust binary sees the data. Use to mutate input + columns — add a new income source, change household composition, + set benefit eligibility flags, etc. + + post(year, persons, benunits, households) -> (persons, benunits, households) + Runs after the binary produces microdata output. All + baseline_*/reform_* columns are populated at this point. Use to + apply a new tax on top of simulated results, offset a benefit, + impose a cap, etc. Aggregation is then done in Python rather than + by the binary. + +Both hooks receive and must return all three DataFrames even if only one is +modified, so the caller can always unpack a consistent triple. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +# Type alias for the hook signature +HookFn = Callable[ + [int, "pd.DataFrame", "pd.DataFrame", "pd.DataFrame"], + tuple["pd.DataFrame", "pd.DataFrame", "pd.DataFrame"], +] + + +@dataclass +class StructuralReform: + """Container for pre- and post-simulation structural reform hooks. + + Both hooks are optional. Omit whichever you don't need. + + Hook signature (same for pre and post): + + def hook( + year: int, + persons: pd.DataFrame, + benunits: pd.DataFrame, + households: pd.DataFrame, + ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + ... + return persons, benunits, households + + Example — add a £50/wk UBI to every adult's reform net income:: + + def ubi_post(year, persons, benunits, households): + ubi_annual = 50 * 52 + mask = persons["age"] >= 18 + persons.loc[mask, "reform_income_tax"] = 0 # illustrative + households["reform_net_income"] += ubi_annual # per-household + return persons, benunits, households + + reform = StructuralReform(post=ubi_post) + + Example — replace employment income with a flat wage in 2025 only:: + + def flat_wage_pre(year, persons, benunits, households): + if year == 2025: + persons["employment_income"] = persons["employment_income"].clip(upper=50_000) + return persons, benunits, households + + reform = StructuralReform(pre=flat_wage_pre) + """ + + pre: Optional[HookFn] = field(default=None) + post: Optional[HookFn] = field(default=None) + + +# ── Python-side aggregation ─────────────────────────────────────────────────── +# +# Used whenever a post-hook is present (or for persons-only datasets). +# Reads the microdata columns produced by the Rust binary and aggregates +# them into a SimulationResult. Column names mirror write_microdata_csv_* in +# src/data/clean.rs. + + +def aggregate_microdata( + persons: "pd.DataFrame", + benunits: "pd.DataFrame", + households: "pd.DataFrame", + year: int, +) -> "SimulationResult": # noqa: F821 – imported lazily to avoid circular import + """Aggregate post-simulation microdata DataFrames into a SimulationResult. + + This mirrors the aggregation logic in src/main.rs but runs in Python, + allowing post-hooks to modify result columns before the final roll-up. + + Deciles and winners/losers are based on reform_net_income (equivalised by + equivalisation_factor where available). This approximates the Rust engine's + use of extended_net_income; the difference only matters for VAT/stamp duty/ + wealth-tax reforms, which are unlikely to be applied as post-hooks. + """ + import numpy as np + from policyengine_uk_compiled.models import ( + BudgetaryImpact, IncomeBreakdown, ProgramBreakdown, Caseloads, + DecileImpact, WinnersLosers, SimulationResult, + HbaiIncomes, PovertyHeadcounts, + ) + + w = households["weight"].values + + # ── Budgetary impact ────────────────────────────────────────────────────── + baseline_revenue = (w * households["baseline_total_tax"].values).sum() + reform_revenue = (w * households["reform_total_tax"].values).sum() + baseline_benefits = (w * households["baseline_total_benefits"].values).sum() + reform_benefits = (w * households["reform_total_benefits"].values).sum() + revenue_change = reform_revenue - baseline_revenue + benefit_change = reform_benefits - baseline_benefits + net_cost = -revenue_change + benefit_change + + # ── Income breakdown (from person-level inputs) ─────────────────────────── + # Persons need to be joined to household weights via household_id + p_with_w = persons.merge( + households[["household_id", "weight"]], on="household_id", how="left" + ) + pw = p_with_w["weight"].fillna(1.0).values + + def _wsum(col: str) -> float: + return float((pw * p_with_w[col].fillna(0.0).values).sum()) if col in p_with_w.columns else 0.0 + + income_breakdown = IncomeBreakdown( + employment_income=_wsum("employment_income"), + self_employment_income=_wsum("self_employment_income"), + pension_income=_wsum("private_pension_income"), + savings_interest_income=_wsum("savings_interest"), + dividend_income=_wsum("dividend_income"), + property_income=_wsum("property_income"), + other_income=_wsum("other_income"), + ) + + # ── Program breakdown (benunit-level benefits, weighted by household) ───── + bu_with_w = benunits.merge( + households[["household_id", "weight"]], on="household_id", how="left" + ) + bw = bu_with_w["weight"].fillna(1.0).values + + def _bwsum(col: str) -> float: + return float((bw * bu_with_w[col].fillna(0.0).values).sum()) if col in bu_with_w.columns else 0.0 + + # Person-level tax totals + it_reform = float((pw * p_with_w["reform_income_tax"].fillna(0.0).values).sum()) if "reform_income_tax" in p_with_w.columns else 0.0 + eni_reform = float((pw * p_with_w["reform_employee_ni"].fillna(0.0).values).sum()) if "reform_employee_ni" in p_with_w.columns else 0.0 + enr_reform = float((pw * p_with_w["reform_employer_ni"].fillna(0.0).values).sum()) if "reform_employer_ni" in p_with_w.columns else 0.0 + + program_breakdown = ProgramBreakdown( + income_tax=it_reform, + employee_ni=eni_reform, + employer_ni=enr_reform, + universal_credit=_bwsum("reform_universal_credit"), + child_benefit=_bwsum("reform_child_benefit"), + state_pension=_bwsum("reform_state_pension"), + pension_credit=_bwsum("reform_pension_credit"), + housing_benefit=_bwsum("reform_housing_benefit"), + child_tax_credit=_bwsum("reform_child_tax_credit"), + working_tax_credit=_bwsum("reform_working_tax_credit"), + income_support=_bwsum("reform_income_support"), + esa_income_related=_bwsum("reform_esa_income_related"), + jsa_income_based=_bwsum("reform_jsa_income_based"), + carers_allowance=_bwsum("reform_carers_allowance"), + scottish_child_payment=_bwsum("reform_scottish_child_payment"), + benefit_cap_reduction=_bwsum("reform_benefit_cap_reduction"), + passthrough_benefits=_bwsum("reform_passthrough_benefits"), + ) + + # ── Caseloads ───────────────────────────────────────────────────────────── + caseloads = Caseloads( + income_tax_payers=float((pw * (p_with_w.get("reform_income_tax", 0) > 0)).sum()) if "reform_income_tax" in p_with_w.columns else 0.0, + ni_payers=float((pw * (p_with_w.get("reform_employee_ni", 0) > 0)).sum()) if "reform_employee_ni" in p_with_w.columns else 0.0, + employer_ni_payers=float((pw * (p_with_w.get("reform_employer_ni", 0) > 0)).sum()) if "reform_employer_ni" in p_with_w.columns else 0.0, + universal_credit=float((bw * (bu_with_w.get("reform_universal_credit", 0) > 0)).sum()) if "reform_universal_credit" in bu_with_w.columns else 0.0, + child_benefit=float((bw * (bu_with_w.get("reform_child_benefit", 0) > 0)).sum()) if "reform_child_benefit" in bu_with_w.columns else 0.0, + state_pension=float((bw * (bu_with_w.get("reform_state_pension", 0) > 0)).sum()) if "reform_state_pension" in bu_with_w.columns else 0.0, + pension_credit=float((bw * (bu_with_w.get("reform_pension_credit", 0) > 0)).sum()) if "reform_pension_credit" in bu_with_w.columns else 0.0, + housing_benefit=float((bw * (bu_with_w.get("reform_housing_benefit", 0) > 0)).sum()) if "reform_housing_benefit" in bu_with_w.columns else 0.0, + child_tax_credit=float((bw * (bu_with_w.get("reform_child_tax_credit", 0) > 0)).sum()) if "reform_child_tax_credit" in bu_with_w.columns else 0.0, + working_tax_credit=float((bw * (bu_with_w.get("reform_working_tax_credit", 0) > 0)).sum()) if "reform_working_tax_credit" in bu_with_w.columns else 0.0, + income_support=float((bw * (bu_with_w.get("reform_income_support", 0) > 0)).sum()) if "reform_income_support" in bu_with_w.columns else 0.0, + esa_income_related=float((bw * (bu_with_w.get("reform_esa_income_related", 0) > 0)).sum()) if "reform_esa_income_related" in bu_with_w.columns else 0.0, + jsa_income_based=float((bw * (bu_with_w.get("reform_jsa_income_based", 0) > 0)).sum()) if "reform_jsa_income_based" in bu_with_w.columns else 0.0, + carers_allowance=float((bw * (bu_with_w.get("reform_carers_allowance", 0) > 0)).sum()) if "reform_carers_allowance" in bu_with_w.columns else 0.0, + scottish_child_payment=float((bw * (bu_with_w.get("reform_scottish_child_payment", 0) > 0)).sum()) if "reform_scottish_child_payment" in bu_with_w.columns else 0.0, + benefit_cap_affected=float((bw * (bu_with_w.get("reform_benefit_cap_reduction", 0) < 0)).sum()) if "reform_benefit_cap_reduction" in bu_with_w.columns else 0.0, + ) + + # ── Decile impacts ──────────────────────────────────────────────────────── + # Rank households by baseline equivalised net income; measure change on + # reform equivalised net income. + eq = households["baseline_equivalisation_factor"].clip(lower=1e-9) if "baseline_equivalisation_factor" in households.columns else 1.0 + bl_equiv = households["baseline_net_income"].values / (eq.values if hasattr(eq, "values") else eq) + rf_equiv = households["reform_net_income"].values / (eq.values if hasattr(eq, "values") else eq) + + order = np.argsort(bl_equiv) + bl_sorted = bl_equiv[order] + rf_sorted = rf_equiv[order] + + n = len(order) + decile_size = n // 10 + decile_impacts = [] + for d in range(10): + start = d * decile_size + end = n if d == 9 else (d + 1) * decile_size + bl_sl = bl_sorted[start:end] + rf_sl = rf_sorted[start:end] + count = len(bl_sl) + if count == 0: + decile_impacts.append(DecileImpact(decile=d + 1)) + continue + avg_base = float(bl_sl.mean()) + avg_ref = float(rf_sl.mean()) + avg_chg = avg_ref - avg_base + pct_chg = 100.0 * avg_chg / avg_base if avg_base != 0 else 0.0 + decile_impacts.append(DecileImpact( + decile=d + 1, + avg_baseline_income=round(avg_base, 2), + avg_reform_income=round(avg_ref, 2), + avg_change=round(avg_chg, 2), + pct_change=round(pct_chg, 2), + )) + + # ── Winners and losers ──────────────────────────────────────────────────── + change = households["reform_net_income"].values - households["baseline_net_income"].values + winners_w = float((w * (change > 1.0)).sum()) + losers_w = float((w * (change < -1.0)).sum()) + unchanged_w = float((w * (np.abs(change) <= 1.0)).sum()) + total_gain = float((w * change * (change > 1.0)).sum()) + total_loss = float((w * np.abs(change) * (change < -1.0)).sum()) + total_w = winners_w + losers_w + unchanged_w + + winners_losers = WinnersLosers( + winners_pct=round(100.0 * winners_w / total_w, 1) if total_w > 0 else 0.0, + losers_pct=round(100.0 * losers_w / total_w, 1) if total_w > 0 else 0.0, + unchanged_pct=round(100.0 * unchanged_w / total_w, 1) if total_w > 0 else 0.0, + avg_gain=round(total_gain / winners_w) if winners_w > 0 else 0.0, + avg_loss=round(total_loss / losers_w) if losers_w > 0 else 0.0, + ) + + fiscal_year = f"{year}/{(year + 1) % 100:02d}" + + return SimulationResult( + fiscal_year=fiscal_year, + budgetary_impact=BudgetaryImpact( + baseline_revenue=float(baseline_revenue), + reform_revenue=float(reform_revenue), + revenue_change=float(revenue_change), + baseline_benefits=float(baseline_benefits), + reform_benefits=float(reform_benefits), + benefit_spending_change=float(benefit_change), + net_cost=float(net_cost), + ), + income_breakdown=income_breakdown, + program_breakdown=program_breakdown, + caseloads=caseloads, + decile_impacts=decile_impacts, + winners_losers=winners_losers, + hbai_incomes=HbaiIncomes( + mean_equiv_bhc=0.0, mean_equiv_ahc=0.0, + mean_bhc=0.0, mean_ahc=0.0, + median_equiv_bhc=0.0, median_equiv_ahc=0.0, + ), + baseline_poverty=PovertyHeadcounts( + relative_bhc_children=0.0, relative_bhc_working_age=0.0, relative_bhc_pensioners=0.0, + relative_ahc_children=0.0, relative_ahc_working_age=0.0, relative_ahc_pensioners=0.0, + absolute_bhc_children=0.0, absolute_bhc_working_age=0.0, absolute_bhc_pensioners=0.0, + absolute_ahc_children=0.0, absolute_ahc_working_age=0.0, absolute_ahc_pensioners=0.0, + ), + reform_poverty=PovertyHeadcounts( + relative_bhc_children=0.0, relative_bhc_working_age=0.0, relative_bhc_pensioners=0.0, + relative_ahc_children=0.0, relative_ahc_working_age=0.0, relative_ahc_pensioners=0.0, + absolute_bhc_children=0.0, absolute_bhc_working_age=0.0, absolute_bhc_pensioners=0.0, + absolute_ahc_children=0.0, absolute_ahc_working_age=0.0, absolute_ahc_pensioners=0.0, + ), + cpi_index=100.0, + ) From 55bb59bfabc618d86ea7e9b662d1382d671dcee3 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Wed, 8 Apr 2026 11:29:04 +0100 Subject: [PATCH 2/2] changelog: add fragment for structural reform hooks --- changelog.d/structural-reform-hooks.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/structural-reform-hooks.added diff --git a/changelog.d/structural-reform-hooks.added b/changelog.d/structural-reform-hooks.added new file mode 100644 index 0000000..dbe5688 --- /dev/null +++ b/changelog.d/structural-reform-hooks.added @@ -0,0 +1 @@ +Add `StructuralReform(pre=..., post=...)` to the Python wrapper, enabling reforms that can't be expressed as parameter overlays. Both hooks take `(year, persons, benunits, households)` and return the modified triple, so multi-year reforms can branch by year.