Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fix-both-young-pension-split.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 17 additions & 14 deletions policyengine_taxsim/runners/policyengine_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,21 +232,24 @@ 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))
if int(row.get("mstat", 1)) != 2:
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
Expand All @@ -256,19 +259,19 @@ 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))
if int(row.get("mstat", 1)) != 2:
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
Expand Down
20 changes: 13 additions & 7 deletions tests/test_spouse_income_splitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
Loading