From a074129b561bb803f73bdb06900e71b9cf177151 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 16 Jun 2026 00:57:09 -0400 Subject: [PATCH] Split MFJ pension/SS 50/50 for both-young couples The Microsimulation runner allocated household pension and Social Security entirely to the primary filer whenever both spouses were under the elderly-eligibility threshold, denying the second spouse's per-person state exclusion. For states whose pension exclusion is age-independent (e.g. KY $31,110/person, OK $10,000/person) this overstated state tax. Change the age-gate so the income is split 50/50 whenever both spouses are on the same side of the threshold (both qualify OR both do not); the mixed-age rule (assign to the older spouse, per taxsim #774/#924) is unchanged. Verified against the NBER taxsimtest binary / TaxAct: - taxsim #965 (KY): $2,424.45 -> $1,180.05 (binary $1,180.05) - taxsim #966 (OK): $3,707.60 -> $3,232.60 (TaxAct $3,232) Cross-state both-young check (pension $40k, both age 45) confirms no regression: DE/KY/OK now match the binary, GA/CO are allocation- invariant. (MD is off under either allocation due to a separate PE-US pension bug, unrelated to this split.) Updates the two both-young splitting unit tests to assert 50/50 and documents the canonical spousal-allocation rule in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 25 +++++++++++++++ .../fix-both-young-pension-split.fixed.md | 1 + .../runners/policyengine_runner.py | 31 ++++++++++--------- tests/test_spouse_income_splitting.py | 20 +++++++----- 4 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fix-both-young-pension-split.fixed.md diff --git a/CLAUDE.md b/CLAUDE.md index 636ed7c..9a7f4b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,31 @@ Fragment names can be anything (branch name, issue number, short description). E - `changelog.d/fix-age-default.fixed.md` - `changelog.d/123.fixed.md` +## Spousal income allocation (MFJ) + +TAXSIM gives several income types as a single household column with no +per-spouse split. For married-filing-jointly records, the emulator allocates +them between spouses by this rule (evidence-backed against the NBER +`taxsimtest` binary; see taxsim issues #774, #924, #965, #966): + +- **Interest, dividends, capital gains, S-corp income → always split 50/50.** +- **Pensions and Social Security → age-aware:** + - Both spouses on the **same side** of the elderly-eligibility line (both + qualify, or both do not) → **split 50/50**. + - **Mixed-age** (one qualifies, one does not) → assign the **whole amount to + the older spouse**, so age-based state exclusions (CO 55, DE 60, GA 62, + MD 65) reach the qualifying filer. + +Rationale: 50/50 matches TAXSIM and gives both spouses age-*independent* +per-person exclusions (e.g. KY, OK), while the mixed-age exception protects +age-*based* exclusions from being half-wasted on an ineligible younger spouse. +The full age-aware rule lives in `runners/policyengine_runner.py` (the +Microsimulation/CLI path), keyed on `_AGE_GATED_SPLIT_AGE` (55, the lowest +state threshold). Note the single-household path in `core/input_mapper.py` +(`income_types_to_split`) currently splits these types 50/50 *unconditionally* +— correct for same-age couples but not yet age-aware for mixed-age pensions/SS; +align it with the runner if that path is used for mixed-age elderly records. + ## Running tests ```bash diff --git a/changelog.d/fix-both-young-pension-split.fixed.md b/changelog.d/fix-both-young-pension-split.fixed.md new file mode 100644 index 0000000..c16b54f --- /dev/null +++ b/changelog.d/fix-both-young-pension-split.fixed.md @@ -0,0 +1 @@ +Fix both-young MFJ pension/Social Security split: the Microsimulation runner now splits these 50/50 when both spouses are on the same side of the elderly-eligibility line (previously it dumped them on the primary filer when both were under the threshold), restoring per-person state exclusions for age-independent states (e.g. KY, OK). Mixed-age allocation to the older spouse is unchanged. diff --git a/policyengine_taxsim/runners/policyengine_runner.py b/policyengine_taxsim/runners/policyengine_runner.py index 0247ec2..9a15e72 100644 --- a/policyengine_taxsim/runners/policyengine_runner.py +++ b/policyengine_taxsim/runners/policyengine_runner.py @@ -232,10 +232,14 @@ def accessor(row): @classmethod def _make_age_gated_primary(cls, source_field): """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.""" + share. The income is split 50/50 whenever both spouses fall on the + same side of the elderly-eligibility line (both qualify OR both do + not); only in mixed-age couples is it assigned entirely to the older + spouse, so age-based state exclusions reach the qualifying filer. + See taxsim #774 (pensions) and #924 (gssi) for the mixed-age -> + older rule, and #965 (KY) / #966 (OK) confirming both-young couples + must still split 50/50 (TAXSIM does, and per-person exclusions like + KY/OK are age-independent).""" def accessor(row): value = float(row.get(source_field, 0)) @@ -243,10 +247,9 @@ def accessor(row): return value 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 - ) - if both_old: + primary_eligible = page >= cls._AGE_GATED_SPLIT_AGE + spouse_eligible = sage >= cls._AGE_GATED_SPLIT_AGE + if primary_eligible == spouse_eligible: return value / 2 primary_is_older_or_equal = page >= sage return value if primary_is_older_or_equal else 0.0 @@ -256,8 +259,9 @@ def accessor(row): @classmethod def _make_age_gated_spouse(cls, source_field): """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.""" + 50/50 when both spouses are on the same side of the elderly-eligibility + line; in mixed-age couples the full amount goes to the spouse only when + the spouse is strictly older than the primary.""" def accessor(row): value = float(row.get(source_field, 0)) @@ -265,10 +269,9 @@ def accessor(row): return 0.0 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 - ) - if both_old: + primary_eligible = page >= cls._AGE_GATED_SPLIT_AGE + spouse_eligible = sage >= cls._AGE_GATED_SPLIT_AGE + if primary_eligible == spouse_eligible: return value / 2 spouse_is_strictly_older = sage > page return value if spouse_is_strictly_older else 0.0 diff --git a/tests/test_spouse_income_splitting.py b/tests/test_spouse_income_splitting.py index ba50488..090c45f 100644 --- a/tests/test_spouse_income_splitting.py +++ b/tests/test_spouse_income_splitting.py @@ -139,12 +139,16 @@ def test_pension_goes_to_spouse_when_spouse_is_older(): 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. Default to primary.""" +def test_pension_splits_when_both_spouses_under_threshold(): + """Pension: when both spouses are on the same (younger) side of the + eligibility line, split 50/50 — matching TAXSIM (which splits the + household pension column) and giving age-independent per-person + exclusions (KY, OK) to both spouses. See taxsim #965 (KY) / #966 (OK): + both filers age 45, where dumping pension on the primary denied the + spouse's exclusion. Verified against the NBER binary.""" 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]) + np.testing.assert_allclose(values, [15000.0, 15000.0]) def test_gssi_splits_when_both_spouses_are_60_plus(): @@ -172,11 +176,13 @@ def test_gssi_goes_to_spouse_when_spouse_is_older(): 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+, default to primary.""" +def test_gssi_splits_when_both_spouses_under_threshold(): + """gssi: when both spouses are on the same (younger) side of the + eligibility line, split 50/50 — matching TAXSIM. Mirrors the pension + rule (see taxsim #924 for the mixed-age -> older convention).""" 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]) + np.testing.assert_allclose(values, [20000.0, 20000.0]) def test_de_elderly_pension_matches_issue_838():