From 8e97ada1030be002574dd7a7e015e9f87da31203 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 08:01:34 -0400 Subject: [PATCH 01/12] Write desired IRA contribution inputs --- .../calibration/puf_impute.py | 13 +++---- policyengine_us_data/datasets/cps/cps.py | 12 +++---- .../datasets/cps/extended_cps.py | 13 ++++--- policyengine_us_data/datasets/puf/puf.py | 4 +-- pyproject.toml | 2 +- .../test_calibration_puf_impute.py | 8 ++--- .../calibration/test_retirement_imputation.py | 28 +++++++-------- tests/unit/test_extended_cps.py | 34 +++++++++++-------- uv.lock | 8 ++--- 9 files changed, 58 insertions(+), 64 deletions(-) diff --git a/policyengine_us_data/calibration/puf_impute.py b/policyengine_us_data/calibration/puf_impute.py index f8dd58fa9..46b0bb1d3 100644 --- a/policyengine_us_data/calibration/puf_impute.py +++ b/policyengine_us_data/calibration/puf_impute.py @@ -162,8 +162,8 @@ CPS_RETIREMENT_VARIABLES = [ "traditional_401k_contributions", "roth_401k_contributions", - "traditional_ira_contributions", - "roth_ira_contributions", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", "self_employed_pension_contributions", ] @@ -850,7 +850,6 @@ def _impute_retirement_contributions( age = X_test["age"].values catch_up_eligible = age >= 50 limit_401k = limits["401k"] + catch_up_eligible * limits["401k_catch_up"] - limit_ira = limits["ira"] + catch_up_eligible * limits["ira_catch_up"] se_income = X_test["self_employment_income"].values se_pension_cap = np.minimum( se_income * limits["se_pension_rate"], @@ -872,10 +871,6 @@ def _impute_retirement_contributions( # Zero out for records with no employment income vals = np.where(emp_income > 0, vals, 0) - # Cap IRA at year-specific limit - if "ira" in var: - vals = np.minimum(vals, limit_ira) - # Cap SE pension at min(25% of SE income, dollar limit) if var == "self_employed_pension_contributions": vals = np.minimum(vals, se_pension_cap) @@ -888,8 +883,8 @@ def _impute_retirement_contributions( "401k mean=$%.0f, IRA mean=$%.0f, SE pension mean=$%.0f", result["traditional_401k_contributions"].mean() + result["roth_401k_contributions"].mean(), - result["traditional_ira_contributions"].mean() - + result["roth_ira_contributions"].mean(), + result["traditional_ira_contributions_desired"].mean() + + result["roth_ira_contributions_desired"].mean(), result["self_employed_pension_contributions"].mean(), ) diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index 50f69a501..90ae0e7bb 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -1380,12 +1380,9 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): limits = get_retirement_limits(year) LIMIT_401K = limits["401k"] LIMIT_401K_CATCH_UP = limits["401k_catch_up"] - LIMIT_IRA = limits["ira"] - LIMIT_IRA_CATCH_UP = limits["ira_catch_up"] CATCH_UP_AGE = 50 catch_up_eligible = person.A_AGE >= CATCH_UP_AGE limit_401k = LIMIT_401K + catch_up_eligible * LIMIT_401K_CATCH_UP - limit_ira = LIMIT_IRA + catch_up_eligible * LIMIT_IRA_CATCH_UP retirement_contributions = person.RETCB_VAL has_wages = person.WSAL_VAL > 0 @@ -1425,11 +1422,10 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): cps["traditional_401k_contributions"] = dc_capped * (1 - roth_dc_share) cps["roth_401k_contributions"] = dc_capped * roth_dc_share - # IRA pool: split into traditional/Roth IRA, cap at combined - # IRA limit. - ira_capped = np.minimum(ira_pool, limit_ira) - cps["traditional_ira_contributions"] = ira_capped * trad_ira_share - cps["roth_ira_contributions"] = ira_capped * (1 - trad_ira_share) + # IRA pool: split into desired traditional/Roth IRA contributions. + # The statutory IRA limit is applied in policyengine-us. + cps["traditional_ira_contributions_desired"] = ira_pool * trad_ira_share + cps["roth_ira_contributions_desired"] = ira_pool * (1 - trad_ira_share) # Allocate capital gains into long-term and short-term based on aggregate split. cps["long_term_capital_gains"] = person.CAP_VAL * (p["long_term_capgain_fraction"]) cps["short_term_capital_gains"] = person.CAP_VAL * ( diff --git a/policyengine_us_data/datasets/cps/extended_cps.py b/policyengine_us_data/datasets/cps/extended_cps.py index a69dfe325..449a7e065 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -174,8 +174,8 @@ def _supports_structural_mortgage_inputs() -> bool: # Retirement contributions "traditional_401k_contributions", "roth_401k_contributions", - "traditional_ira_contributions", - "roth_ira_contributions", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", "self_employed_pension_contributions", # Social Security sub-components "social_security_retirement", @@ -752,7 +752,6 @@ def apply_retirement_constraints(predictions, X_test, time_period): se_income = X_test["self_employment_income"].values limit_401k = limits["401k"] + catch_up * limits["401k_catch_up"] - limit_ira = limits["ira"] + catch_up * limits["ira_catch_up"] se_pension_cap = np.minimum( se_income * se_limits["se_pension_rate"], se_limits["se_pension_dollar_limit"], @@ -762,8 +761,8 @@ def apply_retirement_constraints(predictions, X_test, time_period): _CONSTRAINT_MAP = { "traditional_401k_contributions": (limit_401k, emp_income == 0), "roth_401k_contributions": (limit_401k, emp_income == 0), - "traditional_ira_contributions": (limit_ira, None), - "roth_ira_contributions": (limit_ira, None), + "traditional_ira_contributions_desired": (None, None), + "roth_ira_contributions_desired": (None, None), "self_employed_pension_contributions": ( se_pension_cap, se_income == 0, @@ -816,8 +815,8 @@ def reconcile_ss_subcomponents(predictions, total_ss): _RETIREMENT_VARS = { "traditional_401k_contributions", "roth_401k_contributions", - "traditional_ira_contributions", - "roth_ira_contributions", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", "self_employed_pension_contributions", } diff --git a/policyengine_us_data/datasets/puf/puf.py b/policyengine_us_data/datasets/puf/puf.py index ff390420e..1aa43f2d0 100644 --- a/policyengine_us_data/datasets/puf/puf.py +++ b/policyengine_us_data/datasets/puf/puf.py @@ -692,7 +692,7 @@ def preprocess_puf(puf: pd.DataFrame) -> pd.DataFrame: puf["taxable_ira_distributions"] = puf.E01400 puf["tax_exempt_interest_income"] = puf.E00400 puf["tax_exempt_pension_income"] = puf.E01500 - puf.E01700 - puf["traditional_ira_contributions"] = puf.E03150 + puf["traditional_ira_contributions_desired"] = puf.E03150 puf["unrecaptured_section_1250_gain"] = puf.E24515 puf["foreign_tax_credit"] = puf.E07300 @@ -835,7 +835,7 @@ def preprocess_puf(puf: pd.DataFrame) -> pd.DataFrame: "taxable_ira_distributions", "tax_exempt_interest_income", "tax_exempt_pension_income", - "traditional_ira_contributions", + "traditional_ira_contributions_desired", "unrecaptured_section_1250_gain", "foreign_tax_credit", "amt_foreign_tax_credit", diff --git a/pyproject.toml b/pyproject.toml index 48c623b35..b138f9721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "policyengine-us==1.705.15", + "policyengine-us==1.705.16", # policyengine-core 3.26.1 is the current 3.26.x runtime and includes the fix for # PolicyEngine/policyengine-core#482 (user-set ETERNITY inputs lost # after _invalidate_all_caches) and is required by policyengine-us 1.682.1+. diff --git a/tests/unit/calibration/test_calibration_puf_impute.py b/tests/unit/calibration/test_calibration_puf_impute.py index b29914ca3..439887935 100644 --- a/tests/unit/calibration/test_calibration_puf_impute.py +++ b/tests/unit/calibration/test_calibration_puf_impute.py @@ -408,8 +408,8 @@ def calculate_dataframe(self, columns): "social_security": [0.0, 0.0], "traditional_401k_contributions": [0.0, 0.0], "roth_401k_contributions": [0.0, 0.0], - "traditional_ira_contributions": [0.0, 0.0], - "roth_ira_contributions": [0.0, 0.0], + "traditional_ira_contributions_desired": [0.0, 0.0], + "roth_ira_contributions_desired": [0.0, 0.0], "self_employed_pension_contributions": [0.0, 0.0], } ) @@ -448,8 +448,8 @@ def fit_predict( { "traditional_401k_contributions": [0.0, 0.0], "roth_401k_contributions": [0.0, 0.0], - "traditional_ira_contributions": [0.0, 0.0], - "roth_ira_contributions": [0.0, 0.0], + "traditional_ira_contributions_desired": [0.0, 0.0], + "roth_ira_contributions_desired": [0.0, 0.0], "self_employed_pension_contributions": [50_000.0, 50_000.0], } ) diff --git a/tests/unit/calibration/test_retirement_imputation.py b/tests/unit/calibration/test_retirement_imputation.py index 5b635c792..392b3f19c 100644 --- a/tests/unit/calibration/test_retirement_imputation.py +++ b/tests/unit/calibration/test_retirement_imputation.py @@ -91,8 +91,8 @@ def _make_cps_df(n, rng): # Targets "traditional_401k_contributions": rng.uniform(0, 5000, n), "roth_401k_contributions": rng.uniform(0, 3000, n), - "traditional_ira_contributions": rng.uniform(0, 2000, n), - "roth_ira_contributions": rng.uniform(0, 2000, n), + "traditional_ira_contributions_desired": rng.uniform(0, 2000, n), + "roth_ira_contributions_desired": rng.uniform(0, 2000, n), "self_employed_pension_contributions": rng.uniform(0, 10_000, n), } ) @@ -144,8 +144,8 @@ def test_retirement_variable_names(self): expected = { "traditional_401k_contributions", "roth_401k_contributions", - "traditional_ira_contributions", - "roth_ira_contributions", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", "self_employed_pension_contributions", } assert set(CPS_RETIREMENT_VARIABLES) == expected @@ -326,16 +326,14 @@ def test_401k_capped(self): ): assert np.all(result[var] <= max_401k), f"{var} exceeds 401k limit" - def test_ira_capped(self): + def test_ira_desired_not_capped(self): result = self._call_with_mocks(self._uniform_preds(50_000.0)) - lim = _get_retirement_limits(self.time_period) - max_ira = lim["ira"] + lim["ira_catch_up"] for var in ( - "traditional_ira_contributions", - "roth_ira_contributions", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", ): - assert np.all(result[var] <= max_ira), f"{var} exceeds IRA limit" + assert np.all(result[var] == 50_000.0), f"{var} should remain uncapped" def test_401k_zero_when_no_wages(self): result = self._call_with_mocks(self._uniform_preds(5_000.0)) @@ -377,18 +375,18 @@ def test_catch_up_age_threshold(self): # Old get full value (within catch-up limit) assert np.all(old_401k == val) - def test_ira_catch_up_threshold(self): - """IRA catch-up also works for age >= 50.""" + def test_ira_desired_does_not_apply_age_threshold(self): + """IRA desired inputs are not capped by age in policyengine-us-data.""" self.cps_df["age"] = np.concatenate([np.full(25, 30.0), np.full(25, 55.0)]) lim = _get_retirement_limits(self.time_period) val = float(lim["ira"]) + 500 # 7500 result = self._call_with_mocks(self._uniform_preds(val)) - young_ira = result["traditional_ira_contributions"][:25] - old_ira = result["traditional_ira_contributions"][25:] + young_ira = result["traditional_ira_contributions_desired"][:25] + old_ira = result["traditional_ira_contributions_desired"][25:] - assert np.all(young_ira == lim["ira"]) + assert np.all(young_ira == val) assert np.all(old_ira == val) def test_401k_nonzero_for_positive_wages(self): diff --git a/tests/unit/test_extended_cps.py b/tests/unit/test_extended_cps.py index f1c699b58..b40951be6 100644 --- a/tests/unit/test_extended_cps.py +++ b/tests/unit/test_extended_cps.py @@ -147,8 +147,8 @@ def test_retirement_contributions_in_cps_only(self): expected = { "traditional_401k_contributions", "roth_401k_contributions", - "traditional_ira_contributions", - "roth_ira_contributions", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", "self_employed_pension_contributions", } missing = expected - set(CPS_ONLY_IMPUTED_VARIABLES) @@ -1034,7 +1034,7 @@ def test_zeroes_esi_premiums_for_non_policyholder_clone_records(self): class TestRetirementConstraints: - """Post-processing retirement constraints enforce IRS caps.""" + """Post-processing retirement constraints clean retirement predictions.""" @pytest.fixture def sample_predictions(self): @@ -1042,8 +1042,14 @@ def sample_predictions(self): { "traditional_401k_contributions": [25000, -500, 5000, 10000, 3000], "roth_401k_contributions": [30000, 2000, 0, 50000, 1000], - "traditional_ira_contributions": [8000, -100, 3000, 15000, 500], - "roth_ira_contributions": [10000, 1000, 0, 20000, 200], + "traditional_ira_contributions_desired": [ + 8000, + -100, + 3000, + 15000, + 500, + ], + "roth_ira_contributions_desired": [10000, 1000, 0, 20000, 200], "self_employed_pension_contributions": [80000, -200, 5000, 0, 100000], } ) @@ -1074,16 +1080,16 @@ def test_401k_capped_at_limit(self, sample_predictions, sample_features): for var in ["traditional_401k_contributions", "roth_401k_contributions"]: assert (result[var].values <= cap).all(), f"{var} exceeds 401k cap" - def test_ira_capped_at_limit(self, sample_predictions, sample_features): + def test_ira_desired_not_capped_at_limit(self, sample_predictions, sample_features): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) - from policyengine_us_data.utils.retirement_limits import get_retirement_limits - - limits = get_retirement_limits(2024) - age = sample_features["age"].values - catch_up = age >= 50 - cap = limits["ira"] + catch_up * limits["ira_catch_up"] - for var in ["traditional_ira_contributions", "roth_ira_contributions"]: - assert (result[var].values <= cap).all(), f"{var} exceeds IRA cap" + np.testing.assert_allclose( + result["traditional_ira_contributions_desired"].to_numpy(), + np.array([8000, 0, 3000, 15000, 500]), + ) + np.testing.assert_allclose( + result["roth_ira_contributions_desired"].to_numpy(), + np.array([10000, 1000, 0, 20000, 200]), + ) def test_401k_zeroed_without_employment_income( self, sample_predictions, sample_features diff --git a/uv.lock b/uv.lock index 5cda418f1..872856886 100644 --- a/uv.lock +++ b/uv.lock @@ -2122,7 +2122,7 @@ wheels = [ [[package]] name = "policyengine-us" -version = "1.705.15" +version = "1.705.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microdf-python" }, @@ -2132,9 +2132,9 @@ dependencies = [ { name = "tables" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/cc/921f994e5c688be0f45dbe8d7bce209ee45225d328a9724cf363df225ce8/policyengine_us-1.705.15.tar.gz", hash = "sha256:559d79690cb1d79615479ed2c71a53510e8cfea56d2398a37120f6797548c26b", size = 9927111, upload-time = "2026-05-24T02:32:30.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/9f/faa4ceee8157d4ceb3c589ca25e77af678787662949637409938c97ef5e1/policyengine_us-1.705.16.tar.gz", hash = "sha256:3eb8c31be571492566d6684ce12626eac6ae409d50819e42acdeb4dd5c7712ea", size = 9927762, upload-time = "2026-05-24T02:55:40.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/02/b8ae7ec50124bc4f442c8e3f662bf02d0f670d288c63e367dff75ed8c374/policyengine_us-1.705.15-py3-none-any.whl", hash = "sha256:aeafbbbef2a8de88cb73da8c234943784789023e7d32e05a9560e1a7dd713c70", size = 10758474, upload-time = "2026-05-24T02:32:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/cd/74/44b3eecac9d624c512c85932146a7dabe47dc32c76645ef449edb930c2dd/policyengine_us-1.705.16-py3-none-any.whl", hash = "sha256:eb1f5a04b3b0f1b3fc46706a47ac9ccc2b726bddd15ee9d55055803f415f8aeb", size = 10759039, upload-time = "2026-05-24T02:55:36.665Z" }, ] [[package]] @@ -2204,7 +2204,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.1" }, { name = "pip-system-certs", specifier = ">=3.0" }, { name = "policyengine-core", specifier = ">=3.26.1,<3.27" }, - { name = "policyengine-us", specifier = "==1.705.15" }, + { name = "policyengine-us", specifier = "==1.705.16" }, { name = "requests", specifier = ">=2.25.0" }, { name = "samplics", marker = "extra == 'calibration'" }, { name = "scipy", specifier = ">=1.15.3" }, From 3ff68a666687aa1116ffe3bde35bbd1c5e28a4ee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 08:06:41 -0400 Subject: [PATCH 02/12] Add IRA desired input changelog --- changelog.d/1125.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1125.changed.md diff --git a/changelog.d/1125.changed.md b/changelog.d/1125.changed.md new file mode 100644 index 000000000..8f3f92c52 --- /dev/null +++ b/changelog.d/1125.changed.md @@ -0,0 +1 @@ +Write IRA contribution source data to desired pre-limit variables so PolicyEngine-US applies statutory contribution caps. From 4049df5bc7153dda25819dbfdc667a3cc0336e8e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 08:14:52 -0400 Subject: [PATCH 03/12] Target capped IRA contribution outputs --- .../calibration/target_config.yaml | 4 +-- .../db/etl_national_targets.py | 32 +++++++++++-------- .../calibration_targets/soi_metadata.py | 4 +-- policyengine_us_data/utils/loss.py | 14 ++++---- .../utils/national_target_parity.py | 4 +-- tests/unit/test_income_target_mappings.py | 28 ++++++++++++++++ 6 files changed, 59 insertions(+), 27 deletions(-) diff --git a/policyengine_us_data/calibration/target_config.yaml b/policyengine_us_data/calibration/target_config.yaml index 51c282129..a78294514 100644 --- a/policyengine_us_data/calibration/target_config.yaml +++ b/policyengine_us_data/calibration/target_config.yaml @@ -232,13 +232,13 @@ include: geo_level: national # === NATIONAL — retirement contribution targets === - - variable: traditional_ira_contributions + - variable: capped_traditional_ira_contributions geo_level: national - variable: traditional_401k_contributions geo_level: national - variable: roth_401k_contributions geo_level: national - - variable: roth_ira_contributions + - variable: capped_roth_ira_contributions geo_level: national - variable: self_employed_pension_contribution_ald geo_level: national diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index 56a6d0e92..4281441c5 100644 --- a/policyengine_us_data/db/etl_national_targets.py +++ b/policyengine_us_data/db/etl_national_targets.py @@ -633,16 +633,16 @@ def extract_national_targets(year: int = DEFAULT_YEAR): }, # Retirement contribution targets — see issue #553 { - "variable": "traditional_ira_contributions", - "value": RETIREMENT_CONTRIBUTION_TARGETS["traditional_ira_contributions"][ - "value" - ], - "source": RETIREMENT_CONTRIBUTION_TARGETS["traditional_ira_contributions"][ - "source" - ], - "notes": RETIREMENT_CONTRIBUTION_TARGETS["traditional_ira_contributions"][ - "notes" - ], + "variable": "capped_traditional_ira_contributions", + "value": RETIREMENT_CONTRIBUTION_TARGETS[ + "capped_traditional_ira_contributions" + ]["value"], + "source": RETIREMENT_CONTRIBUTION_TARGETS[ + "capped_traditional_ira_contributions" + ]["source"], + "notes": RETIREMENT_CONTRIBUTION_TARGETS[ + "capped_traditional_ira_contributions" + ]["notes"], "year": 2024, }, { @@ -673,12 +673,16 @@ def extract_national_targets(year: int = DEFAULT_YEAR): "year": 2024, }, { - "variable": "roth_ira_contributions", - "value": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"]["value"], - "source": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"][ + "variable": "capped_roth_ira_contributions", + "value": RETIREMENT_CONTRIBUTION_TARGETS["capped_roth_ira_contributions"][ + "value" + ], + "source": RETIREMENT_CONTRIBUTION_TARGETS["capped_roth_ira_contributions"][ "source" ], - "notes": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"]["notes"], + "notes": RETIREMENT_CONTRIBUTION_TARGETS["capped_roth_ira_contributions"][ + "notes" + ], "year": 2024, }, ] diff --git a/policyengine_us_data/storage/calibration_targets/soi_metadata.py b/policyengine_us_data/storage/calibration_targets/soi_metadata.py index 5a5b6acf3..a17d39d1d 100644 --- a/policyengine_us_data/storage/calibration_targets/soi_metadata.py +++ b/policyengine_us_data/storage/calibration_targets/soi_metadata.py @@ -5,7 +5,7 @@ LATEST_PUBLISHED_IRA_ACCUMULATION_YEAR = 2022 RETIREMENT_CONTRIBUTION_TARGETS = { - "traditional_ira_contributions": { + "capped_traditional_ira_contributions": { "value": 13.771289e9, "source": "https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income", "notes": ( @@ -23,7 +23,7 @@ ), "source_year": 2023, }, - "roth_ira_contributions": { + "capped_roth_ira_contributions": { "value": 34.951077e9, "source": "https://www.irs.gov/statistics/soi-tax-stats-accumulation-and-distribution-of-individual-retirement-arrangements", "notes": ( diff --git a/policyengine_us_data/utils/loss.py b/policyengine_us_data/utils/loss.py index d088ac516..db3e97ade 100644 --- a/policyengine_us_data/utils/loss.py +++ b/policyengine_us_data/utils/loss.py @@ -136,15 +136,15 @@ "social_security_dependents": 84e9, # ~5.8% (spouses/children of retired+disabled) # Retirement contribution calibration targets. # - # traditional_ira_contributions: IRS SOI Publication 1304, Table 1.4 + # capped_traditional_ira_contributions: IRS SOI Publication 1304, Table 1.4 # (TY 2023), "IRA payments" deduction — $13.77B (col DU, row # "All returns, total"). This is the actual above-the-line # deduction claimed on returns. The variable flows directly into # the ALD with no deductibility logic in policyengine-us, so the # target must match the deduction, not total contributions. # https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income - "traditional_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ - "traditional_ira_contributions" + "capped_traditional_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ + "capped_traditional_ira_contributions" ]["value"], # traditional_401k_contributions & roth_401k_contributions: # BEA/FRED National Income Accounts. Total DC employer+employee @@ -170,13 +170,13 @@ "self_employed_pension_contribution_ald": RETIREMENT_CONTRIBUTION_TARGETS[ "self_employed_pension_contribution_ald" ]["value"], - # roth_ira_contributions: IRS SOI IRA Accumulation Tables 5 & 6 + # capped_roth_ira_contributions: IRS SOI IRA Accumulation Tables 5 & 6 # (TY 2022, latest published). Total Roth IRA contributions = # $34.95B (10.04M contributors). Direct administrative source. # https://www.irs.gov/statistics/soi-tax-stats-accumulation-and-distribution-of-individual-retirement-arrangements - "roth_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"][ - "value" - ], + "capped_roth_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ + "capped_roth_ira_contributions" + ]["value"], } AGE_BUCKETED_HEALTH_TARGETS = ( diff --git a/policyengine_us_data/utils/national_target_parity.py b/policyengine_us_data/utils/national_target_parity.py index d2494642f..5059f6ea6 100644 --- a/policyengine_us_data/utils/national_target_parity.py +++ b/policyengine_us_data/utils/national_target_parity.py @@ -58,11 +58,11 @@ "social_security_disability", "social_security_survivors", "social_security_dependents", - "traditional_ira_contributions", + "capped_traditional_ira_contributions", "traditional_401k_contributions", "roth_401k_contributions", "self_employed_pension_contribution_ald", - "roth_ira_contributions", + "capped_roth_ira_contributions", } _SOI_TAXABLE_DETAIL_TARGET_VARIABLES = { diff --git a/tests/unit/test_income_target_mappings.py b/tests/unit/test_income_target_mappings.py index 17217f6e6..f880d4311 100644 --- a/tests/unit/test_income_target_mappings.py +++ b/tests/unit/test_income_target_mappings.py @@ -83,3 +83,31 @@ def test_bea_nipa_direct_sum_targets_are_in_default_target_config(): } assert expected_entries <= include_entries + + +def test_ira_calibration_targets_use_capped_outputs(): + include_entries = _target_config_include_entries() + expected_entries = { + ("capped_traditional_ira_contributions", "national", None), + ("capped_roth_ira_contributions", "national", None), + } + + assert expected_entries <= include_entries + assert "traditional_ira_contributions" not in loss.HARD_CODED_TOTALS + assert "roth_ira_contributions" not in loss.HARD_CODED_TOTALS + assert expected_entries <= { + (variable, "national", None) + for variable in loss.HARD_CODED_TOTALS + if variable.startswith("capped_") and "ira_contributions" in variable + } + + direct_sum_targets = { + target["variable"] + for target in etl_national_targets.extract_national_targets(year=2024)[ + "direct_sum_targets" + ] + } + assert { + "capped_traditional_ira_contributions", + "capped_roth_ira_contributions", + } <= direct_sum_targets From 675bb6c8bcf0ae5d9901b3c11cc55a3764b7b8d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 08:40:50 -0400 Subject: [PATCH 04/12] Write desired retirement contribution inputs --- changelog.d/1125.changed.md | 2 +- .../calibration/puf_impute.py | 33 +++----- .../calibration/target_config.yaml | 4 +- policyengine_us_data/datasets/cps/cps.py | 34 +++----- .../datasets/cps/extended_cps.py | 53 ++++-------- .../db/etl_national_targets.py | 4 +- policyengine_us_data/utils/loss.py | 6 +- .../utils/national_target_parity.py | 4 +- .../test_calibration_puf_impute.py | 20 ++--- .../calibration/test_retirement_imputation.py | 66 +++++++-------- tests/unit/test_extended_cps.py | 81 +++++++++++++------ tests/unit/test_income_target_mappings.py | 10 ++- 12 files changed, 152 insertions(+), 165 deletions(-) diff --git a/changelog.d/1125.changed.md b/changelog.d/1125.changed.md index 8f3f92c52..9a4803157 100644 --- a/changelog.d/1125.changed.md +++ b/changelog.d/1125.changed.md @@ -1 +1 @@ -Write IRA contribution source data to desired pre-limit variables so PolicyEngine-US applies statutory contribution caps. +Write retirement contribution source data to desired pre-limit variables so PolicyEngine-US applies statutory contribution caps. diff --git a/policyengine_us_data/calibration/puf_impute.py b/policyengine_us_data/calibration/puf_impute.py index 46b0bb1d3..6b1c52020 100644 --- a/policyengine_us_data/calibration/puf_impute.py +++ b/policyengine_us_data/calibration/puf_impute.py @@ -160,11 +160,11 @@ ] CPS_RETIREMENT_VARIABLES = [ - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", "traditional_ira_contributions_desired", "roth_ira_contributions_desired", - "self_employed_pension_contributions", + "self_employed_pension_contributions_desired", ] RETIREMENT_DEMOGRAPHIC_PREDICTORS = [ @@ -845,17 +845,9 @@ def _impute_retirement_contributions( n_persons = len(data["person_id"][time_period]) return {var: np.zeros(n_persons) for var in CPS_RETIREMENT_VARIABLES} - # Extract results and apply constraints - limits = _get_retirement_limits(time_period) - age = X_test["age"].values - catch_up_eligible = age >= 50 - limit_401k = limits["401k"] + catch_up_eligible * limits["401k_catch_up"] + # Extract results and apply data-domain constraints. Statutory caps + # are applied by PolicyEngine-US capped variables. se_income = X_test["self_employment_income"].values - se_pension_cap = np.minimum( - se_income * limits["se_pension_rate"], - limits["se_pension_dollar_limit"], - ) - emp_income = X_test["employment_income"].values result = {} @@ -865,15 +857,12 @@ def _impute_retirement_contributions( # Non-negativity vals = np.maximum(vals, 0) - # Cap 401k at year-specific limit + # Zero out employment-based plans for records with no employment income. if "401k" in var: - vals = np.minimum(vals, limit_401k) - # Zero out for records with no employment income vals = np.where(emp_income > 0, vals, 0) - # Cap SE pension at min(25% of SE income, dollar limit) - if var == "self_employed_pension_contributions": - vals = np.minimum(vals, se_pension_cap) + # Zero out self-employed plans for records with no self-employment income. + if var == "self_employed_pension_contributions_desired": vals = np.where(se_income > 0, vals, 0) result[var] = vals @@ -881,11 +870,11 @@ def _impute_retirement_contributions( logger.info( "Imputed retirement contributions for PUF: " "401k mean=$%.0f, IRA mean=$%.0f, SE pension mean=$%.0f", - result["traditional_401k_contributions"].mean() - + result["roth_401k_contributions"].mean(), + result["traditional_401k_contributions_desired"].mean() + + result["roth_401k_contributions_desired"].mean(), result["traditional_ira_contributions_desired"].mean() + result["roth_ira_contributions_desired"].mean(), - result["self_employed_pension_contributions"].mean(), + result["self_employed_pension_contributions_desired"].mean(), ) return result diff --git a/policyengine_us_data/calibration/target_config.yaml b/policyengine_us_data/calibration/target_config.yaml index a78294514..869c3e4cd 100644 --- a/policyengine_us_data/calibration/target_config.yaml +++ b/policyengine_us_data/calibration/target_config.yaml @@ -234,9 +234,9 @@ include: # === NATIONAL — retirement contribution targets === - variable: capped_traditional_ira_contributions geo_level: national - - variable: traditional_401k_contributions + - variable: capped_traditional_401k_contributions geo_level: national - - variable: roth_401k_contributions + - variable: capped_roth_401k_contributions geo_level: national - variable: capped_roth_ira_contributions geo_level: national diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index 90ae0e7bb..b351bf6bf 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -1373,34 +1373,23 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): # split contributions into DC (401k) and IRA pools, then splits # each pool into traditional/Roth using administrative fractions. # See imputation_parameters.yaml for sources. - from policyengine_us_data.utils.retirement_limits import ( - get_retirement_limits, - ) - - limits = get_retirement_limits(year) - LIMIT_401K = limits["401k"] - LIMIT_401K_CATCH_UP = limits["401k_catch_up"] - CATCH_UP_AGE = 50 - catch_up_eligible = person.A_AGE >= CATCH_UP_AGE - limit_401k = LIMIT_401K + catch_up_eligible * LIMIT_401K_CATCH_UP - retirement_contributions = person.RETCB_VAL has_wages = person.WSAL_VAL > 0 has_se = person.SEMP_VAL > 0 has_earned_income = has_wages | has_se - # 1) Self-employed pension: cap at min(25% of SE income, dollar - # limit) so dual-income filers keep a remainder for 401(k)/IRA. + # 1) Self-employed pension: use the plan contribution rate as an + # allocation prior so dual-income filers keep a remainder for + # 401(k)/IRA. PolicyEngine-US applies statutory limits. se_rate = p["se_pension_contribution_rate"] - se_dollar_cap = p["se_pension_contribution_dollar_limit"][year] - se_pension_cap = np.minimum(person.SEMP_VAL * se_rate, se_dollar_cap) - cps["self_employed_pension_contributions"] = np.where( + se_pension_capacity = person.SEMP_VAL * se_rate + cps["self_employed_pension_contributions_desired"] = np.where( has_se, - np.minimum(retirement_contributions, se_pension_cap), + np.minimum(retirement_contributions, se_pension_capacity), 0, ) remaining = np.maximum( - retirement_contributions - cps["self_employed_pension_contributions"], + retirement_contributions - cps["self_employed_pension_contributions_desired"], 0, ) @@ -1416,11 +1405,10 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): # earned income (including SE-only filers). ira_pool = np.where(has_earned_income, remaining - dc_pool, 0) - # DC pool: split into traditional/Roth 401(k), cap at combined - # 401(k) limit. - dc_capped = np.minimum(dc_pool, limit_401k) - cps["traditional_401k_contributions"] = dc_capped * (1 - roth_dc_share) - cps["roth_401k_contributions"] = dc_capped * roth_dc_share + # DC pool: split into desired traditional/Roth 401(k) contributions. + # The statutory elective deferral limit is applied in policyengine-us. + cps["traditional_401k_contributions_desired"] = dc_pool * (1 - roth_dc_share) + cps["roth_401k_contributions_desired"] = dc_pool * roth_dc_share # IRA pool: split into desired traditional/Roth IRA contributions. # The statutory IRA limit is applied in policyengine-us. diff --git a/policyengine_us_data/datasets/cps/extended_cps.py b/policyengine_us_data/datasets/cps/extended_cps.py index 449a7e065..b422eedf8 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -50,10 +50,6 @@ from policyengine_us_data.utils.dataset_validation import ( assert_no_computed_policyengine_us_variables_exported, ) -from policyengine_us_data.utils.retirement_limits import ( - get_retirement_limits, - get_se_pension_limits, -) from policyengine_us_data.utils.randomness import seeded_rng logger = logging.getLogger(__name__) @@ -172,11 +168,11 @@ def _supports_structural_mortgage_inputs() -> bool: "taxable_sep_distributions", "tax_exempt_sep_distributions", # Retirement contributions - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", "traditional_ira_contributions_desired", "roth_ira_contributions_desired", - "self_employed_pension_contributions", + "self_employed_pension_contributions_desired", # Social Security sub-components "social_security_retirement", "social_security_disability", @@ -731,49 +727,34 @@ def _impute_cps_only_variables( def apply_retirement_constraints(predictions, X_test, time_period): - """Enforce IRS contribution limits on retirement variable predictions. + """Clean retirement contribution predictions for data-domain eligibility. Args: predictions: DataFrame of QRF predictions for retirement contribution variables. X_test: DataFrame with at least ``age``, ``employment_income``, and ``self_employment_income``. - time_period: Tax year (int) for IRS limit look-up. + time_period: Tax year (int), accepted for API compatibility. Returns: - DataFrame with constrained values (same columns). + DataFrame with cleaned values (same columns). """ - limits = get_retirement_limits(time_period) - se_limits = get_se_pension_limits(time_period) - - age = X_test["age"].values - catch_up = age >= 50 emp_income = X_test["employment_income"].values se_income = X_test["self_employment_income"].values - limit_401k = limits["401k"] + catch_up * limits["401k_catch_up"] - se_pension_cap = np.minimum( - se_income * se_limits["se_pension_rate"], - se_limits["se_pension_dollar_limit"], - ) - - # Explicit mapping: variable -> (cap array, zero_mask or None). + # Explicit mapping: variable -> zero_mask or None. Statutory limits + # are applied by PolicyEngine-US capped variables. _CONSTRAINT_MAP = { - "traditional_401k_contributions": (limit_401k, emp_income == 0), - "roth_401k_contributions": (limit_401k, emp_income == 0), - "traditional_ira_contributions_desired": (None, None), - "roth_ira_contributions_desired": (None, None), - "self_employed_pension_contributions": ( - se_pension_cap, - se_income == 0, - ), + "traditional_401k_contributions_desired": emp_income == 0, + "roth_401k_contributions_desired": emp_income == 0, + "traditional_ira_contributions_desired": None, + "roth_ira_contributions_desired": None, + "self_employed_pension_contributions_desired": se_income == 0, } result = predictions.clip(lower=0) for var in result.columns: - cap, zero_mask = _CONSTRAINT_MAP.get(var, (None, None)) - if cap is not None: - result[var] = np.minimum(result[var].values, cap) + zero_mask = _CONSTRAINT_MAP.get(var) if zero_mask is not None: result.loc[zero_mask, var] = 0 @@ -813,11 +794,11 @@ def reconcile_ss_subcomponents(predictions, total_ss): _RETIREMENT_VARS = { - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", "traditional_ira_contributions_desired", "roth_ira_contributions_desired", - "self_employed_pension_contributions", + "self_employed_pension_contributions_desired", } _SS_SUBCOMPONENT_VARS = { diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index 4281441c5..6c94f9204 100644 --- a/policyengine_us_data/db/etl_national_targets.py +++ b/policyengine_us_data/db/etl_national_targets.py @@ -646,14 +646,14 @@ def extract_national_targets(year: int = DEFAULT_YEAR): "year": 2024, }, { - "variable": "traditional_401k_contributions", + "variable": "capped_traditional_401k_contributions", "value": 482.7e9, "source": "https://fred.stlouisfed.org/series/Y351RC1A027NBEA", "notes": "BEA/FRED employee DC deferrals ($567.9B) x 85% traditional share (Vanguard HAS 2024)", "year": 2024, }, { - "variable": "roth_401k_contributions", + "variable": "capped_roth_401k_contributions", "value": 85.2e9, "source": "https://fred.stlouisfed.org/series/Y351RC1A027NBEA", "notes": "BEA/FRED employee DC deferrals ($567.9B) x 15% Roth share (Vanguard HAS 2024)", diff --git a/policyengine_us_data/utils/loss.py b/policyengine_us_data/utils/loss.py index db3e97ade..09bc6fa4b 100644 --- a/policyengine_us_data/utils/loss.py +++ b/policyengine_us_data/utils/loss.py @@ -146,7 +146,7 @@ "capped_traditional_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ "capped_traditional_ira_contributions" ]["value"], - # traditional_401k_contributions & roth_401k_contributions: + # capped_traditional_401k_contributions & capped_roth_401k_contributions: # BEA/FRED National Income Accounts. Total DC employer+employee # = $815.4B (Y351RC1A027NBEA), employer-only = $247.5B # (W351RC0A144NBEA), employee elective deferrals = $567.9B. @@ -158,8 +158,8 @@ # https://fred.stlouisfed.org/series/Y351RC1A027NBEA # https://fred.stlouisfed.org/series/W351RC0A144NBEA # https://corporate.vanguard.com/content/dam/corp/research/pdf/how_america_saves_report_2024.pdf - "traditional_401k_contributions": 482.7e9, - "roth_401k_contributions": 85.2e9, + "capped_traditional_401k_contributions": 482.7e9, + "capped_roth_401k_contributions": 85.2e9, # self_employed_pension_contribution_ald: IRS SOI Publication # 1304, Table 1.4 (TY 2023), "Payments to a Keogh plan" — # $30.13B (col DM, row "All returns, total"). Includes diff --git a/policyengine_us_data/utils/national_target_parity.py b/policyengine_us_data/utils/national_target_parity.py index 5059f6ea6..3bdaec7a1 100644 --- a/policyengine_us_data/utils/national_target_parity.py +++ b/policyengine_us_data/utils/national_target_parity.py @@ -59,8 +59,8 @@ "social_security_survivors", "social_security_dependents", "capped_traditional_ira_contributions", - "traditional_401k_contributions", - "roth_401k_contributions", + "capped_traditional_401k_contributions", + "capped_roth_401k_contributions", "self_employed_pension_contribution_ald", "capped_roth_ira_contributions", } diff --git a/tests/unit/calibration/test_calibration_puf_impute.py b/tests/unit/calibration/test_calibration_puf_impute.py index 439887935..6f2f60561 100644 --- a/tests/unit/calibration/test_calibration_puf_impute.py +++ b/tests/unit/calibration/test_calibration_puf_impute.py @@ -384,13 +384,13 @@ def test_indices_sorted(self): assert np.all(idx[1:] >= idx[:-1]) -def test_retirement_imputation_caps_se_pension_using_sstb_income(monkeypatch): +def test_retirement_imputation_uses_sstb_income_for_se_eligibility(monkeypatch): class FakeMicrosimulation: def __init__(self, dataset): self.dataset = dataset def calculate_dataframe(self, columns): - if "self_employed_pension_contributions" in columns: + if "self_employed_pension_contributions_desired" in columns: return pd.DataFrame( { "age": [40, 55], @@ -406,11 +406,11 @@ def calculate_dataframe(self, columns): "qualified_dividend_income": [0.0, 0.0], "taxable_pension_income": [0.0, 0.0], "social_security": [0.0, 0.0], - "traditional_401k_contributions": [0.0, 0.0], - "roth_401k_contributions": [0.0, 0.0], + "traditional_401k_contributions_desired": [0.0, 0.0], + "roth_401k_contributions_desired": [0.0, 0.0], "traditional_ira_contributions_desired": [0.0, 0.0], "roth_ira_contributions_desired": [0.0, 0.0], - "self_employed_pension_contributions": [0.0, 0.0], + "self_employed_pension_contributions_desired": [0.0, 0.0], } ) return pd.DataFrame( @@ -446,11 +446,11 @@ def fit_predict( ) return pd.DataFrame( { - "traditional_401k_contributions": [0.0, 0.0], - "roth_401k_contributions": [0.0, 0.0], + "traditional_401k_contributions_desired": [0.0, 0.0], + "roth_401k_contributions_desired": [0.0, 0.0], "traditional_ira_contributions_desired": [0.0, 0.0], "roth_ira_contributions_desired": [0.0, 0.0], - "self_employed_pension_contributions": [50_000.0, 50_000.0], + "self_employed_pension_contributions_desired": [50_000.0, 50_000.0], } ) @@ -473,8 +473,8 @@ def fit_predict( ) np.testing.assert_array_equal( - result["self_employed_pension_contributions"], - np.array([25.0, 25.0]), + result["self_employed_pension_contributions_desired"], + np.array([50_000.0, 50_000.0]), ) diff --git a/tests/unit/calibration/test_retirement_imputation.py b/tests/unit/calibration/test_retirement_imputation.py index 392b3f19c..f4c3ce0d6 100644 --- a/tests/unit/calibration/test_retirement_imputation.py +++ b/tests/unit/calibration/test_retirement_imputation.py @@ -89,11 +89,11 @@ def _make_cps_df(n, rng): "taxable_pension_income": rng.uniform(0, 20_000, n), "social_security": rng.uniform(0, 15_000, n), # Targets - "traditional_401k_contributions": rng.uniform(0, 5000, n), - "roth_401k_contributions": rng.uniform(0, 3000, n), + "traditional_401k_contributions_desired": rng.uniform(0, 5000, n), + "roth_401k_contributions_desired": rng.uniform(0, 3000, n), "traditional_ira_contributions_desired": rng.uniform(0, 2000, n), "roth_ira_contributions_desired": rng.uniform(0, 2000, n), - "self_employed_pension_contributions": rng.uniform(0, 10_000, n), + "self_employed_pension_contributions_desired": rng.uniform(0, 10_000, n), } ) @@ -142,11 +142,11 @@ def test_five_retirement_variables(self): def test_retirement_variable_names(self): expected = { - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", "traditional_ira_contributions_desired", "roth_ira_contributions_desired", - "self_employed_pension_contributions", + "self_employed_pension_contributions_desired", } assert set(CPS_RETIREMENT_VARIABLES) == expected @@ -315,16 +315,17 @@ def test_nonnegative_output(self): for var in CPS_RETIREMENT_VARIABLES: assert np.all(result[var] >= 0), f"{var} has negative values" - def test_401k_capped(self): + def test_401k_desired_not_capped(self): result = self._call_with_mocks(self._uniform_preds(50_000.0)) - lim = _get_retirement_limits(self.time_period) - max_401k = lim["401k"] + lim["401k_catch_up"] + pos_wage = self.puf_imputations["employment_income"] > 0 for var in ( - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", ): - assert np.all(result[var] <= max_401k), f"{var} exceeds 401k limit" + assert np.all(result[var][pos_wage] == 50_000.0), ( + f"{var} should remain uncapped for records with wages" + ) def test_ira_desired_not_capped(self): result = self._call_with_mocks(self._uniform_preds(50_000.0)) @@ -341,8 +342,8 @@ def test_401k_zero_when_no_wages(self): assert zero_wage.sum() == 10 for var in ( - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", ): assert np.all(result[var][zero_wage] == 0), ( f"{var} should be 0 when employment_income is 0" @@ -352,10 +353,12 @@ def test_se_pension_zero_when_no_se_income(self): result = self._call_with_mocks(self._uniform_preds(5_000.0)) zero_se = self.puf_imputations["self_employment_income"] == 0 assert zero_se.sum() == 20 - assert np.all(result["self_employed_pension_contributions"][zero_se] == 0) + assert np.all( + result["self_employed_pension_contributions_desired"][zero_se] == 0 + ) - def test_catch_up_age_threshold(self): - """Records age >= 50 get higher caps than younger.""" + def test_401k_desired_does_not_apply_age_threshold(self): + """401(k) desired inputs are not capped by age in policyengine-us-data.""" self.cps_df["age"] = np.concatenate([np.full(25, 30.0), np.full(25, 55.0)]) # All have positive income self.puf_imputations["employment_income"] = np.full(self.n, 100_000.0).astype( @@ -367,12 +370,10 @@ def test_catch_up_age_threshold(self): result = self._call_with_mocks(self._uniform_preds(val)) - young_401k = result["traditional_401k_contributions"][:25] - old_401k = result["traditional_401k_contributions"][25:] + young_401k = result["traditional_401k_contributions_desired"][:25] + old_401k = result["traditional_401k_contributions_desired"][25:] - # Young capped at base limit - assert np.all(young_401k == lim["401k"]) - # Old get full value (within catch-up limit) + assert np.all(young_401k == val) assert np.all(old_401k == val) def test_ira_desired_does_not_apply_age_threshold(self): @@ -395,31 +396,24 @@ def test_401k_nonzero_for_positive_wages(self): result = self._call_with_mocks(self._uniform_preds(5_000.0)) pos_wage = self.puf_imputations["employment_income"] > 0 for var in ( - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", ): assert np.all(result[var][pos_wage] > 0) def test_se_pension_nonzero_for_positive_se(self): result = self._call_with_mocks(self._uniform_preds(5_000.0)) pos_se = self.puf_imputations["self_employment_income"] > 0 - assert np.all(result["self_employed_pension_contributions"][pos_se] > 0) + assert np.all(result["self_employed_pension_contributions_desired"][pos_se] > 0) - def test_se_pension_capped_at_rate_times_income(self): - """SE pension should not exceed 25% of SE income.""" - # Predict a large value that would exceed the SE cap + def test_se_pension_desired_not_capped(self): + """SE pension desired inputs are not capped in policyengine-us-data.""" result = self._call_with_mocks(self._uniform_preds(50_000.0)) - lim = _get_retirement_limits(self.time_period) se_income = self.puf_imputations["self_employment_income"] - se_cap = np.minimum( - se_income * lim["se_pension_rate"], - lim["se_pension_dollar_limit"], - ) pos_se = se_income > 0 assert np.all( - result["self_employed_pension_contributions"][pos_se] - <= se_cap[pos_se] + 0.01 - ), "SE pension exceeds 25%-of-income cap" + result["self_employed_pension_contributions_desired"][pos_se] == 50_000.0 + ) def test_qrf_failure_returns_zeros(self): """When QRF fit/predict throws, should return all zeros.""" diff --git a/tests/unit/test_extended_cps.py b/tests/unit/test_extended_cps.py index b40951be6..04e680274 100644 --- a/tests/unit/test_extended_cps.py +++ b/tests/unit/test_extended_cps.py @@ -4,7 +4,7 @@ 1. Sequential QRF preserves covariance between imputed variables 2. CPS-only imputation uses PUF-imputed income (not CPS originals) 3. Variable lists don't overlap (no double-imputation) -4. Post-processing constraints enforce IRS caps and SS normalization +4. Post-processing constraints clean retirement inputs and normalize SS """ from contextlib import contextmanager @@ -136,20 +136,32 @@ def test_cps_only_vars_mostly_exist_in_tbs(self): from policyengine_us import CountryTaxBenefitSystem tbs = CountryTaxBenefitSystem() - valid = [v for v in CPS_ONLY_IMPUTED_VARIABLES if v in tbs.variables] - assert len(valid) >= len(CPS_ONLY_IMPUTED_VARIABLES) * 0.9, ( - f"Only {len(valid)}/{len(CPS_ONLY_IMPUTED_VARIABLES)} " + pending_policyengine_us_release = { + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", + "self_employed_pension_contributions_desired", + } + checked_variables = [ + v + for v in CPS_ONLY_IMPUTED_VARIABLES + if v in tbs.variables or v not in pending_policyengine_us_release + ] + valid = [v for v in checked_variables if v in tbs.variables] + assert len(valid) >= len(checked_variables) * 0.9, ( + f"Only {len(valid)}/{len(checked_variables)} " f"CPS-only vars exist in tax-benefit system" ) def test_retirement_contributions_in_cps_only(self): """All 5 retirement contribution vars should be in CPS_ONLY.""" expected = { - "traditional_401k_contributions", - "roth_401k_contributions", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", "traditional_ira_contributions_desired", "roth_ira_contributions_desired", - "self_employed_pension_contributions", + "self_employed_pension_contributions_desired", } missing = expected - set(CPS_ONLY_IMPUTED_VARIABLES) assert missing == set(), ( @@ -1040,8 +1052,14 @@ class TestRetirementConstraints: def sample_predictions(self): return pd.DataFrame( { - "traditional_401k_contributions": [25000, -500, 5000, 10000, 3000], - "roth_401k_contributions": [30000, 2000, 0, 50000, 1000], + "traditional_401k_contributions_desired": [ + 25000, + -500, + 5000, + 10000, + 3000, + ], + "roth_401k_contributions_desired": [30000, 2000, 0, 50000, 1000], "traditional_ira_contributions_desired": [ 8000, -100, @@ -1050,7 +1068,13 @@ def sample_predictions(self): 500, ], "roth_ira_contributions_desired": [10000, 1000, 0, 20000, 200], - "self_employed_pension_contributions": [80000, -200, 5000, 0, 100000], + "self_employed_pension_contributions_desired": [ + 80000, + -200, + 5000, + 0, + 100000, + ], } ) @@ -1069,16 +1093,18 @@ def test_non_negativity(self, sample_predictions, sample_features): for var in result.columns: assert (result[var] >= 0).all(), f"{var} has negative values" - def test_401k_capped_at_limit(self, sample_predictions, sample_features): + def test_401k_desired_not_capped_at_limit( + self, sample_predictions, sample_features + ): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) - from policyengine_us_data.utils.retirement_limits import get_retirement_limits - - limits = get_retirement_limits(2024) - age = sample_features["age"].values - catch_up = age >= 50 - cap = limits["401k"] + catch_up * limits["401k_catch_up"] - for var in ["traditional_401k_contributions", "roth_401k_contributions"]: - assert (result[var].values <= cap).all(), f"{var} exceeds 401k cap" + np.testing.assert_allclose( + result["traditional_401k_contributions_desired"].to_numpy(), + np.array([25000, 0, 0, 10000, 3000]), + ) + np.testing.assert_allclose( + result["roth_401k_contributions_desired"].to_numpy(), + np.array([30000, 2000, 0, 50000, 1000]), + ) def test_ira_desired_not_capped_at_limit(self, sample_predictions, sample_features): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) @@ -1096,17 +1122,20 @@ def test_401k_zeroed_without_employment_income( ): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) no_emp = sample_features["employment_income"] == 0 - for var in ["traditional_401k_contributions", "roth_401k_contributions"]: + for var in [ + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", + ]: assert (result[var].values[no_emp] == 0).all(), ( f"{var} should be zero without employment income" ) - def test_se_pension_capped(self, sample_predictions, sample_features): + def test_se_pension_desired_not_capped(self, sample_predictions, sample_features): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) - se_income = sample_features["self_employment_income"].values - se_vals = result["self_employed_pension_contributions"].values - rate_cap = se_income * 0.25 - assert (se_vals <= rate_cap + 1).all(), "SE pension exceeds 25% of SE income" + np.testing.assert_allclose( + result["self_employed_pension_contributions_desired"].to_numpy(), + np.array([0, 0, 5000, 0, 100000]), + ) def test_se_pension_zeroed_without_se_income( self, sample_predictions, sample_features @@ -1114,7 +1143,7 @@ def test_se_pension_zeroed_without_se_income( result = apply_retirement_constraints(sample_predictions, sample_features, 2024) no_se = sample_features["self_employment_income"] == 0 assert ( - result["self_employed_pension_contributions"].values[no_se] == 0 + result["self_employed_pension_contributions_desired"].values[no_se] == 0 ).all(), "SE pension should be zero without SE income" diff --git a/tests/unit/test_income_target_mappings.py b/tests/unit/test_income_target_mappings.py index f880d4311..50a49d852 100644 --- a/tests/unit/test_income_target_mappings.py +++ b/tests/unit/test_income_target_mappings.py @@ -85,20 +85,24 @@ def test_bea_nipa_direct_sum_targets_are_in_default_target_config(): assert expected_entries <= include_entries -def test_ira_calibration_targets_use_capped_outputs(): +def test_retirement_calibration_targets_use_capped_outputs(): include_entries = _target_config_include_entries() expected_entries = { + ("capped_traditional_401k_contributions", "national", None), + ("capped_roth_401k_contributions", "national", None), ("capped_traditional_ira_contributions", "national", None), ("capped_roth_ira_contributions", "national", None), } assert expected_entries <= include_entries + assert "traditional_401k_contributions" not in loss.HARD_CODED_TOTALS + assert "roth_401k_contributions" not in loss.HARD_CODED_TOTALS assert "traditional_ira_contributions" not in loss.HARD_CODED_TOTALS assert "roth_ira_contributions" not in loss.HARD_CODED_TOTALS assert expected_entries <= { (variable, "national", None) for variable in loss.HARD_CODED_TOTALS - if variable.startswith("capped_") and "ira_contributions" in variable + if variable.startswith("capped_") and "contributions" in variable } direct_sum_targets = { @@ -108,6 +112,8 @@ def test_ira_calibration_targets_use_capped_outputs(): ] } assert { + "capped_traditional_401k_contributions", + "capped_roth_401k_contributions", "capped_traditional_ira_contributions", "capped_roth_ira_contributions", } <= direct_sum_targets From 146a9937b6f1541580134eb0b8d3a43a7b1c580a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 08:55:05 -0400 Subject: [PATCH 05/12] Remove self-employed pension pre-cap --- policyengine_us_data/datasets/cps/cps.py | 17 ++++++++--------- .../datasets/cps/imputation_parameters.yaml | 13 +++++++++++-- .../datasets/test_cps_income_variables.py | 15 +++++++++++++++ tests/unit/test_extended_cps.py | 19 ++++++++++++------- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index b351bf6bf..9ea150be8 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -1370,22 +1370,21 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): # nearly all of RETCB_VAL and left IRA contributions at $0. # # The proportional approach uses BEA/FRED and IRS SOI shares to - # split contributions into DC (401k) and IRA pools, then splits - # each pool into traditional/Roth using administrative fractions. - # See imputation_parameters.yaml for sources. + # split contributions into self-employed pension, DC (401k), and + # IRA pools, then splits each pool into traditional/Roth using + # administrative fractions. See imputation_parameters.yaml for + # sources. retirement_contributions = person.RETCB_VAL has_wages = person.WSAL_VAL > 0 has_se = person.SEMP_VAL > 0 has_earned_income = has_wages | has_se - # 1) Self-employed pension: use the plan contribution rate as an - # allocation prior so dual-income filers keep a remainder for - # 401(k)/IRA. PolicyEngine-US applies statutory limits. - se_rate = p["se_pension_contribution_rate"] - se_pension_capacity = person.SEMP_VAL * se_rate + # 1) Self-employed pension: allocate a share without statutory + # pre-capping. PolicyEngine-US applies statutory limits. + se_share = p["se_pension_share_of_retirement_contributions"] cps["self_employed_pension_contributions_desired"] = np.where( has_se, - np.minimum(retirement_contributions, se_pension_capacity), + retirement_contributions * se_share, 0, ) remaining = np.maximum( diff --git a/policyengine_us_data/datasets/cps/imputation_parameters.yaml b/policyengine_us_data/datasets/cps/imputation_parameters.yaml index f71fa4d14..d765be1ea 100644 --- a/policyengine_us_data/datasets/cps/imputation_parameters.yaml +++ b/policyengine_us_data/datasets/cps/imputation_parameters.yaml @@ -21,7 +21,15 @@ long_term_capgain_fraction: 0.880 # Used to split CPS RETCB_VAL (a single bundled total) into # account-type-specific variables. # -# DC vs IRA share of non-SE retirement contributions. +# Self-employed pension share of retirement contributions. +# Self-employed pension: $30.13B (IRS SOI Publication 1304, Table 1.4, +# TY 2023, "Payments to a Keogh plan") +# Combined employee DC + IRA + self-employed pension: $655.53B +# Share: $30.13B / $655.53B = 4.6% +# https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income +se_pension_share_of_retirement_contributions: 0.046 + +# DC vs IRA share of remaining non-SE retirement contributions. # Employee DC: $567.9B (BEA/FRED Y351RC1A027NBEA minus W351RC0A144NBEA) # Total IRA: $57.5B (IRS SOI Tables 5 & 6, TY 2022) # Combined: $625.4B @@ -46,7 +54,8 @@ roth_share_of_dc_contributions: 0.15 # https://www.irs.gov/statistics/soi-tax-stats-accumulation-and-distribution-of-individual-retirement-arrangements traditional_share_of_ira_contributions: 0.392 -# SE pension contribution cap. +# SE pension statutory parameters retained for retirement-limit utilities. +# These are not used to pre-cap desired source contribution data. # SEP-IRA / Solo 401(k) contributions are capped at the lesser of # a percentage of net SE earnings and a dollar limit. # The 25% rate is technically ~20% for sole proprietors after the diff --git a/tests/unit/datasets/test_cps_income_variables.py b/tests/unit/datasets/test_cps_income_variables.py index 69570cf33..bbe62945f 100644 --- a/tests/unit/datasets/test_cps_income_variables.py +++ b/tests/unit/datasets/test_cps_income_variables.py @@ -93,3 +93,18 @@ def test_add_personal_income_variables_maps_spm_income_leaves(): np.testing.assert_array_equal(cps["educational_assistance"], [10.0, 11.0, 12.0]) np.testing.assert_array_equal(cps["financial_assistance"], [20.0, 21.0, 22.0]) np.testing.assert_array_equal(cps["survivor_benefits"], [30.0, 31.0, 32.0]) + + +def test_retirement_contributions_write_desired_without_se_rate_cap(): + person = _minimal_person_income_frame() + person["SEMP_VAL"] = [100.0, 0.0] + person["WSAL_VAL"] = [0.0, 100_000.0] + person["RETCB_VAL"] = [100_000.0, 100_000.0] + cps = {} + + add_personal_income_variables(cps, person, 2024) + + assert cps["self_employed_pension_contributions_desired"][0] > 100 * 0.25 + assert cps["self_employed_pension_contributions_desired"][1] == 0 + assert cps["traditional_ira_contributions_desired"][0] > 0 + assert cps["traditional_401k_contributions_desired"][1] > 0 diff --git a/tests/unit/test_extended_cps.py b/tests/unit/test_extended_cps.py index 04e680274..436b5842e 100644 --- a/tests/unit/test_extended_cps.py +++ b/tests/unit/test_extended_cps.py @@ -133,16 +133,21 @@ def test_stage2_uses_esi_coverage_predictor(self): def test_cps_only_vars_mostly_exist_in_tbs(self): """Most CPS-only variables should exist in policyengine-us.""" + from importlib.metadata import version + + from packaging.version import Version from policyengine_us import CountryTaxBenefitSystem tbs = CountryTaxBenefitSystem() - pending_policyengine_us_release = { - "traditional_401k_contributions_desired", - "roth_401k_contributions_desired", - "traditional_ira_contributions_desired", - "roth_ira_contributions_desired", - "self_employed_pension_contributions_desired", - } + pending_policyengine_us_release = set() + if Version(version("policyengine-us")) < Version("1.706.3"): + pending_policyengine_us_release = { + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", + "self_employed_pension_contributions_desired", + } checked_variables = [ v for v in CPS_ONLY_IMPUTED_VARIABLES From d100c94fe0e66c3d342e0a1c878cbd56df02aae8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 09:05:28 -0400 Subject: [PATCH 06/12] Gate retirement target variables on US release --- tests/unit/test_income_target_mappings.py | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/unit/test_income_target_mappings.py b/tests/unit/test_income_target_mappings.py index 50a49d852..d42e2d92d 100644 --- a/tests/unit/test_income_target_mappings.py +++ b/tests/unit/test_income_target_mappings.py @@ -1,9 +1,27 @@ +from importlib.metadata import version + +from packaging.version import Version +from policyengine_us import CountryTaxBenefitSystem + import policyengine_us_data.db.etl_national_targets as etl_national_targets import policyengine_us_data.utils.loss as loss from policyengine_us_data.calibration.unified_calibration import load_target_config TARGET_CONFIG_PATH = "policyengine_us_data/calibration/target_config.yaml" +RETIREMENT_VARIABLE_RELEASE = Version("1.706.3") +REQUIRED_RETIREMENT_POLICYENGINE_US_VARIABLES = { + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", + "traditional_ira_contributions_desired", + "roth_ira_contributions_desired", + "self_employed_pension_contributions_desired", + "capped_traditional_401k_contributions", + "capped_roth_401k_contributions", + "capped_traditional_ira_contributions", + "capped_roth_ira_contributions", + "capped_self_employed_pension_contributions", +} def _target_config_include_entries(): @@ -117,3 +135,18 @@ def test_retirement_calibration_targets_use_capped_outputs(): "capped_traditional_ira_contributions", "capped_roth_ira_contributions", } <= direct_sum_targets + + +def test_retirement_policyengine_us_variables_exist_after_release(): + tbs = CountryTaxBenefitSystem() + missing = REQUIRED_RETIREMENT_POLICYENGINE_US_VARIABLES - set(tbs.variables) + installed_version = Version(version("policyengine-us")) + + if installed_version < RETIREMENT_VARIABLE_RELEASE: + assert missing, ( + "Remove the temporary retirement variable release gate after " + "policyengine-us is bumped." + ) + return + + assert missing == set() From 53136d2bdf152040e205ecdc711a4730e78485fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 09:55:43 -0400 Subject: [PATCH 07/12] Target plain retirement contribution outputs --- .../calibration/puf_impute.py | 4 +- .../calibration/target_config.yaml | 10 ++--- .../datasets/cps/extended_cps.py | 2 +- .../db/etl_national_targets.py | 44 +++++++++---------- .../calibration_targets/soi_metadata.py | 6 +-- policyengine_us_data/utils/loss.py | 36 +++++++-------- .../utils/national_target_parity.py | 10 ++--- tests/unit/test_income_target_mappings.py | 38 +++++++--------- 8 files changed, 71 insertions(+), 79 deletions(-) diff --git a/policyengine_us_data/calibration/puf_impute.py b/policyengine_us_data/calibration/puf_impute.py index 6b1c52020..cda23d446 100644 --- a/policyengine_us_data/calibration/puf_impute.py +++ b/policyengine_us_data/calibration/puf_impute.py @@ -845,8 +845,8 @@ def _impute_retirement_contributions( n_persons = len(data["person_id"][time_period]) return {var: np.zeros(n_persons) for var in CPS_RETIREMENT_VARIABLES} - # Extract results and apply data-domain constraints. Statutory caps - # are applied by PolicyEngine-US capped variables. + # Extract results and apply data-domain constraints. Statutory limits + # are applied by PolicyEngine-US plain contribution variables. se_income = X_test["self_employment_income"].values emp_income = X_test["employment_income"].values diff --git a/policyengine_us_data/calibration/target_config.yaml b/policyengine_us_data/calibration/target_config.yaml index 869c3e4cd..c66aa0ce3 100644 --- a/policyengine_us_data/calibration/target_config.yaml +++ b/policyengine_us_data/calibration/target_config.yaml @@ -232,15 +232,15 @@ include: geo_level: national # === NATIONAL — retirement contribution targets === - - variable: capped_traditional_ira_contributions + - variable: traditional_ira_contributions geo_level: national - - variable: capped_traditional_401k_contributions + - variable: traditional_401k_contributions geo_level: national - - variable: capped_roth_401k_contributions + - variable: roth_401k_contributions geo_level: national - - variable: capped_roth_ira_contributions + - variable: roth_ira_contributions geo_level: national - - variable: self_employed_pension_contribution_ald + - variable: self_employed_pension_contributions geo_level: national # === NATIONAL — IRS SOI domain-constrained dollar targets (restored: |rel_err| < 15%) === diff --git a/policyengine_us_data/datasets/cps/extended_cps.py b/policyengine_us_data/datasets/cps/extended_cps.py index b422eedf8..fe66c75d6 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -743,7 +743,7 @@ def apply_retirement_constraints(predictions, X_test, time_period): se_income = X_test["self_employment_income"].values # Explicit mapping: variable -> zero_mask or None. Statutory limits - # are applied by PolicyEngine-US capped variables. + # are applied by PolicyEngine-US plain contribution variables. _CONSTRAINT_MAP = { "traditional_401k_contributions_desired": emp_income == 0, "roth_401k_contributions_desired": emp_income == 0, diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index 6c94f9204..c8ab1a4a7 100644 --- a/policyengine_us_data/db/etl_national_targets.py +++ b/policyengine_us_data/db/etl_national_targets.py @@ -633,56 +633,52 @@ def extract_national_targets(year: int = DEFAULT_YEAR): }, # Retirement contribution targets — see issue #553 { - "variable": "capped_traditional_ira_contributions", - "value": RETIREMENT_CONTRIBUTION_TARGETS[ - "capped_traditional_ira_contributions" - ]["value"], - "source": RETIREMENT_CONTRIBUTION_TARGETS[ - "capped_traditional_ira_contributions" - ]["source"], - "notes": RETIREMENT_CONTRIBUTION_TARGETS[ - "capped_traditional_ira_contributions" - ]["notes"], + "variable": "traditional_ira_contributions", + "value": RETIREMENT_CONTRIBUTION_TARGETS["traditional_ira_contributions"][ + "value" + ], + "source": RETIREMENT_CONTRIBUTION_TARGETS["traditional_ira_contributions"][ + "source" + ], + "notes": RETIREMENT_CONTRIBUTION_TARGETS["traditional_ira_contributions"][ + "notes" + ], "year": 2024, }, { - "variable": "capped_traditional_401k_contributions", + "variable": "traditional_401k_contributions", "value": 482.7e9, "source": "https://fred.stlouisfed.org/series/Y351RC1A027NBEA", "notes": "BEA/FRED employee DC deferrals ($567.9B) x 85% traditional share (Vanguard HAS 2024)", "year": 2024, }, { - "variable": "capped_roth_401k_contributions", + "variable": "roth_401k_contributions", "value": 85.2e9, "source": "https://fred.stlouisfed.org/series/Y351RC1A027NBEA", "notes": "BEA/FRED employee DC deferrals ($567.9B) x 15% Roth share (Vanguard HAS 2024)", "year": 2024, }, { - "variable": "self_employed_pension_contribution_ald", + "variable": "self_employed_pension_contributions", "value": RETIREMENT_CONTRIBUTION_TARGETS[ - "self_employed_pension_contribution_ald" + "self_employed_pension_contributions" ]["value"], "source": RETIREMENT_CONTRIBUTION_TARGETS[ - "self_employed_pension_contribution_ald" + "self_employed_pension_contributions" ]["source"], "notes": RETIREMENT_CONTRIBUTION_TARGETS[ - "self_employed_pension_contribution_ald" + "self_employed_pension_contributions" ]["notes"], "year": 2024, }, { - "variable": "capped_roth_ira_contributions", - "value": RETIREMENT_CONTRIBUTION_TARGETS["capped_roth_ira_contributions"][ - "value" - ], - "source": RETIREMENT_CONTRIBUTION_TARGETS["capped_roth_ira_contributions"][ + "variable": "roth_ira_contributions", + "value": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"]["value"], + "source": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"][ "source" ], - "notes": RETIREMENT_CONTRIBUTION_TARGETS["capped_roth_ira_contributions"][ - "notes" - ], + "notes": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"]["notes"], "year": 2024, }, ] diff --git a/policyengine_us_data/storage/calibration_targets/soi_metadata.py b/policyengine_us_data/storage/calibration_targets/soi_metadata.py index a17d39d1d..1c61bf1a7 100644 --- a/policyengine_us_data/storage/calibration_targets/soi_metadata.py +++ b/policyengine_us_data/storage/calibration_targets/soi_metadata.py @@ -5,7 +5,7 @@ LATEST_PUBLISHED_IRA_ACCUMULATION_YEAR = 2022 RETIREMENT_CONTRIBUTION_TARGETS = { - "capped_traditional_ira_contributions": { + "traditional_ira_contributions": { "value": 13.771289e9, "source": "https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income", "notes": ( @@ -14,7 +14,7 @@ ), "source_year": 2023, }, - "self_employed_pension_contribution_ald": { + "self_employed_pension_contributions": { "value": 30.130848e9, "source": "https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income", "notes": ( @@ -23,7 +23,7 @@ ), "source_year": 2023, }, - "capped_roth_ira_contributions": { + "roth_ira_contributions": { "value": 34.951077e9, "source": "https://www.irs.gov/statistics/soi-tax-stats-accumulation-and-distribution-of-individual-retirement-arrangements", "notes": ( diff --git a/policyengine_us_data/utils/loss.py b/policyengine_us_data/utils/loss.py index 09bc6fa4b..789980e7c 100644 --- a/policyengine_us_data/utils/loss.py +++ b/policyengine_us_data/utils/loss.py @@ -136,17 +136,17 @@ "social_security_dependents": 84e9, # ~5.8% (spouses/children of retired+disabled) # Retirement contribution calibration targets. # - # capped_traditional_ira_contributions: IRS SOI Publication 1304, Table 1.4 + # traditional_ira_contributions: IRS SOI Publication 1304, Table 1.4 # (TY 2023), "IRA payments" deduction — $13.77B (col DU, row - # "All returns, total"). This is the actual above-the-line - # deduction claimed on returns. The variable flows directly into - # the ALD with no deductibility logic in policyengine-us, so the + # "All returns, total"). This is the above-the-line deduction + # claimed on returns. The variable flows directly into the ALD + # with no deductibility logic in policyengine-us, so the # target must match the deduction, not total contributions. # https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income - "capped_traditional_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ - "capped_traditional_ira_contributions" + "traditional_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ + "traditional_ira_contributions" ]["value"], - # capped_traditional_401k_contributions & capped_roth_401k_contributions: + # traditional_401k_contributions & roth_401k_contributions: # BEA/FRED National Income Accounts. Total DC employer+employee # = $815.4B (Y351RC1A027NBEA), employer-only = $247.5B # (W351RC0A144NBEA), employee elective deferrals = $567.9B. @@ -158,25 +158,25 @@ # https://fred.stlouisfed.org/series/Y351RC1A027NBEA # https://fred.stlouisfed.org/series/W351RC0A144NBEA # https://corporate.vanguard.com/content/dam/corp/research/pdf/how_america_saves_report_2024.pdf - "capped_traditional_401k_contributions": 482.7e9, - "capped_roth_401k_contributions": 85.2e9, - # self_employed_pension_contribution_ald: IRS SOI Publication + "traditional_401k_contributions": 482.7e9, + "roth_401k_contributions": 85.2e9, + # self_employed_pension_contributions: IRS SOI Publication # 1304, Table 1.4 (TY 2023), "Payments to a Keogh plan" — # $30.13B (col DM, row "All returns, total"). Includes # SEP-IRAs, SIMPLE-IRAs, and traditional Keogh/HR-10 plans. - # Targeting the ALD (not the input) because policyengine-us - # applies a min(contributions, SE_income) cap. + # Targeting the contribution output because policyengine-us applies + # statutory limits before the ALD formula. # https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income - "self_employed_pension_contribution_ald": RETIREMENT_CONTRIBUTION_TARGETS[ - "self_employed_pension_contribution_ald" + "self_employed_pension_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ + "self_employed_pension_contributions" ]["value"], - # capped_roth_ira_contributions: IRS SOI IRA Accumulation Tables 5 & 6 + # roth_ira_contributions: IRS SOI IRA Accumulation Tables 5 & 6 # (TY 2022, latest published). Total Roth IRA contributions = # $34.95B (10.04M contributors). Direct administrative source. # https://www.irs.gov/statistics/soi-tax-stats-accumulation-and-distribution-of-individual-retirement-arrangements - "capped_roth_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS[ - "capped_roth_ira_contributions" - ]["value"], + "roth_ira_contributions": RETIREMENT_CONTRIBUTION_TARGETS["roth_ira_contributions"][ + "value" + ], } AGE_BUCKETED_HEALTH_TARGETS = ( diff --git a/policyengine_us_data/utils/national_target_parity.py b/policyengine_us_data/utils/national_target_parity.py index 3bdaec7a1..00f0655d8 100644 --- a/policyengine_us_data/utils/national_target_parity.py +++ b/policyengine_us_data/utils/national_target_parity.py @@ -58,11 +58,11 @@ "social_security_disability", "social_security_survivors", "social_security_dependents", - "capped_traditional_ira_contributions", - "capped_traditional_401k_contributions", - "capped_roth_401k_contributions", - "self_employed_pension_contribution_ald", - "capped_roth_ira_contributions", + "traditional_ira_contributions", + "traditional_401k_contributions", + "roth_401k_contributions", + "self_employed_pension_contributions", + "roth_ira_contributions", } _SOI_TAXABLE_DETAIL_TARGET_VARIABLES = { diff --git a/tests/unit/test_income_target_mappings.py b/tests/unit/test_income_target_mappings.py index d42e2d92d..4db967126 100644 --- a/tests/unit/test_income_target_mappings.py +++ b/tests/unit/test_income_target_mappings.py @@ -16,11 +16,11 @@ "traditional_ira_contributions_desired", "roth_ira_contributions_desired", "self_employed_pension_contributions_desired", - "capped_traditional_401k_contributions", - "capped_roth_401k_contributions", - "capped_traditional_ira_contributions", - "capped_roth_ira_contributions", - "capped_self_employed_pension_contributions", + "traditional_401k_contributions", + "roth_401k_contributions", + "traditional_ira_contributions", + "roth_ira_contributions", + "self_employed_pension_contributions", } @@ -103,24 +103,19 @@ def test_bea_nipa_direct_sum_targets_are_in_default_target_config(): assert expected_entries <= include_entries -def test_retirement_calibration_targets_use_capped_outputs(): +def test_retirement_calibration_targets_use_contribution_outputs(): include_entries = _target_config_include_entries() expected_entries = { - ("capped_traditional_401k_contributions", "national", None), - ("capped_roth_401k_contributions", "national", None), - ("capped_traditional_ira_contributions", "national", None), - ("capped_roth_ira_contributions", "national", None), + ("traditional_401k_contributions", "national", None), + ("roth_401k_contributions", "national", None), + ("traditional_ira_contributions", "national", None), + ("roth_ira_contributions", "national", None), + ("self_employed_pension_contributions", "national", None), } assert expected_entries <= include_entries - assert "traditional_401k_contributions" not in loss.HARD_CODED_TOTALS - assert "roth_401k_contributions" not in loss.HARD_CODED_TOTALS - assert "traditional_ira_contributions" not in loss.HARD_CODED_TOTALS - assert "roth_ira_contributions" not in loss.HARD_CODED_TOTALS assert expected_entries <= { - (variable, "national", None) - for variable in loss.HARD_CODED_TOTALS - if variable.startswith("capped_") and "contributions" in variable + (variable, "national", None) for variable in loss.HARD_CODED_TOTALS } direct_sum_targets = { @@ -130,10 +125,11 @@ def test_retirement_calibration_targets_use_capped_outputs(): ] } assert { - "capped_traditional_401k_contributions", - "capped_roth_401k_contributions", - "capped_traditional_ira_contributions", - "capped_roth_ira_contributions", + "traditional_401k_contributions", + "roth_401k_contributions", + "traditional_ira_contributions", + "roth_ira_contributions", + "self_employed_pension_contributions", } <= direct_sum_targets From 5043157a4aa350b64617f2bc68db417c1b4e333b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 12:16:12 -0400 Subject: [PATCH 08/12] Clarify desired retirement contribution inputs --- policyengine_us_data/datasets/cps/cps.py | 4 ++-- .../datasets/cps/imputation_parameters.yaml | 4 ++-- .../calibration/test_retirement_imputation.py | 18 ++++++++++-------- tests/unit/test_extended_cps.py | 10 +++++++--- tests/unit/test_income_target_mappings.py | 2 +- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index 9ea150be8..7687ab698 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -1379,8 +1379,8 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): has_se = person.SEMP_VAL > 0 has_earned_income = has_wages | has_se - # 1) Self-employed pension: allocate a share without statutory - # pre-capping. PolicyEngine-US applies statutory limits. + # 1) Self-employed pension: allocate a share without applying statutory + # limits. PolicyEngine-US applies those limits. se_share = p["se_pension_share_of_retirement_contributions"] cps["self_employed_pension_contributions_desired"] = np.where( has_se, diff --git a/policyengine_us_data/datasets/cps/imputation_parameters.yaml b/policyengine_us_data/datasets/cps/imputation_parameters.yaml index d765be1ea..71cbf67fc 100644 --- a/policyengine_us_data/datasets/cps/imputation_parameters.yaml +++ b/policyengine_us_data/datasets/cps/imputation_parameters.yaml @@ -55,8 +55,8 @@ roth_share_of_dc_contributions: 0.15 traditional_share_of_ira_contributions: 0.392 # SE pension statutory parameters retained for retirement-limit utilities. -# These are not used to pre-cap desired source contribution data. -# SEP-IRA / Solo 401(k) contributions are capped at the lesser of +# These are not used to reduce desired source contribution data. +# SEP-IRA / Solo 401(k) contributions are limited to the lesser of # a percentage of net SE earnings and a dollar limit. # The 25% rate is technically ~20% for sole proprietors after the # deduction-for-half-of-SE-tax adjustment, but 25% is the standard diff --git a/tests/unit/calibration/test_retirement_imputation.py b/tests/unit/calibration/test_retirement_imputation.py index f4c3ce0d6..29ec3103d 100644 --- a/tests/unit/calibration/test_retirement_imputation.py +++ b/tests/unit/calibration/test_retirement_imputation.py @@ -315,7 +315,7 @@ def test_nonnegative_output(self): for var in CPS_RETIREMENT_VARIABLES: assert np.all(result[var] >= 0), f"{var} has negative values" - def test_401k_desired_not_capped(self): + def test_401k_preserves_desired_amounts(self): result = self._call_with_mocks(self._uniform_preds(50_000.0)) pos_wage = self.puf_imputations["employment_income"] > 0 @@ -324,17 +324,19 @@ def test_401k_desired_not_capped(self): "roth_401k_contributions_desired", ): assert np.all(result[var][pos_wage] == 50_000.0), ( - f"{var} should remain uncapped for records with wages" + f"{var} should preserve desired amounts for records with wages" ) - def test_ira_desired_not_capped(self): + def test_ira_preserves_desired_amounts(self): result = self._call_with_mocks(self._uniform_preds(50_000.0)) for var in ( "traditional_ira_contributions_desired", "roth_ira_contributions_desired", ): - assert np.all(result[var] == 50_000.0), f"{var} should remain uncapped" + assert np.all(result[var] == 50_000.0), ( + f"{var} should preserve desired amounts" + ) def test_401k_zero_when_no_wages(self): result = self._call_with_mocks(self._uniform_preds(5_000.0)) @@ -358,7 +360,7 @@ def test_se_pension_zero_when_no_se_income(self): ) def test_401k_desired_does_not_apply_age_threshold(self): - """401(k) desired inputs are not capped by age in policyengine-us-data.""" + """401(k) desired inputs do not apply age-based statutory limits here.""" self.cps_df["age"] = np.concatenate([np.full(25, 30.0), np.full(25, 55.0)]) # All have positive income self.puf_imputations["employment_income"] = np.full(self.n, 100_000.0).astype( @@ -377,7 +379,7 @@ def test_401k_desired_does_not_apply_age_threshold(self): assert np.all(old_401k == val) def test_ira_desired_does_not_apply_age_threshold(self): - """IRA desired inputs are not capped by age in policyengine-us-data.""" + """IRA desired inputs do not apply age-based statutory limits here.""" self.cps_df["age"] = np.concatenate([np.full(25, 30.0), np.full(25, 55.0)]) lim = _get_retirement_limits(self.time_period) val = float(lim["ira"]) + 500 # 7500 @@ -406,8 +408,8 @@ def test_se_pension_nonzero_for_positive_se(self): pos_se = self.puf_imputations["self_employment_income"] > 0 assert np.all(result["self_employed_pension_contributions_desired"][pos_se] > 0) - def test_se_pension_desired_not_capped(self): - """SE pension desired inputs are not capped in policyengine-us-data.""" + def test_se_pension_preserves_desired_amounts(self): + """SE pension desired inputs preserve source amounts when SE income exists.""" result = self._call_with_mocks(self._uniform_preds(50_000.0)) se_income = self.puf_imputations["self_employment_income"] pos_se = se_income > 0 diff --git a/tests/unit/test_extended_cps.py b/tests/unit/test_extended_cps.py index 436b5842e..2ed4419f6 100644 --- a/tests/unit/test_extended_cps.py +++ b/tests/unit/test_extended_cps.py @@ -1098,7 +1098,7 @@ def test_non_negativity(self, sample_predictions, sample_features): for var in result.columns: assert (result[var] >= 0).all(), f"{var} has negative values" - def test_401k_desired_not_capped_at_limit( + def test_401k_preserves_desired_amounts_above_limit( self, sample_predictions, sample_features ): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) @@ -1111,7 +1111,9 @@ def test_401k_desired_not_capped_at_limit( np.array([30000, 2000, 0, 50000, 1000]), ) - def test_ira_desired_not_capped_at_limit(self, sample_predictions, sample_features): + def test_ira_preserves_desired_amounts_above_limit( + self, sample_predictions, sample_features + ): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) np.testing.assert_allclose( result["traditional_ira_contributions_desired"].to_numpy(), @@ -1135,7 +1137,9 @@ def test_401k_zeroed_without_employment_income( f"{var} should be zero without employment income" ) - def test_se_pension_desired_not_capped(self, sample_predictions, sample_features): + def test_se_pension_preserves_desired_amounts( + self, sample_predictions, sample_features + ): result = apply_retirement_constraints(sample_predictions, sample_features, 2024) np.testing.assert_allclose( result["self_employed_pension_contributions_desired"].to_numpy(), diff --git a/tests/unit/test_income_target_mappings.py b/tests/unit/test_income_target_mappings.py index 4db967126..b8031d531 100644 --- a/tests/unit/test_income_target_mappings.py +++ b/tests/unit/test_income_target_mappings.py @@ -9,7 +9,7 @@ TARGET_CONFIG_PATH = "policyengine_us_data/calibration/target_config.yaml" -RETIREMENT_VARIABLE_RELEASE = Version("1.706.3") +RETIREMENT_VARIABLE_RELEASE = Version("1.706.4") REQUIRED_RETIREMENT_POLICYENGINE_US_VARIABLES = { "traditional_401k_contributions_desired", "roth_401k_contributions_desired", From b97f7841204063e85d26d320160237bc5601196b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 13:18:40 -0400 Subject: [PATCH 09/12] Pin policyengine-us to merged GitHub ref --- .../check_policyengine_us_dependency.py | 56 +++++++++++++++++-- pyproject.toml | 5 +- uv.lock | 10 +--- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/.github/scripts/check_policyengine_us_dependency.py b/.github/scripts/check_policyengine_us_dependency.py index 1fd574bf1..d42d75de6 100644 --- a/.github/scripts/check_policyengine_us_dependency.py +++ b/.github/scripts/check_policyengine_us_dependency.py @@ -17,7 +17,9 @@ REPO_ROOT = Path(__file__).resolve().parents[2] PYPI_JSON_TIMEOUT_SECONDS = 20 POLICYENGINE_US = "policyengine-us" +POLICYENGINE_US_GITHUB_REPO = "github.com/PolicyEngine/policyengine-us" STALE_LOCK_PREFIX = "uv.lock has policyengine-us " +GIT_REF_PREFIX = "uv.lock resolves policyengine-us from a Git ref" def _annotation(level: str, message: str) -> str: @@ -83,9 +85,39 @@ def _latest_pypi_version() -> str: return version +def _is_policyengine_us_git_source(source: dict[str, object]) -> bool: + git_source = source.get("git") + return isinstance(git_source, str) and POLICYENGINE_US_GITHUB_REPO in git_source + + +def _is_policyengine_us_git_dependency(dependency: str) -> bool: + return ( + dependency.startswith(f"{POLICYENGINE_US} @ git+") + and POLICYENGINE_US_GITHUB_REPO in dependency + and re.search(r"@[0-9a-f]{40}$", dependency) is not None + ) + + +def _allows_temporary_git_ref( + locked_version: str, + source: dict[str, object], + project_dependency: str, + latest_version: str | None, +) -> bool: + return ( + latest_version is not None + and _compare_versions(locked_version, latest_version) > 0 + and _is_policyengine_us_git_source(source) + and _is_policyengine_us_git_dependency(project_dependency) + ) + + def check_dependency(root: Path, latest_version: str | None = None) -> list[str]: locked_version, source = _locked_policyengine_us(root) project_dependency = _project_policyengine_us_dependency(root) + git_ref_allowed = _allows_temporary_git_ref( + locked_version, source, project_dependency, latest_version + ) violations: list[str] = [] if ( @@ -99,19 +131,19 @@ def check_dependency(root: Path, latest_version: str | None = None) -> list[str] ) expected_dependency = f"{POLICYENGINE_US}=={locked_version}" - if project_dependency != expected_dependency: + if project_dependency != expected_dependency and not git_ref_allowed: violations.append( f"pyproject.toml must pin {expected_dependency} to match uv.lock; " f"found {project_dependency!r}." ) - if "git" in source: + if "git" in source and not git_ref_allowed: violations.append( - "uv.lock resolves policyengine-us from a Git ref. Prefer an exact " + f"{GIT_REF_PREFIX}. Prefer an exact " f"PyPI release pin once policyengine-us {locked_version} is published." ) - if "@" in project_dependency and "git+" in project_dependency: + if "@" in project_dependency and "git+" in project_dependency and not git_ref_allowed: violations.append( "pyproject.toml pins policyengine-us to a Git ref. Prefer an exact " "PyPI release pin for production data builds." @@ -159,8 +191,22 @@ def main() -> int: return 0 if not violations: - locked_version, _source = _locked_policyengine_us(REPO_ROOT) + locked_version, source = _locked_policyengine_us(REPO_ROOT) print(f"policyengine-us dependency is current at {locked_version}.") + if _allows_temporary_git_ref( + locked_version, + source, + _project_policyengine_us_dependency(REPO_ROOT), + latest_version, + ): + print( + _annotation( + "warning", + f"policyengine-us {locked_version} is temporarily pinned to " + "GitHub because it is newer than the latest PyPI release. " + "Replace it with an exact PyPI release pin once published.", + ) + ) return 0 has_blocking_violation = False diff --git a/pyproject.toml b/pyproject.toml index b138f9721..c146aa821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,10 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "policyengine-us==1.705.16", + # Temporary GitHub pin: policyengine-us 1.706.4 is blocked from PyPI by + # the project-size limit, but us-data needs the merged desired retirement + # contribution variables before the next PyPI release is available. + "policyengine-us @ git+https://github.com/PolicyEngine/policyengine-us.git@ac00eb138a00d24222ff1577d3f844f167cda44a", # policyengine-core 3.26.1 is the current 3.26.x runtime and includes the fix for # PolicyEngine/policyengine-core#482 (user-set ETERNITY inputs lost # after _invalidate_all_caches) and is required by policyengine-us 1.682.1+. diff --git a/uv.lock b/uv.lock index 872856886..e529b5e95 100644 --- a/uv.lock +++ b/uv.lock @@ -2122,8 +2122,8 @@ wheels = [ [[package]] name = "policyengine-us" -version = "1.705.16" -source = { registry = "https://pypi.org/simple" } +version = "1.706.4" +source = { git = "https://github.com/PolicyEngine/policyengine-us.git?rev=ac00eb138a00d24222ff1577d3f844f167cda44a#ac00eb138a00d24222ff1577d3f844f167cda44a" } dependencies = [ { name = "microdf-python" }, { name = "pandas" }, @@ -2132,10 +2132,6 @@ dependencies = [ { name = "tables" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/9f/faa4ceee8157d4ceb3c589ca25e77af678787662949637409938c97ef5e1/policyengine_us-1.705.16.tar.gz", hash = "sha256:3eb8c31be571492566d6684ce12626eac6ae409d50819e42acdeb4dd5c7712ea", size = 9927762, upload-time = "2026-05-24T02:55:40.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/74/44b3eecac9d624c512c85932146a7dabe47dc32c76645ef449edb930c2dd/policyengine_us-1.705.16-py3-none-any.whl", hash = "sha256:eb1f5a04b3b0f1b3fc46706a47ac9ccc2b726bddd15ee9d55055803f415f8aeb", size = 10759039, upload-time = "2026-05-24T02:55:36.665Z" }, -] [[package]] name = "policyengine-us-data" @@ -2204,7 +2200,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.1" }, { name = "pip-system-certs", specifier = ">=3.0" }, { name = "policyengine-core", specifier = ">=3.26.1,<3.27" }, - { name = "policyengine-us", specifier = "==1.705.16" }, + { name = "policyengine-us", git = "https://github.com/PolicyEngine/policyengine-us.git?rev=ac00eb138a00d24222ff1577d3f844f167cda44a" }, { name = "requests", specifier = ">=2.25.0" }, { name = "samplics", marker = "extra == 'calibration'" }, { name = "scipy", specifier = ">=1.15.3" }, From 153d2f198a2f43700f97ca6240b4cabed4349226 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 13:19:55 -0400 Subject: [PATCH 10/12] Format PE-US dependency guard --- .github/scripts/check_policyengine_us_dependency.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/scripts/check_policyengine_us_dependency.py b/.github/scripts/check_policyengine_us_dependency.py index d42d75de6..0f4c9f548 100644 --- a/.github/scripts/check_policyengine_us_dependency.py +++ b/.github/scripts/check_policyengine_us_dependency.py @@ -143,7 +143,11 @@ def check_dependency(root: Path, latest_version: str | None = None) -> list[str] f"PyPI release pin once policyengine-us {locked_version} is published." ) - if "@" in project_dependency and "git+" in project_dependency and not git_ref_allowed: + if ( + "@" in project_dependency + and "git+" in project_dependency + and not git_ref_allowed + ): violations.append( "pyproject.toml pins policyengine-us to a Git ref. Prefer an exact " "PyPI release pin for production data builds." From f5088cdcef6c6816bc7c8262027f6009adfa6dbc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 26 May 2026 07:18:18 -0400 Subject: [PATCH 11/12] Trigger PR checks From 1cee9739696065c99d4e557fed0cb2d757d85762 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 26 May 2026 07:22:11 -0400 Subject: [PATCH 12/12] Clarify retirement contribution changelog --- changelog.d/1125.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1125.changed.md b/changelog.d/1125.changed.md index 9a4803157..b1b5f446d 100644 --- a/changelog.d/1125.changed.md +++ b/changelog.d/1125.changed.md @@ -1 +1 @@ -Write retirement contribution source data to desired pre-limit variables so PolicyEngine-US applies statutory contribution caps. +Write retirement contribution source data to desired pre-limit variables and target the plain PolicyEngine-US contribution outputs after statutory caps.