From 7f732cd859b5154453c51cff1903626f9b677635 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 25 May 2026 16:15:20 -0400 Subject: [PATCH 1/3] Use age-aware allocation for Social Security input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #924. `gssi` is a household-aggregate TAXSIM input with no per-spouse pair field. The previous unconditional 50/50 split pushed half the SS onto the younger spouse in mixed-age households, eliminating per-person state elderly exclusions (CO, MD) on that half. Apply the same age-gated rule already used for pensions: split only when both spouses are 60+, otherwise keep the full amount on the primary filer. For taxsim #924 (CO, primary 75 / spouse 40, $94K gssi), this brings CO subtractions to $80,220 — matching the TaxAct DR 0104AD. Co-Authored-By: Claude Opus 4.7 (1M context) --- changelog.d/gssi-age-aware-split.fixed.md | 1 + .../runners/policyengine_runner.py | 46 ++++++++++--------- tests/test_spouse_income_splitting.py | 34 ++++++++++++-- 3 files changed, 56 insertions(+), 25 deletions(-) create mode 100644 changelog.d/gssi-age-aware-split.fixed.md diff --git a/changelog.d/gssi-age-aware-split.fixed.md b/changelog.d/gssi-age-aware-split.fixed.md new file mode 100644 index 0000000..b336cd0 --- /dev/null +++ b/changelog.d/gssi-age-aware-split.fixed.md @@ -0,0 +1 @@ +Allocate Social Security (`gssi`) using the same age-aware rule as pensions: in mixed-age MFJ households, keep the full amount on the primary filer so state per-person SS exclusions (CO, MD, etc.) reach the qualifying spouse. diff --git a/policyengine_taxsim/runners/policyengine_runner.py b/policyengine_taxsim/runners/policyengine_runner.py index 13dcfa3..5169a05 100644 --- a/policyengine_taxsim/runners/policyengine_runner.py +++ b/policyengine_taxsim/runners/policyengine_runner.py @@ -190,17 +190,19 @@ def _initialize_dataset_structure(self) -> dict: "long_term_capital_gains", "partnership_s_corp_income", "short_term_capital_gains", - "social_security_retirement", } ) - # Pension income is split only when both spouses meet the state-pension - # eligibility age (60, the lowest common threshold across states such - # as DE). When ages are mixed or both under 60, pension stays with the - # primary filer so the allocation still matches TAXSIM for records where - # age-based state rules don't apply. - _PENSION_FIELD = "taxable_private_pension_income" - _PENSION_SPLIT_AGE = 60 + # Pension and Social Security income are split only when both spouses + # meet the state-level age threshold (60, the lowest common threshold + # across states such as DE). In mixed-age households (e.g. spouse under + # 60 while filer is elderly), the income stays with the primary filer so + # age-based state exclusions aren't lost on the younger spouse's + # incorrectly-allocated share. + _AGE_GATED_FIELDS = frozenset( + {"taxable_private_pension_income", "social_security_retirement"} + ) + _AGE_GATED_SPLIT_AGE = 60 @staticmethod def _make_primary_split(source_field): @@ -223,32 +225,32 @@ def accessor(row): return accessor @classmethod - def _make_pension_primary(cls, source_field): - """Pension stays on primary unless both spouses are 60 or older.""" + def _make_age_gated_primary(cls, source_field): + """Age-gated income stays on primary unless both spouses are 60+.""" def accessor(row): value = float(row.get(source_field, 0)) if int(row.get("mstat", 1)) != 2: return value both_old = ( - int(row.get("page", 0)) >= cls._PENSION_SPLIT_AGE - and int(row.get("sage", 0)) >= cls._PENSION_SPLIT_AGE + int(row.get("page", 0)) >= cls._AGE_GATED_SPLIT_AGE + and int(row.get("sage", 0)) >= cls._AGE_GATED_SPLIT_AGE ) return value / 2 if both_old else value return accessor @classmethod - def _make_pension_spouse(cls, source_field): - """Spouse only receives a pension share if both spouses are 60+.""" + def _make_age_gated_spouse(cls, source_field): + """Spouse only shares age-gated income if both spouses are 60+.""" def accessor(row): value = float(row.get(source_field, 0)) if int(row.get("mstat", 1)) != 2: return 0.0 both_old = ( - int(row.get("page", 0)) >= cls._PENSION_SPLIT_AGE - and int(row.get("sage", 0)) >= cls._PENSION_SPLIT_AGE + int(row.get("page", 0)) >= cls._AGE_GATED_SPLIT_AGE + and int(row.get("sage", 0)) >= cls._AGE_GATED_SPLIT_AGE ) return value / 2 if both_old else 0.0 @@ -309,12 +311,14 @@ def _get_taxsim_to_pe_variable_mapping(self) -> dict: "dependent": 0.0, "default": 0.0, } - elif pe_var == self._PENSION_FIELD: - # Pension requires the age-aware split (both - # spouses must be 60+ to share the exclusion). + elif pe_var in self._AGE_GATED_FIELDS: + # Pension and Social Security require the + # age-aware split (both spouses must be 60+ + # to share, otherwise it stays with the primary + # filer so age-based exclusions aren't lost). variable_mapping[pe_var] = { - "primary": self._make_pension_primary(taxsim_var), - "spouse": self._make_pension_spouse(taxsim_var), + "primary": self._make_age_gated_primary(taxsim_var), + "spouse": self._make_age_gated_spouse(taxsim_var), "dependent": 0.0, "default": 0.0, } diff --git a/tests/test_spouse_income_splitting.py b/tests/test_spouse_income_splitting.py index ecb5666..96752df 100644 --- a/tests/test_spouse_income_splitting.py +++ b/tests/test_spouse_income_splitting.py @@ -6,9 +6,10 @@ exclusion, DE pension exclusion) apply to both spouses. See issues #665 and #838. -Pension income uses an age-aware rule: split only when both spouses are -60 or older; otherwise the full amount stays with the primary filer to -preserve state per-person elderly exclusions (GA, MD, NJ, etc.). +Pension and Social Security income use an age-aware rule: split only when +both spouses are 60 or older; otherwise the full amount stays with the +primary filer to preserve state per-person elderly exclusions (CO, GA, MD, +NJ, etc.). See issue #924. """ import numpy as np @@ -54,7 +55,6 @@ def _base_mfj_record(taxsimid=1, **overrides): ("dividends", "qualified_dividend_income", 80000), ("ltcg", "long_term_capital_gains", 60000), ("stcg", "short_term_capital_gains", 30000), - ("gssi", "social_security_retirement", 40000), ("scorp", "partnership_s_corp_income", 50000), ], ) @@ -126,6 +126,32 @@ def test_pension_stays_on_primary_when_both_under_60(): np.testing.assert_allclose(values, [30000.0, 0.0]) +def test_gssi_splits_when_both_spouses_are_60_plus(): + """gssi: when both spouses ≥ 60, allocate 50/50 so both qualify + for state per-person SS exclusions.""" + df = pd.DataFrame([_base_mfj_record(page=65, sage=65, gssi=40000)]) + values = _run_allocation(df, "social_security_retirement") + np.testing.assert_allclose(values, [20000.0, 20000.0]) + + +def test_gssi_stays_on_primary_for_mixed_age_couple(): + """gssi: in a mixed-age household (e.g. primary 75, spouse 40), + keep full Social Security on the primary so age-based state + exclusions (e.g. CO, MD) reach the qualifying spouse. Splitting + 50/50 would push half onto the under-60 spouse and lose the + exclusion on that half. See taxsim issue #924.""" + df = pd.DataFrame([_base_mfj_record(page=75, sage=40, gssi=40000)]) + values = _run_allocation(df, "social_security_retirement") + np.testing.assert_allclose(values, [40000.0, 0.0]) + + +def test_gssi_stays_on_primary_when_both_under_60(): + """gssi: when neither spouse is 60+, keep on primary.""" + df = pd.DataFrame([_base_mfj_record(page=45, sage=45, gssi=40000)]) + values = _run_allocation(df, "social_security_retirement") + np.testing.assert_allclose(values, [40000.0, 0.0]) + + def test_de_elderly_pension_matches_issue_838(): """End-to-end: DE elderly couple with pension income should produce a state tax close to TAXSIM after the 50/50 split. Issue #838 reported From dceadebe27266bae77d150aced5b97b9525e82fa Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 25 May 2026 16:50:02 -0400 Subject: [PATCH 2/3] Allocate age-gated income to older spouse in mixed-age households In mixed-age MFJ couples, route pension and Social Security to the older spouse (matching the original intent in #774) rather than unconditionally to the primary filer. The previous primary-filer rule missed the #774 case (page=54, sage=55) where the qualifying spouse is the secondary filer. Both-60+ still splits 50/50; both-under-60 still defaults to primary. Verified: - #924 CO (primary 75, spouse 40, $94K gssi): SS on primary, co_subtractions = $80,221 vs TaxAct $80,220 ($1 rounding). - #774 IA (primary 54, spouse 55, $262K pension): pension on spouse, ia_income_tax = $0 (full eligible exclusion applied). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runners/policyengine_runner.py | 32 ++++++++++---- tests/test_spouse_income_splitting.py | 43 +++++++++++++------ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/policyengine_taxsim/runners/policyengine_runner.py b/policyengine_taxsim/runners/policyengine_runner.py index 5169a05..d97206a 100644 --- a/policyengine_taxsim/runners/policyengine_runner.py +++ b/policyengine_taxsim/runners/policyengine_runner.py @@ -226,33 +226,49 @@ def accessor(row): @classmethod def _make_age_gated_primary(cls, source_field): - """Age-gated income stays on primary unless both spouses are 60+.""" + """Allocate age-gated income (pension, gssi) to the primary filer's + share. Both spouses 60+: 50/50. Mixed-age: assign entirely to the + older spouse so age-based state exclusions reach the qualifying + filer (see taxsim issues #774 for pensions, #924 for gssi). Both + under 60: all to primary by default.""" def accessor(row): value = float(row.get(source_field, 0)) if int(row.get("mstat", 1)) != 2: return value + page = int(row.get("page", 0)) + sage = int(row.get("sage", 0)) both_old = ( - int(row.get("page", 0)) >= cls._AGE_GATED_SPLIT_AGE - and int(row.get("sage", 0)) >= cls._AGE_GATED_SPLIT_AGE + page >= cls._AGE_GATED_SPLIT_AGE + and sage >= cls._AGE_GATED_SPLIT_AGE ) - return value / 2 if both_old else value + if both_old: + return value / 2 + primary_is_older_or_equal = page >= sage + return value if primary_is_older_or_equal else 0.0 return accessor @classmethod def _make_age_gated_spouse(cls, source_field): - """Spouse only shares age-gated income if both spouses are 60+.""" + """Spouse's share of age-gated income. Mirror of `_make_age_gated_primary`: + 50/50 if both 60+, full amount to spouse only when spouse is strictly + older than primary in mixed-age cases.""" def accessor(row): value = float(row.get(source_field, 0)) if int(row.get("mstat", 1)) != 2: return 0.0 + page = int(row.get("page", 0)) + sage = int(row.get("sage", 0)) both_old = ( - int(row.get("page", 0)) >= cls._AGE_GATED_SPLIT_AGE - and int(row.get("sage", 0)) >= cls._AGE_GATED_SPLIT_AGE + page >= cls._AGE_GATED_SPLIT_AGE + and sage >= cls._AGE_GATED_SPLIT_AGE ) - return value / 2 if both_old else 0.0 + if both_old: + return value / 2 + spouse_is_strictly_older = sage > page + return value if spouse_is_strictly_older else 0.0 return accessor diff --git a/tests/test_spouse_income_splitting.py b/tests/test_spouse_income_splitting.py index 96752df..4789641 100644 --- a/tests/test_spouse_income_splitting.py +++ b/tests/test_spouse_income_splitting.py @@ -107,20 +107,29 @@ def test_pension_splits_when_both_spouses_are_60_plus(): np.testing.assert_allclose(values, [20000.0, 20000.0]) -def test_pension_stays_on_primary_for_mixed_age_couple(): - """Pension: when only one spouse is 60+, keep full pension on primary - so the qualifying spouse claims the per-person exclusion. Splitting - 50/50 would push half the pension onto the under-60 spouse and - eliminate half the exclusion (see #838 validation).""" +def test_pension_goes_to_primary_when_primary_is_older(): + """Pension: mixed-age, primary older — keep full pension on primary + so they claim the per-person elderly exclusion. Splitting 50/50 would + push half onto the under-60 spouse and lose half the exclusion + (see #838 validation).""" df = pd.DataFrame([_base_mfj_record(page=70, sage=50, pensions=40000)]) values = _run_allocation(df, "taxable_private_pension_income") np.testing.assert_allclose(values, [40000.0, 0.0]) +def test_pension_goes_to_spouse_when_spouse_is_older(): + """Pension: mixed-age, spouse older — assign full pension to spouse + so the qualifying filer claims the exclusion. See taxsim issue #774: + IA pension subtraction is age-55+, and with page=54 / sage=55 the + older spouse should hold the pension.""" + df = pd.DataFrame([_base_mfj_record(page=54, sage=55, pensions=40000)]) + values = _run_allocation(df, "taxable_private_pension_income") + np.testing.assert_allclose(values, [0.0, 40000.0]) + + def test_pension_stays_on_primary_when_both_under_60(): """Pension: when neither spouse is 60+, state elderly exclusions - do not apply. Keep on primary to match pre-fix behavior for the - non-elderly case.""" + do not apply. Default to primary.""" df = pd.DataFrame([_base_mfj_record(page=45, sage=45, pensions=30000)]) values = _run_allocation(df, "taxable_private_pension_income") np.testing.assert_allclose(values, [30000.0, 0.0]) @@ -134,19 +143,25 @@ def test_gssi_splits_when_both_spouses_are_60_plus(): np.testing.assert_allclose(values, [20000.0, 20000.0]) -def test_gssi_stays_on_primary_for_mixed_age_couple(): - """gssi: in a mixed-age household (e.g. primary 75, spouse 40), - keep full Social Security on the primary so age-based state - exclusions (e.g. CO, MD) reach the qualifying spouse. Splitting - 50/50 would push half onto the under-60 spouse and lose the - exclusion on that half. See taxsim issue #924.""" +def test_gssi_goes_to_older_spouse_for_mixed_age_couple(): + """gssi: mixed-age (primary 75, spouse 40), keep full SS on the older + primary so age-based state exclusions (CO, MD) reach the qualifying + filer. See taxsim issue #924.""" df = pd.DataFrame([_base_mfj_record(page=75, sage=40, gssi=40000)]) values = _run_allocation(df, "social_security_retirement") np.testing.assert_allclose(values, [40000.0, 0.0]) +def test_gssi_goes_to_spouse_when_spouse_is_older(): + """gssi: mixed-age, spouse older — assign all SS to spouse so they + claim the age-based subtraction.""" + df = pd.DataFrame([_base_mfj_record(page=40, sage=75, gssi=40000)]) + values = _run_allocation(df, "social_security_retirement") + np.testing.assert_allclose(values, [0.0, 40000.0]) + + def test_gssi_stays_on_primary_when_both_under_60(): - """gssi: when neither spouse is 60+, keep on primary.""" + """gssi: when neither spouse is 60+, default to primary.""" df = pd.DataFrame([_base_mfj_record(page=45, sage=45, gssi=40000)]) values = _run_allocation(df, "social_security_retirement") np.testing.assert_allclose(values, [40000.0, 0.0]) From 7756803e2f45456c5a15f69a35868fa0b5449e14 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 25 May 2026 17:03:44 -0400 Subject: [PATCH 3/3] Apply ruff format Co-Authored-By: Claude Opus 4.7 (1M context) --- policyengine_taxsim/runners/policyengine_runner.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/policyengine_taxsim/runners/policyengine_runner.py b/policyengine_taxsim/runners/policyengine_runner.py index d97206a..dfa3d5d 100644 --- a/policyengine_taxsim/runners/policyengine_runner.py +++ b/policyengine_taxsim/runners/policyengine_runner.py @@ -239,8 +239,7 @@ def accessor(row): page = int(row.get("page", 0)) sage = int(row.get("sage", 0)) both_old = ( - page >= cls._AGE_GATED_SPLIT_AGE - and sage >= cls._AGE_GATED_SPLIT_AGE + page >= cls._AGE_GATED_SPLIT_AGE and sage >= cls._AGE_GATED_SPLIT_AGE ) if both_old: return value / 2 @@ -262,8 +261,7 @@ def accessor(row): page = int(row.get("page", 0)) sage = int(row.get("sage", 0)) both_old = ( - page >= cls._AGE_GATED_SPLIT_AGE - and sage >= cls._AGE_GATED_SPLIT_AGE + page >= cls._AGE_GATED_SPLIT_AGE and sage >= cls._AGE_GATED_SPLIT_AGE ) if both_old: return value / 2